File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-19
"use client";
import { useState, useSyncExternalStore } from "react";
import { FoxBadge } from "../core/components/fox-badge";
import { FoxCaretDownIcon, FoxCaretUpIcon, FoxStarIcon } from "../core/icons";
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<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: "Etc", subgroups: [{ label: "Unsorted", examples: rest }] });
}
return groups;
}
type TokenGroup = ReturnType<typeof groupedSections>[number];
type ComponentGroupView = ReturnType<typeof groupedComponents>[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<string> {
const ids = new Set<string>();
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;
}
function CountBadge({ value }: { value: number }) {
return (
<span className={styles.count}>
<FoxBadge type="pastel" color="neutral" size="sm">
{String(value)}
</FoxBadge>
</span>
);
}
export function DevTestPage() {
const selection = useSelection();
const sections = useCssTokens();
// 사용자가 직접 누른 것만 담는다. 없으면 선택 항목에서 파생된 기본값을 쓴다.
const [openOverrides, setOpenOverrides] = useState<Record<string, boolean>>({});
if (!sections) {
return (
<div className={styles.shell}>
<p className={styles.loading}>스타일시트를 읽는 중…</p>
</div>
);
}
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 isOpen = (id: string) => 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 (
<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>
<ThemeSwitch />
</div>
<nav aria-label="섹션">
<section className={styles.section}>
<h2 className={styles.sectionTitle}>토큰 · {totalTokens}</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,
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}>
<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>
<main className={styles.content}>
{current.kind === "component" ? (
(() => {
const example = COMPONENT_EXAMPLES.find((entry) => entry.id === current.id);
return example ? <ComponentView example={example} /> : <ComponentEmpty />;
})()
) : activeSection ? (
<TokenView section={activeSection} />
) : (
<p className={styles.loading}>선택한 토큰 섹션이 없습니다.</p>
)}
</main>
</div>
);
}