"use client"; import { useState, useSyncExternalStore } from "react"; import { FoxBadge } from "../core/components/fox-badge"; import { FoxInput } from "../core/components/fox-input"; 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"; import { ThemeSwitch } from "./theme-switch"; import { TokenView } from "./token-view"; import { useCssTokens } from "./use-css-tokens"; import styles from "./dev-test.module.scss"; // 선택 상태는 URL 해시가 SSOT다. 형식: `#token:light` / `#component:fox-button` function subscribeHash(onChange: () => void): () => void { window.addEventListener("hashchange", onChange); return () => window.removeEventListener("hashchange", onChange); } function getHash(): string { return window.location.hash.slice(1); } function getServerHash(): string { return ""; } function useSelection(): { kind: string; id: string } | null { const hash = useSyncExternalStore(subscribeHash, getHash, getServerHash); const [kind, id] = hash.split(":"); return kind && id ? { kind, id } : null; } function select(kind: string, id: string): void { window.location.hash = `${kind}:${id}`; } const GUIDE_ID = "guide"; function isRegistered(id: string): boolean { return COMPONENT_EXAMPLES.some((example) => example.id === id); } /** Figma 변수 페이지와 대조하는 화면이라 우리 CSS 그룹이 아닌 원본 구획을 따른다. */ function groupedSections(sections: CssSection[]) { const groups: { category: string | null; sections: CssSection[] }[] = []; for (const section of sections) { const last = groups[groups.length - 1]; if (last && last.category === section.category && section.category !== null) { last.sections.push(section); } else { groups.push({ category: section.category, sections: [section] }); } } return groups; } /** 분류표를 실제 예제로 바꾼다. 표에 없는 컴포넌트는 마지막 "기타"에 모인다. */ function groupedComponents() { const used = new Set(); 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: "Etc", subgroups: [{ label: "Unsorted", examples: rest }] }); } return groups; } type TokenGroup = ReturnType[number]; type ComponentGroupView = ReturnType[number]; type Selection = { kind: string; id: string }; function tokenGroupId(category: string): string { return `token:${category}`; } function componentGroupId(label: string): string { return `component:${label}`; } /** * 선택된 항목을 품은 가지 — 기본 펼침 대상이다. 사용자가 직접 누른 값이 있으면 그쪽이 * 이기고, 없으면 이 값을 쓴다(본 프로젝트 사이드바 `useSidebarNav`와 같은 방식). */ function defaultOpenIds( current: Selection, tokenGroups: TokenGroup[], componentGroups: ComponentGroupView[] ): Set { const ids = new Set(); if (current.kind === "token") { for (const group of tokenGroups) { if (group.category && group.sections.some((section) => section.id === current.id)) { ids.add(tokenGroupId(group.category)); } } return ids; } for (const group of componentGroups) { for (const subgroup of group.subgroups) { if (subgroup.examples.some((example) => example.id === current.id)) { ids.add(componentGroupId(group.label)); ids.add(`${componentGroupId(group.label)}/${subgroup.label}`); } } } 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 ( {String(value)} ); } export function DevTestPage() { const selection = useSelection(); const sections = useCssTokens(); // 사용자가 직접 누른 것만 담는다. 없으면 선택 항목에서 파생된 기본값을 쓴다. const [openOverrides, setOpenOverrides] = useState>({}); const [keyword, setKeyword] = useState(""); if (!sections) { return (

스타일시트를 읽는 중…

); } const current = selection ?? { kind: "token", id: sections[0]?.id ?? "" }; const activeSection = sections.find((section) => section.id === current.id); const query = keyword.trim().toLowerCase(); const hit = (text: string) => text.toLowerCase().includes(query); 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)) })); const isTokenActive = (id: string) => current.kind === "token" && current.id === id; const isComponentActive = (id: string) => current.kind === "component" && current.id === id; return (