feat: add design system platform

This commit is contained in:
donghyeon-ka
2026-07-26 15:49:07 +09:00
parent e49d90f713
commit 13f28ef811
53 changed files with 3477 additions and 410 deletions
@@ -0,0 +1,269 @@
// @vitest-environment jsdom
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import {
Button,
Checkbox,
DataTable,
Drawer,
IconButton,
Menu,
MenuIcon,
Pagination,
Popover,
RadioGroup,
Switch,
Tabs,
ToastProvider,
useToast,
} from "../../src/presentation/design-system/index.js";
describe("design-system platform interactions", () => {
it("keeps decorative icons out of the accessibility tree and names icon actions", () => {
render(
<>
<MenuIcon />
<IconButton accessibleName="탐색 열기">
<MenuIcon />
</IconButton>
</>,
);
expect(screen.getByRole("button", { name: "탐색 열기" })).toBeVisible();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("supports native choices, indeterminate state, radio arrows and switches", async () => {
const user = userEvent.setup();
const onRadio = vi.fn();
const onSwitch = vi.fn();
render(
<>
<Checkbox indeterminate label="일부 선택" />
<RadioGroup
label="밀도"
name="density"
onChange={onRadio}
options={[
{ value: "normal", label: "보통" },
{ value: "compact", label: "조밀" },
]}
value="normal"
/>
<Switch
checked={false}
label="알림"
onChange={onSwitch}
/>
</>,
);
const checkbox = screen.getByRole("checkbox", { name: "일부 선택" });
expect(checkbox).toBePartiallyChecked();
const normal = screen.getByRole("radio", { name: "보통" });
normal.focus();
await user.keyboard("{ArrowRight}");
expect(onRadio).toHaveBeenCalledWith("compact");
await user.click(screen.getByRole("switch", { name: "알림" }));
expect(onSwitch).toHaveBeenCalledWith(true);
});
it("moves through a menu, executes once, dismisses and restores focus", async () => {
const user = userEvent.setup();
const inspect = vi.fn();
render(
<Menu
items={[
{ id: "open", label: "열기", onSelect: vi.fn() },
{ id: "inspect", label: "검사", onSelect: inspect },
]}
triggerLabel="작업"
/>,
);
const trigger = screen.getByRole("button", { name: "작업" });
trigger.focus();
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: "열기" })).toHaveFocus();
await user.keyboard("{ArrowDown}{Enter}");
expect(inspect).toHaveBeenCalledOnce();
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("supports manual tab activation with arrow-key roving focus", async () => {
const user = userEvent.setup();
render(
<Tabs
activation="manual"
label="계층"
tabs={[
{ id: "tokens", label: "토큰", panel: "토큰 내용" },
{ id: "patterns", label: "패턴", panel: "패턴 내용" },
]}
/>,
);
const tokenTab = screen.getByRole("tab", { name: "토큰" });
tokenTab.focus();
await user.keyboard("{ArrowRight}");
const patternTab = screen.getByRole("tab", { name: "패턴" });
expect(patternTab).toHaveFocus();
expect(patternTab).toHaveAttribute("aria-selected", "false");
await user.keyboard("{Enter}");
expect(patternTab).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel", { name: "패턴" })).toHaveTextContent(
"패턴 내용",
);
});
it("uses a modal drawer and restores focus after Escape", async () => {
const user = userEvent.setup();
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}> </Button>
<Drawer onClose={() => setOpen(false)} open={open} title="탐색">
<a href="/target"></a>
</Drawer>
</>
);
}
render(<Harness />);
const trigger = screen.getByRole("button", { name: "탐색 열기" });
await user.click(trigger);
expect(screen.getByRole("dialog", { name: "탐색" })).toHaveAttribute("open");
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
});
it("bounds the toast queue and collapses duplicate IDs", async () => {
const user = userEvent.setup();
function Harness() {
const toast = useToast();
return (
<>
<Button
onClick={() =>
toast.push({ id: "same", title: "저장됨", durationMs: 60_000 })
}
>
</Button>
<Button
onClick={() => {
for (let index = 0; index < 5; index += 1) {
toast.push({
id: `toast-${index}`,
title: `알림 ${index}`,
durationMs: 60_000,
});
}
}}
>
</Button>
</>
);
}
render(
<ToastProvider limit={3}>
<Harness />
</ToastProvider>,
);
await user.click(screen.getByRole("button", { name: "중복" }));
await user.click(screen.getByRole("button", { name: "중복" }));
expect(screen.getByText("×2")).toBeVisible();
await user.click(screen.getByRole("button", { name: "다섯 개" }));
const region = screen.getByRole("region", { name: "Notifications" });
expect(within(region).getAllByRole("article")).toHaveLength(3);
expect(within(region).queryByText("알림 0")).not.toBeInTheDocument();
expect(within(region).getByText("알림 4")).toBeVisible();
});
it("pauses toast timeout while the user is interacting", () => {
vi.useFakeTimers();
function Harness() {
const toast = useToast();
return (
<Button
onClick={() =>
toast.push({ id: "timed", title: "시간 제한", durationMs: 1000 })
}
>
</Button>
);
}
render(
<ToastProvider>
<Harness />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "표시" }));
const item = screen.getByRole("article");
fireEvent.mouseEnter(item);
act(() => vi.advanceTimersByTime(2000));
expect(item).toBeVisible();
fireEvent.mouseLeave(item);
act(() => vi.advanceTimersByTime(1000));
expect(screen.queryByText("시간 제한")).not.toBeInTheDocument();
vi.useRealTimers();
});
it("provides dismissible popover and data/navigation patterns", async () => {
const user = userEvent.setup();
const onPage = vi.fn();
render(
<>
<Popover triggerLabel="설명 열기">
<Button> </Button>
</Popover>
<DataTable
caption="결과"
columns={[
{ id: "name", header: "이름", cell: (row) => row.name },
]}
empty="비어 있음"
rowKey={(row) => row.id}
rows={[{ id: "one", name: "첫 항목" }]}
/>
<Pagination
label="페이지"
nextLabel="다음"
onChange={onPage}
page={1}
pageCount={2}
pageLabel={(page) => `${page}페이지`}
previousLabel="이전"
/>
</>,
);
const popoverTrigger = screen.getByRole("button", { name: "설명 열기" });
await user.click(popoverTrigger);
expect(screen.getByRole("dialog")).toHaveTextContent("내부 작업");
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(popoverTrigger).toHaveFocus();
expect(screen.getByRole("table", { name: "결과" })).toHaveTextContent(
"첫 항목",
);
await user.click(screen.getByRole("link", { name: "2페이지" }));
expect(onPage).toHaveBeenCalledWith(2);
});
});
+16
View File
@@ -40,3 +40,19 @@ test("@a11y reduced-motion policy disables long animation", async ({ page }) =>
.evaluate((body) => getComputedStyle(body).animationDuration);
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
});
test("@a11y opened design-system dialog has no blocking violations", async ({
page,
}) => {
await page.goto("/examples/ui");
await page.getByRole("button", { name: "모달 열기" }).click();
const results = await new AxeBuilder({ page })
.include(".ui-dialog")
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
results.violations.filter((violation) =>
["critical", "serious"].includes(violation.impact ?? ""),
),
).toEqual([]);
});
+5
View File
@@ -53,8 +53,13 @@ test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
expect(
await page.locator(".ui-drawer").evaluate((drawer) => drawer.matches(":modal")),
).toBe(true);
await page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
await expect(menu).toBeFocused();
});
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
page,
}) => {
await page.goto("/examples/ui");
const menuTrigger = page.getByRole("button", { name: "작업 메뉴" });
await menuTrigger.focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.type("toast");
const toastItem = page.getByRole("menuitem", { name: "Toast 표시" });
await expect(toastItem).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByText("예제가 저장되었습니다.")).toBeVisible();
await expect(menuTrigger).toBeFocused();
const tokenTab = page.getByRole("tab", { name: "토큰" });
await tokenTab.focus();
await page.keyboard.press("ArrowRight");
const primitiveTab = page.getByRole("tab", { name: "프리미티브" });
await expect(primitiveTab).toBeFocused();
await expect(primitiveTab).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("Enter");
await expect(primitiveTab).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tabpanel", { name: "프리미티브" }),
).toContainText("native semantics");
});
@@ -0,0 +1,3 @@
import { Button } from "../../../../src/presentation/design-system/primitives/core.js";
export const DeepImport = Button;
@@ -0,0 +1,3 @@
import { Menu } from "lucide-react";
export const DirectIcon = Menu;
@@ -0,0 +1,3 @@
export function RawPalette() {
return <div style={{ color: "#ff0000" }}>raw palette</div>;
}
@@ -0,0 +1 @@
export const TOOLTIP_ONLY_REQUIRED_INFORMATION = "Required approval details";
+3
View File
@@ -0,0 +1,3 @@
import { IconButton, MenuIcon } from "../../../src/presentation/design-system/index.js";
export const InvalidIconButton = <IconButton><MenuIcon /></IconButton>;
+48
View File
@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
REQUIRED_COMPONENT_TOKENS,
REQUIRED_PRIMITIVE_TOKENS,
REQUIRED_SEMANTIC_TOKENS,
} from "../../src/presentation/design-system/index.js";
describe("design token contract", () => {
it("defines every registered token in its owning layer", async () => {
const layers = [
[
REQUIRED_PRIMITIVE_TOKENS,
"src/presentation/design-system/tokens/primitive.css",
],
[
REQUIRED_SEMANTIC_TOKENS,
"src/presentation/design-system/tokens/semantic.css",
],
[
REQUIRED_COMPONENT_TOKENS,
"src/presentation/design-system/tokens/component.css",
],
] as const;
for (const [tokens, file] of layers) {
const source = await readFile(file, "utf8");
for (const token of tokens) expect(source).toContain(`${token}:`);
}
});
it("provides dark and forced-color contracts", async () => {
const semantic = await readFile(
"src/presentation/design-system/tokens/semantic.css",
"utf8",
);
const component = await readFile(
"src/presentation/design-system/tokens/component.css",
"utf8",
);
expect(semantic).toContain(':root[data-theme="dark"]');
expect(component).toContain("@media (forced-colors: active)");
expect(component).toContain("--color-focus: Highlight");
});
});