임동욱 임동욱 08-18
feat: Tab 컴포넌트 2종 추가
관리자페이지 파일(QsFVFFUKGH68xAPVkF3SJ9)의 tab(391:4497)·tab-item(391:4431)을
FoxTab / FoxTabItem 둘로 옮겼다. 계열(line·fill) × 크기(lg·md) × 상태 다섯이 모두
시안에 있다.

크기가 배치를 정한다. lg는 탭이 폭을 고르게 나눠 갖고 목록 전체에 옅은 밑줄이 깔리며,
md는 글자만큼만 차지하고 8px 간격으로 선다. 아래 여백도 크기마다 다르다.

계열·크기 수식어는 항목이 아니라 묶음에만 붙인다 — 항목이 홀로 쓰이는 자리가 없어
`.fox-tab--line .fox-tab-item`으로 내려받는 편이 단순하다. 탭은 탭 안에 들어가지 않으므로
후손 선택자가 새는 문제도 없다.

line 계열은 기본 상태에도 투명한 1px 밑줄을 둔다(시안 그대로) — 고름으로 바뀔 때 글자가
밀리지 않으려면 자리를 미리 잡아야 한다. 고른 탭의 4px 밑줄은 목록의 1px 선 위로
끌어올려 겹치는 자리에서 굵은 선이 이기게 했다.

키보드는 ARIA 탭 규약을 따른다 — 좌우 화살표·Home·End로 옮기고 탭키는 고른 탭에만
닿으며, 잠긴 탭은 건너뛴다. 항목이 href를 가지면 주소가 바뀌는 이동이라 보고 로
내며 탭 역할을 붙이지 않는다.

검증: 여백·글자·색·모서리·마크업 23항목이 시안과 일치. 화살표 이동이 양끝에서 감기고
잠긴 탭을 건너뛰는 것을 확인했다. sass 550규칙 · tsc · eslint 통과.

Co-Authored-By: Claude Opus 5 
@24976caa07fce9a7fb58f56d965d33838f7ce955
 
@fox/core/components/fox-tab-item/fox-tab-item.tsx (added)
+++ @fox/core/components/fox-tab-item/fox-tab-item.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import type { KeyboardEvent, ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxTabItemProps {
+  children: ReactNode;
+  /** 이 탭을 가리키는 값. `FoxTab`의 `value`와 같으면 고른 탭이 된다. */
+  value: string;
+  /**
+   * 주면 `<a>`가 된다 — 주소가 바뀌는 탭(라우팅)일 때 쓴다. 그때는 목록이 탭 역할을
+   * 갖지 않는다(그건 `FoxTab`이 판단한다).
+   */
+  href?: string;
+  /** 고른 탭이 보여 줄 영역의 id. `role="tab"`일 때만 쓰인다. */
+  controls?: string;
+  disabled?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+
+  /** FoxTab이 채운다 — 고른 탭인지. 직접 넘기지 않는다. */
+  active?: boolean;
+  /** FoxTab이 채운다 — 탭 역할을 갖는 목록인지. 직접 넘기지 않는다. */
+  tabRole?: boolean;
+  /** FoxTab이 채운다. 직접 넘기지 않는다. */
+  onSelect?: (value: string) => void;
+  /** FoxTab이 채운다 — 화살표 이동. 직접 넘기지 않는다. */
+  onNavigate?: (event: KeyboardEvent<HTMLElement>) => void;
+  /** FoxTab이 채운다. 직접 넘기지 않는다. */
+  ref?: Ref<HTMLElement>;
+}
+
+/**
+ * @fox 탭 하나. 고른 상태는 `FoxTab`이 소유한다 — 항목이 스스로 뒤집지 않는다.
+ *
+ * `href`를 주면 `<a>`가 되고, 없으면 `<button role="tab">`이 된다. 주소가 바뀌는 탭에
+ * `role="tab"`을 붙이면 스크린리더가 같은 화면 안의 전환으로 잘못 안내한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxTabItem({
+  children,
+  value,
+  href,
+  controls,
+  disabled = false,
+  hidden = false,
+  className,
+  active = false,
+  tabRole = true,
+  onSelect,
+  onNavigate,
+  ref,
+}: FoxTabItemProps) {
+  if (hidden) {
+    return null;
+  }
+
+  const classes = cx("fox-tab-item", active && "fox-tab-item--active", className);
+
+  if (href) {
+    return (
+      <a
+        ref={ref as Ref<HTMLAnchorElement>}
+        className={classes}
+        href={href}
+        aria-current={active ? "page" : undefined}
+        aria-disabled={disabled || undefined}
+        onClick={() => onSelect?.(value)}
+        onKeyDown={onNavigate}
+      >
+        {children}
+      </a>
+    );
+  }
+
+  return (
+    <button
+      ref={ref as Ref<HTMLButtonElement>}
+      className={classes}
+      type="button"
+      role={tabRole ? "tab" : undefined}
+      aria-selected={tabRole ? active : undefined}
+      aria-controls={tabRole ? controls : undefined}
+      // 고른 탭만 탭키로 닿고 나머지는 화살표로 옮긴다(ARIA 탭 규약).
+      tabIndex={tabRole && !active ? -1 : undefined}
+      disabled={disabled}
+      onClick={() => onSelect?.(value)}
+      onKeyDown={onNavigate}
+    >
+      {children}
+    </button>
+  );
+}
 
@fox/core/components/fox-tab-item/index.ts (added)
+++ @fox/core/components/fox-tab-item/index.ts
@@ -0,0 +1,1 @@
+export { FoxTabItem, type FoxTabItemProps } from "./fox-tab-item";
 
@fox/core/components/fox-tab/fox-tab.tsx (added)
+++ @fox/core/components/fox-tab/fox-tab.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import {
+  Children,
+  Fragment,
+  cloneElement,
+  createRef,
+  isValidElement,
+  type KeyboardEvent,
+  type ReactElement,
+  type ReactNode,
+  type RefObject,
+} from "react";
+import { cx } from "../../utils";
+import type { FoxTabItemProps } from "../fox-tab-item";
+
+export type FoxTabType = "line" | "fill";
+export type FoxTabSize = "lg" | "md";
+
+export interface FoxTabProps {
+  /** `FoxTabItem`들. */
+  children?: ReactNode;
+  /** `line`은 밑줄로, `fill`은 색을 채워 고른 탭을 표시한다. */
+  type?: FoxTabType;
+  /** `lg`는 폭을 고르게 나눠 가지고, `md`는 글자만큼만 차지하고 나란히 선다. */
+  size?: FoxTabSize;
+  /** 고른 탭의 `value`. */
+  value?: string;
+  onValueChange?: (value: string) => void;
+  /** 목록을 읽어 줄 이름. */
+  label?: string;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. */
+  className?: string;
+}
+
+type ItemElement = ReactElement<FoxTabItemProps>;
+
+/** 프래그먼트 안까지 훑어 탭을 모은다. */
+function collect(children: ReactNode): ItemElement[] {
+  const items: ItemElement[] = [];
+  Children.forEach(children, (child) => {
+    if (!isValidElement(child)) {
+      return;
+    }
+    if (child.type === Fragment) {
+      const fragment = child as ReactElement<{ children?: ReactNode }>;
+      items.push(...collect(fragment.props.children));
+      return;
+    }
+    items.push(child as ItemElement);
+  });
+  return items;
+}
+
+/**
+ * @fox 탭 묶음. 어느 탭이 골라졌는지는 이 컴포넌트가 소유한다.
+ *
+ * 항목이 `href`를 하나라도 가지면 주소가 바뀌는 탭으로 보고 탭 역할(`role="tablist"`)을
+ * 붙이지 않는다 — 같은 화면 안의 전환이 아니라 이동이기 때문이다. 그 밖에는 ARIA 탭
+ * 규약대로 좌우 화살표·Home·End로 옮길 수 있고 탭키는 고른 탭에만 닿는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxTab({
+  children,
+  type = "line",
+  size = "lg",
+  value,
+  onValueChange,
+  label = "탭",
+  hidden = false,
+  className,
+}: FoxTabProps) {
+  if (hidden) {
+    return null;
+  }
+
+  const items = collect(children);
+  const tabRole = !items.some((item) => item.props.href);
+  const refs = items.map(() => createRef<HTMLElement>());
+
+  // 잠긴 탭은 건너뛴다 — 옮겨 가도 고를 수 없어 막다른 길이 된다.
+  const move = (from: number, step: number) => {
+    const count = items.length;
+    for (let hop = 1; hop <= count; hop += 1) {
+      const next = (from + step * hop + count * count) % count;
+      if (!items[next].props.disabled) {
+        refs[next].current?.focus();
+        return;
+      }
+    }
+  };
+
+  const navigate = (index: number) => (event: KeyboardEvent<HTMLElement>) => {
+    if (!tabRole) {
+      return;
+    }
+    if (event.key === "ArrowRight") {
+      event.preventDefault();
+      move(index, 1);
+    } else if (event.key === "ArrowLeft") {
+      event.preventDefault();
+      move(index, -1);
+    } else if (event.key === "Home") {
+      event.preventDefault();
+      move(-1, 1);
+    } else if (event.key === "End") {
+      event.preventDefault();
+      move(0, -1);
+    }
+  };
+
+  return (
+    <div className={cx("fox-tab", `fox-tab--${type}`, `fox-tab--${size}`, className)}>
+      <div
+        className="fox-tab__list"
+        role={tabRole ? "tablist" : undefined}
+        aria-label={tabRole ? label : undefined}
+      >
+        {items.map((item, index) =>
+          cloneElement(item, {
+            key: item.key ?? item.props.value,
+            active: item.props.value === value,
+            tabRole,
+            onSelect: onValueChange,
+            onNavigate: navigate(index),
+            ref: refs[index] as RefObject<HTMLElement>,
+          })
+        )}
+      </div>
+    </div>
+  );
+}
 
@fox/core/components/fox-tab/index.ts (added)
+++ @fox/core/components/fox-tab/index.ts
@@ -0,0 +1,1 @@
+export { FoxTab, type FoxTabProps, type FoxTabType, type FoxTabSize } from "./fox-tab";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -30,6 +30,8 @@
 export * from "./fox-select";
 export * from "./fox-select-option";
 export * from "./fox-select-option-item";
+export * from "./fox-tab";
+export * from "./fox-tab-item";
 export * from "./fox-table";
 export * from "./fox-table-cell";
 export * from "./fox-table-row";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -47,6 +47,8 @@
 import { FoxTable } from "../core/components/fox-table";
 import { FoxTableCell } from "../core/components/fox-table-cell";
 import { FoxTableRow } from "../core/components/fox-table-row";
+import { FoxTab } from "../core/components/fox-tab";
+import { FoxTabItem } from "../core/components/fox-tab-item";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -542,6 +544,18 @@
         </FoxTableRow>
       ))}
     </FoxTable>
+  );
+}
+
+/** 시안 tab(391:4497) — 계열·크기 조합을 눌러 확인한다. */
+function TabLiveDemo({ type, size }: { type: "line" | "fill"; size: "lg" | "md" }) {
+  const [value, setValue] = useState("a");
+  return (
+    <FoxTab type={type} size={size} value={value} onValueChange={setValue} label={`${type} ${size} 탭`}>
+      <FoxTabItem value="a">label</FoxTabItem>
+      <FoxTabItem value="b">label</FoxTabItem>
+      <FoxTabItem value="c">label</FoxTabItem>
+    </FoxTab>
   );
 }
 
@@ -2752,4 +2766,44 @@
       },
     ],
   },
+  {
+    id: "fox-tab",
+    name: "FoxTab",
+    description:
+      "Figma 시안(tab 171:14016 · tab-item 171:13927, 관리자페이지 파일)을 FoxTab / FoxTabItem 둘로 옮겼습니다. lg는 폭을 고르게 나눠 갖고 md는 글자만큼만 차지합니다. 좌우 화살표·Home·End로 옮길 수 있고 탭키는 고른 탭에만 닿습니다(ARIA 탭 규약). hover·pressed는 마우스를 올리거나 눌러 확인하세요.",
+    variants: [
+      { label: "line · lg — 폭을 고르게 나눠 갖고 목록에 밑줄이 깔립니다", node: <TabLiveDemo type="line" size="lg" /> },
+      { label: "line · md — 글자만큼만 차지하고 8px 간격", node: <TabLiveDemo type="line" size="md" /> },
+      { label: "fill · lg — 고른 탭이 남색으로 채워집니다", node: <TabLiveDemo type="fill" size="lg" /> },
+      { label: "fill · md — 모서리가 둥글어집니다", node: <TabLiveDemo type="fill" size="md" /> },
+      {
+        label: "잠긴 탭 — 화살표 이동에서도 건너뜁니다",
+        node: (
+          <FoxTab type="line" size="md" value="a" label="잠긴 탭 예시">
+            <FoxTabItem value="a">label</FoxTabItem>
+            <FoxTabItem value="b" disabled>
+              label
+            </FoxTabItem>
+            <FoxTabItem value="c">label</FoxTabItem>
+          </FoxTab>
+        ),
+      },
+      {
+        label: "주소가 바뀌는 탭 (href) — <a>가 되고 탭 역할을 갖지 않습니다",
+        node: (
+          <FoxTab type="fill" size="md" value="a">
+            <FoxTabItem value="a" href="#fox-tab">
+              label
+            </FoxTabItem>
+            <FoxTabItem value="b" href="#fox-tab">
+              label
+            </FoxTabItem>
+            <FoxTabItem value="c" href="#fox-tab">
+              label
+            </FoxTabItem>
+          </FoxTab>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-tab-item.scss (added)
+++ @fox/styles/_fox-tab-item.scss
@@ -0,0 +1,91 @@
+// FoxTabItem — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) tab-item (171:13927 · 섹션 391:4431)
+//
+// 마크업 계약: `_fox-tab.scss` 상단 참고.
+//
+// 계열과 크기는 묶음이 정한다 — 항목에 수식어를 붙이지 않고 `.fox-tab--line`처럼 묶음
+// 클래스에서 내려온다. 항목이 홀로 쓰이는 자리가 없어 그게 더 단순하다.
+//
+// 상태는 다섯이다. 기본·hover·pressed는 바탕만 바뀌고, 고름(active)은 계열마다 다르며,
+// 잠김은 글자만 흐려진다.
+//   - line 고름: 흰 바탕 + 4px 밑줄 + 파란 글자
+//   - fill 고름: 남색 바탕 + 흰 글자
+//
+// line 계열의 기본 상태도 밑줄 자리를 1px 비워 둔다 — 고름으로 바뀔 때 글자가 밀리지
+// 않게 하려면 자리를 미리 잡아야 한다(시안도 투명 1px을 둔다).
+//
+// 토큰이 아닌 값:
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - appearance / cursor: 구조·상호작용 규칙.
+
+@use "abstracts" as fox;
+
+.fox-tab-item {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin: 0;
+  border: none;
+  background: fox.color(action-secondary);
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(title);
+  font-weight: fox.font-weight(bold);
+  line-height: 1.4;
+  text-align: center;
+  text-decoration: none;
+  cursor: pointer;
+
+  &:hover:not(:disabled):not([aria-disabled="true"]) {
+    background: fox.color(action-secondary-hover);
+  }
+
+  &:active:not(:disabled):not([aria-disabled="true"]) {
+    background: fox.color(action-secondary-pressed);
+  }
+
+  &:disabled,
+  &[aria-disabled="true"] {
+    color: fox.color(font-neutral-disabled);
+    cursor: default;
+  }
+}
+
+// ── 크기 (묶음이 정한다) ────────────────────────────────────────────────────
+.fox-tab--lg .fox-tab-item {
+  padding-inline: fox.padding(8);
+  padding-block: fox.padding(6);
+  font-size: fox.font-size(heading-md);
+  letter-spacing: -0.025em;
+}
+
+.fox-tab--md .fox-tab-item {
+  padding-inline: fox.padding(5);
+  padding-block: fox.padding(3);
+  font-size: fox.font-size(heading-sm);
+  letter-spacing: -0.025em;
+  white-space: nowrap;
+}
+
+// ── line 계열 ───────────────────────────────────────────────────────────────
+// 고름으로 바뀔 때 글자가 밀리지 않게 밑줄 자리를 미리 잡는다.
+.fox-tab--line .fox-tab-item {
+  border-block-end: fox.border(1) solid fox.color(action-secondary);
+}
+
+.fox-tab--line .fox-tab-item--active {
+  border-block-end: fox.border(3) solid fox.color(action-secondary-active);
+  background: fox.color(surface-neutral-default);
+  color: fox.color(font-theme-secondary);
+}
+
+// ── fill 계열 ───────────────────────────────────────────────────────────────
+.fox-tab--fill .fox-tab-item--active {
+  background: fox.color(surface-theme-secondary-inverse);
+  color: fox.color(font-neutral-strong-inverse);
+}
+
+// md에서만 모서리가 둥글다(시안).
+.fox-tab--fill.fox-tab--md .fox-tab-item {
+  border-radius: fox.radius(4);
+}
 
@fox/styles/_fox-tab.scss (added)
+++ @fox/styles/_fox-tab.scss
@@ -0,0 +1,67 @@
+// FoxTab — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) tab (171:14016 · 섹션 391:4497)
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-tab fox-tab--line fox-tab--lg">
+//     <div class="fox-tab__list" role="tablist" aria-label="탭">
+//       <button class="fox-tab-item fox-tab-item--active" role="tab" aria-selected="true">…</button>
+//       <button class="fox-tab-item" role="tab" aria-selected="false" tabindex="-1">…</button>
+//     </div>
+//   </div>
+//
+// 크기가 배치를 정한다.
+//   - lg: 탭이 폭을 고르게 나눠 갖고, 목록 전체에 옅은 밑줄이 깔린다.
+//   - md: 탭이 글자만큼만 차지하고 8px 간격으로 선다. 목록 밑줄은 없다.
+// 아래 여백도 크기마다 다르다(시안 spacing/bottom/*).
+//
+// line 계열에서 고른 탭의 4px 밑줄은 항목이 그리고, 그 아래 1px 선은 목록이 그린다.
+// 둘이 겹치는 자리에서 굵은 선이 이겨야 하므로 항목 밑줄을 목록 선 위로 끌어올린다.
+
+@use "abstracts" as fox;
+
+.fox-tab {
+  inline-size: 100%;
+
+  &__list {
+    display: flex;
+    align-items: center;
+  }
+
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  &--lg {
+    padding-block-end: fox.spacing(bottom-lg);
+  }
+
+  // 탭이 폭을 고르게 나눠 갖는다.
+  &--lg &__list {
+    inline-size: 100%;
+  }
+
+  &--lg .fox-tab-item {
+    flex: 1 0 0;
+    min-inline-size: 0;
+  }
+
+  &--md {
+    padding-block-end: fox.spacing(bottom-md);
+  }
+
+  &--md &__list {
+    gap: fox.gap(3);
+  }
+
+  &--md .fox-tab-item {
+    flex-shrink: 0;
+  }
+
+  // ── line 계열 ─────────────────────────────────────────────────────────────
+  // lg만 목록 전체에 밑줄이 깔린다(시안).
+  &--line#{&}--lg &__list {
+    border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
+    background: fox.color(surface-neutral-default);
+  }
+
+  // 고른 탭의 굵은 밑줄이 목록의 옅은 선을 덮는다.
+  &--line#{&}--lg .fox-tab-item {
+    margin-block-end: calc(-1 * #{fox.border(1)});
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -41,3 +41,5 @@
 @use "fox-table";
 @use "fox-table-row";
 @use "fox-table-cell";
+@use "fox-tab";
+@use "fox-tab-item";
Add a comment
List