Tab Menu

Tabbed switch between views.

Installation

$npx @309-thingspire/ui@latest add tab-menu

Usage

import { TabMenu } from "@/components/tab-menu/tab-menu"
<TabMenu />

Examples

Live preview rendered from TabMenu.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
import React, { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';

import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';

import type { TabMenuItem, TabMenuProps, TabMenuSize, TabMenuType, TabMenuVisualState } from './TabMenu.types';

type TypographyToken = {
  fontFamily: string;
  fontSize: number;
  fontWeight: number;
  lineHeight: number;
  letterSpacing: number;
};

type SizeConfig = {
  fillPaddingX: number;
  fillPaddingY: number;
  fillRadius: number;
  fillTypography: TypographyToken;
  linePaddingTop: number;
  linePaddingBottom: number;
  lineTypography: TypographyToken;
  segmentedRootRadius: number;
  segmentedItemRadius: number;
  segmentedPaddingX: number;
  segmentedPaddingY: number;
  segmentedTypography: TypographyToken;
  badgePaddingX: number;
  badgePaddingY: number;
  badgeRadius: number;
  badgeTypography: TypographyToken;
  labelWrapPaddingX: number;
};

const SIZE_CONFIG: Record<TabMenuSize, SizeConfig> = {
  lg: {
    fillPaddingX: spacing.scale['16'],
    fillPaddingY: spacing.scale['12'],
    fillRadius: radius.scale.xl,
    fillTypography: typography.scale.bodyS.medium,
    linePaddingTop: spacing.scale['10'],
    linePaddingBottom: spacing.scale['14'],
    lineTypography: typography.scale.bodyS.medium,
    segmentedRootRadius: radius.scale.xl,
    segmentedItemRadius: radius.scale.lg,
    segmentedPaddingX: spacing.scale['12'],
    segmentedPaddingY: spacing.scale['10'],
    segmentedTypography: typography.scale.bodyS.medium,
    badgePaddingX: spacing.scale['8'],
    badgePaddingY: spacing.scale['2'],
    badgeRadius: radius.scale.md,
    badgeTypography: typography.scale.captionL.medium,
    labelWrapPaddingX: spacing.scale['4'],
  },
  md: {
    fillPaddingX: spacing.scale['12'],
    fillPaddingY: spacing.scale['8'],
    fillRadius: radius.scale.xl,
    fillTypography: typography.scale.bodyS.medium,
    linePaddingTop: spacing.scale['6'],
    linePaddingBottom: spacing.scale['10'],
    lineTypography: typography.scale.bodyS.medium,
    segmentedRootRadius: radius.scale.xl,
    segmentedItemRadius: radius.scale.lg,
    segmentedPaddingX: spacing.scale['10'],
    segmentedPaddingY: spacing.scale['6'],
    segmentedTypography: typography.scale.bodyS.medium,
    badgePaddingX: spacing.scale['8'],
    badgePaddingY: spacing.scale['2'],
    badgeRadius: radius.scale.md,
    badgeTypography: typography.scale.captionL.medium,
    labelWrapPaddingX: spacing.scale['4'],
  },
  sm: {
    fillPaddingX: spacing.scale['12'],
    fillPaddingY: spacing.scale['6'],
    fillRadius: radius.scale.lg,
    fillTypography: typography.scale.captionL.medium,
    linePaddingTop: spacing.primitive['5'],
    linePaddingBottom: spacing.primitive['7'],
    lineTypography: typography.scale.captionL.medium,
    segmentedRootRadius: radius.scale.lg,
    segmentedItemRadius: radius.scale.md,
    segmentedPaddingX: spacing.scale['8'],
    segmentedPaddingY: spacing.scale['4'],
    segmentedTypography: typography.scale.captionL.medium,
    badgePaddingX: spacing.scale['6'],
    badgePaddingY: spacing.scale['2'],
    badgeRadius: radius.scale.sm,
    badgeTypography: typography.scale.captionM.medium,
    labelWrapPaddingX: spacing.scale['4'],
  },
};

const ITEM_GAP_BY_TYPE: Record<TabMenuType, number> = {
  fill: spacing.scale['8'],
  line: spacing.scale['24'],
  segmented: spacing.scale['2'],
};

const DEFAULT_ITEMS: TabMenuItem[] = [
  { id: 'tab-01', label: 'Label' },
  { id: 'tab-02', label: 'Label', badge: '12' },
  { id: 'tab-03', label: 'Label' },
  { id: 'tab-04', label: 'Label', badge: '08' },
  { id: 'tab-05', label: 'Label' },
  { id: 'tab-06', label: 'Label' },
  { id: 'tab-07', label: 'Label' },
  { id: 'tab-08', label: 'Label' },
  { id: 'tab-09', label: 'Label' },
  { id: 'tab-10', label: 'Label' },
];

const SEGMENTED_DEFAULT_ITEMS: TabMenuItem[] = DEFAULT_ITEMS.slice(0, 5);

const textBase = colors.semantic.theme.text.base;
const backgroundButton = colors.semantic.theme.background.button;
const overlayBackground = colors.semantic.theme.background.overlay;

function toTypographyStyle(token: TypographyToken) {
  return {
    fontFamily: token.fontFamily,
    fontSize: token.fontSize,
    fontWeight: token.fontWeight,
    lineHeight: `${token.lineHeight}px`,
    letterSpacing: `${token.letterSpacing}px`,
  };
}

function getDefaultItems(type: TabMenuType): TabMenuItem[] {
  return type === 'segmented' ? SEGMENTED_DEFAULT_ITEMS : DEFAULT_ITEMS;
}

function findInitialSelectedId(items: TabMenuItem[], preferredId?: string): string {
  if (preferredId) {
    const preferredItem = items.find((item) => item.id === preferredId && !item.disabled);

    if (preferredItem) {
      return preferredItem.id;
    }
  }

  const second = items[1];
  if (second && !second.disabled) {
    return second.id;
  }

  const firstEnabled = items.find((item) => !item.disabled);

  if (firstEnabled) {
    return firstEnabled.id;
  }

  return items[0]?.id ?? '';
}

function resolveItemState(item: TabMenuItem, globalDisabled: boolean, forceState: TabMenuVisualState | undefined): TabMenuVisualState {
  if (globalDisabled || item.disabled || item.state === 'disabled' || forceState === 'disabled') {
    return 'disabled';
  }

  if (item.state && item.state !== 'default') {
    return item.state;
  }

  if (forceState && forceState !== 'default') {
    return forceState;
  }

  return 'default';
}

function getTextColor(selected: boolean): string {
  return selected ? textBase.staticDark : textBase.staticDarkSecondary;
}

function getTypographyToken(type: TabMenuType, config: SizeConfig): TypographyToken {
  if (type === 'line') {
    return config.lineTypography;
  }

  if (type === 'segmented') {
    return config.segmentedTypography;
  }

  return config.fillTypography;
}

export function TabMenu({
  type = 'fill',
  size = 'md',
  items,
  selectedId,
  defaultSelectedId,
  forceItemState,
  disabled = false,
  onSelectedIdChange,
  className,
  style,
  ...rest
}: TabMenuProps) {
  const config = SIZE_CONFIG[size];
  const resolvedItems = useMemo(() => items ?? getDefaultItems(type), [items, type]);

  const fallbackSelectedId = useMemo(
    () => findInitialSelectedId(resolvedItems, defaultSelectedId),
    [resolvedItems, defaultSelectedId],
  );

  const isControlled = selectedId !== undefined;
  const [internalSelectedId, setInternalSelectedId] = useState<string>(fallbackSelectedId);

  useEffect(() => {
    if (isControlled) {
      return;
    }

    setInternalSelectedId((previous) => {
      const stillExists = resolvedItems.some((item) => item.id === previous && !item.disabled);
      return stillExists ? previous : fallbackSelectedId;
    });
  }, [fallbackSelectedId, isControlled, resolvedItems]);

  const currentSelectedId = isControlled ? selectedId : internalSelectedId;
  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const rootStyle =
    type === 'segmented'
      ? {
          display: 'inline-flex',
          alignItems: 'center',
          justifyContent: 'center',
          gap: ITEM_GAP_BY_TYPE.segmented,
          padding: spacing.scale['2'],
          borderRadius: config.segmentedRootRadius,
          backgroundColor: overlayBackground.custom,
        }
      : {
          display: 'inline-flex',
          alignItems: 'flex-start',
          gap: ITEM_GAP_BY_TYPE[type],
          padding: spacing.scale['0'],
        };

  const handleSelection = (item: TabMenuItem, itemState: TabMenuVisualState) => {
    if (itemState === 'disabled') {
      return;
    }

    if (!isControlled) {
      setInternalSelectedId(item.id);
    }

    onSelectedIdChange?.(item.id);
  };

  const moveByKeyboard = (currentIndex: number, direction: 1 | -1) => {
    const enabledIndices = resolvedItems
      .map((item, index) => ({ item, index }))
      .filter(({ item }) => resolveItemState(item, disabled, forceItemState) !== 'disabled')
      .map(({ index }) => index);

    if (enabledIndices.length === 0) {
      return;
    }

    const currentEnabledIndex = enabledIndices.indexOf(currentIndex);
    const safeIndex = currentEnabledIndex === -1 ? 0 : currentEnabledIndex;
    const nextEnabledIndex = (safeIndex + direction + enabledIndices.length) % enabledIndices.length;
    const targetIndex = enabledIndices[nextEnabledIndex];
    const targetItem = resolvedItems[targetIndex];

    if (!targetItem) {
      return;
    }

    handleSelection(targetItem, 'default');
    itemRefs.current[targetIndex]?.focus();
  };

  const moveToEdgeByKeyboard = (edge: 'first' | 'last') => {
    const enabledItems = resolvedItems
      .map((item, index) => ({ item, index }))
      .filter(({ item }) => resolveItemState(item, disabled, forceItemState) !== 'disabled');

    if (enabledItems.length === 0) {
      return;
    }

    const target = edge === 'first' ? enabledItems[0] : enabledItems[enabledItems.length - 1];
    handleSelection(target.item, 'default');
    itemRefs.current[target.index]?.focus();
  };

  return (
    <div
      {...rest}
      className={className}
      role="tablist"
      aria-orientation="horizontal"
      aria-disabled={disabled || undefined}
      style={{
        ...rootStyle,
        ...style,
      }}
    >
      {resolvedItems.map((item, index) => {
        const itemState = resolveItemState(item, disabled, forceItemState);
        const isItemDisabled = itemState === 'disabled';
        const isSelected = !isItemDisabled && item.id === currentSelectedId;
        const labelColor = getTextColor(isSelected);
        const showBadge = typeof item.badge === 'string' && item.badge.trim().length > 0;
        const textTypography = getTypographyToken(type, config);

        const buttonStyle: CSSProperties =
          type === 'fill'
            ? {
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                gap: spacing.scale['0'],
                paddingInline: config.fillPaddingX,
                paddingBlock: config.fillPaddingY,
                borderRadius: config.fillRadius,
                borderStyle: 'solid',
                borderWidth: border.width['0'],
                borderColor: colors.primitive.palette.base.transparent,
                backgroundColor: isSelected ? backgroundButton.tertiary : colors.primitive.palette.base.transparent,
                boxShadow: 'none',
              }
            : type === 'line'
              ? {
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  gap: spacing.scale['8'],
                  paddingInline: spacing.scale['0'],
                  paddingTop: config.linePaddingTop,
                  paddingBottom: config.linePaddingBottom,
                  borderStyle: 'solid',
                  borderWidth: border.width['0'],
                  borderBottomStyle: 'solid',
                  borderBottomWidth: isSelected ? border.width['2'] : border.width['0'],
                  borderBottomColor: isSelected ? border.color.theme.select.primary : colors.primitive.palette.base.transparent,
                  borderRadius: spacing.scale['0'],
                  backgroundColor: colors.primitive.palette.base.transparent,
                  boxShadow: 'none',
                }
              : {
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  gap: spacing.scale['0'],
                  flex: '1 0 0',
                  minWidth: spacing.scale['0'],
                  paddingInline: config.segmentedPaddingX,
                  paddingBlock: config.segmentedPaddingY,
                  borderRadius: config.segmentedItemRadius,
                  borderStyle: 'solid',
                  borderWidth: isSelected ? border.width['1'] : border.width['0'],
                  borderColor: isSelected ? border.color.theme.action.normal : colors.primitive.palette.base.transparent,
                  backgroundColor: isSelected ? backgroundButton.secondary : colors.primitive.palette.base.transparent,
                  boxShadow: isSelected ? shadows.elevation.xs.css : 'none',
                };

        const textWrapStyle =
          type === 'line'
            ? {
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                paddingInline: spacing.scale['0'],
                paddingBlock: spacing.scale['0'],
              }
            : {
                display: 'inline-flex',
                alignItems: 'center',
                justifyContent: 'center',
                paddingInline: config.labelWrapPaddingX,
                paddingBlock: spacing.scale['0'],
              };

        return (
          <button
            key={item.id}
            ref={(node) => {
              itemRefs.current[index] = node;
            }}
            type="button"
            role="tab"
            aria-selected={isSelected}
            aria-disabled={isItemDisabled || undefined}
            tabIndex={isSelected ? 0 : -1}
            disabled={isItemDisabled}
            onClick={() => handleSelection(item, itemState)}
            onKeyDown={(event) => {
              if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
                event.preventDefault();
                moveByKeyboard(index, 1);
                return;
              }

              if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
                event.preventDefault();
                moveByKeyboard(index, -1);
                return;
              }

              if (event.key === 'Home') {
                event.preventDefault();
                moveToEdgeByKeyboard('first');
                return;
              }

              if (event.key === 'End') {
                event.preventDefault();
                moveToEdgeByKeyboard('last');
              }
            }}
            style={{
              ...buttonStyle,
              cursor: isItemDisabled ? 'default' : 'pointer',
            }}
          >
            <span style={textWrapStyle}>
              <span
                style={{
                  ...toTypographyStyle(textTypography),
                  color: labelColor,
                  textAlign: 'center',
                  whiteSpace: 'nowrap',
                }}
              >
                {item.label}
              </span>
            </span>

            {showBadge && (
              <span
                style={
                  type === 'line'
                    ? {
                        display: 'inline-flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        paddingInline: spacing.scale['0'],
                        paddingBlock: spacing.scale['0'],
                      }
                    : {
                        display: 'inline-flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        paddingInline: config.labelWrapPaddingX,
                        paddingBlock: spacing.scale['0'],
                      }
                }
              >
                <span
                  style={{
                    display: 'inline-flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    paddingInline: config.badgePaddingX,
                    paddingBlock: config.badgePaddingY,
                    borderRadius: config.badgeRadius,
                    backgroundColor: backgroundButton.tertiary,
                  }}
                >
                  <span
                    style={{
                      ...toTypographyStyle(config.badgeTypography),
                      color: labelColor,
                      textAlign: 'center',
                      whiteSpace: 'nowrap',
                    }}
                  >
                    {item.badge}
                  </span>
                </span>
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

export default TabMenu;

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)