BoxTab

Navigation

드래그로 순서를 바꿀 수 있는 박스형 탭.

Usage

드래그로 순서를 바꿀 수 있는 박스형 탭. 순서 변경이 필요 없으면 Tabs

import

import

import { BoxTab } from '@mildang/design-system/BoxTab';

BoxTab 하나만 가져오면 BoxTab.List · BoxTab.Item · BoxTab.Panel 를 그 아래에서 쓸 수 있다.

예제를 복사해 쓸 때 필요한 준비

@mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

Anatomy

tsx

import { BoxTab } from '@mildang/design-system/BoxTab';

export default function Example() {
  return (
    <BoxTab>
      <BoxTabList />
      <BoxTabItem />
      <BoxTabPanel />
    </BoxTab>
  );
}

부품

필수 여부

반복

위치

BoxTab

필수

BoxTabList

필수

BoxTab 안에 둔다.

BoxTabItem

필수

여러 개 가능

BoxTabList 안에 둔다.

BoxTabPanel

선택

여러 개 가능

BoxTab 안에 둔다.

  • BoxTab 안에 BoxTabList와 BoxTabItem을 배치하고 필요하면 BoxTabPanel을 연결한다.

API Reference

BoxTab Props

Prop

Type

Default

activationMode

"manual" | "automatic"

automatic

children

ReactNode

지정 안 함

className

string

지정 안 함

fill

boolean

false

onReorder

(order: string[]) => void

지정 안 함

onValueChange

(value: string) => void

지정 안 함

order

string[]

지정 안 함

size

"sm" | "md" | "lg"

md

value

string

지정 안 함

BoxTab

탭 상태와 패널을 관리하는 루트다.

공개 Props 없음

BoxTabList

탭 항목을 감싸는 목록이다.

Prop

Type

Default

aria-label

string

지정 안 함

children

ReactNode

지정 안 함

className

string

지정 안 함

BoxTabItem

하나의 선택 가능한 탭이다.

Prop

Type

Default

label
필수

ReactNode

지정 안 함

value
필수

string

지정 안 함

className

string

지정 안 함

count

string | number

지정 안 함

deletable

boolean

false

disabled

boolean

false

draggable

boolean

false

endIcon

ReactNode

지정 안 함

maxWidth

string | number

지정 안 함

onDelete

(value: string) => void

지정 안 함

BoxTabPanel

선택된 탭의 콘텐츠 패널이다.

Prop

Type

Default

value
필수

string

지정 안 함

children

ReactNode

지정 안 함

className

string

지정 안 함

예제

기본 사용

선택 탭이 흰 박스로 뜨고 패널이 함께 바뀌는 controlled 형태다 — value 를 안 넘기면 탭이 하나도 안 뽑힌다. 순서 변경이 필요 없으면 Tabs 를 쓴다.

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const ActivationModeDemo = ({ activationMode }: { activationMode: 'automatic' | 'manual' }) => {
  const [value, setValue] = useState('a1');
  const [clearCount, setClearCount] = useState(0);
  return (
    <div>
      <BoxTab
        activationMode={activationMode}
        value={value}
        onValueChange={(next) => {
          setValue(next);
          // 탭 전환에 딸려오는 파괴적 부수효과를 흉내낸다 (실제 캘린더는 선택 카트를 비운다)
          setClearCount((prev) => prev + 1);
        }}
      >
        <BoxTab.List aria-label={`${activationMode} 예시`}>
          {['a1', 'a2', 'a3', 'a4'].map((v, i) => (
            <BoxTab.Item key={v} value={v} label={`그룹 ${i + 1}`} />
          ))}
        </BoxTab.List>
      </BoxTab>
      <div className={panelStyle}>선택 카트가 비워진 횟수: {clearCount}</div>
    </div>
  );
};

const BoxTabActivationModeExample = () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
      <div>
        <div className={captionStyle}>automatic (기본) — 화살표로 지나가기만 해도 전환된다</div>
        <ActivationModeDemo activationMode="automatic" />
      </div>
      <div>
        <div className={captionStyle}>manual — 화살표는 포커스만, Enter/Space로 확정</div>
        <ActivationModeDemo activationMode="manual" />
      </div>
    </div>
  );

export default BoxTabActivationModeExample;

크기

sm·md·lg 로 높이와 라벨 크기를 나란히 비교한다. size 를 변수로 돌리면 Panda 정적 추출에서 빠지므로 세 벌을 펼쳐 썼다.

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabSizesExample = () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
      <div>
        <div className={captionStyle}>sm — 높이 36px</div>
        <Template size="sm" />
      </div>
      <div>
        <div className={captionStyle}>md (기본) — 높이 40px</div>
        <Template size="md" />
      </div>
      <div>
        <div className={captionStyle}>lg — 높이 42px</div>
        <Template size="lg" />
      </div>
    </div>
  );

export default BoxTabSizesExample;

Draggable

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabDraggableExample = () => (
    <div>
      <div className={captionStyle}>
        비선택 탭에 마우스를 올리면 좌측에 핸들이 나타납니다 — 잡아 끌어 순서를 바꿔 보세요
      </div>
      <Template size="md" draggable />
    </div>
  );

export default BoxTabDraggableExample;

FillContainer

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabFillContainerExample = () => (
    <div style={{ width: 480, border: '1px dashed #ccc' }}>
      <Template size="md" fill />
    </div>
  );

export default BoxTabFillContainerExample;

WithCount

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabWithCountExample = () => <Template size="md" withCount />;

export default BoxTabWithCountExample;

WithEndIcon

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabWithEndIconExample = () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
      <div>
        <div className={captionStyle}>기본 — 좌우 여백 20px</div>
        <Template size="md" />
      </div>
      <div>
        <div className={captionStyle}>endIcon 있음 — 우측 여백이 4px로 좁아진다</div>
        <Template size="md" withEndIcon />
      </div>
    </div>
  );

export default BoxTabWithEndIconExample;

Deletable

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const BoxTabDeletableExample = () => {
    const [order, setOrder] = useState(TABS.map((t) => t.value));
    const [value, setValue] = useState('tab1');
    const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));
    return (
      <BoxTab value={value} onValueChange={setValue} order={order} onReorder={setOrder}>
        <BoxTab.List aria-label="삭제 가능한 탭">
          {order.map((v) => (
            <BoxTab.Item
              key={v}
              value={v}
              label={byValue[v].label}
              deletable
              onDelete={(dv) => {
                setOrder((prev) => prev.filter((x) => x !== dv));
                // 선택된 탭을 지우면 선택도 이웃으로 옮긴다.
                // 안 하면 value 가 사라진 탭을 가리켜 aria-selected 전부 false + Panel 이 빈다.
                if (dv === value) {
                  const idx = order.indexOf(dv);
                  const remaining = order.filter((x) => x !== dv);
                  const neighbor = remaining[idx] ?? remaining[idx - 1];
                  if (neighbor) setValue(neighbor);
                }
              }}
            />
          ))}
        </BoxTab.List>
        {order.map((v) => (
          <BoxTab.Panel key={v} value={v}>
            <div className={panelStyle}>{byValue[v].body}</div>
          </BoxTab.Panel>
        ))}
      </BoxTab>
    );
  };

export default BoxTabDeletableExample;

MaxWidth

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const LONG = [
  { value: 'l1', label: '제목이 엄청나게 길어진 경우의 탭 라벨은 이렇게 잘립니다' },
  { value: 'l2', label: 'Verrrry Loooonng Tab Label Text That Never Ends' },
  { value: 'l3', label: '짧은 탭' },
];

const LongLabelTabs = ({ maxWidth }: { maxWidth?: number }) => {
  const [order, setOrder] = useState(LONG.map((t) => t.value));
  const [value, setValue] = useState('l1');
  const byValue = Object.fromEntries(LONG.map((t) => [t.value, t]));
  return (
    <BoxTab value={value} onValueChange={setValue} order={order} onReorder={setOrder}>
      <BoxTab.List aria-label="긴 라벨 탭">
        {order.map((v) => (
          <BoxTab.Item key={v} value={v} label={byValue[v].label} maxWidth={maxWidth} />
        ))}
      </BoxTab.List>
      {order.map((v) => (
        <BoxTab.Panel key={v} value={v}>
          <div className={panelStyle}>{byValue[v].label}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabMaxWidthExample = () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
      <div>
        <div className={captionStyle}>기본값 — maxWidth 미지정 (280px)</div>
        <LongLabelTabs />
      </div>
      <div>
        <div className={captionStyle}>maxWidth={'{160}'} 으로 좁게 덮어쓴 경우</div>
        <LongLabelTabs maxWidth={160} />
      </div>
    </div>
  );

export default BoxTabMaxWidthExample;

Overflow

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const MANY = Array.from({ length: 10 }, (_, i) => ({ value: `t${i}`, label: `탭 항목 ${i + 1}` }));

const ManyTabs = ({ fill }: { fill?: boolean }) => {
  const [order, setOrder] = useState(MANY.map((t) => t.value));
  const [value, setValue] = useState('t0');
  const byValue = Object.fromEntries(MANY.map((t) => [t.value, t]));
  return (
    <BoxTab fill={fill} value={value} onValueChange={setValue} order={order} onReorder={setOrder}>
      <BoxTab.List aria-label="많은 탭">
        {order.map((v) => (
          <BoxTab.Item key={v} value={v} label={byValue[v].label} />
        ))}
      </BoxTab.List>
      {order.map((v) => (
        <BoxTab.Panel key={v} value={v}>
          <div className={panelStyle}>{byValue[v].label} 콘텐츠</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const BoxTabOverflowExample = () => (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
      <div style={{ width: 360, border: '1px dashed #ccc', padding: 8 }}>
        <div className={captionStyle}>fit-contents (기본)</div>
        <ManyTabs />
      </div>
      <div style={{ width: 360, border: '1px dashed #ccc', padding: 8 }}>
        <div className={captionStyle}>fill-container — 여기서도 이동 버튼이 나와야 한다</div>
        <ManyTabs fill />
      </div>
    </div>
  );

export default BoxTabOverflowExample;

KeyboardNavigation

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const KEYBOARD_TABS = [
  { value: 'k1', label: '첫 번째 탭' },
  { value: 'k2', label: '두 번째 탭' },
  { value: 'k3', label: '비활성 탭', disabled: true },
  { value: 'k4', label: '네 번째 탭' },
  { value: 'k5', label: '다섯 번째 탭' },
];

const BoxTabKeyboardNavigationExample = () => {
    const [value, setValue] = useState('k1');
    return (
      <div>
        <div className={captionStyle}>
          탭을 클릭해 포커스를 준 뒤 ← / → / Home / End 를 눌러 보세요 (비활성 탭은 건너뜀 · 양끝 순환)
        </div>
        <BoxTab value={value} onValueChange={setValue}>
          <BoxTab.List aria-label="키보드 이동 예시">
            {KEYBOARD_TABS.map((t) => (
              <BoxTab.Item key={t.value} value={t.value} label={t.label} disabled={t.disabled} />
            ))}
          </BoxTab.List>
          {KEYBOARD_TABS.map((t) => (
            <BoxTab.Panel key={t.value} value={t.value}>
              <div className={panelStyle}>{t.label} 패널입니다. Tab 키로 이 영역까지 도달할 수 있습니다.</div>
            </BoxTab.Panel>
          ))}
        </BoxTab>
        <button type="button" style={{ marginTop: 24 }}>
          탭바 다음 요소 (Tab 순서 확인용)
        </button>
      </div>
    );
  };

export default BoxTabKeyboardNavigationExample;

LongLabelOverflow

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';

const BoxTabLongLabelOverflowExample = () => {
    const [value, setValue] = useState('g1');
    const groups = [
      { value: 'g1', label: '2026학년도 1학기 중등 내신대비 집중반' },
      { value: 'g2', label: '고3 수능 파이널 실전 모의고사반' },
      { value: 'g3', label: '초등 저학년 파닉스' },
      { value: 'g4', label: '중등 문법 심화 클리닉 화목반' },
      { value: 'g5', label: '전체보기' },
    ];
    return (
      <div style={{ width: 420, border: '1px dashed #ccc', padding: 8 }}>
        <BoxTab size="lg" value={value} onValueChange={setValue}>
          <BoxTab.List aria-label="긴 라벨 + 오버플로우">
            {groups.map((g) => (
              <BoxTab.Item key={g.value} value={g.value} label={g.label} count={12} />
            ))}
          </BoxTab.List>
        </BoxTab>
      </div>
    );
  };

export default BoxTabLongLabelOverflowExample;

WithoutPanel

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const BoxTabWithoutPanelExample = () => {
    const [value, setValue] = useState('w1');
    return (
      <div>
        <BoxTab size="lg" value={value} onValueChange={setValue}>
          <BoxTab.List aria-label="패널 없는 탭">
            {['w1', 'w2', 'w3'].map((v, i) => (
              <BoxTab.Item key={v} value={v} label={`그룹 ${i + 1}`} />
            ))}
          </BoxTab.List>
        </BoxTab>
        <div className={panelStyle}>
          선택된 값: <code>{value}</code> — 이 영역은 BoxTab.Panel이 아니라 화면이 직접 그린 콘텐츠입니다.
        </div>
      </div>
    );
  };

export default BoxTabWithoutPanelExample;

EdgeCases

import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import { css } from '@mildang/styled-system/css';

const captionStyle = css({ pb: '8', textStyle: 'caption-lg', color: 'primary.text.lowest' });

const BoxTabEdgeCasesExample = () => {
    const [single, setSingle] = useState('only');
    const [unmatched, setUnmatched] = useState('존재하지-않는-값');
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
        <div>
          <div className={captionStyle}>탭이 1개일 때 — 단일 그룹 화면</div>
          <BoxTab size="lg" value={single} onValueChange={setSingle}>
            <BoxTab.List aria-label="단일 탭">
              <BoxTab.Item value="only" label="중등 내신대비반" count={8} />
            </BoxTab.List>
          </BoxTab>
        </div>
        <div>
          <div className={captionStyle}>
            선택값이 어떤 탭과도 매칭되지 않을 때 — 그래도 Tab으로 탭바에 들어갈 수 있어야 한다
          </div>
          <BoxTab value={unmatched} onValueChange={setUnmatched}>
            <BoxTab.List aria-label="선택값 미매칭">
              {['e1', 'e2', 'e3'].map((v, i) => (
                <BoxTab.Item key={v} value={v} label={`탭 ${i + 1}`} />
              ))}
            </BoxTab.List>
          </BoxTab>
        </div>
        <div>
          <div className={captionStyle}>
            첫 탭이 비활성일 때 — 폴백은 첫 번째 &quot;활성&quot; 탭이어야 한다
          </div>
          <BoxTab value="없음">
            <BoxTab.List aria-label="첫 탭 비활성">
              <BoxTab.Item value="d1" label="비활성 탭" disabled />
              <BoxTab.Item value="d2" label="두 번째 탭" />
              <BoxTab.Item value="d3" label="세 번째 탭" />
            </BoxTab.List>
          </BoxTab>
        </div>
      </div>
    );
  };

export default BoxTabEdgeCasesExample;

Demo


import { useState } from 'react';
import { BoxTab } from '@mildang/design-system/BoxTab';
import Eye from '@mildang/icons/react/eye';
import { css } from '@mildang/styled-system/css';

const TABS = [
  {
    value: 'tab1',
    label: '역질문 트리거',
    body: '역질문 트리거 탭의 내용입니다. 여기에 관련 콘텐츠가 표시됩니다.',
  },
  {
    value: 'tab2',
    label: '카테고리명',
    body: '카테고리명 탭의 내용입니다. 탭을 클릭하면 이 영역이 바뀝니다.',
  },
  {
    value: 'tab3',
    label: '고정된 자료',
    body: '고정된 자료 탭의 내용입니다. 드래그로 탭 순서도 바꿀 수 있어요.',
  },
];

const panelStyle = css({ pt: '16', textStyle: 'body-lg', color: 'neutral.text.base' });

const Template = ({
  size,
  fill,
  activationMode,
  withCount,
  withEndIcon,
  draggable,
}: {
  size?: 'sm' | 'md' | 'lg';
  fill?: boolean;
  activationMode?: 'automatic' | 'manual';
  withCount?: boolean;
  withEndIcon?: boolean;
  draggable?: boolean;
}) => {
  const [order, setOrder] = useState(TABS.map((t) => t.value));
  const [value, setValue] = useState('tab1');
  const byValue = Object.fromEntries(TABS.map((t) => [t.value, t]));

  return (
    <BoxTab
      size={size}
      fill={fill}
      activationMode={activationMode}
      value={value}
      onValueChange={setValue}
      order={order}
      onReorder={setOrder}
    >
      <BoxTab.List aria-label="예시 탭">
        {order.map((v) => (
          <BoxTab.Item
            key={v}
            value={v}
            label={byValue[v].label}
            draggable={draggable}
            count={withCount ? 3 : undefined}
            endIcon={withEndIcon ? <Eye /> : undefined}
          />
        ))}
      </BoxTab.List>
      {TABS.map((t) => (
        <BoxTab.Panel key={t.value} value={t.value}>
          <div className={panelStyle}>{t.body}</div>
        </BoxTab.Panel>
      ))}
    </BoxTab>
  );
};

const STORY_DEFAULT_ARGS = { ...({}), ...({ size: 'md', fill: false, activationMode: 'automatic' }) } as { value?: string | undefined; onValueChange?: ((value: string) => void) | undefined; order?: string[] | undefined; onReorder?: ((order: string[]) => void) | undefined; activationMode?: "automatic" | "manual" | undefined; className?: string | undefined; children?: import("react").ReactNode; size?: import("@mildang/styled-system/types").ConditionalValue<"sm" | "md" | "lg"> | undefined; fill?: import("@mildang/styled-system/types").ConditionalValue<boolean> | undefined; };

const BoxTabDemoExampleRender = (args: { value?: string | undefined; onValueChange?: ((value: string) => void) | undefined; order?: string[] | undefined; onReorder?: ((order: string[]) => void) | undefined; activationMode?: "automatic" | "manual" | undefined; className?: string | undefined; children?: import("react").ReactNode; size?: import("@mildang/styled-system/types").ConditionalValue<"sm" | "md" | "lg"> | undefined; fill?: import("@mildang/styled-system/types").ConditionalValue<boolean> | undefined; }) => (
    <Template
      size={args.size as 'sm' | 'md' | 'lg'}
      fill={args.fill as boolean}
      activationMode={args.activationMode}
      draggable
    />
  );

export default function BoxTabDemoExample(props: Partial<{ value?: string | undefined; onValueChange?: ((value: string) => void) | undefined; order?: string[] | undefined; onReorder?: ((order: string[]) => void) | undefined; activationMode?: "automatic" | "manual" | undefined; className?: string | undefined; children?: import("react").ReactNode; size?: import("@mildang/styled-system/types").ConditionalValue<"sm" | "md" | "lg"> | undefined; fill?: import("@mildang/styled-system/types").ConditionalValue<boolean> | undefined; }>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as { value?: string | undefined; onValueChange?: ((value: string) => void) | undefined; order?: string[] | undefined; onReorder?: ((order: string[]) => void) | undefined; activationMode?: "automatic" | "manual" | undefined; className?: string | undefined; children?: import("react").ReactNode; size?: import("@mildang/styled-system/types").ConditionalValue<"sm" | "md" | "lg"> | undefined; fill?: import("@mildang/styled-system/types").ConditionalValue<boolean> | undefined; };
  return BoxTabDemoExampleRender(mergedProps);
}