Compare commits

...
93 changed files with 5502 additions and 521 deletions
+18
View File
@@ -23,6 +23,24 @@ module.exports = {
from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
{
name: "page-templates-own-layout-only",
severity: "error",
from: { path: "^src/presentation/templates" },
to: {
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",
severity: "error",
+1 -1
View File
@@ -15,4 +15,4 @@ M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: 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.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_DETAIL accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_DETAIL
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_FORM accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_FORM
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
@@ -1,4 +1,4 @@
# SAMPLE_RESOURCE_LIST accessibility review
# REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_LIST
@@ -0,0 +1,18 @@
# REFERENCE_RESOURCE_STATUS accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_STATUS
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: not-applicable (no modal on this route)
M5 Error association: not-applicable (no form error on this route)
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending.
+7 -1
View File
@@ -80,7 +80,9 @@
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "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:icon-button", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -150,6 +152,8 @@
"name": "architecture",
"steps": [
{ "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:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
@@ -157,6 +161,8 @@
"logPath": "artifacts/quality/gates/FE-GATE-010.txt",
"evidence": [
"artifacts/quality/dependency-report.json",
"artifacts/quality/design-system.json",
"artifacts/quality/design-system-fixture.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
+11 -2
View File
@@ -8,12 +8,15 @@
"owner": "feature-routing-navigation-guard-contract",
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
"paramsSchema": [null, "NotFoundSplat"],
"paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
"reference-resource-detail",
"reference-resource-form",
"reference-resource-status",
"none"
],
"errorSurface": [
@@ -27,6 +30,9 @@
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
"route-reference-resource-detail",
"route-reference-resource-form",
"route-reference-resource-status",
"route-not-found"
]
},
@@ -67,9 +73,12 @@
"state-gallery-page",
"auth-example-page",
"reference-resource-page",
"reference-resource-detail-page",
"reference-resource-form-page",
"reference-resource-status-page",
"not-found-page"
],
"paramsCodec": ["none", "NotFoundSplat"],
"paramsCodec": ["none", "NotFoundSplat", "ReferenceResourceParams"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"references": [
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -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를 유지한다.
@@ -67,18 +67,18 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
| 검증 | 준비 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 |
| 폼 | 미제공 | 수동 문자열 검증 예제만 존재 | schema 기반 form facade와 422/dirty/pending 정책 |
| 앱 셸·반응형 | 준비 | native modal Drawer, compact/desktop layout, Escape/link dismiss와 focus restore | RP-08에서 RTL/direction 검증 |
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status와 public design-system entry | feature별 slot 조합 유지 |
| 디자인 토큰 | 준비 | primitive/semantic/component CSS, 48-token 자동 계약, dark/forced-colors/reduced-motion | 제품 brand token은 외부 프로젝트에서 확장 |
| 공통 UI | 준비 | action/form/feedback/overlay/navigation primitive와 pattern, compatibility export | Storybook/visual은 RP-10 |
| 아이콘 | 준비됨 | 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 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
@@ -236,8 +236,8 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
### 5.2 P1: 공통 플랫폼 기본 제공 항목
- schema 기반 form facade와 field/error/pending/dirty/422 정책
- standard, collection, detail, form, status page template
- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton`
@@ -508,6 +508,22 @@ RP-06은 기존 reference controls/layout으로 돌아가도 controller/applicat
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다.
**구현 증거 (2026-07-26)**
- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
채택했고 vendor 도입 조건을 문서화했다.
- `src/presentation/forms`가 field registration, parse/error map,
dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
duplicate submit과 dirty navigation을 제공한다.
- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
계약을 제공하며 architecture negative fixture가 application/vendor import를
거절한다.
- reference list/detail/create/status route가 네 구체 template를 사용하고
production composition test가 list → form → command → HTTP → invalidation →
list 경로를 실행한다.
- component/integration/E2E test가 validation, transform, 422, conflict,
secret 비노출, navigation focus와 320px reflow를 검증한다.
### 07. `feature-frontend-design-system-platform`
**목표**
@@ -572,6 +588,22 @@ RP-07은 compatibility export로 기존 primitive import를 복구할 수 있어
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`
**목표**
@@ -163,6 +163,21 @@ contract/presentation은 `src/features/reference-feature`가 소유하고, gener
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
presentation schema, controlled field state, error focus, 422 allowlist,
pending/deduplication과 dirty navigation을 local facade로 감싼다.
`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
query vendor import는 architecture gate가 거절한다. reference feature의
list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
실제 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로 올리지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
@@ -1249,8 +1264,8 @@ contract와 실패 분기를 우선한다.
### P1: 기본 플랫폼 완성도
1. error/result/validation/form kernel
2. 배타적인 async 상태와 page template
1. 완료: error/result/validation/form kernel
2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계
@@ -12,9 +12,9 @@
- page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴
## 2. 현재 상태와 문제
## 2. 현재 상태와 구현 기준
RP-04 이후 현재 구현에는 다음 장점이 있다.
RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다.
- contract에서 Data Router route object와 navigation을 생성한다.
@@ -22,6 +22,12 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
- 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
- `src/presentation/templates`가 standard/collection/detail/form/status slot,
landmark와 responsive layout을 제공한다.
- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
duplicate submit과 dirty route blocker를 소유한다.
- reference feature의 list/detail/create/status route가 네 template variation을
production composition에서 실행한다.
platform route 계약, `src/features/installed-feature-contracts.js`,
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
@@ -31,9 +37,11 @@ access, title, navigation, chunk ID는
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
구현해야 한다.
template는 데이터를 가져오지 않는다. reference page controller가 route input과
application input을 query/form facade에 연결하고, template에는 render할 slot과
안전한 callback만 전달한다. 이 분리는
`page-templates-own-layout-only` dependency rule과 forbidden import fixture가
검증한다.
## 3. React Router mode 결정
@@ -487,8 +487,12 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
## 8. 폼 표준
form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에
둔다. vendor 선택과 무관하게 다음 API를 제공한다.
VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
Form 또는 TanStack Form adapter를 평가한다.
현재 public API는 다음과 같다.
- `Form`
- `FormField`
@@ -500,6 +504,18 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- `mapValidationFailureToFields`
- `useDirtyNavigationGuard`
구현 위치:
- controller와 mapping: `src/presentation/forms`
- layout-only template: `src/presentation/templates`
- feature form schema/command mapper:
`src/features/reference-feature/presentation/reference-resource-form.ts`
- 실제 create page:
`src/features/reference-feature/presentation/reference-resource-form-page.tsx`
`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path``code`만 담는다.
backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
필수 동작:
1. label, description, error를 stable ID와 `aria-describedby`로 연결
+17 -13
View File
@@ -34,6 +34,10 @@
- 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음
- `src/presentation/forms`
- local form facade, field/error summary, dirty navigation dialog
- `src/presentation/templates`
- Standard, Collection, Detail, Form, Status page template
- `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트
@@ -43,23 +47,23 @@
- Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다.
RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
breakpoint가 계약으로 닫혀 있지 않다.
2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수
다.
3. 앱 셸 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
class를 다시 작성하는 곳이 있다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다.
1. typography, elevation, motion, z-layer, control size와 breakpoint는
`design-system/tokens`의 3계층과 자동 gate로 닫혔다.
2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸, 예제와 reference feature는 public design-system barrel을 소비한다.
4. 문자 glyph는 semantic Lucide facade로 교체됐다.
5. token은 세 CSS 파일로 분리됐고 `theme.css`는 layout/component styling만
소유한다.
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지
템플릿이 없다.
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus
복원, 배경 비활성화 계약은 없다.
8. page template, form과 pattern은 public barrel에서 제공된다.
9. 모바일 navigation은 native modal Drawer로 focus 이동, 배경 비활성화,
Escape/link dismiss와 trigger focus restore를 제공한다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
@@ -454,6 +454,16 @@ composition을 검증한다. 제거 모드는 feature source/tests와 installed
contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset
잔여 0개를 다시 검증한다.
RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`,
`tests/component/page-templates.test.tsx`,
`tests/features/reference-feature/reference-page.test.tsx`
`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와
첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422
field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation
confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다.
HTTP integration test는 backend copy를 버리고 422 `path`/`code`만 전달하는지
별도로 검사한다.
## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을
@@ -552,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
- native role/name/value
+12
View File
@@ -190,6 +190,17 @@ export default [
]),
},
},
{
files: [`src/presentation/templates/**/*.${sourceExtensions}`],
rules: {
"no-restricted-imports": restrictedImports([
"**/application/**",
"**/adapters/**",
"**/bootstrap/**",
"@tanstack/**",
]),
},
},
{
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: {
@@ -272,6 +283,7 @@ export default [
"@tanstack/**",
"react",
"react-dom",
"**/application/**",
]),
"no-restricted-globals": [
"error",
+5
View File
@@ -15,6 +15,8 @@
"preview": "vite preview",
"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: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:app": "tsc --project tsconfig.app.json",
"check:types:node": "tsc --project tsconfig.node.json",
@@ -26,6 +28,8 @@
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.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: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: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",
@@ -57,6 +61,7 @@
},
"dependencies": {
"@tanstack/react-query": "5.101.4",
"lucide-react": "1.25.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.1",
+12
View File
@@ -11,6 +11,9 @@ importers:
'@tanstack/react-query':
specifier: 5.101.4
version: 5.101.4(react@19.2.8)
lucide-react:
specifier: 1.25.0
version: 1.25.0(react@19.2.8)
react:
specifier: 19.2.8
version: 19.2.8
@@ -1536,6 +1539,11 @@ packages:
lru-cache@5.1.1:
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:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -3314,6 +3322,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.25.0(react@19.2.8):
dependencies:
react: 19.2.8
lz-string@1.5.0: {}
magic-string@0.30.21:
+3
View File
@@ -14,6 +14,9 @@
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.jsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
"route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
"route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
"route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
}
+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`,
);
+5
View File
@@ -173,6 +173,11 @@ const adHocPatterns = [
/** @param {string} directory */
async function scanDirectory(directory) {
try {
await access(directory);
} catch {
return;
}
const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }),
);
+11 -8
View File
@@ -14,6 +14,11 @@ const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
const featureOwnedPaths = [
featureSource,
featureTests,
"tests/e2e/reference-form.spec.js",
];
const copyTargets = [
"src",
"tests",
@@ -112,14 +117,12 @@ for (const target of copyTargets) {
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
await rm(path.join(fixtureRoot, featureSource), {
recursive: true,
force: true,
});
await rm(path.join(fixtureRoot, featureTests), {
recursive: true,
force: true,
});
for (const ownedPath of featureOwnedPaths) {
await rm(path.join(fixtureRoot, ownedPath), {
recursive: true,
force: true,
});
}
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
+9
View File
@@ -4,6 +4,7 @@ import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
@@ -415,6 +416,10 @@ async function parseResponse(
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
const backendError =
envelopeRecord.error && typeof envelopeRecord.error === "object"
? /** @type {Record<string, unknown>} */ (envelopeRecord.error)
: {};
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
@@ -426,6 +431,10 @@ async function parseResponse(
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
validationIssues:
response.status === 422
? safeValidationIssues(backendError.details)
: undefined,
}),
};
}
+58
View File
@@ -195,6 +195,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* userMessageKey: string,
* action: ErrorAction,
* causeClass?: string
@@ -211,6 +212,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
* validationIssues?: readonly Readonly<{path: string, code: string}>[],
* causeClass?: string
* }} [details]
* @returns {ApiFailure}
@@ -236,6 +238,24 @@ export function createFailure(kind, operationId, attempt, details = {}) {
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
...(Array.isArray(details.validationIssues)
? {
validationIssues: Object.freeze(
details.validationIssues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string",
)
.slice(0, 50)
.map((issue) =>
Object.freeze({ path: issue.path, code: issue.code }),
),
),
}
: {}),
...(typeof details.causeClass === "string"
? { causeClass: details.causeClass }
: {}),
@@ -244,6 +264,44 @@ export function createFailure(kind, operationId, attempt, details = {}) {
});
}
/**
* Projects an untrusted 422 details payload into the only validation metadata
* allowed to cross the HTTP boundary. Backend copy and additional values are
* deliberately discarded.
*
* @param {unknown} value
* @returns {readonly Readonly<{path: string, code: string}>[]}
*/
export function safeValidationIssues(value) {
if (!value || typeof value !== "object") return Object.freeze([]);
const candidate =
/** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value);
const issues = Array.isArray(candidate.issues)
? candidate.issues
: Array.isArray(candidate.fieldErrors)
? candidate.fieldErrors
: [];
return Object.freeze(
issues
.filter(
(issue) =>
issue &&
typeof issue === "object" &&
typeof issue.path === "string" &&
typeof issue.code === "string" &&
issue.path.length <= 120 &&
issue.code.length <= 80,
)
.slice(0, 50)
.map((issue) =>
Object.freeze({
path: issue.path,
code: issue.code,
}),
),
);
}
/** @param {number} status */
export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED";
+5 -5
View File
@@ -6,11 +6,11 @@ reference implementation이다.
## 소유 경계
- `domain`: 외부 DTO와 React를 모르는 불변 model
- `application`: UI가 호출하는 list/create input과 gateway 계약
- `application`: UI가 호출하는 list/get/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/mutation controller로 연결하는 inbound
adapter page
- `presentation`: route input을 query/form controller로 연결하는 inbound
adapter, 독립 form schema/command mapper와 list/detail/form/status page
generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이
자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output
@@ -33,7 +33,7 @@ corepack pnpm test:sample-removal
```
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
query/mutation 상태와 production composition을 검증한다. 두 번째 명령은 임시
query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 fixture ID 잔여 0개를 검사한다.
production build와 source/built fixture ID 잔여 0개를 검사한다.
@@ -14,6 +14,7 @@ type HttpExecutor = Readonly<{
request: Readonly<{
operationId: string;
routeId: string;
pathParams?: Record<string, string | number>;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
@@ -42,7 +43,7 @@ export function createReferenceHttpGateway(
}
: result;
},
async create(command: Readonly<{ name: string }>) {
async create(command: Readonly<{ name: string; note?: string }>) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
@@ -52,5 +53,19 @@ export function createReferenceHttpGateway(
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
async get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId },
signal: context?.signal,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
});
}
@@ -21,7 +21,11 @@ export type ReferenceFeatureInput = Readonly<{
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: Readonly<{ name: string }>,
command: Readonly<{ name: string; note?: string }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
@@ -31,7 +35,11 @@ export type ReferenceGateway = Readonly<{
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string }>,
command: Readonly<{ name: string; note?: string }>,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResource>>;
}>;
@@ -54,5 +62,11 @@ export function createReferenceFeatureInput(
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
async getResource(resourceId, context) {
const result = await gateway.get(resourceId, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
});
}
@@ -7,6 +7,9 @@ export const referenceQueryKeys = Object.freeze({
all: () => REFERENCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]),
});
export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
@@ -25,6 +28,45 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
navigationLabel: "Reference feature",
navigationOrder: 50,
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
path: "/examples/reference-resources/:resourceId",
paramsSchema: "ReferenceResourceParams",
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-detail",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-detail",
title: "Reference detail",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
path: "/examples/reference-resources/new",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-form",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-form",
title: "Reference form",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
path: "/examples/reference-resources/status",
paramsSchema: null,
searchSchema: null,
access: "integration-defined",
loadingSurface: "reference-resource-status",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-status",
title: "Reference status",
navigationLabel: null,
navigationOrder: null,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
@@ -33,6 +75,24 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
moduleId: "reference-resource-detail-page",
paramsCodec: "ReferenceResourceParams",
searchCodec: "none",
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
moduleId: "reference-resource-form-page",
paramsCodec: "none",
searchCodec: "none",
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
moduleId: "reference-resource-status-page",
paramsCodec: "none",
searchCodec: "none",
}),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: Object.freeze({
@@ -61,6 +121,19 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
GET_REFERENCE_RESOURCE: Object.freeze({
method: "GET",
path: "/api/reference-resources/{resourceId}",
operationId: "GET_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "none",
requestSchema: "NoRequest",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-form-page-platform",
}),
}),
queryRegistry: Object.freeze({
REFERENCE_RESOURCE: Object.freeze({
@@ -33,7 +33,10 @@ export function mapReferenceOperation(
if (!Array.isArray(payload)) throw new TypeError("Expected a reference list");
return payload.map(mapReferenceDto);
}
if (operationId === "CREATE_REFERENCE_RESOURCE") {
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
@@ -18,6 +18,12 @@ export const referenceResourceListQuerySchema = z
})
.strict();
export const referenceResourceParamsSchema = z
.object({
resourceId: z.string().trim().min(1).max(120),
})
.strict();
const referenceResourceDtoSchema = z
.object({
id: z.string().min(1),
@@ -36,6 +42,7 @@ const requestSchemas = {
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
} satisfies Record<string, z.ZodType>;
@@ -1,9 +1,13 @@
import { lazy } from "react";
import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
@@ -11,4 +15,16 @@ export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
Component: lazy(() => import("./reference-resource-detail-page.js")),
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
moduleId: "reference-resource-form-page",
Component: lazy(() => import("./reference-resource-form-page.js")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.js")),
}),
} as const;
@@ -0,0 +1,49 @@
import { Link } from "react-router-dom";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useReferenceDetail } from "./use-reference-feature.js";
export default function ReferenceResourceDetailPage() {
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
return (
<DetailPage
key={resourceId}
breadcrumb={
<Link to="/examples/reference-resources">Reference resources</Link>
}
heading={{
eyebrow: "DetailPage",
title: resource?.title ?? "Reference detail",
description: "route param과 detail query의 reset 경계를 확인합니다.",
}}
metadata={
resource ? (
<dl>
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>{resource.createdAtLabel ?? "표시 정보 없음"}</dd>
</dl>
) : (
<p> .</p>
)
}
feedback={
<AsyncSurface state={query.state} onRetry={query.retry}>
{resource ? (
<p> section을 .</p>
) : null}
</AsyncSurface>
}
aside={<p> slot입니다.</p>}
/>
);
}
@@ -0,0 +1,131 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/design-system/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.js";
import { useReferenceCreate } from "./use-reference-feature.js";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
note: "설명",
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
export default function ReferenceResourceFormPage() {
const navigate = useNavigate();
const mutation = useReferenceCreate();
const submit = useCallback(
(command: ReturnType<typeof toCreateReferenceCommand>) =>
mutation.submit(command),
[mutation],
);
const form = useAppForm({
schema: referenceResourceFormSchema,
defaultValues: REFERENCE_FORM_DEFAULTS,
allowedServerFields: ["name", "note"],
mapToCommand: toCreateReferenceCommand,
submit,
});
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => void form.submitForm(event)}
>
<FormPage
breadcrumb={
<button
className="ui-button ui-button--ghost"
type="button"
onClick={() => navigate("/examples/reference-resources")}
>
</button>
}
heading={{
eyebrow: "FormPage",
title: "Reference resource 만들기",
description:
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
}}
errorSummary={
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={FIELD_LABELS}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
}
fields={
<>
<FormField
{...form.field("name")}
label={FIELD_LABELS.name}
description="앞뒤 공백은 command mapper 전에 제거됩니다."
autoComplete="off"
required
/>
<FormField
{...form.field("note")}
label={FIELD_LABELS.note}
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
autoComplete="off"
/>
</>
}
formActions={
<FormActions sticky>
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending}
>
</Button>
<Button type="submit" disabled={form.pending}>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending}
>
</Button>
</FormActions>
}
feedback={
form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
) : null
}
aside={
<p>
form value는 URL, storage, telemetry에 submit
application command로 .
</p>
}
guard={<DirtyNavigationDialog guard={guard} />}
/>
</Form>
);
}
@@ -0,0 +1,29 @@
import { z } from "zod";
export const referenceResourceFormSchema = z
.object({
name: z
.string()
.trim()
.min(2, "이름은 두 글자 이상이어야 합니다.")
.max(120),
note: z.string().trim().max(500).default(""),
})
.strict();
export type ReferenceResourceFormValues = z.infer<
typeof referenceResourceFormSchema
>;
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
Object.freeze({
name: "",
note: "",
});
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
return Object.freeze({
name: values.name,
...(values.note ? { note: values.note } : {}),
});
}
@@ -1,60 +1,55 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { PageHeader } from "../../../presentation/components/page-header.jsx";
import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
const { filters, query, mutation } = useReferenceFeature();
const [name, setName] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const result = await mutation.submit({ name });
if (result.ok) setName("");
}
const navigate = useNavigate();
const { filters, query } = useReferenceFeature();
return (
<section className="ui-page">
<PageHeader
eyebrow="제거 가능한 수직 슬라이스"
title="Reference feature"
description="URL codec, application input, HTTP/schema/mapper와 query/mutation 상태를 한 경로로 검증합니다."
/>
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
<CollectionPage
heading={{
eyebrow: "제거 가능한 수직 슬라이스",
title: "Reference feature",
description:
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
}}
actions={[
{
kind: "button",
label: "새 항목 만들기",
onAction: () => navigate("/examples/reference-resources/new"),
},
]}
activeFilters={
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
}
toolbar={<Button onClick={() => void query.retry()}></Button>}
resultCount={
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface state={query.state} onRetry={query.retry}>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li
key={resource.resourceId}
data-optimistic={resource.optimistic || undefined}
>
{resource.title}
<li key={resource.resourceId}>
<Link
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
>
{resource.title}
</Link>
</li>
))}
</ul>
</AsyncSurface>
<AsyncSurface
state={mutation.state}
onResolveConflict={mutation.resolveConflict}
>
<form onSubmit={(event) => void submit(event)}>
<label htmlFor="reference-resource-name"> </label>
<input
id="reference-resource-name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<Button type="submit" disabled={mutation.state.overlay.mutationPending}>
</Button>
</form>
</AsyncSurface>
</section>
</CollectionPage>
);
}
@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
return (
<StatusPage
variant="maintenance"
heading={{
eyebrow: "StatusPage · maintenance",
title: "잠시 사용할 수 없습니다.",
description:
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
}}
primaryAction={{
kind: "button",
label: "목록으로 이동",
onAction: () => navigate("/examples/reference-resources"),
}}
supportReference="REFERENCE-STATUS-DEMO"
/>
);
}
@@ -14,19 +14,38 @@ import type {
ReferenceListFilters,
} from "../application/reference-feature-api.js";
function useReferenceFeatureInput(): ReferenceFeatureInput {
export function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
typeof (candidate as ReferenceFeatureInput).createResource !== "function"
typeof (candidate as ReferenceFeatureInput).createResource !== "function" ||
typeof (candidate as ReferenceFeatureInput).getResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const query = useApplicationQuery({
queryKey: referenceQueryKeys.detail(resourceId),
execute: ({ signal }) => input.getResource(resourceId, { signal }),
});
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
return useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
});
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const routeInput = useRouteInput();
+1 -36
View File
@@ -1,36 +1 @@
/**
* @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>
);
}
export { Alert } from "../../design-system/primitives/core.js";
+1 -9
View File
@@ -1,9 +1 @@
/**
* @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>;
}
export { Badge } from "../../design-system/primitives/core.js";
+1 -24
View File
@@ -1,24 +1 @@
/**
* @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} />;
}
export { Button } from "../../design-system/primitives/core.js";
+1 -28
View File
@@ -1,28 +1 @@
import { useId } from "react";
/**
* @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>
);
}
export { Card } from "../../design-system/primitives/core.js";
+1 -88
View File
@@ -1,88 +1 @@
import { useEffect, useId, useRef } from "react";
/**
* @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>
);
}
export { Dialog } from "../../design-system/primitives/core.js";
+1 -52
View File
@@ -1,52 +1 @@
import { useId } from "react";
/**
* @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>
);
}
export { TextField } from "../../design-system/primitives/core.js";
@@ -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 { 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";
export default function AuthExamplePage() {
@@ -7,15 +7,13 @@ import {
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
import { PageHeader } from "../components/page-header.jsx";
import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
Button,
Card,
PageHeader,
} from "../design-system/index.js";
export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
+142 -8
View File
@@ -1,12 +1,25 @@
import { useState } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Alert } from "../components/ui/alert.jsx";
import { Badge } from "../components/ui/badge.jsx";
import { Button } from "../components/ui/button.jsx";
import { Card } from "../components/ui/card.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { TextField } from "../components/ui/text-field.jsx";
import {
Alert,
Badge,
Button,
Card,
Checkbox,
Dialog,
Menu,
PageHeader,
ProgressBar,
RadioGroup,
Select,
Switch,
Tabs,
TextArea,
TextField,
ToastProvider,
Tooltip,
useToast,
} from "../design-system/index.js";
const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"],
@@ -19,7 +32,21 @@ const COLOR_TOKENS = Object.freeze([
]);
export default function UiGalleryPage() {
return (
<ToastProvider>
<UiGalleryContent />
</ToastProvider>
);
}
function UiGalleryContent() {
const toast = useToast();
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 [dialogOpen, setDialogOpen] = useState(false);
const [notice, setNotice] = useState(
@@ -92,6 +119,60 @@ export default function UiGalleryPage() {
</div>
</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">
<header className="gallery-section__header">
<h2 id="feedback-title">피드백과 모달</h2>
@@ -126,7 +207,32 @@ export default function UiGalleryPage() {
</div>
</Card>
<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
open={dialogOpen}
onClose={() => setDialogOpen(false)}
@@ -157,6 +263,34 @@ export default function UiGalleryPage() {
</div>
</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">
<header className="gallery-section__header">
<h2 id="tokens-title">디자인 토큰</h2>
@@ -0,0 +1,90 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import type {
FieldErrors,
FieldName,
FormValues,
} from "./form-contracts.js";
export function Form(
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
) {
const { pending = false, children, ...formProps } = props;
return (
<form {...formProps} noValidate aria-busy={pending || undefined}>
{children}
</form>
);
}
export function FormField(
props: React.ComponentProps<typeof TextField>,
) {
return <TextField {...props} />;
}
export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors?: readonly string[];
fieldLabels: Readonly<Record<FieldName<Values>, string>>;
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const {
fieldErrors,
formErrors = [],
fieldLabels,
fieldId,
onFocusField,
} = props;
const headingId = useId();
const entries = Object.entries(fieldErrors) as [
FieldName<Values>,
string,
][];
if (entries.length === 0 && formErrors.length === 0) return null;
return (
<section
className="form-error-summary"
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}> .</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
<li key={name}>
<a
href={`#${fieldId(name)}`}
onClick={(event) => {
if (!onFocusField) return;
event.preventDefault();
onFocusField(name);
}}
>
{fieldLabels[name]}: {message}
</a>
</li>
))}
</ul>
) : null}
{formErrors.map((message) => (
<p key={message}>{message}</p>
))}
</section>
);
}
export function FormActions(props: Readonly<{
children: ReactNode;
sticky?: boolean;
}>) {
return (
<div
className={`form-actions${props.sticky ? " form-actions--sticky" : ""}`}
>
{props.children}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { ApiFailure } from "../../contracts/errors.js";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
export type FieldErrors<Values extends FormValues> = Readonly<
Partial<Record<FieldName<Values>, string>>
>;
export type FormResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type FormResultState =
| "idle"
| "success"
| "validation-error"
| "conflict"
| "unavailable";
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
fieldErrors: FieldErrors<Values>;
formErrors: readonly string[];
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "필수 입력값입니다.",
too_small: "입력값이 너무 짧습니다.",
too_big: "입력값이 너무 깁니다.",
invalid_type: "입력 형식을 확인해 주세요.",
invalid_format: "입력 형식을 확인해 주세요.",
invalid_value: "허용된 값을 선택해 주세요.",
});
export function validationMessage(code: string): string {
return (
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"입력값을 확인해 주세요."
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
allowedFields: readonly FieldName<Values>[],
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
}
const allowed = new Set<string>(allowedFields);
const fieldErrors: Partial<Record<FieldName<Values>, string>> = {};
const formErrors: string[] = [];
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push("입력값을 다시 확인해 주세요.");
}
for (const issue of issues) {
const field = issue.path.split(".").at(0) ?? "";
if (allowed.has(field)) {
const name = field as FieldName<Values>;
fieldErrors[name] ??= validationMessage(issue.code);
} else {
formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
}
}
return Object.freeze({
fieldErrors: Object.freeze(fieldErrors),
formErrors: Object.freeze([...new Set(formErrors)]),
});
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./form-components.js";
export * from "./form-contracts.js";
export * from "./use-app-form.js";
export * from "./use-dirty-navigation-guard.js";
+244
View File
@@ -0,0 +1,244 @@
import {
useCallback,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
mapValidationFailureToFields,
validationMessage,
type FieldErrors,
type FieldName,
type FormResult,
type FormResultState,
type FormValues,
} from "./form-contracts.js";
type AppFormOptions<
Values extends FormValues,
Command,
Output,
> = Readonly<{
schema: ZodType<Values>;
defaultValues: Values;
allowedServerFields: readonly FieldName<Values>[];
mapToCommand(values: Values): Command;
submit(command: Command): Promise<FormResult<Output>>;
resetOnSuccess?: boolean;
}>;
export function useAppForm<
Values extends FormValues,
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const {
schema,
defaultValues,
allowedServerFields,
mapToCommand,
submit,
resetOnSuccess = true,
} = options;
const generatedId = useId().replaceAll(":", "");
const formId = `app-form-${generatedId}`;
const [values, setValues] = useState<Values>(defaultValues);
const [initialValues, setInitialValues] = useState<Values>(defaultValues);
const [touched, setTouched] = useState<ReadonlySet<FieldName<Values>>>(
() => new Set(),
);
const [fieldErrors, setFieldErrors] = useState<FieldErrors<Values>>(
() => ({} as FieldErrors<Values>),
);
const [formErrors, setFormErrors] = useState<readonly string[]>([]);
const [pending, setPending] = useState(false);
const [result, setResult] = useState<FormResultState>("idle");
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
const dirty = useMemo(
() => JSON.stringify(values) !== JSON.stringify(initialValues),
[initialValues, values],
);
const fieldId = useCallback(
(name: FieldName<Values>) => `${formId}-${name}`,
[formId],
);
const focusField = useCallback(
(name: FieldName<Values>) => {
const field = document.getElementById(fieldId(name));
if (field instanceof HTMLElement) field.focus();
},
[fieldId],
);
const focusFirstError = useCallback(
(errors: FieldErrors<Values>) => {
const first = allowedServerFields.find((name) => Boolean(errors[name]));
if (first) focusField(first);
},
[allowedServerFields, focusField],
);
const setValue = useCallback(
(name: FieldName<Values>, value: Values[FieldName<Values>]) => {
setValues((current) => ({ ...current, [name]: value }) as Values);
setFieldErrors((current) => {
if (!current[name]) return current;
const next = { ...current };
delete next[name];
return next;
});
setFormErrors([]);
setResult("idle");
},
[],
);
const field = useCallback(
(name: FieldName<Values>) => ({
id: fieldId(name),
name,
value: String(values[name] ?? ""),
onChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setValue(name, event.currentTarget.value as Values[FieldName<Values>]);
},
onBlur() {
setTouched((current) => new Set(current).add(name));
},
error: fieldErrors[name],
"aria-invalid": fieldErrors[name] ? ("true" as const) : undefined,
}),
[fieldErrors, fieldId, setValue, values],
);
const reset = useCallback(
(nextValues: Values = defaultValues) => {
setValues(nextValues);
setInitialValues(nextValues);
setTouched(new Set());
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
setResult("idle");
},
[defaultValues],
);
const submitForm = useCallback(
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
event?.preventDefault();
if (pendingRef.current) return pendingRef.current;
setFieldErrors({} as FieldErrors<Values>);
setFormErrors([]);
const parsed = await schema.safeParseAsync(values);
if (!parsed.success) {
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? ["입력 구성을 다시 확인해 주세요."]
: [],
);
setTouched(new Set(allowedServerFields));
setResult("validation-error");
focusFirstError(errors);
return null;
}
const command = mapToCommand(parsed.data);
setPending(true);
const execution = submit(command);
pendingRef.current = execution;
try {
const outcome = await execution;
if (outcome.ok) {
setResult("success");
if (resetOnSuccess) {
setValues(defaultValues);
setInitialValues(defaultValues);
setTouched(new Set());
} else {
setInitialValues(parsed.data);
}
return outcome;
}
if (outcome.error.kind === "VALIDATION_REJECTED") {
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
setResult("validation-error");
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
]);
setResult("conflict");
} else {
setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
setResult("unavailable");
}
return outcome;
} finally {
pendingRef.current = null;
setPending(false);
}
},
[
allowedServerFields,
defaultValues,
focusFirstError,
mapToCommand,
resetOnSuccess,
schema,
submit,
values,
],
);
return Object.freeze({
formId,
values,
dirty,
touched,
fieldErrors,
formErrors,
pending,
result,
field,
fieldId,
focusField,
setValue,
submitForm,
reset,
});
}
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
for (const issue of issues) {
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code);
}
return Object.freeze(errors);
}
@@ -0,0 +1,54 @@
import { useCallback } from "react";
import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
useBeforeUnload(
useCallback(
(event) => {
if (!when) return;
event.preventDefault();
event.returnValue = "";
},
[when],
),
{ capture: true },
);
return Object.freeze({
blocked: blocker.state === "blocked",
stay() {
blocker.reset?.();
},
leave() {
blocker.proceed?.();
},
});
}
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title="저장하지 않은 변경이 있습니다."
description="이 화면을 떠나면 입력한 내용이 사라집니다."
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
</Button>
<Button variant="danger" onClick={props.guard.leave}>
</Button>
</>
}
/>
);
}
+59 -59
View File
@@ -5,6 +5,13 @@ import {
NAVIGATION_ROUTES,
routePath,
} 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 { useTheme } from "../providers/theme-provider.jsx";
@@ -27,16 +34,6 @@ export function AppShell() {
setNavigationOpen(false);
}, [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() {
setSessionActionPending(true);
setSessionActionFailed(false);
@@ -71,26 +68,29 @@ export function AppShell() {
본문으로 건너뛰기
</a>
<header className="app-shell__header">
<button
<IconButton
accessibleName="메뉴"
className="app-shell__menu-button"
type="button"
aria-controls="primary-navigation"
aria-controls="mobile-primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
variant="secondary"
>
<span aria-hidden="true"></span>
<span>메뉴</span>
</button>
<MenuIcon />
</IconButton>
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
Frontend Skeleton
</NavLink>
<div className="app-shell__session">
<label className="visually-hidden" htmlFor="theme-preference">
색상 테마
</label>
<select
<Select
className="theme-selector"
id="theme-preference"
label="색상 테마"
options={[
{ value: "system", label: "시스템 테마" },
{ value: "light", label: "라이트 테마" },
{ value: "dark", label: "다크 테마" },
]}
value={preference}
onChange={(event) =>
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}>
{SESSION_LABELS[sessionState]}
</span>
{integrationAvailable ? (
<button
className="ui-button ui-button--compact"
type="button"
<Button
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
size="compact"
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
</button>
</Button>
) : null}
</div>
{sessionActionFailed ? (
@@ -124,40 +119,45 @@ export function AppShell() {
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
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 className="app-shell__sidebar" aria-label="사이드바">
<PrimaryNavigation id="primary-navigation" />
</aside>
{navigationOpen ? (
<button
className="app-shell__scrim"
type="button"
aria-label="메뉴 닫기"
onClick={() => setNavigationOpen(false)}
/>
) : null}
<Drawer
closeLabel="메뉴 닫기"
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
title="메뉴"
>
<PrimaryNavigation id="mobile-primary-navigation" />
</Drawer>
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</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 { 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";
const READINESS_ITEMS = Object.freeze([
+1 -1
View File
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
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() {
return (
+1 -1
View File
@@ -28,7 +28,7 @@ import {
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
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 { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
+607 -69
View File
@@ -1,35 +1,7 @@
@import "tailwindcss";
@theme {
--color-surface: oklch(0.985 0.003 247);
--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;
}
@import "../design-system/tokens/primitive.css";
@import "../design-system/tokens/semantic.css";
@import "../design-system/tokens/component.css";
@layer base {
:root {
@@ -38,33 +10,6 @@
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 {
margin: 0;
min-width: 20rem;
@@ -187,13 +132,25 @@
}
.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;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 0.35rem 2rem 0.35rem 0.65rem;
color: var(--color-content);
background: var(--color-panel);
cursor: pointer;
}
@@ -305,7 +262,12 @@
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.55;
opacity: var(--opacity-disabled);
}
.ui-button--pending {
gap: var(--space-2);
cursor: progress;
}
.ui-button--secondary {
@@ -339,11 +301,59 @@
}
.ui-button--compact {
min-height: 2.25rem;
min-height: var(--size-control-sm);
padding: 0.4rem 0.75rem;
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 {
@apply h-24 animate-pulse rounded-surface bg-surface-muted;
}
@@ -391,7 +401,110 @@
}
.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 {
@@ -406,7 +519,7 @@
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 {
margin-block-end: 0;
}
@@ -478,6 +591,13 @@
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-dialog__close:hover {
background: color-mix(in oklch, var(--color-content) 8%, transparent);
@@ -523,7 +643,7 @@
padding: 0;
color: var(--color-content);
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 {
@@ -531,6 +651,271 @@
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 {
padding: 1.5rem;
}
@@ -590,6 +975,141 @@
line-height: 1.7;
}
.page-template {
display: grid;
gap: 1.25rem;
min-width: 0;
}
.page-template__breadcrumb {
padding-block-start: 1rem;
color: var(--color-content-muted);
font-size: 0.9rem;
}
.page-template__heading {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 1rem;
}
.page-template__heading .page-header {
grid-row: span 2;
min-width: 0;
}
.page-template__status,
.page-template__actions,
.status-page__actions,
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.page-template__layout {
display: grid;
min-width: 0;
gap: 1.5rem;
}
.page-template__layout[data-has-aside="true"] {
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
}
.page-template__content,
.page-template__aside,
.collection-page__results,
.detail-page__sections,
.form-page__fields {
min-width: 0;
}
.page-template__aside,
.detail-page__metadata,
.collection-page__toolbar,
.form-error-summary {
border: 1px solid var(--color-border);
border-radius: var(--radius-surface);
padding: 1rem;
background: var(--color-panel);
}
.collection-page__toolbar,
.collection-page__active-filters,
.collection-page__bulk-actions {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
}
.collection-page__result-count {
color: var(--color-content-muted);
}
.collection-page__results,
.detail-page__sections,
.form-page__fields,
.form-page__error-summary {
display: grid;
gap: 1rem;
}
.collection-page__pagination {
margin-block-start: 1.25rem;
}
.detail-page__danger {
margin-block-start: 2rem;
border-top: 1px solid var(--color-danger-border);
padding-block-start: 1rem;
}
.form-error-summary {
border-color: var(--color-danger-border);
background: var(--color-danger-surface);
}
.form-error-summary h2 {
font-size: 1.1rem;
}
.form-error-summary p,
.form-error-summary ul {
margin-block-end: 0;
}
.form-actions {
padding-block-start: 1rem;
border-top: 1px solid var(--color-border);
}
.form-actions--sticky {
position: sticky;
z-index: 10;
bottom: 0;
padding: 0.75rem;
background: color-mix(in oklch, var(--color-panel) 94%, transparent);
}
.status-page {
max-width: 48rem;
padding-block: 2rem;
}
.status-page__actions {
justify-content: flex-start;
}
.status-page__support {
color: var(--color-content-muted);
}
.readiness-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -785,7 +1305,7 @@
padding-inline: 0.75rem;
}
.app-shell__menu-button {
.app-shell__menu-button.ui-icon-button {
display: inline-flex;
}
@@ -832,6 +1352,24 @@
.component-grid--three {
grid-template-columns: minmax(0, 1fr);
}
.page-template__heading,
.page-template__layout[data-has-aside="true"] {
grid-template-columns: minmax(0, 1fr);
}
.page-template__heading .page-header {
grid-row: auto;
}
.page-template__actions,
.page-template__status {
justify-content: flex-start;
}
.form-actions--sticky {
margin-inline: calc(var(--spacing-page) * -1);
}
}
@media (prefers-reduced-motion: reduce) {
+1
View File
@@ -0,0 +1 @@
export * from "./page-templates.js";
@@ -0,0 +1,246 @@
import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Button } from "../components/ui/button.jsx";
export type PageHeading = Readonly<{
title: string;
description?: string;
eyebrow?: string;
}>;
export type PageActionDefinition =
| Readonly<{
kind: "button";
label: string;
onAction(): void;
disabled?: boolean;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>
| Readonly<{
kind: "link";
label: string;
href: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
}>;
export type PageTemplateSlots = Readonly<{
heading: PageHeading;
breadcrumb?: ReactNode;
status?: ReactNode;
actions?: readonly PageActionDefinition[];
notices?: ReactNode;
children?: ReactNode;
aside?: ReactNode;
feedback?: ReactNode;
}>;
export function StandardPage(props: PageTemplateSlots) {
return (
<article className="ui-page page-template page-template--standard">
{props.breadcrumb ? (
<nav className="page-template__breadcrumb" aria-label="현재 위치">
{props.breadcrumb}
</nav>
) : null}
<div className="page-template__heading">
<PageHeader {...props.heading} />
{props.status ? (
<div className="page-template__status">{props.status}</div>
) : null}
{props.actions?.length ? (
<PageActionBar actions={props.actions} />
) : null}
</div>
{props.notices ? (
<div className="page-template__notices">{props.notices}</div>
) : null}
{props.feedback ? (
<div className="page-template__feedback">{props.feedback}</div>
) : null}
<div
className="page-template__layout"
data-has-aside={props.aside ? "true" : "false"}
>
<div className="page-template__content">{props.children}</div>
{props.aside ? (
<aside className="page-template__aside" aria-label="관련 정보">
{props.aside}
</aside>
) : null}
</div>
</article>
);
}
export function CollectionPage(
props: PageTemplateSlots &
Readonly<{
toolbar?: ReactNode;
activeFilters?: ReactNode;
resultCount?: ReactNode;
bulkActions?: ReactNode;
pagination?: ReactNode;
}>,
) {
return (
<StandardPage
{...props}
notices={
<>
{props.notices}
{props.toolbar ? (
<section
className="collection-page__toolbar"
aria-label="검색과 필터"
>
{props.toolbar}
</section>
) : null}
{props.activeFilters ? (
<div className="collection-page__active-filters">
{props.activeFilters}
</div>
) : null}
{props.resultCount ? (
<div className="collection-page__result-count" role="status">
{props.resultCount}
</div>
) : null}
{props.bulkActions ? (
<div className="collection-page__bulk-actions">
{props.bulkActions}
</div>
) : null}
</>
}
>
<div className="collection-page__results">{props.children}</div>
{props.pagination ? (
<nav className="collection-page__pagination" aria-label="페이지 탐색">
{props.pagination}
</nav>
) : null}
</StandardPage>
);
}
export function DetailPage(
props: PageTemplateSlots &
Readonly<{
metadata?: ReactNode;
destructiveAction?: ReactNode;
}>,
) {
return (
<StandardPage {...props}>
{props.metadata ? (
<section className="detail-page__metadata" aria-label="요약 정보">
{props.metadata}
</section>
) : null}
<div className="detail-page__sections">{props.children}</div>
{props.destructiveAction ? (
<section className="detail-page__danger" aria-label="위험 작업">
{props.destructiveAction}
</section>
) : null}
</StandardPage>
);
}
export function FormPage(
props: PageTemplateSlots &
Readonly<{
errorSummary?: ReactNode;
fields?: ReactNode;
formActions?: ReactNode;
guard?: ReactNode;
}>,
) {
return (
<StandardPage {...props}>
{props.errorSummary ? (
<div className="form-page__error-summary">{props.errorSummary}</div>
) : null}
<div className="form-page__fields">{props.fields ?? props.children}</div>
{props.formActions ? (
<div className="form-page__actions">{props.formActions}</div>
) : null}
{props.guard}
</StandardPage>
);
}
export type StatusPageVariant =
| "unauthenticated"
| "forbidden"
| "not-found"
| "unavailable"
| "offline"
| "maintenance"
| "unexpected";
export function StatusPage(
props: Readonly<{
variant: StatusPageVariant;
heading: PageHeading;
primaryAction?: PageActionDefinition;
secondaryAction?: PageActionDefinition;
supportReference?: string;
}>,
) {
return (
<section
className={`ui-page page-template status-page status-page--${props.variant}`}
data-status-variant={props.variant}
>
<PageHeader {...props.heading} />
{props.primaryAction || props.secondaryAction ? (
<PageActionBar
className="status-page__actions"
actions={
[props.primaryAction, props.secondaryAction].filter(
Boolean,
) as PageActionDefinition[]
}
/>
) : null}
{props.supportReference ? (
<p className="status-page__support">
: <code>{props.supportReference}</code>
</p>
) : null}
</section>
);
}
function PageActionBar(props: Readonly<{
actions: readonly PageActionDefinition[];
className?: string;
}>) {
return (
<div className={props.className ?? "page-template__actions"}>
{props.actions.map((action) =>
action.kind === "link" ? (
<a
className={`ui-button ui-button--${action.variant ?? "primary"}`}
href={action.href}
key={`${action.kind}:${action.label}`}
>
{action.label}
</a>
) : (
<Button
disabled={action.disabled}
key={`${action.kind}:${action.label}`}
onClick={action.onAction}
variant={action.variant}
>
{action.label}
</Button>
),
)}
</div>
);
}
@@ -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);
});
});
+199
View File
@@ -0,0 +1,199 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
import { z } from "zod";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.js";
import { Button } from "../../src/presentation/components/ui/button.jsx";
import {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../src/presentation/forms/index.js";
type Values = Readonly<Record<"name" | "note", string>>;
const schema = z
.object({
name: z.string().trim().min(2),
note: z.string().trim().default(""),
})
.strict();
const defaults: Values = { name: "", note: "" };
function FormHarness(props: Readonly<{
submit(command: Readonly<{ name: string; note?: string }>): Promise<
| Readonly<{ ok: true; value: string }>
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
>;
}>) {
const form = useAppForm({
schema,
defaultValues: defaults,
allowedServerFields: ["name", "note"],
mapToCommand(values) {
return {
name: values.name,
...(values.note ? { note: values.note } : {}),
};
},
submit: props.submit,
});
return (
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={{ name: "Name", note: "Note" }}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
<FormField {...form.field("name")} label="Name" required />
<FormField {...form.field("note")} label="Note" />
<Button type="submit" disabled={form.pending}>
{form.pending ? "Pending" : "Submit"}
</Button>
<Button onClick={() => form.reset()} disabled={!form.dirty}>
Reset
</Button>
<output data-testid="dirty">{String(form.dirty)}</output>
<output data-testid="result">{form.result}</output>
</Form>
);
}
describe("local form facade", () => {
it("focuses the first invalid field and performs no command", async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<FormHarness submit={submit} />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent("Name");
});
it("submits transformed data once and clears dirty state after success", async () => {
const user = userEvent.setup();
let finish: ((value: { ok: true; value: string }) => void) | undefined;
const submit = vi.fn(
() =>
new Promise<{ ok: true; value: string }>((resolve) => {
finish = resolve;
}),
);
render(<FormHarness submit={submit} />);
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
finish?.({ ok: true, value: "saved" });
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
expect(screen.getByTestId("result")).toHaveTextContent("success");
});
it("maps only approved 422 fields and never renders backend copy", async () => {
const user = userEvent.setup();
const failure = createFailure(
"VALIDATION_REJECTED",
"CREATE_ENTITY",
0,
{
validationIssues: [
{ path: "name", code: "REQUIRED" },
{ path: "serverOnly", code: "raw-secret-message" },
],
},
);
render(
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
);
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
expect(screen.getByRole("alert")).toHaveTextContent(
"서버가 확인하지 못한 입력 항목",
);
expect(document.body).not.toHaveTextContent("raw-secret-message");
});
it("keeps conflict input out of URL and storage", async () => {
const user = userEvent.setup();
localStorage.clear();
window.history.replaceState({}, "", "/form-test");
render(
<FormHarness
submit={async () => ({
ok: false,
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
})}
/>,
);
const secretLike = "token-like-do-not-copy";
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
expect(window.location.href).not.toContain(secretLike);
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
});
});
describe("dirty navigation guard", () => {
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
const user = userEvent.setup();
function GuardedPage() {
const navigate = useNavigate();
const [dirty, setDirty] = useState(false);
const guard = useDirtyNavigationGuard(dirty);
return (
<>
<label htmlFor="guard-field">Guard field</label>
<input
id="guard-field"
onChange={() => setDirty(true)}
/>
<Button onClick={() => navigate("/target")}>Leave</Button>
<DirtyNavigationDialog guard={guard} />
</>
);
}
const router = createMemoryRouter(
[
{ path: "/", element: <GuardedPage /> },
{ path: "/target", element: <h1>Target</h1> },
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
const leave = screen.getByRole("button", { name: "Leave" });
await user.click(leave);
expect(
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toHaveAttribute("open");
await user.click(screen.getByRole("button", { name: "계속 작성" }));
await waitFor(() => expect(leave).toHaveFocus());
await user.click(leave);
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
});
});
+92
View File
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CollectionPage,
DetailPage,
FormPage,
StandardPage,
StatusPage,
} from "../../src/presentation/templates/index.js";
describe("page template slot contracts", () => {
it("renders StandardPage minimum and full landmarks with one h1", () => {
const { rerender } = render(
<StandardPage heading={{ title: "Minimum" }}>Content</StandardPage>,
);
expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible();
rerender(
<StandardPage
heading={{ title: "Full", description: "Long heading contract" }}
breadcrumb={<a href="/">Home</a>}
status={<span>Ready</span>}
actions={[
{ kind: "button", label: "Action", onAction: () => {} },
]}
notices={<p>Notice</p>}
feedback={<p role="status">Refreshing</p>}
aside={<p>Aside</p>}
>
Content
</StandardPage>,
);
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible();
expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible();
});
it("places collection, detail and form state in stable slots", () => {
const { rerender } = render(
<CollectionPage
heading={{ title: "Collection" }}
toolbar={<button type="button">Filter</button>}
resultCount="12 results"
pagination={<a href="?page=2">Next</a>}
>
Results
</CollectionPage>,
);
expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible();
expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible();
rerender(
<DetailPage
heading={{ title: "Detail" }}
metadata={<dl><dt>ID</dt><dd>1</dd></dl>}
destructiveAction={<button type="button">Delete</button>}
>
Sections
</DetailPage>,
);
expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible();
expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible();
rerender(
<FormPage
heading={{ title: "Form" }}
errorSummary={<p role="alert">Invalid</p>}
fields={<input aria-label="Field" />}
formActions={<button type="button">Save</button>}
/>,
);
expect(screen.getByRole("alert")).toBeVisible();
expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
});
it("renders safe status variants without raw failure values", () => {
render(
<StatusPage
variant="offline"
heading={{ title: "Offline", description: "Safe recovery copy" }}
primaryAction={{ kind: "button", label: "Retry", onAction: () => {} }}
supportReference="SAFE-123"
/>,
);
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
expect(screen.getByText("SAFE-123")).toBeVisible();
expect(document.body).not.toHaveTextContent("stack");
});
});
+20 -3
View File
@@ -2,9 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
for (const route of Object.values(ROUTE_REGISTRY).map((definition) =>
definition.path === "*" ? "/not-found" : definition.path,
)) {
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
if (definition.path === "*") return "/not-found";
return definition.path.replace(":resourceId", "reference-1");
})) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
@@ -39,3 +40,19 @@ test("@a11y reduced-motion policy disables long animation", async ({ page }) =>
.evaluate((body) => getComputedStyle(body).animationDuration);
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
});
test("@a11y opened design-system dialog has no blocking violations", async ({
page,
}) => {
await page.goto("/examples/ui");
await page.getByRole("button", { name: "모달 열기" }).click();
const results = await new AxeBuilder({ page })
.include(".ui-dialog")
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
results.violations.filter((violation) =>
["critical", "serious"].includes(violation.impact ?? ""),
),
).toEqual([]);
});
+5
View File
@@ -53,8 +53,13 @@ test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
expect(
await page.locator(".ui-drawer").evaluate((drawer) => drawer.matches(":modal")),
).toBe(true);
await page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
await expect(menu).toBeFocused();
});
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
page,
}) => {
await page.goto("/examples/ui");
const menuTrigger = page.getByRole("button", { name: "작업 메뉴" });
await menuTrigger.focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.type("toast");
const toastItem = page.getByRole("menuitem", { name: "Toast 표시" });
await expect(toastItem).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByText("예제가 저장되었습니다.")).toBeVisible();
await expect(menuTrigger).toBeFocused();
const tokenTab = page.getByRole("tab", { name: "토큰" });
await tokenTab.focus();
await page.keyboard.press("ArrowRight");
const primitiveTab = page.getByRole("tab", { name: "프리미티브" });
await expect(primitiveTab).toBeFocused();
await expect(primitiveTab).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("Enter");
await expect(primitiveTab).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tabpanel", { name: "프리미티브" }),
).toContainText("native semantics");
});
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
/** @param {import("@playwright/test").Page} page */
async function openReferenceForm(page) {
await page.goto("/examples/reference-resources/new");
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "Reference resource 만들기" }),
).toBeVisible();
}
test("validates a reference form and focuses the first invalid field", async ({
page,
}) => {
await openReferenceForm(page);
await page.getByRole("button", { name: "저장" }).click();
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
await expect(firstField).toBeFocused();
await expect(firstField).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
});
test("guards dirty cancellation and restores focus when writing continues", async ({
page,
}) => {
await openReferenceForm(page);
await page
.getByRole("textbox", { name: /새 항목 이름/ })
.fill("Unsaved reference");
const cancel = page.getByRole("button", { name: "취소" });
await cancel.click();
await expect(
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toBeVisible();
await page.getByRole("button", { name: "계속 작성" }).click();
await expect(cancel).toBeFocused();
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
});
test("keeps the form template within a 320px viewport", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 720 });
await openReferenceForm(page);
const viewport = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
});
@@ -82,10 +82,14 @@ describe("reference feature boundary contracts", () => {
it("owns route, operation and query contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
"REFERENCE_RESOURCE_FORM",
"REFERENCE_RESOURCE_STATUS",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
"GET_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
@@ -58,6 +58,14 @@ function inputWith(
createdAtLabel: null,
},
}),
getResource: async (resourceId) => ({
ok: true,
value: {
resourceId,
title: "Detail",
createdAtLabel: null,
},
}),
...overrides,
};
}
@@ -129,7 +137,7 @@ describe("reference feature page states", () => {
);
});
it("deduplicates optimistic create and rolls back a conflict", async () => {
it("deduplicates create, preserves input and surfaces a conflict", async () => {
const user = userEvent.setup();
let finish:
| ((result: ReferenceResult<ReferenceResourceView>) => void)
@@ -141,31 +149,22 @@ describe("reference feature page states", () => {
}),
);
renderReference(
inputWith({
listResources: async () => ({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
}),
createResource,
}),
inputWith({ createResource }),
"/examples/reference-resources/new",
);
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
const submit = screen.getByRole("button", { name: "추가" });
await screen.findByRole("heading", {
name: "Reference resource 만들기",
});
await user.type(
screen.getByRole("textbox", { name: /새 항목 이름/ }),
"Conflicting",
);
await user.type(screen.getByLabelText("설명"), "Keep this input");
const submit = screen.getByRole("button", { name: "저장" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
expect(await screen.findByText("Conflicting")).toHaveAttribute(
"data-optimistic",
"true",
);
expect(screen.getByText("mutation-pending")).toBeVisible();
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
finish?.({
ok: false,
@@ -175,14 +174,11 @@ describe("reference feature page states", () => {
0,
),
});
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(
await screen.findByRole("button", { name: "충돌 해결" }),
).toBeVisible();
expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
await waitFor(() =>
expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
);
screen.getByRole("textbox", { name: /새 항목 이름/ }),
).toHaveValue("Conflicting");
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
@@ -219,8 +215,7 @@ describe("reference feature page states", () => {
});
renderReference(inputWith({ listResources }));
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Created");
await user.click(screen.getByRole("button", { name: "추가" }));
await user.click(screen.getByRole("button", { name: "새로고침" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
@@ -46,6 +46,9 @@ const releaseManifest = {
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-reference-resources": "assets/reference.js",
"route-reference-resource-detail": "assets/reference-detail.js",
"route-reference-resource-form": "assets/reference-form.js",
"route-reference-resource-status": "assets/reference-status.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -120,8 +123,14 @@ describe("reference feature production vertical path", () => {
"?limit=5&tags=open&tags=new",
);
await user.type(screen.getByLabelText("새 항목 이름"), " Created ");
await user.click(screen.getByRole("button", { name: "추가" }));
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
await user.type(
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
" Created ",
);
await user.click(screen.getByRole("button", { name: "저장" }));
expect(await screen.findByText("저장했습니다.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
expect(await screen.findByText("Created")).toBeVisible();
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
@@ -0,0 +1,5 @@
import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.js";
export function ForbiddenTemplate(_props: { application: ApplicationApi }) {
return <main>Template must not select an application use case.</main>;
}
@@ -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>;
+6
View File
@@ -0,0 +1,6 @@
import type { PageActionDefinition } from "../../../src/presentation/templates/index.js";
export const activeButtonWithoutCallback = {
kind: "button",
label: "Unsafe active action",
} satisfies PageActionDefinition;
+39
View File
@@ -106,6 +106,45 @@ describe("shared HTTP client", () => {
});
});
it("projects only approved 422 issue path and code metadata", async () => {
server.use(
http.post("https://api.test/api/entities", () =>
HttpResponse.json(
{
success: false,
error: {
code: "INVALID_INPUT",
message: "raw backend secret",
details: {
issues: [
{ path: "name", code: "REQUIRED", message: "raw field copy" },
{ path: 42, code: "INVALID" },
],
},
},
meta: { requestId: "request-422", traceId: "trace-422" },
},
{ status: 422 },
),
),
);
const client = testClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("CREATE_ENTITY", {
routeId: "TEST_FORM",
body: { name: "Valid" },
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "VALIDATION_REJECTED",
validationIssues: [{ path: "name", code: "REQUIRED" }],
},
});
expect(JSON.stringify(result)).not.toContain("raw backend secret");
expect(JSON.stringify(result)).not.toContain("raw field copy");
});
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
http.get("https://api.test/api/entities", () =>
+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");
});
});
+2 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
@@ -8,6 +8,7 @@ export default defineConfig({
clearMocks: true,
mockReset: true,
testTimeout: 10_000,
exclude: [...configDefaults.exclude, ".tmp/**"],
coverage: {
reporter: ["text", "json-summary"],
},