임동욱 임동욱 08-20
feat: 사이드바 메뉴 검색 — 제목·부제 자리를 검색창으로
본 프로젝트 사이드바처럼 상단에 FoxInput 검색창을 두고, 그 아래 테마 컨트롤을
가운데 세웠다. 제목·부제는 지웠다.

검색은 라벨과 id를 함께 본다(FoxButton / fox-button). 분류 이름이 걸리면 그 아래는
통째로 남기고, 검색 중에는 접힘을 무시한다 — 걸린 항목이 접힌 가지에 숨으면 안 된다.
섹션 개수도 걸린 것만 센다.

Co-Authored-By: Claude Opus 5 
@8a17a8455263b167bbafe3e031825048d5fa1f65
@fox/dev-test/dev-test-page.tsx
--- @fox/dev-test/dev-test-page.tsx
+++ @fox/dev-test/dev-test-page.tsx
@@ -2,7 +2,13 @@
 
 import { useState, useSyncExternalStore } from "react";
 import { FoxBadge } from "../core/components/fox-badge";
-import { FoxCaretDownIcon, FoxCaretUpIcon, FoxStarIcon } from "../core/icons";
+import { FoxInput } from "../core/components/fox-input";
+import {
+  FoxCaretDownIcon,
+  FoxCaretUpIcon,
+  FoxMagnifyingGlassIcon,
+  FoxStarIcon,
+} from "../core/icons";
 import { cx } from "../core/utils";
 import { COMPONENT_GROUPS } from "./component-groups";
 import { COMPONENT_EXAMPLES } from "./component-registry";
@@ -131,6 +137,39 @@
   return ids;
 }
 
+/**
+ * 검색은 라벨과 id를 함께 본다 — 화면에는 `FoxButton`으로 보이지만 `fox-button`으로 찾는
+ * 사람도 있다. 분류 이름이 맞으면 그 아래는 통째로 남긴다.
+ */
+function filterTokenGroups(groups: TokenGroup[], hit: (text: string) => boolean): TokenGroup[] {
+  return groups.flatMap((group) => {
+    if (group.category && hit(group.category)) {
+      return [group];
+    }
+    const sections = group.sections.filter((section) => hit(section.label) || hit(section.id));
+    return sections.length > 0 ? [{ ...group, sections }] : [];
+  });
+}
+
+function filterComponentGroups(
+  groups: ComponentGroupView[],
+  hit: (text: string) => boolean
+): ComponentGroupView[] {
+  return groups.flatMap((group) => {
+    const groupHit = hit(group.label);
+    const subgroups = group.subgroups.flatMap((subgroup) => {
+      if (groupHit || hit(subgroup.label)) {
+        return [subgroup];
+      }
+      const examples = subgroup.examples.filter(
+        (example) => hit(example.name) || hit(example.id)
+      );
+      return examples.length > 0 ? [{ ...subgroup, examples }] : [];
+    });
+    return subgroups.length > 0 ? [{ ...group, subgroups }] : [];
+  });
+}
+
 function CountBadge({ value }: { value: number }) {
   return (
     <span className={styles.count}>
@@ -146,6 +185,7 @@
   const sections = useCssTokens();
   // 사용자가 직접 누른 것만 담는다. 없으면 선택 항목에서 파생된 기본값을 쓴다.
   const [openOverrides, setOpenOverrides] = useState<Record<string, boolean>>({});
+  const [keyword, setKeyword] = useState("");
 
   if (!sections) {
     return (
@@ -155,15 +195,32 @@
     );
   }
 
-  const totalTokens = sections.reduce((sum, section) => sum + section.tokens.length, 0);
   const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" };
   const activeSection = sections.find((section) => section.id === current.id);
 
-  const tokenGroups = groupedSections(sections);
-  const componentGroups = groupedComponents();
-  const openByDefault = defaultOpenIds(current, tokenGroups, componentGroups);
+  const query = keyword.trim().toLowerCase();
+  const hit = (text: string) => text.toLowerCase().includes(query);
 
-  const isOpen = (id: string) => openOverrides[id] ?? openByDefault.has(id);
+  const allTokenGroups = groupedSections(sections);
+  const allComponentGroups = groupedComponents();
+  const tokenGroups = query ? filterTokenGroups(allTokenGroups, hit) : allTokenGroups;
+  const componentGroups = query
+    ? filterComponentGroups(allComponentGroups, hit)
+    : allComponentGroups;
+
+  const tokenCount = tokenGroups.reduce(
+    (sum, group) => sum + group.sections.reduce((n, section) => n + section.tokens.length, 0),
+    0
+  );
+  const componentCount = componentGroups.reduce(
+    (sum, group) => sum + group.subgroups.reduce((n, sub) => n + sub.examples.length, 0),
+    0
+  );
+
+  const openByDefault = defaultOpenIds(current, allTokenGroups, allComponentGroups);
+
+  // 검색 중에는 접힘을 무시한다 — 걸린 항목이 접힌 가지 안에 숨으면 찾은 의미가 없다.
+  const isOpen = (id: string) => (query ? true : openOverrides[id] ?? openByDefault.has(id));
   const toggle = (id: string) =>
     setOpenOverrides((prev) => ({ ...prev, [id]: !(prev[id] ?? openByDefault.has(id)) }));
 
@@ -174,198 +231,216 @@
     <div className={styles.shell}>
       <aside className={styles.sidebar}>
         <div className={styles.top}>
-          <div className={styles.brand}>
-            <span className={styles.brandTitle}>@fox 디자인 시스템</span>
-            <span className={styles.brandNote}>개발 전용 테스트 페이지</span>
+          <div className={styles.search}>
+            <FoxInput
+              size="md"
+              value={keyword}
+              onChange={setKeyword}
+              placeholder="검색어를 입력하세요."
+              icon={<FoxMagnifyingGlassIcon />}
+              clearable
+              clearLabel="검색어 지우기"
+              aria-label="메뉴 검색"
+            />
           </div>
 
-          <ThemeSwitch />
+          <div className={styles.themeRow}>
+            <ThemeSwitch />
+          </div>
         </div>
 
         <nav aria-label="섹션">
-          <section className={styles.section}>
-            <h2 className={styles.sectionTitle}>토큰 · {totalTokens}</h2>
+          {tokenGroups.length === 0 && componentGroups.length === 0 && (
+            <p className={styles.empty}>검색 결과가 없습니다.</p>
+          )}
 
-            <ul className={styles.list}>
-              {tokenGroups.map((group) => {
-                // 상위 분류가 없는 섹션은 1depth에서 바로 고른다.
-                if (!group.category) {
-                  return group.sections.map((section) => (
-                    <li key={section.id} className={styles.item}>
+          {tokenGroups.length > 0 && (
+            <section className={styles.section}>
+              <h2 className={styles.sectionTitle}>토큰 · {tokenCount}</h2>
+
+              <ul className={styles.list}>
+                {tokenGroups.map((group) => {
+                  // 상위 분류가 없는 섹션은 1depth에서 바로 고른다.
+                  if (!group.category) {
+                    return group.sections.map((section) => (
+                      <li key={section.id} className={styles.item}>
+                        <button
+                          type="button"
+                          className={cx(
+                            styles.itemButton,
+                            isTokenActive(section.id) && styles.itemButtonActive
+                          )}
+                          aria-current={isTokenActive(section.id) ? "page" : undefined}
+                          onClick={() => select("token", section.id)}
+                        >
+                          <span className={styles.itemLabel}>{section.label}</span>
+                          <CountBadge value={section.tokens.length} />
+                        </button>
+                      </li>
+                    ));
+                  }
+
+                  const groupId = tokenGroupId(group.category);
+                  const open = isOpen(groupId);
+                  const containsActive = group.sections.some((section) =>
+                    isTokenActive(section.id)
+                  );
+
+                  return (
+                    <li key={groupId} className={styles.item}>
                       <button
                         type="button"
                         className={cx(
                           styles.itemButton,
-                          isTokenActive(section.id) && styles.itemButtonActive
+                          containsActive && styles.itemButtonActive
                         )}
-                        aria-current={isTokenActive(section.id) ? "page" : undefined}
-                        onClick={() => select("token", section.id)}
+                        aria-expanded={open}
+                        onClick={() => toggle(groupId)}
                       >
-                        <span className={styles.itemLabel}>{section.label}</span>
-                        <CountBadge value={section.tokens.length} />
+                        <span className={styles.itemLabel}>{group.category}</span>
                       </button>
-                    </li>
-                  ));
-                }
 
-                const groupId = tokenGroupId(group.category);
-                const open = isOpen(groupId);
-                const containsActive = group.sections.some((section) =>
-                  isTokenActive(section.id)
-                );
-
-                return (
-                  <li key={groupId} className={styles.item}>
-                    <button
-                      type="button"
-                      className={cx(
-                        styles.itemButton,
-                        containsActive && styles.itemButtonActive
-                      )}
-                      aria-expanded={open}
-                      onClick={() => toggle(groupId)}
-                    >
-                      <span className={styles.itemLabel}>{group.category}</span>
-                    </button>
-
-                    {open && (
-                      <ul className={styles.childGroup}>
-                        {group.sections.map((section) => (
-                          <li key={section.id} className={styles.child}>
-                            <button
-                              type="button"
-                              className={cx(
-                                styles.childRow,
-                                isTokenActive(section.id) && styles.childRowActive
-                              )}
-                              aria-current={isTokenActive(section.id) ? "page" : undefined}
-                              onClick={() => select("token", section.id)}
-                            >
-                              <span className={cx(styles.childMark, styles.childMarkHidden)}>
-                                <FoxStarIcon />
-                              </span>
-                              <span className={styles.childLabel}>{section.label}</span>
-                              <CountBadge value={section.tokens.length} />
-                            </button>
-                          </li>
-                        ))}
-                      </ul>
-                    )}
-                  </li>
-                );
-              })}
-            </ul>
-          </section>
-
-          <section className={styles.section}>
-            <h2 className={styles.sectionTitle}>컴포넌트 · {COMPONENT_EXAMPLES.length}</h2>
-
-            <ul className={styles.list}>
-              {componentGroups.map((group) => {
-                const groupId = componentGroupId(group.label);
-                const open = isOpen(groupId);
-                const containsActive = group.subgroups.some((subgroup) =>
-                  subgroup.examples.some((example) => isComponentActive(example.id))
-                );
-
-                return (
-                  <li key={group.label} className={styles.item}>
-                    <button
-                      type="button"
-                      className={cx(
-                        styles.itemButton,
-                        containsActive && styles.itemButtonActive
-                      )}
-                      aria-expanded={open}
-                      onClick={() => toggle(groupId)}
-                    >
-                      <span className={styles.itemLabel}>{group.label}</span>
-                    </button>
-
-                    {open && (
-                      <ul className={styles.childGroup}>
-                        {group.subgroups.map((subgroup) => {
-                          const subgroupId = `${groupId}/${subgroup.label}`;
-                          const subgroupOpen = isOpen(subgroupId);
-
-                          return (
-                            <li key={subgroup.label} className={styles.child}>
+                      {open && (
+                        <ul className={styles.childGroup}>
+                          {group.sections.map((section) => (
+                            <li key={section.id} className={styles.child}>
                               <button
                                 type="button"
                                 className={cx(
                                   styles.childRow,
-                                  subgroupOpen && styles.childRowActive
+                                  isTokenActive(section.id) && styles.childRowActive
                                 )}
-                                aria-expanded={subgroupOpen}
-                                onClick={() => toggle(subgroupId)}
+                                aria-current={isTokenActive(section.id) ? "page" : undefined}
+                                onClick={() => select("token", section.id)}
                               >
-                                <span className={styles.childMark}>
+                                <span className={cx(styles.childMark, styles.childMarkHidden)}>
                                   <FoxStarIcon />
                                 </span>
-                                <span className={styles.childLabel}>{subgroup.label}</span>
-                                <span className={styles.childMark}>
-                                  {subgroupOpen ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />}
-                                </span>
+                                <span className={styles.childLabel}>{section.label}</span>
+                                <CountBadge value={section.tokens.length} />
                               </button>
-
-                              {subgroupOpen && (
-                                <ul className={styles.leafGroup}>
-                                  {subgroup.examples.map((example) => (
-                                    <li key={example.id} className={styles.child}>
-                                      <button
-                                        type="button"
-                                        className={cx(
-                                          styles.leaf,
-                                          isComponentActive(example.id) && styles.leafActive
-                                        )}
-                                        aria-current={
-                                          isComponentActive(example.id) ? "page" : undefined
-                                        }
-                                        onClick={() => select("component", example.id)}
-                                      >
-                                        <span className={styles.childMark}>
-                                          <FoxStarIcon />
-                                        </span>
-                                        <span className={styles.childLabel}>{example.name}</span>
-                                        <CountBadge value={example.variants.length} />
-                                      </button>
-                                    </li>
-                                  ))}
-                                </ul>
-                              )}
                             </li>
-                          );
-                        })}
-                      </ul>
-                    )}
-                  </li>
-                );
-              })}
+                          ))}
+                        </ul>
+                      )}
+                    </li>
+                  );
+                })}
+              </ul>
+            </section>
+          )}
 
-              {/* 컴포넌트가 아니라 안내라 항상 맨 아래에 두고 생김새를 달리한다. */}
-              <li className={styles.item}>
-                <button
-                  type="button"
-                  className={cx(
-                    styles.itemButton,
-                    styles.guideButton,
-                    current.kind === "component" &&
-                      !isRegistered(current.id) &&
-                      styles.guideButtonActive
-                  )}
-                  aria-current={
-                    current.kind === "component" && !isRegistered(current.id) ? "page" : undefined
-                  }
-                  onClick={() => select("component", GUIDE_ID)}
-                >
-                  <span className={styles.itemLabel}>추가 방법</span>
-                  <span aria-hidden="true" className={styles.guideMark}>
-                    +
-                  </span>
-                </button>
-              </li>
-            </ul>
-          </section>
+          {componentGroups.length > 0 && (
+            <section className={styles.section}>
+              <h2 className={styles.sectionTitle}>컴포넌트 · {componentCount}</h2>
+
+              <ul className={styles.list}>
+                {componentGroups.map((group) => {
+                  const groupId = componentGroupId(group.label);
+                  const open = isOpen(groupId);
+                  const containsActive = group.subgroups.some((subgroup) =>
+                    subgroup.examples.some((example) => isComponentActive(example.id))
+                  );
+
+                  return (
+                    <li key={group.label} className={styles.item}>
+                      <button
+                        type="button"
+                        className={cx(
+                          styles.itemButton,
+                          containsActive && styles.itemButtonActive
+                        )}
+                        aria-expanded={open}
+                        onClick={() => toggle(groupId)}
+                      >
+                        <span className={styles.itemLabel}>{group.label}</span>
+                      </button>
+
+                      {open && (
+                        <ul className={styles.childGroup}>
+                          {group.subgroups.map((subgroup) => {
+                            const subgroupId = `${groupId}/${subgroup.label}`;
+                            const subgroupOpen = isOpen(subgroupId);
+
+                            return (
+                              <li key={subgroup.label} className={styles.child}>
+                                <button
+                                  type="button"
+                                  className={cx(
+                                    styles.childRow,
+                                    subgroupOpen && styles.childRowActive
+                                  )}
+                                  aria-expanded={subgroupOpen}
+                                  onClick={() => toggle(subgroupId)}
+                                >
+                                  <span className={styles.childMark}>
+                                    <FoxStarIcon />
+                                  </span>
+                                  <span className={styles.childLabel}>{subgroup.label}</span>
+                                  <span className={styles.childMark}>
+                                    {subgroupOpen ? <FoxCaretUpIcon /> : <FoxCaretDownIcon />}
+                                  </span>
+                                </button>
+
+                                {subgroupOpen && (
+                                  <ul className={styles.leafGroup}>
+                                    {subgroup.examples.map((example) => (
+                                      <li key={example.id} className={styles.child}>
+                                        <button
+                                          type="button"
+                                          className={cx(
+                                            styles.leaf,
+                                            isComponentActive(example.id) && styles.leafActive
+                                          )}
+                                          aria-current={
+                                            isComponentActive(example.id) ? "page" : undefined
+                                          }
+                                          onClick={() => select("component", example.id)}
+                                        >
+                                          <span className={styles.childMark}>
+                                            <FoxStarIcon />
+                                          </span>
+                                          <span className={styles.childLabel}>{example.name}</span>
+                                          <CountBadge value={example.variants.length} />
+                                        </button>
+                                      </li>
+                                    ))}
+                                  </ul>
+                                )}
+                              </li>
+                            );
+                          })}
+                        </ul>
+                      )}
+                    </li>
+                  );
+                })}
+
+                {/* 컴포넌트가 아니라 안내라 항상 맨 아래에 두고 생김새를 달리한다. */}
+                <li className={styles.item}>
+                  <button
+                    type="button"
+                    className={cx(
+                      styles.itemButton,
+                      styles.guideButton,
+                      current.kind === "component" &&
+                        !isRegistered(current.id) &&
+                        styles.guideButtonActive
+                    )}
+                    aria-current={
+                      current.kind === "component" && !isRegistered(current.id) ? "page" : undefined
+                    }
+                    onClick={() => select("component", GUIDE_ID)}
+                  >
+                    <span className={styles.itemLabel}>추가 방법</span>
+                    <span aria-hidden="true" className={styles.guideMark}>
+                      +
+                    </span>
+                  </button>
+                </li>
+              </ul>
+            </section>
+          )}
         </nav>
       </aside>
 
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -54,31 +54,26 @@
 .top {
   display: flex;
   flex-direction: column;
-  // 세그먼티드 컨트롤은 inline-flex라 내용만큼만 넓다 — stretch로 늘리면 마지막 칸 뒤에
-  // 빈 자리가 붙어 그 칸이 길어 보인다.
-  align-items: flex-start;
   gap: fox.gap(3);
   padding-block-end: fox.spacing(bottom-sm);
   border-block-end: fox.border(1) solid fox.color(border-neutral-subtle);
 }
 
-.brand {
+// FoxInput의 기본 테두리는 input-border(진한 회색)인데 시안 검색창은 border-neutral-subtler다.
+.search :global(.fox-input__box) {
+  border-color: fox.color(border-neutral-subtler);
+}
+
+// 세그먼티드 컨트롤은 inline-flex라 내용만큼만 넓다 — 늘리지 않고 줄 가운데에 세운다.
+.themeRow {
   display: flex;
-  flex-direction: column;
-  gap: fox.gap(1);
+  justify-content: center;
 }
 
-.brandTitle {
-  color: fox.color(font-neutral-default);
-  font-family: fox.font-family(title);
-  font-size: fox.font-size(heading-sm);
-  font-weight: fox.font-weight(bold);
-  letter-spacing: -0.025em;
-}
-
-.brandNote {
+.empty {
+  padding-block: fox.spacing(top-md);
   color: fox.color(font-neutral-subtler);
-  font-size: fox.font-size(label-xsm);
+  font-size: fox.font-size(label-sm);
 }
 
 // ── 섹션 ────────────────────────────────────────────────────────────────────
Add a comment
List