임동욱 임동욱 08-20
merge: 디자인 테스트 페이지 레이아웃·컴포넌트 3depth
@8d5de37285bd6006fcfe49c9bacd39a366604366
 
@fox/dev-test/component-groups.ts (added)
+++ @fox/dev-test/component-groups.ts
@@ -0,0 +1,92 @@
+/**
+ * 대분류는 Figma 섹션을 그대로 따른다.
+ * Action 391:698 · Select 391:2209 · Input 391:3733 · Navigation 391:4035 ·
+ * Layout 392:5035 · Feedback 392:5381 · Help 392:5712
+ */
+export type ComponentGroup = {
+  label: string;
+  subgroups: { label: string; ids: string[] }[];
+};
+
+export const COMPONENT_GROUPS: ComponentGroup[] = [
+  {
+    label: "Action",
+    subgroups: [
+      {
+        label: "버튼",
+        ids: ["fox-button", "fox-text-button", "fox-link-button", "fox-icon-button"],
+      },
+      { label: "묶음", ids: ["fox-button-group", "fox-button-panel"] },
+    ],
+  },
+  {
+    label: "Select",
+    subgroups: [
+      { label: "라디오", ids: ["fox-radio-icon", "fox-radio", "fox-radio-group"] },
+      { label: "체크박스", ids: ["fox-check-icon", "fox-checkbox", "fox-checkbox-group"] },
+      {
+        label: "셀렉트",
+        ids: ["fox-select", "fox-select-option", "fox-select-option-item", "fox-select-text"],
+      },
+      { label: "태그·칩", ids: ["fox-tag", "fox-tag-list", "fox-chip", "fox-chip-area"] },
+      {
+        label: "스위치",
+        ids: [
+          "fox-toggle-switch",
+          "fox-segmented-control",
+          "fox-segmented-control-item",
+          "fox-toggle-quantity",
+        ],
+      },
+      { label: "부가", ids: ["fox-form-label", "fox-conditional-option"] },
+    ],
+  },
+  {
+    label: "Input",
+    subgroups: [
+      { label: "텍스트", ids: ["fox-input", "fox-text-area"] },
+      {
+        label: "형식 입력",
+        ids: ["fox-phone-number", "fox-email", "fox-certified", "fox-address"],
+      },
+      { label: "파일", ids: ["fox-file-upload"] },
+    ],
+  },
+  {
+    label: "Navigation",
+    subgroups: [
+      { label: "위치·이동", ids: ["fox-breadcrumb", "fox-pagination"] },
+      {
+        label: "캐러셀",
+        ids: ["fox-carousel-indicator", "fox-carousel-button", "fox-carousel-area"],
+      },
+    ],
+  },
+  {
+    label: "Layout",
+    subgroups: [
+      { label: "표·목록", ids: ["fox-table", "fox-bullet-list"] },
+      { label: "탭", ids: ["fox-tab"] },
+      { label: "배지", ids: ["fox-badge", "fox-badge-dot", "fox-badge-group"] },
+      { label: "펼침", ids: ["fox-accordion", "fox-disclosure"] },
+      { label: "컨테이너", ids: ["fox-card", "fox-modal", "fox-page-header"] },
+    ],
+  },
+  {
+    label: "Feedback",
+    subgroups: [
+      { label: "알림", ids: ["fox-alert", "fox-toast", "fox-snackbar", "fox-snackbar-list"] },
+      { label: "상태", ids: ["fox-status-indicator", "fox-helper-text", "fox-spinner"] },
+      { label: "단계", ids: ["fox-step-indicator", "fox-step-indicator-item"] },
+    ],
+  },
+  {
+    label: "Help",
+    subgroups: [
+      {
+        label: "도움말",
+        ids: ["fox-tooltip", "fox-tooltip-rich", "fox-popover", "fox-contextual-trigger"],
+      },
+    ],
+  },
+];
@fox/dev-test/dev-test-page.tsx
--- @fox/dev-test/dev-test-page.tsx
+++ @fox/dev-test/dev-test-page.tsx
@@ -1,7 +1,9 @@
 "use client";
 
 import { useSyncExternalStore } from "react";
+import { FoxBadge } from "../core/components/fox-badge";
 import { cx } from "../core/utils";
+import { COMPONENT_GROUPS } from "./component-groups";
 import { COMPONENT_EXAMPLES } from "./component-registry";
 import { ComponentEmpty, ComponentView } from "./component-view";
 import type { CssSection } from "./read-css-tokens";
@@ -56,6 +58,34 @@
   return groups;
 }
 
+/** 분류표를 실제 예제로 바꾼다. 표에 없는 컴포넌트는 마지막 "기타"에 모인다. */
+function groupedComponents() {
+  const used = new Set<string>();
+  const groups = COMPONENT_GROUPS.map((group) => ({
+    label: group.label,
+    subgroups: group.subgroups
+      .map((subgroup) => ({
+        label: subgroup.label,
+        examples: subgroup.ids.flatMap((id) => {
+          const example = COMPONENT_EXAMPLES.find((entry) => entry.id === id);
+          if (!example) {
+            return [];
+          }
+          used.add(id);
+          return [example];
+        }),
+      }))
+      .filter((subgroup) => subgroup.examples.length > 0),
+  })).filter((group) => group.subgroups.length > 0);
+
+  const rest = COMPONENT_EXAMPLES.filter((example) => !used.has(example.id));
+  if (rest.length > 0) {
+    groups.push({ label: "기타", subgroups: [{ label: "미분류", examples: rest }] });
+  }
+
+  return groups;
+}
+
 export function DevTestPage() {
   const selection = useSelection();
   const sections = useCssTokens();
@@ -103,7 +133,9 @@
                         onClick={() => select("token", section.id)}
                       >
                         <span>{section.label}</span>
-                        <span className={styles.navCount}>{section.tokens.length}</span>
+                        <FoxBadge type="pastel" color="neutral" size="sm">
+                          {String(section.tokens.length)}
+                        </FoxBadge>
                       </button>
                     </li>
                   ))}
@@ -114,25 +146,41 @@
 
           <div className={styles.navGroup}>
             <h2 className={styles.navGroupTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2>
-            <ul className={styles.navList}>
-              {COMPONENT_EXAMPLES.map((example) => (
-                <li key={example.id}>
-                  <button
-                    type="button"
-                    className={styles.navItem}
-                    aria-current={
-                      current.kind === "component" && current.id === example.id
-                        ? "page"
-                        : undefined
-                    }
-                    onClick={() => select("component", example.id)}
-                  >
-                    <span>{example.name}</span>
-                    <span className={styles.navCount}>{example.variants.length}</span>
-                  </button>
-                </li>
-              ))}
 
+            {groupedComponents().map((group) => (
+              <div key={group.label} className={styles.navSubGroup}>
+                <h3 className={styles.navSubTitle}>{group.label}</h3>
+
+                {group.subgroups.map((subgroup) => (
+                  <div key={subgroup.label} className={styles.navLeaf}>
+                    <h4 className={styles.navLeafTitle}>{subgroup.label}</h4>
+                    <ul className={styles.navList}>
+                      {subgroup.examples.map((example) => (
+                        <li key={example.id}>
+                          <button
+                            type="button"
+                            className={styles.navItem}
+                            aria-current={
+                              current.kind === "component" && current.id === example.id
+                                ? "page"
+                                : undefined
+                            }
+                            onClick={() => select("component", example.id)}
+                          >
+                            <span>{example.name}</span>
+                            <FoxBadge type="pastel" color="neutral" size="sm">
+                              {String(example.variants.length)}
+                            </FoxBadge>
+                          </button>
+                        </li>
+                      ))}
+                    </ul>
+                  </div>
+                ))}
+              </div>
+            ))}
+
+            <ul className={styles.navList}>
               {/* 컴포넌트가 아니라 안내라 항상 맨 아래에 두고 생김새를 달리한다. */}
               <li>
                 <button
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -9,12 +9,15 @@
 .shell {
   display: grid;
   grid-template-columns: 1fr;
-  min-block-size: 100vh;
+  min-block-size: 100dvh;
   background: fox.color(background-default);
   color: fox.color(font-neutral-default);
 
   @include fox.pc {
+    // 화면을 넘지 않게 못박고 스크롤은 두 칸이 각자 갖는다.
+    block-size: 100dvh;
     grid-template-columns: 26rem 1fr;
+    overflow: hidden;
   }
 }
 
@@ -34,9 +37,7 @@
   background: fox.color(surface-neutral-gray);
 
   @include fox.pc {
-    position: sticky;
-    inset-block-start: 0;
-    block-size: 100vh;
+    min-block-size: 0;
     overflow-y: auto;
     border-block-end: 0;
     border-inline-end: fox.border(1) solid fox.color(border-neutral-subtle);
@@ -57,34 +58,6 @@
 .brandNote {
   color: fox.color(font-neutral-subtler);
   font-size: fox.font-size(label-xsm);
-}
-
-.themeSwitch {
-  display: flex;
-  gap: fox.gap(1);
-  padding: fox.padding(1);
-  border: fox.border(1) solid fox.color(border-neutral-default);
-  border-radius: fox.radius(max);
-  background: fox.color(surface-neutral-default);
-}
-
-.themeButton {
-  flex: 1;
-  padding: fox.padding(3) fox.padding(4);
-  border-radius: fox.radius(max);
-  color: fox.color(font-neutral-subtle);
-  font-size: fox.font-size(label-xsm);
-  font-weight: fox.font-weight(medium);
-
-  &[aria-pressed="true"] {
-    background: fox.color(button-primary-surface);
-    color: fox.color(button-primary-font);
-  }
-
-  &:focus-visible {
-    outline: fox.border(2) solid fox.color(border-theme-primary);
-    outline-offset: fox.border(2);
-  }
 }
 
 .nav {
@@ -121,6 +94,23 @@
   font-size: fox.font-size(label-xsm);
   font-weight: fox.font-weight(medium);
   opacity: 0.8;
+}
+
+// 3depth — 대분류(navSubTitle) 아래 소분류.
+.navLeaf {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(1);
+  margin-block-start: fox.gap(1);
+  padding-inline-start: fox.padding(3);
+}
+
+.navLeafTitle {
+  padding-inline: fox.padding(3);
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-xsm);
+  font-weight: fox.font-weight(regular);
+  opacity: 0.7;
 }
 
 .navList {
@@ -160,12 +150,6 @@
   }
 }
 
-.navCount {
-  color: currentcolor;
-  font-size: fox.font-size(label-xsm);
-  opacity: 0.7;
-}
-
 // "추가 방법"은 컴포넌트가 아니라 안내다. 목록 맨 아래에 두고 점선 테두리로 구분해,
 // 등록된 컴포넌트 탭과 같은 층위로 읽히지 않게 한다.
 // `.navItem` 뒤에 와야 선택 상태(aria-current)를 덮어쓴다 — 특이도가 같아 순서가 정한다.
@@ -200,6 +184,11 @@
   padding: fox.spacing(top-md) fox.padding(7) fox.spacing(bottom-xlg);
 
   @include fox.pc {
+    min-block-size: 0;
+    overflow-y: auto;
+  }
+
+  @include fox.pc {
     padding: fox.spacing(top-lg) fox.padding(11) fox.spacing(bottom-xlg);
   }
 }
@fox/dev-test/theme-switch.tsx
--- @fox/dev-test/theme-switch.tsx
+++ @fox/dev-test/theme-switch.tsx
@@ -1,9 +1,9 @@
 "use client";
 
 import { useSyncExternalStore } from "react";
-import styles from "./dev-test.module.scss";
+import { FoxSegmentedControl } from "../core/components/fox-segmented-control";
+import { FoxSegmentedControlItem } from "../core/components/fox-segmented-control-item";
 
-/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */
 const THEME_ATTRIBUTE = "data-theme";
 
 type ThemeChoice = "system" | "light" | "dark";
@@ -14,10 +14,6 @@
   { value: "dark", label: "다크" },
 ];
 
-/**
- * `<html data-theme>`가 SSOT라 React state를 두지 않는다. 저장 키는 호스트 앱이
- * 소유하는 값이라 `@fox`가 알면 포터빌리티가 깨지므로 선택을 저장하지 않는다.
- */
 function subscribe(onChange: () => void): () => void {
   const observer = new MutationObserver(onChange);
   observer.observe(document.documentElement, {
@@ -49,18 +45,16 @@
   const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
 
   return (
-    <div className={styles.themeSwitch} role="group" aria-label="테마 전환">
+    <FoxSegmentedControl size="sm" name="dev-test-theme" label="테마 전환">
       {CHOICES.map(({ value, label }) => (
-        <button
+        <FoxSegmentedControlItem
           key={value}
-          type="button"
-          className={styles.themeButton}
-          aria-pressed={current === value}
-          onClick={() => apply(value)}
-        >
-          {label}
-        </button>
+          value={value}
+          label={label}
+          checked={current === value}
+          onChange={(next) => apply(next as ThemeChoice)}
+        />
       ))}
-    </div>
+    </FoxSegmentedControl>
   );
 }
Add a comment
List