Compare commits

..
Author SHA1 Message Date
donghyeon-ka 13f28ef811 feat: add design system platform 2026-07-26 15:49:07 +09:00
donghyeon-ka e49d90f713 merge: form and page platform 2026-07-26 15:22:58 +09:00
53 changed files with 3477 additions and 410 deletions
+10
View File
@@ -31,6 +31,16 @@ module.exports = {
path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)", path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
}, },
}, },
{
name: "icon-vendor-is-facade-only",
severity: "error",
from: {
path: "^src",
pathNot:
"^src/presentation/design-system/icons/vendors/lucide\\.tsx$",
},
to: { path: "^lucide-react$" },
},
{ {
name: "adapters-do-not-know-presentation", name: "adapters-do-not-know-presentation",
severity: "error", severity: "error",
+1 -1
View File
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending M6 Color signal: pending
M7 Reduced motion: pending M7 Reduced motion: pending
Screen reader: pending Screen reader: pending
Notes: Review the text-field error association and modal focus containment/restoration. Notes: Review form primitives, Menu/Tabs keyboard behavior, Toast announcements, Tooltip supplemental copy, text-field error association and modal focus containment/restoration.
+6 -1
View File
@@ -81,7 +81,8 @@
{ "script": "check:types:fixture:application-input", "expect": "fail" }, { "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" }, { "script": "check:types:fixture:async-overlay", "expect": "fail" },
{ "script": "check:types:fixture:route-runtime", "expect": "fail" }, { "script": "check:types:fixture:route-runtime", "expect": "fail" },
{ "script": "check:types:fixture:page-action", "expect": "fail" } { "script": "check:types:fixture:page-action", "expect": "fail" },
{ "script": "check:types:fixture:icon-button", "expect": "fail" }
], ],
"logPath": "artifacts/quality/check-types.txt", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
@@ -151,6 +152,8 @@
"name": "architecture", "name": "architecture",
"steps": [ "steps": [
{ "script": "check:architecture", "expect": "pass" }, { "script": "check:architecture", "expect": "pass" },
{ "script": "check:design-system", "expect": "pass" },
{ "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:registries", "expect": "pass" }, { "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" }, { "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" } { "script": "check:routes:fixture", "expect": "fail" }
@@ -158,6 +161,8 @@
"logPath": "artifacts/quality/gates/FE-GATE-010.txt", "logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [ "evidence": [
"artifacts/quality/dependency-report.json", "artifacts/quality/dependency-report.json",
"artifacts/quality/design-system.json",
"artifacts/quality/design-system-fixture.json",
"artifacts/quality/registries.json", "artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json", "artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json" "artifacts/quality/route-registry-fixture.json"
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.
@@ -73,11 +73,11 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 | | 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 | | 앱 셸·반응형 | 준비 | native modal Drawer, compact/desktop layout, Escape/link dismiss와 focus restore | RP-08에서 RTL/direction 검증 |
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status slot 계약과 reference 소비 화면 | RP-07 public design-system entry로 이동 | | 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status public design-system entry | feature별 slot 조합 유지 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 | | 디자인 토큰 | 준비 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 | | 공통 UI | 준비 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 | | 아이콘 | 준비됨 | Lucide static vendor facade와 semantic icon/IconButton 접근성 계약 | 의미 icon 추가 시 bundle/접근성 기준 적용 |
| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 | | 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
@@ -588,6 +588,22 @@ RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어
token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을 token rename은 alias/migration 기간을 두고, vendor adapter와 local API commit을
분리한다. 분리한다.
**구현 증거 (2026-07-26)**
- VD-05에서 `lucide-react@1.25.0` static semantic facade와 native-first
interaction을 채택하고 headless vendor 재평가 조건을 닫았다.
- `src/presentation/design-system`이 48개 필수 token, public TypeScript barrel,
action/form/feedback/overlay/navigation primitive와 공통 pattern을 제공한다.
- 기존 `components/ui` 경로는 compatibility export로 유지하고 앱 셸, gallery와
reference feature는 public entry를 소비한다.
- 모바일 navigation은 native modal Drawer로 전환되어 배경 비활성화, Escape,
route dismiss와 trigger focus restore를 제공한다.
- source/negative fixture gate가 undefined token, raw palette, direct vendor,
deep import, tooltip-only 정보와 accessible name 누락을 거절한다.
- component/browser test가 Menu typeahead, Tabs activation, Toast queue,
form controls, compact reflow와 open-dialog axe를 실행한다. 로컬 WebKit은 host
`libevent-2.1.so.7` 부재로 환경 검증 상태를 유지한다.
### 08. `feature-frontend-i18n-message-formatting-contract` ### 08. `feature-frontend-i18n-message-formatting-contract`
**목표** **목표**
@@ -171,6 +171,13 @@ query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의 list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 consumer다. 실제 consumer다.
RP-07에서 React inbound adapter 안의 UI 공급자 경계도 닫혔다.
`src/presentation/design-system/index.ts`는 token → primitive → pattern →
template public API이며 feature와 shell은 이 entry만 소비한다. Lucide는
`icons/vendors/lucide.tsx`에 격리된 inbound vendor facade이므로 application
port가 아니다. native Dialog/Drawer/Menu/Tabs의 focus·keyboard 상태도
presentation이 소유하고 use case나 outbound adapter로 올리지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
+11 -11
View File
@@ -47,23 +47,23 @@
- Chromium, Firefox, WebKit - Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사 - 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다. RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size, 1. typography, elevation, motion, z-layer, control size와 breakpoint는
breakpoint가 계약으로 닫혀 있지 않다. `design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox, 2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다. RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크 3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
class를 다시 작성하는 곳이 있다. 4. 문자 glyph는 semantic Lucide facade로 교체됐다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다. 5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다. 소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이 6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다. 없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다. 7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. page template은 준비됐지만 RP-07 design-system public entry와 token/pattern 8. page template, form과 pattern은 public barrel에서 제공된다.
계층으로 정리해야 한다. 9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus Escape/link dismiss와 trigger focus restore를 제공한다.
복원, 배경 비활성화 계약은 없다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다. 따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
@@ -562,6 +562,13 @@ client route guard는 UX이며 authorization이 아님을 test 이름과 문서
공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다. 공용 컴포넌트는 한 번의 결함이 모든 페이지로 전파되므로 위험도가 높다.
RP-07의 실행 경로는 `check:design-system`,
`check:design-system:fixture`, `check:types:fixture:icon-button`,
`tests/component/design-system-platform.test.tsx`
`tests/e2e/design-system-interactions.spec.js`다. Story interaction/visual
baseline은 VD-08/RP-10에서 추가하며 현재 runtime gallery를 isolated workshop
완료 증거로 사용하지 않는다.
### 10.1 Component behavior ### 10.1 Component behavior
- native role/name/value - native role/name/value
+4
View File
@@ -15,6 +15,8 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0", "lint": "eslint src scripts tests vite.config.js vitest.config.js playwright.config.js --max-warnings=0",
"check:architecture": "node scripts/check-architecture.mjs", "check:architecture": "node scripts/check-architecture.mjs",
"check:design-system": "node scripts/check-design-system.mjs",
"check:design-system:fixture": "node scripts/check-design-system.mjs --fixture",
"check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test", "check:types": "corepack pnpm check:types:app && corepack pnpm check:types:node && corepack pnpm check:types:test",
"check:types:app": "tsc --project tsconfig.app.json", "check:types:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json", "check:types:node": "tsc --project tsconfig.node.json",
@@ -27,6 +29,7 @@
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", "check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", "check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
"check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx", "check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"check:types:fixture:icon-button": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
@@ -58,6 +61,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-router-dom": "7.18.1", "react-router-dom": "7.18.1",
+12
View File
@@ -11,6 +11,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 5.101.4 specifier: 5.101.4
version: 5.101.4(react@19.2.8) version: 5.101.4(react@19.2.8)
lucide-react:
specifier: 1.25.0
version: 1.25.0(react@19.2.8)
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@@ -1536,6 +1539,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.25.0:
resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -3314,6 +3322,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.25.0(react@19.2.8):
dependencies:
react: 19.2.8
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
+148
View File
@@ -0,0 +1,148 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
// @ts-expect-error Node 24 executes erasable TypeScript for this build-time gate.
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const tokenFiles = {
primitive: "src/presentation/design-system/tokens/primitive.css",
semantic: "src/presentation/design-system/tokens/semantic.css",
component: "src/presentation/design-system/tokens/component.css",
};
/** @type {Array<readonly [string, string, readonly string[]]>} */
const tokenLayers = [
["primitive", tokenFiles.primitive, REQUIRED_PRIMITIVE_TOKENS],
["semantic", tokenFiles.semantic, REQUIRED_SEMANTIC_TOKENS],
["component", tokenFiles.component, REQUIRED_COMPONENT_TOKENS],
];
for (const [layer, file, tokens] of tokenLayers) {
const source = await readFile(file, "utf8");
for (const token of tokens) {
if (!source.includes(`${token}:`)) {
failures.push(`${layer} token is missing: ${token}`);
}
}
}
const cssSources = await Promise.all(
[...Object.values(tokenFiles), "src/presentation/styles/theme.css"].map(
async (file) => ({ file, source: await readFile(file, "utf8") }),
),
);
const definitions = new Set(
cssSources.flatMap(({ source }) =>
[...source.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]),
),
);
for (const { file, source } of cssSources) {
for (const match of source.matchAll(/var\((--[a-z0-9-]+)/g)) {
if (!definitions.has(match[1])) {
failures.push(`${file} uses undefined token ${match[1]}`);
}
}
}
const semanticSource = await readFile(tokenFiles.semantic, "utf8");
const darkSource =
semanticSource.match(/:root\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/)?.[1] ??
"";
for (const token of [
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
]) {
if (!darkSource.includes(`${token}:`)) {
failures.push(`dark theme token is missing: ${token}`);
}
}
const componentSource = await readFile(tokenFiles.component, "utf8");
if (!componentSource.includes("@media (forced-colors: active)")) {
failures.push("forced-colors token fallback is missing");
}
/** @param {string} directory @returns {Promise<string[]>} */
async function listSourceFiles(directory) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...(await listSourceFiles(target)));
else if (/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) result.push(target);
}
return result;
}
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/design-system/forbidden")
: await listSourceFiles("src");
for (const file of sources) {
const source = await readFile(file, "utf8");
const vendorFacade =
file === "src/presentation/design-system/icons/vendors/lucide.tsx";
if (!vendorFacade && /from\s+["']lucide-react["']/.test(source)) {
failures.push(`direct icon vendor import in ${file}`);
}
if (
/from\s+["'](?:react-aria-components|@radix-ui\/[^"']+)["']/.test(source)
) {
failures.push(`direct headless vendor import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/") &&
/presentation\/design-system\/(?!index(?:\.js)?["'])/.test(source)
) {
failures.push(`design-system deep import in ${file}`);
}
if (
!file.includes("src/presentation/design-system/tokens/") &&
/(?:#[0-9a-f]{3,8}\b|oklch\(|rgba?\()/i.test(source)
) {
failures.push(`raw palette value in ${file}`);
}
if (
file.includes("tests/fixtures/design-system/forbidden") &&
source.includes("TOOLTIP_ONLY_REQUIRED_INFORMATION")
) {
failures.push(`tooltip-only required information in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
checkedTokenCount:
REQUIRED_PRIMITIVE_TOKENS.length +
REQUIRED_SEMANTIC_TOKENS.length +
REQUIRED_COMPONENT_TOKENS.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/design-system-fixture.json"
: "artifacts/quality/design-system.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`Design system contract: ${report.checkedTokenCount} tokens and vendor boundaries PASS\n`,
);
@@ -1,7 +1,9 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx"; import {
import { DetailPage } from "../../../presentation/templates/index.js"; AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js"; import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useReferenceDetail } from "./use-reference-feature.js"; import { useReferenceDetail } from "./use-reference-feature.js";
@@ -1,17 +1,17 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { import {
Button,
DirtyNavigationDialog, DirtyNavigationDialog,
ErrorSummary, ErrorSummary,
Form, Form,
FormActions, FormActions,
FormPage,
FormField, FormField,
useAppForm, useAppForm,
useDirtyNavigationGuard, useDirtyNavigationGuard,
} from "../../../presentation/forms/index.js"; } from "../../../presentation/design-system/index.js";
import { FormPage } from "../../../presentation/templates/index.js";
import { import {
REFERENCE_FORM_DEFAULTS, REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema, referenceResourceFormSchema,
@@ -1,8 +1,10 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx"; import {
import { Button } from "../../../presentation/components/ui/button.jsx"; AsyncSurface,
import { CollectionPage } from "../../../presentation/templates/index.js"; Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js"; import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() { export default function ReferenceResourcePage() {
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/templates/index.js"; import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() { export default function ReferenceResourceStatusPage() {
const navigate = useNavigate(); const navigate = useNavigate();
+1 -36
View File
@@ -1,36 +1 @@
/** export { Alert } from "../../design-system/primitives/core.js";
* @param {{
* title: string,
* children?: React.ReactNode,
* variant?: "info" | "success" | "warning" | "danger",
* onDismiss?: () => void
* }} props
*/
export function Alert({
title,
children,
variant = "info",
onDismiss,
}) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<button
className="ui-alert__dismiss"
type="button"
aria-label={`${title} 알림 닫기`}
onClick={onDismiss}
>
×
</button>
) : null}
</section>
);
}
+1 -9
View File
@@ -1,9 +1 @@
/** export { Badge } from "../../design-system/primitives/core.js";
* @param {{
* children: React.ReactNode,
* variant?: "neutral" | "info" | "success" | "warning" | "danger"
* }} props
*/
export function Badge({ children, variant = "neutral" }) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
+1 -24
View File
@@ -1,24 +1 @@
/** export { Button } from "../../design-system/primitives/core.js";
* @param {React.ButtonHTMLAttributes<HTMLButtonElement> & {
* variant?: "primary" | "secondary" | "danger" | "ghost",
* size?: "default" | "compact"
* }} props
*/
export function Button({
variant = "primary",
size = "default",
className = "",
type = "button",
...props
}) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ");
return <button {...props} className={classes} type={type} />;
}
+1 -28
View File
@@ -1,28 +1 @@
import { useId } from "react"; export { Card } from "../../design-system/primitives/core.js";
/**
* @param {{
* title: string,
* description?: string,
* children?: React.ReactNode,
* footer?: React.ReactNode,
* className?: string
* }} props
*/
export function Card({ title, description, children, footer, className = "" }) {
const titleId = useId();
return (
<article
className={`ui-card ${className}`.trim()}
aria-labelledby={titleId}
>
<div className="ui-card__header">
<h3 id={titleId}>{title}</h3>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
+1 -88
View File
@@ -1,88 +1 @@
import { useEffect, useId, useRef } from "react"; export { Dialog } from "../../design-system/primitives/core.js";
/**
* @param {{
* open: boolean,
* onClose: () => void,
* title: string,
* description?: string,
* children?: React.ReactNode,
* actions?: React.ReactNode
* }} props
*/
export function Dialog({
open,
onClose,
title,
description,
children,
actions,
}) {
const dialogRef = useRef(/** @type {HTMLDialogElement | null} */ (null));
const previousFocusRef = useRef(/** @type {HTMLElement | null} */ (null));
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
if (firstFocusable instanceof HTMLElement) firstFocusable.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [open]);
return (
<dialog
className="ui-dialog"
ref={dialogRef}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<button
className="ui-dialog__close"
type="button"
aria-label={`${title} 닫기`}
onClick={onClose}
>
×
</button>
</header>
{children ? <div className="ui-dialog__content">{children}</div> : null}
{actions ? <footer className="ui-dialog__actions">{actions}</footer> : null}
</div>
</dialog>
);
}
+1 -52
View File
@@ -1,52 +1 @@
import { useId } from "react"; export { TextField } from "../../design-system/primitives/core.js";
/**
* @param {Omit<React.InputHTMLAttributes<HTMLInputElement>, "id"> & {
* id?: string,
* label: string,
* description?: string,
* error?: string
* }} props
*/
export function TextField({
id,
label,
description,
error,
className = "",
required,
...inputProps
}) {
const generatedId = useId();
const inputId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${inputId}-description` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={inputId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
<input
{...inputProps}
className="ui-field__input"
id={inputId}
required={required}
aria-describedby={describedBy || undefined}
aria-invalid={error ? "true" : undefined}
/>
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,47 @@
import type { ComponentType, SVGProps } from "react";
import {
CloseGlyph,
ErrorGlyph,
InfoGlyph,
MenuGlyph,
NextGlyph,
PreviousGlyph,
SearchGlyph,
SuccessGlyph,
WarningGlyph,
} from "./vendors/lucide.js";
export type SemanticIconProps = Readonly<{
label?: string;
size?: "small" | "medium" | "large";
}>;
function createSemanticIcon(
Glyph: ComponentType<SVGProps<SVGSVGElement>>,
) {
return function SemanticIcon({
label,
size = "medium",
}: SemanticIconProps) {
return (
<Glyph
aria-hidden={label ? undefined : "true"}
aria-label={label}
className={`ui-icon ui-icon--${size}`}
focusable="false"
role={label ? "img" : undefined}
/>
);
};
}
export const MenuIcon = createSemanticIcon(MenuGlyph);
export const CloseIcon = createSemanticIcon(CloseGlyph);
export const WarningIcon = createSemanticIcon(WarningGlyph);
export const SuccessIcon = createSemanticIcon(SuccessGlyph);
export const ErrorIcon = createSemanticIcon(ErrorGlyph);
export const InfoIcon = createSemanticIcon(InfoGlyph);
export const SearchIcon = createSemanticIcon(SearchGlyph);
export const PreviousIcon = createSemanticIcon(PreviousGlyph);
export const NextIcon = createSemanticIcon(NextGlyph);
+21
View File
@@ -0,0 +1,21 @@
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
CircleX,
Info,
Menu,
Search,
X,
} from "lucide-react";
export const MenuGlyph = Menu;
export const CloseGlyph = X;
export const WarningGlyph = AlertTriangle;
export const SuccessGlyph = Check;
export const ErrorGlyph = CircleX;
export const InfoGlyph = Info;
export const SearchGlyph = Search;
export const PreviousGlyph = ChevronLeft;
export const NextGlyph = ChevronRight;
+146
View File
@@ -0,0 +1,146 @@
export {
Alert,
Badge,
Button,
Card,
Dialog,
Field,
FocusRing,
IconButton,
LinkButton,
Portal,
Spinner,
TextField,
VisuallyHidden,
} from "./primitives/core.js";
export type {
AlertProps,
BadgeProps,
ButtonProps,
ButtonSize,
ButtonVariant,
CardProps,
DialogProps,
IconButtonProps,
LinkButtonProps,
SpinnerProps,
TextFieldProps,
} from "./primitives/core.js";
export {
Checkbox,
RadioGroup,
SearchField,
Select,
Switch,
TextArea,
} from "./primitives/forms.js";
export type {
CheckboxProps,
RadioGroupProps,
RadioOption,
SearchFieldProps,
SelectOption,
SelectProps,
SwitchProps,
TextAreaProps,
} from "./primitives/forms.js";
export {
ProgressBar,
Separator,
Skeleton,
} from "./primitives/feedback.js";
export type {
ProgressBarProps,
SkeletonProps,
} from "./primitives/feedback.js";
export {
ConfirmationDialog,
Drawer,
Menu,
Popover,
ToastProvider,
Tooltip,
useToast,
} from "./primitives/overlays.js";
export type {
DrawerProps,
MenuItemDefinition,
MenuProps,
PopoverProps,
TooltipProps,
} from "./primitives/overlays.js";
export {
Breadcrumbs,
Pagination,
Tabs,
} from "./primitives/navigation.js";
export type {
BreadcrumbItem,
TabDefinition,
} from "./primitives/navigation.js";
export {
CloseIcon,
ErrorIcon,
InfoIcon,
MenuIcon,
NextIcon,
PreviousIcon,
SearchIcon,
SuccessIcon,
WarningIcon,
} from "./icons/semantic-icons.js";
export type {
SemanticIconProps,
} from "./icons/semantic-icons.js";
export {
DESIGN_TOKEN_CONTRACT,
REQUIRED_COMPONENT_TOKENS,
REQUIRED_PRIMITIVE_TOKENS,
REQUIRED_SEMANTIC_TOKENS,
} from "./tokens/token-contract.js";
export {
AccessSurface,
DataTable,
DisclosureGroup,
PaginationBar,
SearchFilterToolbar,
} from "./patterns/common-patterns.js";
export type {
AccessSurfaceProps,
DataTableColumn,
DisclosureDefinition,
} from "./patterns/common-patterns.js";
export {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
export { PageHeader } from "../components/page-header.jsx";
export {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
export {
DetailPage,
CollectionPage,
FormPage,
StandardPage,
StatusPage,
} from "../templates/index.js";
export {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../forms/index.js";
export type {
PageActionDefinition,
PageHeading,
} from "../templates/page-templates.js";
export type { FormResult } from "../forms/form-contracts.js";
@@ -0,0 +1,139 @@
import { useId } from "react";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../../components/state-surfaces.jsx";
import { Button } from "../primitives/core.js";
import { Pagination } from "../primitives/navigation.js";
export type DataTableColumn<Row> = Readonly<{
id: string;
header: string;
cell(row: Row): React.ReactNode;
}>;
export function DataTable<Row>({
caption,
columns,
rows,
rowKey,
empty,
}: Readonly<{
caption: string;
columns: readonly DataTableColumn<Row>[];
rows: readonly Row[];
rowKey(row: Row): string;
empty: React.ReactNode;
}>) {
if (rows.length === 0) return <>{empty}</>;
return (
<div className="ui-data-table__scroll" tabIndex={0}>
<table className="ui-data-table">
<caption>{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th key={column.id} scope="col">
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.id}>{column.cell(row)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function SearchFilterToolbar({
label,
search,
filters,
resetLabel,
onReset,
resultCount,
}: Readonly<{
label: string;
search: React.ReactNode;
filters?: React.ReactNode;
resetLabel: string;
onReset(): void;
resultCount: React.ReactNode;
}>) {
return (
<section aria-label={label} className="ui-search-filter-toolbar">
<div>{search}</div>
{filters ? <div>{filters}</div> : null}
<Button onClick={onReset} variant="ghost">
{resetLabel}
</Button>
<output>{resultCount}</output>
</section>
);
}
export function PaginationBar({
range,
...pagination
}: React.ComponentProps<typeof Pagination> & Readonly<{ range: string }>) {
return (
<div className="ui-pagination-bar">
<p>{range}</p>
<Pagination {...pagination} />
</div>
);
}
export type DisclosureDefinition = Readonly<{
id: string;
title: string;
content: React.ReactNode;
}>;
export function DisclosureGroup({
label,
items,
}: Readonly<{
label: string;
items: readonly DisclosureDefinition[];
}>) {
const groupId = useId();
return (
<section aria-labelledby={groupId} className="ui-disclosure-group">
<h2 className="visually-hidden" id={groupId}>
{label}
</h2>
{items.map((item) => (
<details key={item.id}>
<summary>{item.title}</summary>
<div>{item.content}</div>
</details>
))}
</section>
);
}
export type AccessSurfaceProps =
| Readonly<{ kind: "auth-required"; onAction(): void }>
| Readonly<{ kind: "forbidden"; onAction(): void }>
| Readonly<{ kind: "not-found"; onAction(): void }>;
export function AccessSurface(props: AccessSurfaceProps) {
if (props.kind === "auth-required") {
return <AuthRequiredSurface onSignIn={props.onAction} />;
}
if (props.kind === "forbidden") {
return <ForbiddenSurface onNavigate={props.onAction} />;
}
return <NotFoundSurface onNavigate={props.onAction} />;
}
@@ -0,0 +1,446 @@
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
variant?: ButtonVariant;
size?: ButtonSize;
pending?: boolean;
pendingLabel?: string;
}>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "default",
pending = false,
pendingLabel,
className = "",
type = "button",
children,
disabled,
...props
},
ref,
) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
pending ? "ui-button--pending" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
{...props}
aria-busy={pending || undefined}
className={classes}
disabled={disabled}
ref={ref}
type={type}
>
{pending ? (
<>
<Spinner decorative />
{pendingLabel ?? children}
</>
) : (
children
)}
</button>
);
},
);
export type LinkButtonProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
Readonly<{
href: string;
variant?: ButtonVariant;
size?: ButtonSize;
}>;
export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
function LinkButton(
{
href,
variant = "primary",
size = "default",
className = "",
...props
},
ref,
) {
return (
<a
{...props}
className={[
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ")}
href={href}
ref={ref}
/>
);
},
);
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children"> &
Readonly<{
accessibleName: string;
children: React.ReactElement;
}>;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ accessibleName, className = "", ...props }, ref) {
return (
<Button
{...props}
aria-label={accessibleName}
className={`ui-icon-button ${className}`.trim()}
ref={ref}
/>
);
},
);
type FieldFrameProps = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
required?: boolean;
className?: string;
children(
contract: Readonly<{
controlId: string;
describedBy: string | undefined;
invalid: boolean;
}>,
): React.ReactNode;
}>;
export function Field({
id,
label,
description,
error,
required,
className = "",
children,
}: FieldFrameProps) {
const generatedId = useId();
const controlId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={controlId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{children({
controlId,
describedBy: describedBy || undefined,
invalid: Boolean(error),
})}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
export type TextFieldProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"id"
> &
Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField(
{
id,
label,
description,
error,
className = "",
required,
...inputProps
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
required={required}
/>
)}
</Field>
);
},
);
export type CardProps = Readonly<{
title: string;
headingLevel?: 2 | 3 | 4;
description?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}>;
export function Card({
title,
headingLevel = 3,
description,
children,
footer,
className = "",
}: CardProps) {
const titleId = useId();
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
return (
<article
aria-labelledby={titleId}
className={`ui-card ${className}`.trim()}
>
<div className="ui-card__header">
<Heading id={titleId}>{title}</Heading>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
export type AlertProps = Readonly<{
title: string;
children?: React.ReactNode;
variant?: "info" | "success" | "warning" | "danger";
dismissLabel?: string;
onDismiss?: () => void;
}>;
export function Alert({
title,
children,
variant = "info",
dismissLabel,
onDismiss,
}: AlertProps) {
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<IconButton
accessibleName={dismissLabel ?? `${title} 알림 닫기`}
className="ui-alert__dismiss"
onClick={onDismiss}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</section>
);
}
export type BadgeProps = Readonly<{
children: React.ReactNode;
variant?: "neutral" | "info" | "success" | "warning" | "danger";
}>;
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
export type DialogProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
description?: string;
closeLabel?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}>;
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
function Dialog(
{
open,
onClose,
title,
description,
closeLabel,
children,
actions,
className = "",
},
forwardedRef,
) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector<HTMLElement>(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
firstFocusable?.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}, [open]);
return (
<dialog
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
className={`ui-dialog ${className}`.trim()}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
}}
ref={dialogRef}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<IconButton
accessibleName={closeLabel ?? `${title} 닫기`}
className="ui-dialog__close"
onClick={onClose}
variant="ghost"
>
<CloseIcon />
</IconButton>
</header>
{children ? (
<div className="ui-dialog__content">{children}</div>
) : null}
{actions ? (
<footer className="ui-dialog__actions">{actions}</footer>
) : null}
</div>
</dialog>
);
},
);
export type SpinnerProps =
| Readonly<{ decorative: true; label?: never }>
| Readonly<{ decorative?: false; label: string }>;
export function Spinner(props: SpinnerProps) {
const accessibility = props.decorative
? { "aria-hidden": true as const }
: { "aria-label": props.label, role: "status" };
return <span {...accessibility} className="ui-spinner" />;
}
export function VisuallyHidden({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="visually-hidden">{children}</span>;
}
export function Portal({
children,
}: Readonly<{ children: React.ReactNode }>) {
if (typeof document === "undefined") return <>{children}</>;
return createPortal(children, document.body);
}
export function FocusRing({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="ui-focus-ring">{children}</span>;
}
@@ -0,0 +1,55 @@
export type ProgressBarProps = Readonly<{
label: string;
value?: number;
max?: number;
}>;
export function ProgressBar({ label, value, max = 100 }: ProgressBarProps) {
const determinate = typeof value === "number";
return (
<div className="ui-progress">
<div className="ui-progress__label">
<span>{label}</span>
{determinate ? <span>{Math.round((value / max) * 100)}%</span> : null}
</div>
<progress
aria-label={label}
className="ui-progress__bar"
max={max}
value={determinate ? Math.min(Math.max(value, 0), max) : undefined}
/>
</div>
);
}
export type SkeletonProps = Readonly<{
label?: string;
height?: "text" | "control" | "surface";
}>;
export function Skeleton({
label,
height = "surface",
}: SkeletonProps) {
return (
<span
aria-label={label}
aria-hidden={label ? undefined : true}
className={`ui-skeleton ui-skeleton--${height}`}
role={label ? "status" : undefined}
/>
);
}
export function Separator({
label,
decorative = true,
}: Readonly<{ label?: string; decorative?: boolean }>) {
return (
<hr
aria-label={decorative ? undefined : label}
aria-hidden={decorative || undefined}
className="ui-separator"
/>
);
}
@@ -0,0 +1,409 @@
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
} from "react";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
import { Field, IconButton } from "./core.js";
import type { TextFieldProps } from "./core.js";
type FieldCopy = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export type TextAreaProps = Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"id"
> &
FieldCopy &
Readonly<{ maxLengthMessage?: (remaining: number) => string }>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(
{
id,
label,
description,
error,
required,
className = "",
maxLength,
maxLengthMessage,
value,
defaultValue,
...props
},
ref,
) {
const [uncontrolledValue, setUncontrolledValue] = useState(
String(defaultValue ?? ""),
);
const currentValue =
value === undefined ? uncontrolledValue : String(value ?? "");
const remaining =
typeof maxLength === "number" ? maxLength - currentValue.length : null;
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<>
<textarea
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__textarea"
defaultValue={value === undefined ? defaultValue : undefined}
id={controlId}
maxLength={maxLength}
onChange={(event) => {
if (value === undefined) setUncontrolledValue(event.currentTarget.value);
props.onChange?.(event);
}}
ref={ref}
required={required}
value={value}
/>
{remaining !== null ? (
<output className="ui-field__counter">
{maxLengthMessage
? maxLengthMessage(remaining)
: `${remaining}자 남음`}
</output>
) : null}
</>
)}
</Field>
);
},
);
export type SelectOption = Readonly<{
value: string;
label: string;
disabled?: boolean;
}>;
export type SelectProps = Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"id" | "children"
> &
FieldCopy &
Readonly<{
options: readonly SelectOption[];
placeholder?: string;
}>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{
id,
label,
description,
error,
options,
placeholder,
required,
className = "",
...props
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<select
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__select"
id={controlId}
ref={ref}
required={required}
>
{placeholder ? (
<option disabled value="">
{placeholder}
</option>
) : null}
{options.map((option) => (
<option
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
)}
</Field>
);
},
);
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> &
Readonly<{
label: string;
description?: string;
error?: string;
indeterminate?: boolean;
}>;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, error, indeterminate = false, id, ...props },
forwardedRef,
) {
const generatedId = useId();
const controlId = id ?? `checkbox-${generatedId}`;
const inputRef = useRef<HTMLInputElement | null>(null);
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<div className="ui-choice-field">
<label className="ui-choice-field__control" htmlFor={controlId}>
<input
{...props}
aria-describedby={
[descriptionId, errorId].filter(Boolean).join(" ") || undefined
}
aria-invalid={error ? true : undefined}
id={controlId}
ref={(node) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type="checkbox"
/>
<span>{label}</span>
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
},
);
export type RadioOption = Readonly<{
value: string;
label: string;
description?: string;
disabled?: boolean;
}>;
export type RadioGroupProps = Readonly<{
name: string;
label: string;
options: readonly RadioOption[];
value?: string;
defaultValue?: string;
onChange?(value: string): void;
disabled?: boolean;
error?: string;
}>;
export function RadioGroup({
name,
label,
options,
value,
defaultValue,
onChange,
disabled,
error,
}: RadioGroupProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const selected = value ?? internalValue;
const errorId = useId();
const refs = useRef<Array<HTMLInputElement | null>>([]);
function choose(nextValue: string) {
if (value === undefined) setInternalValue(nextValue);
onChange?.(nextValue);
}
return (
<fieldset
aria-describedby={error ? errorId : undefined}
className="ui-radio-group"
disabled={disabled}
>
<legend>{label}</legend>
{options.map((option, index) => (
<label className="ui-choice-field__control" key={option.value}>
<input
checked={selected === option.value}
disabled={option.disabled}
name={name}
onChange={() => choose(option.value)}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(event.key)) {
return;
}
event.preventDefault();
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
let next = index;
do {
next = (next + direction + options.length) % options.length;
} while (options[next]?.disabled && next !== index);
const nextOption = options[next];
if (nextOption && !nextOption.disabled) {
choose(nextOption.value);
refs.current[next]?.focus();
}
}}
ref={(node) => {
refs.current[index] = node;
}}
type="radio"
value={option.value}
/>
<span>
{option.label}
{option.description ? <small>{option.description}</small> : null}
</span>
</label>
))}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</fieldset>
);
}
export type SwitchProps = Readonly<{
label: string;
checked: boolean;
onChange(checked: boolean): void;
disabled?: boolean;
description?: string;
}>;
export function Switch({
label,
checked,
onChange,
disabled,
description,
}: SwitchProps) {
const descriptionId = useId();
return (
<div className="ui-switch-field">
<button
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
className="ui-switch"
disabled={disabled}
onClick={() => onChange(!checked)}
role="switch"
type="button"
>
<span aria-hidden="true" className="ui-switch__thumb" />
<span>{label}</span>
</button>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
</div>
);
}
export type SearchFieldProps = Omit<TextFieldProps, "type"> &
Readonly<{
clearLabel: string;
onClear(): void;
}>;
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
function SearchField(
{
clearLabel,
onClear,
value,
className = "",
id,
label,
description,
error,
required,
...inputProps
},
ref,
) {
return (
<div className={`ui-search-field ${className}`.trim()}>
<SearchIcon />
<Field
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
type="search"
value={value}
/>
)}
</Field>
{String(value ?? "").length > 0 ? (
<IconButton
accessibleName={clearLabel}
onClick={onClear}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</div>
);
},
);
@@ -0,0 +1,193 @@
import { useId, useRef, useState } from "react";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
import { IconButton, LinkButton } from "./core.js";
export type BreadcrumbItem = Readonly<{
label: string;
href?: string;
}>;
export function Breadcrumbs({
label,
items,
}: Readonly<{ label: string; items: readonly BreadcrumbItem[] }>) {
return (
<nav aria-label={label} className="ui-breadcrumbs">
<ol>
{items.map((item, index) => {
const current = index === items.length - 1;
return (
<li key={`${item.label}-${index}`}>
{item.href && !current ? (
<a href={item.href}>{item.label}</a>
) : (
<span aria-current={current ? "page" : undefined}>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
export type TabDefinition = Readonly<{
id: string;
label: string;
panel: React.ReactNode;
disabled?: boolean;
}>;
export function Tabs({
label,
tabs,
value,
defaultValue,
activation = "automatic",
onChange,
}: Readonly<{
label: string;
tabs: readonly TabDefinition[];
value?: string;
defaultValue?: string;
activation?: "automatic" | "manual";
onChange?(id: string): void;
}>) {
const fallback = tabs.find((tab) => !tab.disabled)?.id ?? "";
const [internalValue, setInternalValue] = useState(defaultValue ?? fallback);
const [focusValue, setFocusValue] = useState(value ?? internalValue);
const selected = value ?? internalValue;
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const baseId = useId();
function select(id: string) {
if (value === undefined) setInternalValue(id);
onChange?.(id);
}
function move(currentIndex: number, direction: 1 | -1) {
let next = currentIndex;
do {
next = (next + direction + tabs.length) % tabs.length;
} while (tabs[next]?.disabled && next !== currentIndex);
const nextTab = tabs[next];
if (!nextTab || nextTab.disabled) return;
setFocusValue(nextTab.id);
if (activation === "automatic") select(nextTab.id);
tabRefs.current[next]?.focus();
}
return (
<div className="ui-tabs">
<div aria-label={label} className="ui-tabs__list" role="tablist">
{tabs.map((tab, index) => (
<button
aria-controls={`${baseId}-${tab.id}-panel`}
aria-selected={selected === tab.id}
className="ui-tabs__tab"
disabled={tab.disabled}
id={`${baseId}-${tab.id}-tab`}
key={tab.id}
onClick={() => {
setFocusValue(tab.id);
select(tab.id);
}}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
move(index, 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, -1);
} else if (
activation === "manual" &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
select(tab.id);
}
}}
ref={(node) => {
tabRefs.current[index] = node;
}}
role="tab"
tabIndex={focusValue === tab.id ? 0 : -1}
type="button"
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
aria-labelledby={`${baseId}-${tab.id}-tab`}
className="ui-tabs__panel"
hidden={selected !== tab.id}
id={`${baseId}-${tab.id}-panel`}
key={tab.id}
role="tabpanel"
tabIndex={0}
>
{tab.panel}
</div>
))}
</div>
);
}
export function Pagination({
label,
page,
pageCount,
previousLabel,
nextLabel,
pageLabel,
onChange,
}: Readonly<{
label: string;
page: number;
pageCount: number;
previousLabel: string;
nextLabel: string;
pageLabel(page: number): string;
onChange(page: number): void;
}>) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
return (
<nav aria-label={label} className="ui-pagination">
<IconButton
accessibleName={previousLabel}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
variant="secondary"
>
<PreviousIcon />
</IconButton>
{pages.map((candidate) => (
<LinkButton
aria-current={candidate === page ? "page" : undefined}
href={`?page=${candidate}`}
key={candidate}
onClick={(event) => {
event.preventDefault();
onChange(candidate);
}}
variant={candidate === page ? "primary" : "ghost"}
>
{pageLabel(candidate)}
</LinkButton>
))}
<IconButton
accessibleName={nextLabel}
disabled={page >= pageCount}
onClick={() => onChange(page + 1)}
variant="secondary"
>
<NextIcon />
</IconButton>
</nav>
);
}
@@ -0,0 +1,441 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { CloseIcon } from "../icons/semantic-icons.js";
import { Button, Dialog, IconButton } from "./core.js";
export type DrawerProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
closeLabel?: string;
placement?: "start" | "end";
children: React.ReactNode;
}>;
export function Drawer({
open,
onClose,
title,
closeLabel,
placement = "start",
children,
}: DrawerProps) {
return (
<Dialog
className={`ui-drawer ui-drawer--${placement}`}
closeLabel={closeLabel}
onClose={onClose}
open={open}
title={title}
>
{children}
</Dialog>
);
}
export type PopoverProps = Readonly<{
triggerLabel: string;
children: React.ReactNode;
}>;
export function Popover({ triggerLabel, children }: PopoverProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentId = useId();
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
triggerRef.current?.focus();
}
}
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div className="ui-popover" ref={rootRef}>
<Button
aria-controls={contentId}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div className="ui-popover__content" id={contentId} role="dialog">
{children}
</div>
) : null}
</div>
);
}
export type TooltipProps = Readonly<{
content: string;
children: React.ReactElement;
}>;
export function Tooltip({ content, children }: TooltipProps) {
const tooltipId = useId();
return (
<span className="ui-tooltip">
<span aria-describedby={tooltipId} className="ui-tooltip__trigger">
{children}
</span>
<span className="ui-tooltip__content" id={tooltipId} role="tooltip">
{content}
</span>
</span>
);
}
export type MenuItemDefinition = Readonly<{
id: string;
label: string;
disabled?: boolean;
onSelect(): void;
}>;
export type MenuProps = Readonly<{
triggerLabel: string;
items: readonly MenuItemDefinition[];
}>;
export function Menu({ triggerLabel, items }: MenuProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const searchRef = useRef("");
const resetSearchRef = useRef<number | undefined>(undefined);
const enabledIndexes = useMemo(
() => items.flatMap((item, index) => (item.disabled ? [] : [index])),
[items],
);
const focusIndex = useCallback(
(index: number) => {
setActiveIndex(index);
queueMicrotask(() => itemRefs.current[index]?.focus());
},
[],
);
function openAt(index: number) {
setOpen(true);
focusIndex(index);
}
function dismiss() {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
function move(direction: 1 | -1) {
const currentPosition = Math.max(enabledIndexes.indexOf(activeIndex), 0);
const nextPosition =
(currentPosition + direction + enabledIndexes.length) %
enabledIndexes.length;
const nextIndex = enabledIndexes[nextPosition];
if (nextIndex !== undefined) focusIndex(nextIndex);
}
useEffect(() => {
if (!open) return;
function closeOutside(event: PointerEvent) {
if (
event.target instanceof Node &&
!rootRef.current?.contains(event.target)
) {
setOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
}
document.addEventListener("pointerdown", closeOutside);
return () => document.removeEventListener("pointerdown", closeOutside);
}, [open]);
return (
<div className="ui-menu" ref={rootRef}>
<Button
aria-expanded={open}
aria-haspopup="menu"
onClick={() => {
if (open) dismiss();
else openAt(enabledIndexes[0] ?? 0);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAt(enabledIndexes[0] ?? 0);
}
if (event.key === "ArrowUp") {
event.preventDefault();
openAt(enabledIndexes.at(-1) ?? 0);
}
}}
ref={triggerRef}
variant="secondary"
>
{triggerLabel}
</Button>
{open ? (
<div
className="ui-menu__content"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
dismiss();
} else if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Home") {
event.preventDefault();
focusIndex(enabledIndexes[0] ?? 0);
} else if (event.key === "End") {
event.preventDefault();
focusIndex(enabledIndexes.at(-1) ?? 0);
} else if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
searchRef.current += event.key.toLocaleLowerCase();
window.clearTimeout(resetSearchRef.current);
resetSearchRef.current = window.setTimeout(() => {
searchRef.current = "";
}, 500);
const match = items.findIndex(
(item) =>
!item.disabled &&
item.label
.toLocaleLowerCase()
.startsWith(searchRef.current),
);
if (match >= 0) focusIndex(match);
}
}}
role="menu"
>
{items.map((item, index) => (
<button
className="ui-menu__item"
disabled={item.disabled}
key={item.id}
onClick={() => {
item.onSelect();
dismiss();
}}
ref={(node) => {
itemRefs.current[index] = node;
}}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
type="button"
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
type ToastTone = "info" | "success" | "warning" | "danger";
type ToastInput = Readonly<{
id: string;
title: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
}>;
type ToastEntry = ToastInput & Readonly<{ count: number }>;
type ToastContextValue = Readonly<{
push(toast: ToastInput): void;
dismiss(id: string): void;
}>;
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({
children,
limit = 3,
}: Readonly<{ children: React.ReactNode; limit?: number }>) {
const [toasts, setToasts] = useState<readonly ToastEntry[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback(
(toast: ToastInput) => {
setToasts((current) => {
const existing = current.find((entry) => entry.id === toast.id);
if (existing) {
return current.map((entry) =>
entry.id === toast.id
? { ...entry, ...toast, count: entry.count + 1 }
: entry,
);
}
return [...current, { ...toast, count: 1 }].slice(-limit);
});
},
[limit],
);
const value = useMemo(() => ({ push, dismiss }), [dismiss, push]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastRegion dismiss={dismiss} toasts={toasts} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("ToastProvider is required.");
}
return context;
}
function ToastRegion({
toasts,
dismiss,
}: Readonly<{
toasts: readonly ToastEntry[];
dismiss(id: string): void;
}>) {
return (
<section
aria-label="Notifications"
aria-live="polite"
className="ui-toast-region"
>
{toasts.map((toast) => (
<ToastItem dismiss={dismiss} key={toast.id} toast={toast} />
))}
</section>
);
}
function ToastItem({
toast,
dismiss,
}: Readonly<{
toast: ToastEntry;
dismiss(id: string): void;
}>) {
const [paused, setPaused] = useState(false);
useEffect(() => {
if (paused) return;
const timeout = window.setTimeout(
() => dismiss(toast.id),
toast.durationMs ?? 5000,
);
return () => window.clearTimeout(timeout);
}, [dismiss, paused, toast.durationMs, toast.id, toast.count]);
return (
<article
className={`ui-toast ui-toast--${toast.tone ?? "info"}`}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
}}
onFocus={() => setPaused(true)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div>
<strong>{toast.title}</strong>
{toast.count > 1 ? (
<span className="ui-toast__count"> ×{toast.count}</span>
) : null}
{toast.description ? <p>{toast.description}</p> : null}
</div>
<IconButton
accessibleName={`${toast.title} 닫기`}
onClick={() => dismiss(toast.id)}
variant="ghost"
>
<CloseIcon />
</IconButton>
</article>
);
}
export function ConfirmationDialog({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
onConfirm,
onCancel,
}: Readonly<{
open: boolean;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
danger?: boolean;
onConfirm(): void;
onCancel(): void;
}>) {
return (
<Dialog
actions={
<>
<Button onClick={onCancel} variant="secondary">
{cancelLabel}
</Button>
<Button onClick={onConfirm} variant={danger ? "danger" : "primary"}>
{confirmLabel}
</Button>
</>
}
description={description}
onClose={onCancel}
open={open}
title={title}
/>
);
}
@@ -0,0 +1,26 @@
@theme {
--button-primary-background: var(--color-action);
--button-primary-content: var(--color-on-action);
--button-primary-hover: var(--color-action-hover);
--field-border: var(--color-border-strong);
--field-border-invalid: var(--color-danger);
--dialog-elevation: var(--elevation-dialog);
--navigation-active-background: color-mix(
in oklch,
var(--color-action) 14%,
var(--color-panel)
);
--drawer-width: min(20rem, 88vw);
--toast-width: min(24rem, calc(100vw - 2rem));
}
@media (forced-colors: active) {
:root {
--color-border: CanvasText;
--color-border-strong: CanvasText;
--color-focus: Highlight;
--color-action: LinkText;
--color-danger: MarkText;
--color-danger-surface: Mark;
}
}
@@ -0,0 +1,63 @@
@theme {
--palette-white: oklch(1 0 0);
--palette-slate-50: oklch(0.985 0.003 247);
--palette-slate-100: oklch(0.94 0.01 247);
--palette-slate-300: oklch(0.87 0.015 247);
--palette-slate-500: oklch(0.48 0.025 247);
--palette-slate-800: oklch(0.25 0.025 247);
--palette-slate-950: oklch(0.16 0.02 255);
--palette-blue-500: oklch(0.7 0.14 250);
--palette-blue-600: oklch(0.55 0.18 255);
--palette-blue-700: oklch(0.48 0.2 255);
--palette-red-500: oklch(0.68 0.19 25);
--palette-red-600: oklch(0.55 0.2 25);
--palette-red-700: oklch(0.47 0.2 25);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.7;
--font-weight-regular: 400;
--font-weight-semibold: 650;
--font-weight-bold: 750;
--radius-xs: 0.25rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-full: 999px;
--shadow-panel: 0 8px 28px rgb(15 23 42 / 8%);
--shadow-popover: 0 14px 40px rgb(15 23 42 / 16%);
--shadow-dialog: 0 24px 70px rgb(15 23 42 / 25%);
--duration-fast: 100ms;
--duration-normal: 180ms;
--duration-slow: 300ms;
--easing-standard: cubic-bezier(0.2, 0, 0, 1);
--easing-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
--size-control-sm: 2.25rem;
--size-control-md: 2.75rem;
--size-touch-target: 2.75rem;
--size-icon-sm: 1rem;
--size-icon-md: 1.25rem;
--size-icon-lg: 1.5rem;
--icon-stroke-default: 2;
--breakpoint-compact: 48rem;
--breakpoint-medium: 64rem;
--breakpoint-wide: 80rem;
--container-content: 72rem;
}
@@ -0,0 +1,83 @@
@theme {
--color-surface: var(--palette-slate-50);
--color-surface-muted: var(--palette-slate-100);
--color-panel: var(--palette-white);
--color-surface-elevated: var(--palette-white);
--color-border: var(--palette-slate-300);
--color-border-strong: var(--palette-slate-500);
--color-content: var(--palette-slate-800);
--color-content-muted: var(--palette-slate-500);
--color-content-inverse: var(--palette-white);
--color-action: var(--palette-blue-600);
--color-action-hover: var(--palette-blue-700);
--color-action-pressed: var(--palette-blue-700);
--color-danger: var(--palette-red-600);
--color-danger-hover: var(--palette-red-700);
--color-on-action: var(--palette-white);
--color-focus: oklch(0.72 0.16 225);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--spacing-page: var(--space-6);
--radius-control: var(--radius-sm);
--radius-surface: var(--radius-md);
--radius-modal: var(--radius-md);
--elevation-panel: var(--shadow-panel);
--elevation-popover: var(--shadow-popover);
--elevation-dialog: var(--shadow-dialog);
--layer-base: 0;
--layer-sticky: 30;
--layer-navigation: 50;
--layer-popover: 60;
--layer-modal: 70;
--layer-toast: 80;
--opacity-disabled: 0.55;
--opacity-scrim: 0.55;
--opacity-skeleton: 0.7;
}
:root[data-theme="dark"] {
--color-surface: var(--palette-slate-950);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-surface-elevated: oklch(0.235 0.025 255);
--color-border: oklch(0.36 0.025 255);
--color-border-strong: oklch(0.58 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-content-inverse: var(--palette-slate-950);
--color-action: var(--palette-blue-500);
--color-action-hover: oklch(0.79 0.12 245);
--color-action-pressed: oklch(0.83 0.1 245);
--color-danger: var(--palette-red-500);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: var(--palette-slate-950);
--color-focus: oklch(0.82 0.15 220);
--color-disabled-content: color-mix(in oklch, var(--color-content) 55%, transparent);
--color-disabled-surface: var(--color-surface-muted);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
@@ -0,0 +1,62 @@
export const REQUIRED_PRIMITIVE_TOKENS = Object.freeze([
"--space-1",
"--space-2",
"--space-4",
"--font-size-sm",
"--line-height-normal",
"--radius-sm",
"--shadow-dialog",
"--duration-fast",
"--easing-standard",
"--size-control-md",
"--size-touch-target",
"--size-icon-md",
"--breakpoint-compact",
] as const);
export const REQUIRED_SEMANTIC_TOKENS = Object.freeze([
"--color-surface",
"--color-surface-muted",
"--color-surface-elevated",
"--color-content",
"--color-content-muted",
"--color-content-inverse",
"--color-border",
"--color-border-strong",
"--color-action",
"--color-action-hover",
"--color-action-pressed",
"--color-danger",
"--color-warning-content",
"--color-success-content",
"--color-info-content",
"--color-focus",
"--color-disabled-content",
"--color-disabled-surface",
"--elevation-panel",
"--layer-navigation",
"--layer-popover",
"--layer-modal",
"--layer-toast",
"--opacity-disabled",
"--opacity-scrim",
"--opacity-skeleton",
] as const);
export const REQUIRED_COMPONENT_TOKENS = Object.freeze([
"--button-primary-background",
"--button-primary-content",
"--button-primary-hover",
"--field-border",
"--field-border-invalid",
"--dialog-elevation",
"--navigation-active-background",
"--drawer-width",
"--toast-width",
] as const);
export const DESIGN_TOKEN_CONTRACT = Object.freeze({
primitive: REQUIRED_PRIMITIVE_TOKENS,
semantic: REQUIRED_SEMANTIC_TOKENS,
component: REQUIRED_COMPONENT_TOKENS,
});
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../design-system/index.js";
import { useSession } from "../providers/session-provider.jsx"; import { useSession } from "../providers/session-provider.jsx";
export default function AuthExamplePage() { export default function AuthExamplePage() {
@@ -7,15 +7,13 @@ import {
EmptySurface, EmptySurface,
LoadingSurface, LoadingSurface,
TerminalErrorSurface, TerminalErrorSurface,
} from "../components/async-surface.jsx";
import { PageHeader } from "../components/page-header.jsx";
import {
AuthRequiredSurface, AuthRequiredSurface,
ForbiddenSurface, ForbiddenSurface,
NotFoundSurface, NotFoundSurface,
} from "../components/state-surfaces.jsx"; Button,
import { Button } from "../components/ui/button.jsx"; Card,
import { Card } from "../components/ui/card.jsx"; PageHeader,
} from "../design-system/index.js";
export default function StateGalleryPage() { export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState( const [lastAction, setLastAction] = useState(
+142 -8
View File
@@ -1,12 +1,25 @@
import { useState } from "react"; import { useState } from "react";
import { PageHeader } from "../components/page-header.jsx"; import {
import { Alert } from "../components/ui/alert.jsx"; Alert,
import { Badge } from "../components/ui/badge.jsx"; Badge,
import { Button } from "../components/ui/button.jsx"; Button,
import { Card } from "../components/ui/card.jsx"; Card,
import { Dialog } from "../components/ui/dialog.jsx"; Checkbox,
import { TextField } from "../components/ui/text-field.jsx"; Dialog,
Menu,
PageHeader,
ProgressBar,
RadioGroup,
Select,
Switch,
Tabs,
TextArea,
TextField,
ToastProvider,
Tooltip,
useToast,
} from "../design-system/index.js";
const COLOR_TOKENS = Object.freeze([ const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"], ["Surface", "--color-surface"],
@@ -19,7 +32,21 @@ const COLOR_TOKENS = Object.freeze([
]); ]);
export default function UiGalleryPage() { export default function UiGalleryPage() {
return (
<ToastProvider>
<UiGalleryContent />
</ToastProvider>
);
}
function UiGalleryContent() {
const toast = useToast();
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
const [description, setDescription] = useState("");
const [template, setTemplate] = useState("application");
const [reviewed, setReviewed] = useState(false);
const [notifications, setNotifications] = useState(true);
const [density, setDensity] = useState("comfortable");
const [fieldTouched, setFieldTouched] = useState(false); const [fieldTouched, setFieldTouched] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [notice, setNotice] = useState( const [notice, setNotice] = useState(
@@ -92,6 +119,60 @@ export default function UiGalleryPage() {
</div> </div>
</section> </section>
<section className="gallery-section" aria-labelledby="form-controls-title">
<header className="gallery-section__header">
<h2 id="form-controls-title">폼과 선택 컨트롤</h2>
<p>native semantics, 설명·오류 연결과 controlled 상태를 제공합니다.</p>
</header>
<div className="component-grid component-grid--two">
<Card title="긴 입력과 선택">
<div className="component-stack">
<TextArea
label="설명"
maxLength={120}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Select
label="시작 템플릿"
options={[
{ value: "application", label: "Application" },
{ value: "library", label: "Library" },
]}
value={template}
onChange={(event) => setTemplate(event.currentTarget.value)}
/>
<Checkbox
checked={reviewed}
label="접근성 계약을 확인했습니다."
onChange={(event) => setReviewed(event.currentTarget.checked)}
/>
</div>
</Card>
<Card title="단일 선택과 설정">
<div className="component-stack">
<RadioGroup
label="화면 밀도"
name="density"
onChange={setDensity}
options={[
{ value: "comfortable", label: "여유롭게" },
{ value: "compact", label: "조밀하게" },
]}
value={density}
/>
<Switch
checked={notifications}
description="boolean 설정에만 switch를 사용합니다."
label="알림 사용"
onChange={setNotifications}
/>
<ProgressBar label="설정 준비도" max={4} value={3} />
</div>
</Card>
</div>
</section>
<section className="gallery-section" aria-labelledby="feedback-title"> <section className="gallery-section" aria-labelledby="feedback-title">
<header className="gallery-section__header"> <header className="gallery-section__header">
<h2 id="feedback-title">피드백과 모달</h2> <h2 id="feedback-title">피드백과 모달</h2>
@@ -126,7 +207,32 @@ export default function UiGalleryPage() {
</div> </div>
</Card> </Card>
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다."> <Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button> <div className="button-row">
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
<Menu
items={[
{
id: "inspect",
label: "상태 확인",
onSelect: () => setNotice("메뉴 작업을 실행했습니다."),
},
{
id: "notify",
label: "Toast 표시",
onSelect: () =>
toast.push({
id: "gallery-saved",
title: "예제가 저장되었습니다.",
tone: "success",
}),
},
]}
triggerLabel="작업 메뉴"
/>
<Tooltip content="이 설명은 필수 정보가 아닙니다.">
<Button variant="ghost">도움말</Button>
</Tooltip>
</div>
<Dialog <Dialog
open={dialogOpen} open={dialogOpen}
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
@@ -157,6 +263,34 @@ export default function UiGalleryPage() {
</div> </div>
</section> </section>
<section className="gallery-section" aria-labelledby="navigation-title">
<header className="gallery-section__header">
<h2 id="navigation-title">탐색 패턴</h2>
<p>화살표 키와 명시적인 활성화 정책을 갖는 예제입니다.</p>
</header>
<Tabs
activation="manual"
label="디자인 시스템 계층"
tabs={[
{
id: "tokens",
label: "토큰",
panel: <p>원시 값에서 의미와 컴포넌트 토큰을 파생합니다.</p>,
},
{
id: "primitives",
label: "프리미티브",
panel: <p>native semantics와 interaction을 닫습니다.</p>,
},
{
id: "patterns",
label: "패턴",
panel: <p>반복되는 사용자 문제를 조합으로 해결합니다.</p>,
},
]}
/>
</section>
<section className="gallery-section" aria-labelledby="tokens-title"> <section className="gallery-section" aria-labelledby="tokens-title">
<header className="gallery-section__header"> <header className="gallery-section__header">
<h2 id="tokens-title">디자인 토큰</h2> <h2 id="tokens-title">디자인 토큰</h2>
+59 -59
View File
@@ -5,6 +5,13 @@ import {
NAVIGATION_ROUTES, NAVIGATION_ROUTES,
routePath, routePath,
} from "../../features/installed-feature-contracts.js"; } from "../../features/installed-feature-contracts.js";
import {
Button,
Drawer,
IconButton,
MenuIcon,
Select,
} from "../design-system/index.js";
import { useSession } from "../providers/session-provider.jsx"; import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx"; import { useTheme } from "../providers/theme-provider.jsx";
@@ -27,16 +34,6 @@ export function AppShell() {
setNavigationOpen(false); setNavigationOpen(false);
}, [location.pathname]); }, [location.pathname]);
useEffect(() => {
if (!navigationOpen) return undefined;
/** @param {KeyboardEvent} event */
const closeOnEscape = (event) => {
if (event.key === "Escape") setNavigationOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [navigationOpen]);
async function runSessionAction() { async function runSessionAction() {
setSessionActionPending(true); setSessionActionPending(true);
setSessionActionFailed(false); setSessionActionFailed(false);
@@ -71,26 +68,29 @@ export function AppShell() {
본문으로 건너뛰기 본문으로 건너뛰기
</a> </a>
<header className="app-shell__header"> <header className="app-shell__header">
<button <IconButton
accessibleName="메뉴"
className="app-shell__menu-button" className="app-shell__menu-button"
type="button" aria-controls="mobile-primary-navigation"
aria-controls="primary-navigation"
aria-expanded={navigationOpen} aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)} onClick={() => setNavigationOpen((open) => !open)}
variant="secondary"
> >
<span aria-hidden="true"></span> <MenuIcon />
<span>메뉴</span> </IconButton>
</button>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}> <NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton Frontend Skeleton
</NavLink> </NavLink>
<div className="app-shell__session"> <div className="app-shell__session">
<label className="visually-hidden" htmlFor="theme-preference"> <Select
색상 테마
</label>
<select
className="theme-selector" className="theme-selector"
id="theme-preference" id="theme-preference"
label="색상 테마"
options={[
{ value: "system", label: "시스템 테마" },
{ value: "light", label: "라이트 테마" },
{ value: "dark", label: "다크 테마" },
]}
value={preference} value={preference}
onChange={(event) => onChange={(event) =>
setPreference( setPreference(
@@ -99,23 +99,18 @@ export function AppShell() {
), ),
) )
} }
> />
<option value="system">시스템 테마</option>
<option value="light">라이트 테마</option>
<option value="dark">다크 테마</option>
</select>
<span className="session-status" data-state={sessionState}> <span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]} {SESSION_LABELS[sessionState]}
</span> </span>
{integrationAvailable ? ( {integrationAvailable ? (
<button <Button
className="ui-button ui-button--compact"
type="button"
disabled={sessionActionPending} disabled={sessionActionPending}
onClick={() => void runSessionAction()} onClick={() => void runSessionAction()}
size="compact"
> >
{sessionActionPending ? "처리 중…" : sessionActionLabel} {sessionActionPending ? "처리 중…" : sessionActionLabel}
</button> </Button>
) : null} ) : null}
</div> </div>
{sessionActionFailed ? ( {sessionActionFailed ? (
@@ -124,40 +119,45 @@ export function AppShell() {
</p> </p>
) : null} ) : null}
</header> </header>
<aside <aside className="app-shell__sidebar" aria-label="사이드바">
className="app-shell__sidebar" <PrimaryNavigation id="primary-navigation" />
data-open={navigationOpen}
aria-label="사이드바"
>
<nav id="primary-navigation" aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
</aside> </aside>
{navigationOpen ? ( <Drawer
<button closeLabel="메뉴 닫기"
className="app-shell__scrim" onClose={() => setNavigationOpen(false)}
type="button" open={navigationOpen}
aria-label="메뉴 닫기" title="메뉴"
onClick={() => setNavigationOpen(false)} >
/> <PrimaryNavigation id="mobile-primary-navigation" />
) : null} </Drawer>
<main className="app-shell__content" id="main-content" tabIndex={-1}> <main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>
); );
} }
/**
* @param {{ id: string }} props
*/
function PrimaryNavigation({ id }) {
return (
<nav id={id} aria-label="주요 탐색">
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
<NavLink
className={({ isActive }) =>
`app-navigation__link${isActive ? " is-active" : ""}`
}
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
</NavLink>
</li>
))}
</ul>
</nav>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js"; import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../design-system/index.js";
import { useApplication } from "../providers/application-provider.js"; import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([ const READINESS_ITEMS = Object.freeze([
+1 -1
View File
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js"; import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../design-system/index.js";
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
+1 -1
View File
@@ -28,7 +28,7 @@ import {
RouteBoundary, RouteBoundary,
} from "../boundaries/render-error-boundary.jsx"; } from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js"; import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { PageHeader } from "../components/page-header.jsx"; import { PageHeader } from "../design-system/index.js";
import { AppShell } from "../layouts/app-shell.jsx"; import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js"; import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx"; import { SessionProvider, useSession } from "../providers/session-provider.jsx";
+454 -69
View File
@@ -1,35 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "../design-system/tokens/primitive.css";
@theme { @import "../design-system/tokens/semantic.css";
--color-surface: oklch(0.985 0.003 247); @import "../design-system/tokens/component.css";
--color-surface-muted: oklch(0.94 0.01 247);
--color-panel: oklch(1 0 0);
--color-border: oklch(0.87 0.015 247);
--color-content: oklch(0.25 0.025 247);
--color-content-muted: oklch(0.48 0.025 247);
--color-action: oklch(0.55 0.18 255);
--color-action-hover: oklch(0.48 0.2 255);
--color-danger: oklch(0.55 0.2 25);
--color-danger-hover: oklch(0.47 0.2 25);
--color-on-action: oklch(1 0 0);
--color-focus: oklch(0.72 0.16 225);
--color-info-content: oklch(0.38 0.16 255);
--color-info-surface: oklch(0.95 0.03 255);
--color-info-border: oklch(0.75 0.08 250);
--color-success-content: oklch(0.35 0.12 155);
--color-success-surface: oklch(0.95 0.04 155);
--color-success-border: oklch(0.72 0.1 155);
--color-warning-content: oklch(0.38 0.12 70);
--color-warning-surface: oklch(0.96 0.05 80);
--color-warning-border: oklch(0.75 0.12 80);
--color-danger-content: oklch(0.42 0.18 25);
--color-danger-surface: oklch(0.96 0.035 25);
--color-danger-border: oklch(0.72 0.12 25);
--radius-control: 0.5rem;
--radius-surface: 0.75rem;
--spacing-page: 1.5rem;
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
}
@layer base { @layer base {
:root { :root {
@@ -38,33 +10,6 @@
font-family: var(--font-sans); font-family: var(--font-sans);
} }
:root[data-theme="dark"] {
--color-surface: oklch(0.16 0.02 255);
--color-surface-muted: oklch(0.25 0.025 255);
--color-panel: oklch(0.205 0.022 255);
--color-border: oklch(0.36 0.025 255);
--color-content: oklch(0.94 0.012 255);
--color-content-muted: oklch(0.74 0.025 255);
--color-action: oklch(0.7 0.14 250);
--color-action-hover: oklch(0.79 0.12 245);
--color-danger: oklch(0.68 0.19 25);
--color-danger-hover: oklch(0.76 0.16 25);
--color-on-action: oklch(0.16 0.02 255);
--color-focus: oklch(0.82 0.15 220);
--color-info-content: oklch(0.83 0.09 250);
--color-info-surface: oklch(0.27 0.045 255);
--color-info-border: oklch(0.55 0.09 250);
--color-success-content: oklch(0.83 0.1 155);
--color-success-surface: oklch(0.27 0.045 155);
--color-success-border: oklch(0.53 0.1 155);
--color-warning-content: oklch(0.88 0.1 80);
--color-warning-surface: oklch(0.29 0.045 80);
--color-warning-border: oklch(0.58 0.11 80);
--color-danger-content: oklch(0.84 0.11 25);
--color-danger-surface: oklch(0.28 0.055 25);
--color-danger-border: oklch(0.56 0.13 25);
}
body { body {
margin: 0; margin: 0;
min-width: 20rem; min-width: 20rem;
@@ -187,13 +132,25 @@
} }
.theme-selector { .theme-selector {
min-height: 2.25rem; display: block;
}
.theme-selector .ui-field__label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.theme-selector .ui-field__input {
min-height: var(--size-control-sm);
max-width: 8.5rem; max-width: 8.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 0.35rem 2rem 0.35rem 0.65rem; padding: 0.35rem 2rem 0.35rem 0.65rem;
color: var(--color-content);
background: var(--color-panel);
cursor: pointer; cursor: pointer;
} }
@@ -305,7 +262,12 @@
.ui-button:disabled { .ui-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: var(--opacity-disabled);
}
.ui-button--pending {
gap: var(--space-2);
cursor: progress;
} }
.ui-button--secondary { .ui-button--secondary {
@@ -339,11 +301,59 @@
} }
.ui-button--compact { .ui-button--compact {
min-height: 2.25rem; min-height: var(--size-control-sm);
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.ui-icon-button {
width: var(--size-touch-target);
min-width: var(--size-touch-target);
padding: 0;
}
.app-shell__menu-button.ui-icon-button {
display: none;
}
.ui-icon {
display: inline-block;
flex: 0 0 auto;
stroke-width: var(--icon-stroke-default);
}
.ui-icon--small {
width: var(--size-icon-sm);
height: var(--size-icon-sm);
}
.ui-icon--medium {
width: var(--size-icon-md);
height: var(--size-icon-md);
}
.ui-icon--large {
width: var(--size-icon-lg);
height: var(--size-icon-lg);
}
.ui-spinner {
display: inline-block;
width: var(--size-icon-sm);
height: var(--size-icon-sm);
box-sizing: border-box;
border: 2px solid currentColor;
border-inline-end-color: transparent;
border-radius: var(--radius-full);
animation: ui-spin var(--duration-slow) linear infinite;
}
@keyframes ui-spin {
to {
transform: rotate(360deg);
}
}
.ui-skeleton { .ui-skeleton {
@apply h-24 animate-pulse rounded-surface bg-surface-muted; @apply h-24 animate-pulse rounded-surface bg-surface-muted;
} }
@@ -391,7 +401,110 @@
} }
.ui-field__input[aria-invalid="true"] { .ui-field__input[aria-invalid="true"] {
border-color: var(--color-danger); border-color: var(--field-border-invalid);
}
.ui-field__textarea {
min-height: 7rem;
resize: vertical;
}
.ui-field__counter {
justify-self: end;
color: var(--color-content-muted);
font-size: var(--font-size-sm);
}
.ui-choice-field,
.ui-radio-group,
.ui-switch-field {
display: grid;
gap: var(--space-2);
}
.ui-choice-field__control {
display: flex;
min-height: var(--size-touch-target);
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.ui-choice-field__control input {
width: 1.15rem;
height: 1.15rem;
accent-color: var(--color-action);
}
.ui-choice-field__control small {
display: block;
color: var(--color-content-muted);
}
.ui-radio-group {
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-3);
}
.ui-radio-group legend {
padding-inline: var(--space-1);
font-weight: var(--font-weight-bold);
}
.ui-switch {
display: inline-flex;
width: fit-content;
min-height: var(--size-touch-target);
align-items: center;
gap: var(--space-2);
border: 0;
color: var(--color-content);
background: transparent;
cursor: pointer;
}
.ui-switch__thumb {
display: inline-block;
width: 2.5rem;
height: 1.5rem;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
background: var(--color-disabled-surface);
}
.ui-switch__thumb::after {
display: block;
width: 1rem;
height: 1rem;
margin: 0.2rem;
border-radius: var(--radius-full);
background: var(--color-content-muted);
content: "";
transition: transform var(--duration-normal) var(--easing-standard);
}
.ui-switch[aria-checked="true"] .ui-switch__thumb {
border-color: var(--color-action);
background: var(--color-action);
}
.ui-switch[aria-checked="true"] .ui-switch__thumb::after {
background: var(--color-on-action);
transform: translateX(1rem);
}
[dir="rtl"] .ui-switch[aria-checked="true"] .ui-switch__thumb::after {
transform: translateX(-1rem);
}
.ui-search-field {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: end;
gap: var(--space-2);
} }
.ui-card { .ui-card {
@@ -406,7 +519,7 @@
box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent); box-shadow: 0 8px 28px color-mix(in oklch, var(--color-content) 6%, transparent);
} }
.ui-card__header h3, .ui-card__header :is(h2, h3, h4),
.ui-card__header p { .ui-card__header p {
margin-block-end: 0; margin-block-end: 0;
} }
@@ -478,6 +591,13 @@
cursor: pointer; cursor: pointer;
} }
.ui-alert__dismiss.ui-icon-button,
.ui-dialog__close.ui-icon-button {
min-width: 2rem;
min-height: 2rem;
padding: 0;
}
.ui-alert__dismiss:hover, .ui-alert__dismiss:hover,
.ui-dialog__close:hover { .ui-dialog__close:hover {
background: color-mix(in oklch, var(--color-content) 8%, transparent); background: color-mix(in oklch, var(--color-content) 8%, transparent);
@@ -523,7 +643,7 @@
padding: 0; padding: 0;
color: var(--color-content); color: var(--color-content);
background: var(--color-panel); background: var(--color-panel);
box-shadow: 0 24px 70px color-mix(in oklch, var(--color-content) 25%, transparent); box-shadow: var(--dialog-elevation);
} }
.ui-dialog::backdrop { .ui-dialog::backdrop {
@@ -531,6 +651,271 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.ui-drawer {
width: var(--drawer-width);
max-width: none;
height: 100dvh;
max-height: none;
margin-block: 0;
border-radius: 0;
}
.ui-drawer--start {
margin-inline: 0 auto;
}
.ui-drawer--end {
margin-inline: auto 0;
}
.ui-drawer .ui-dialog__surface {
min-height: 100%;
box-sizing: border-box;
}
.ui-popover,
.ui-menu,
.ui-tooltip {
position: relative;
display: inline-block;
}
.ui-popover__content,
.ui-menu__content {
position: absolute;
z-index: var(--layer-popover);
top: calc(100% + var(--space-2));
inset-inline-start: 0;
min-width: 12rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-2);
background: var(--color-surface-elevated);
box-shadow: var(--elevation-popover);
}
.ui-menu__content {
display: grid;
gap: var(--space-1);
}
.ui-menu__item {
min-height: var(--size-control-sm);
border: 0;
border-radius: var(--radius-control);
padding: var(--space-2) var(--space-3);
color: var(--color-content);
background: transparent;
text-align: start;
cursor: pointer;
}
.ui-menu__item:hover,
.ui-menu__item:focus-visible {
background: var(--color-surface-muted);
}
.ui-tooltip__content {
position: absolute;
z-index: var(--layer-popover);
inset-block-end: calc(100% + var(--space-2));
inset-inline-start: 50%;
width: max-content;
max-width: 16rem;
padding: var(--space-2);
border-radius: var(--radius-control);
color: var(--color-content-inverse);
background: var(--color-content);
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity var(--duration-fast) var(--easing-standard);
}
.ui-tooltip:focus-within .ui-tooltip__content,
.ui-tooltip:hover .ui-tooltip__content {
opacity: 1;
}
.ui-progress,
.ui-progress__label {
display: grid;
gap: var(--space-2);
}
.ui-progress__label {
grid-template-columns: 1fr auto;
}
.ui-progress__bar {
width: 100%;
accent-color: var(--color-action);
}
.ui-skeleton--text {
height: 1em;
}
.ui-skeleton--control {
height: var(--size-control-md);
}
.ui-skeleton--surface {
height: 6rem;
}
.ui-separator {
width: 100%;
border: 0;
border-block-start: 1px solid var(--color-border);
}
.ui-breadcrumbs ol,
.ui-pagination,
.ui-tabs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.ui-breadcrumbs li:not(:last-child)::after {
margin-inline-start: var(--space-2);
color: var(--color-content-muted);
content: "/";
}
.ui-tabs {
display: grid;
gap: var(--space-3);
}
.ui-tabs__list {
border-block-end: 1px solid var(--color-border);
}
.ui-tabs__tab {
min-height: var(--size-touch-target);
border: 0;
border-block-end: 0.2rem solid transparent;
padding: var(--space-2) var(--space-3);
color: var(--color-content-muted);
background: transparent;
cursor: pointer;
}
.ui-tabs__tab[aria-selected="true"] {
border-block-end-color: var(--color-action);
color: var(--color-content);
font-weight: var(--font-weight-bold);
}
.ui-tabs__panel {
padding: var(--space-3);
}
.ui-toast-region {
position: fixed;
z-index: var(--layer-toast);
inset-block-end: var(--space-4);
inset-inline-end: var(--space-4);
display: grid;
width: var(--toast-width);
gap: var(--space-2);
}
.ui-toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
border: 1px solid var(--color-border);
border-inline-start: 0.25rem solid var(--color-info-border);
border-radius: var(--radius-control);
padding: var(--space-3);
background: var(--color-surface-elevated);
box-shadow: var(--elevation-popover);
}
.ui-toast--success {
border-inline-start-color: var(--color-success-border);
}
.ui-toast--warning {
border-inline-start-color: var(--color-warning-border);
}
.ui-toast--danger {
border-inline-start-color: var(--color-danger-border);
}
.ui-toast p {
margin: var(--space-1) 0 0;
color: var(--color-content-muted);
}
.ui-data-table__scroll {
max-width: 100%;
overflow-x: auto;
}
.ui-data-table {
width: 100%;
border-collapse: collapse;
}
.ui-data-table caption {
margin-block-end: var(--space-2);
font-weight: var(--font-weight-bold);
text-align: start;
}
.ui-data-table th,
.ui-data-table td {
border-block-end: 1px solid var(--color-border);
padding: var(--space-3);
text-align: start;
}
.ui-search-filter-toolbar,
.ui-pagination-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: var(--space-3);
}
.ui-disclosure-group {
display: grid;
gap: var(--space-2);
}
.ui-disclosure-group details {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: var(--space-3);
}
.ui-disclosure-group summary {
min-height: var(--size-control-sm);
cursor: pointer;
font-weight: var(--font-weight-bold);
}
.ui-focus-ring {
display: inline-flex;
border-radius: var(--radius-control);
}
.ui-focus-ring:focus-within {
outline: 0.1875rem solid var(--color-focus);
outline-offset: 0.1875rem;
}
.ui-dialog__surface { .ui-dialog__surface {
padding: 1.5rem; padding: 1.5rem;
} }
@@ -920,7 +1305,7 @@
padding-inline: 0.75rem; padding-inline: 0.75rem;
} }
.app-shell__menu-button { .app-shell__menu-button.ui-icon-button {
display: inline-flex; display: inline-flex;
} }
@@ -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); .evaluate((body) => getComputedStyle(body).animationDuration);
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration); 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 menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true"); await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); 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 page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false"); await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden(); 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");
});
});