Compare commits

..
93 changed files with 5159 additions and 582 deletions
+10
View File
@@ -31,6 +31,16 @@ module.exports = {
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.
+12 -1
View File
@@ -81,7 +81,10 @@
{ "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:page-action", "expect": "fail" }
{ "script": "check:types:fixture:page-action", "expect": "fail" },
{ "script": "check:types:fixture:icon-button", "expect": "fail" },
{ "script": "check:types:fixture:i18n-key", "expect": "fail" },
{ "script": "check:types:fixture:i18n-params", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
@@ -151,6 +154,10 @@
"name": "architecture",
"steps": [
{ "script": "check:architecture", "expect": "pass" },
{ "script": "check:design-system", "expect": "pass" },
{ "script": "check:design-system:fixture", "expect": "fail" },
{ "script": "check:i18n", "expect": "pass" },
{ "script": "check:i18n:fixture", "expect": "fail" },
{ "script": "check:registries", "expect": "pass" },
{ "script": "check:registries:fixture", "expect": "fail" },
{ "script": "check:routes:fixture", "expect": "fail" }
@@ -158,6 +165,10 @@
"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/i18n.json",
"artifacts/quality/i18n-fixture.json",
"artifacts/quality/registries.json",
"artifacts/quality/registry-fixture.json",
"artifacts/quality/route-registry-fixture.json"
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.
@@ -0,0 +1,96 @@
# VD-06: Intl과 typed local message catalog
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
## 배경
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
## 결정
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
강제한다.
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
거절한다.
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
않는다.
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
문자열 대신 의미 값과 timestamp를 반환한다.
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
않는다. transport/application failure kind를 등록된 사용자 message key로
매핑한 뒤 presentation이 해석한다.
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
사용한다.
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
design-system public prop으로 노출하지 않는다.
## 실행 경계
```text
route/form/failure 의미 값
-> presentation message key
-> LocaleProvider
-> typed catalog / Intl formatter
-> text node와 accessible name
```
- canonical catalog: `src/presentation/i18n/catalog.ts`
- feature contribution: `src/features/*/contracts/*-message-catalog.js`
`src/features/installed-feature-messages.js`에서 조립
- key/보간/fallback/alias: `message-contract.ts`
- locale-safe value formatting: `formatters.ts`
- React composition과 document metadata: `locale-provider.tsx`
- public entry: `src/presentation/i18n/index.ts`
## 검증
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
list/plural/select의 결정성을 검증한다.
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
Drawer semantics를 검증한다.
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
Chromium, Firefox, WebKit project에서 실행한다.
## 한계와 재검토 조건
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
## Rollback
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.
@@ -12,7 +12,7 @@
- 기본 번들에 포함할 역량과 필요할 때 설치할 확장 역량을 구분한다.
- 특정 벤더를 채택하더라도 제품 코드가 벤더 API에 직접 결합되지 않는지 확인한다.
최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-04 구현 결과를 이 문서에
최초 검토 기준은 `develop``cb195f8`이며, RP-01~RP-08 구현 결과를 이 문서에
누적 반영했다. 이후 구현으로 경로나 세부 내용이 달라질 수 있으므로, 각 항목은
문서의 경로뿐 아니라 해당 테스트와 아키텍처 게이트로 계속 검증해야 한다.
@@ -33,13 +33,14 @@
특히 다음은 선행 해결이 필요하다.
RP-01~RP-05에서 TypeScript 도구 안전망, application runtime 주입,
RP-01~RP-08에서 TypeScript 도구 안전망, application runtime 주입,
query/mutation inbound adapter, HTTP 실행 계약과 executable route/release
recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다. 현재 선행 해결
recovery 계약, 제거 가능한 reference 수직 슬라이스, form/page, design system과
i18n 실행 경계는 구현됐다. 현재 선행 해결
대상은 다음과 같다.
1. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
2. 국제화, diagnostics, optional adapter recipe 심화 품질 게이트
1. diagnostics/telemetry 실제 producer 연결
2. registry evidence, 공급망과 optional adapter recipe 심화 게이트
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다.
@@ -73,13 +74,13 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| 클라이언트 상태 | 부분 준비 | 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 복원 |
| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status slot 계약과 reference 소비 화면 | RP-07 public design-system entry로 이동 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 |
| 앱 셸·반응형 | 준비 | native modal Drawer, compact/desktop layout, Escape/link dismiss/focus restore, pseudo reflow와 RTL direction | compact browser matrix 유지 |
| 페이지 템플릿 | 준비됨 | 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 경계 |
| 국제화 | 준비됨 | 137-key typed catalog, locale provider, Intl formatter, safe fallback/alias, pseudo·RTL gate | 실제 locale·번역 승인은 프로젝트에서 연결 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 |
@@ -250,6 +251,24 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
- registry/compatibility의 실제 diff와 orphan reference 검사
- transitive vulnerability, license, SBOM/provenance 공급망 gate
#### RP-08에서 국제화 실행 경계 구현
`src/presentation/i18n`은 shell, route, async/form error, page template와
design-system 기본 copy의 canonical 경계다. `MessageKey`와 key별
`MessageParameters`가 잘못된 key/보간을 compile time에 막고, runtime
`resolveMessage`는 unknown locale/key와 누락 보간에서 raw 값 대신 안전한
fallback을 반환한다. application mapper는 locale-formatted date를 반환하지
않고 timestamp를 유지하며 presentation formatter가 `UTC` 또는 명시 timezone을
적용한다.
`LocaleProvider``ko-KR`, `en-US`, `en-XA`, `ar-EG` smoke set과 document
`lang/dir`을 동기화한다. `en-XA`는 긴 문구 reflow, `ar-EG`는 logical CSS,
Drawer, Tabs arrow와 pagination 방향 icon을 검증하기 위한 개발 locale이다.
실제 아랍어 번역 완료를 뜻하지 않는다. `check:i18n`과 negative fixture는 common
UI literal, backend raw message render와 raw HTML interpolation을 거절한다.
새 key rename은 canonical type에는 넣지 않고 runtime alias/migration window로
호환한다.
### 5.3 P2: 경계와 recipe를 제공할 선택 항목
다음 기능을 모든 앱의 초기 번들에 설치할 필요는 없다. 대신 port 또는 local
@@ -588,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`
**목표**
@@ -635,6 +651,26 @@ token rename은 alias/migration 기간을 두고, vendor adapter와 local API co
RP-08은 기존 기본 언어 catalog를 fallback으로 유지한다. 번역 catalog를
파괴적으로 덮어쓰지 않는다.
**구현 증거 (2026-07-26)**
- VD-06에서 dependency를 추가하지 않는 browser `Intl` + typed local catalog를
채택하고 vendor 재평가 조건, fallback과 key migration 정책을 문서화했다.
- `src/presentation/i18n`이 137개 common key, key별 interpolation,
`ko-KR` fallback, compatibility alias, date/number/relative/list/plural/select
formatter와 `LocaleProvider`를 제공한다.
- shell, route lifecycle/access/recovery, async/form error, page template와
design-system default copy가 catalog consumer로 연결됐다.
- feature route copy는 feature-owned catalog contribution으로 분리되어 reference
feature 제거 시 source와 built artifact에 전용 message key가 남지 않는다.
- reference application mapper는 locale-formatted date 대신 timestamp를
반환하고 presentation formatter가 UTC 또는 명시 timezone을 적용한다.
- `check:i18n`과 type negative fixture가 catalog/placeholder 불일치,
hardcoded common literal, backend raw message render, unsafe HTML,
unknown key와 interpolation mismatch를 거절한다.
- unit/component/browser test가 safe fallback, pseudo 320px reflow,
document `lang/dir`, RTL Drawer/Tabs/directional icon과 deterministic formatter를
검증한다.
### 09. `feature-frontend-diagnostics-telemetry-runtime`
**목표**
@@ -171,6 +171,22 @@ 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로 올리지 않는다.
RP-08에서 i18n은 application output port가 아니라 React inbound adapter의
local facade로 확정됐다. domain/application은 locale이나 번역 문장을 알지 않고
timestamp, number, failure kind 같은 의미 값만 반환한다.
`src/presentation/i18n`이 typed message catalog, formatter, fallback,
`<html lang/dir>`과 pseudo/RTL smoke를 소유한다. backend raw `message`
application failure registry를 우회해 렌더링할 수 없으며 `check:i18n` negative
fixture가 이 경계를 집행한다. 번역 vendor를 나중에 선택해도 이 facade 뒤의
adapter만 교체한다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
@@ -447,6 +447,13 @@ controller가 소유하지 않는 것:
| Page Template | 반복 layout/state | 접근성과 반응형 구조 재사용 | data fetching을 template에 포함 |
| Registry + Runtime Map | route/operation/event | 선언과 실행 완전성 | 모든 설정을 하나의 거대 전역 파일에 집중 |
RP-08 이후 route contract의 `title`/`navigation` 필드는 fallback metadata이며
실제 document title, navigation, loading/error/access surface는
`route.<ROUTE_ID>.title|navigation` typed catalog key를 해석한다. route params,
search, backend message를 translation key로 조립하지 않는다. locale 변경은
현재 route를 재요청하거나 query key를 바꾸지 않고 document title과 화면 copy만
다시 렌더링한다.
패턴은 추상화 파일만 만든 것으로 완료되지 않는다. reference usage, negative
architecture test, 실패 상태 test가 있어야 제공된 패턴으로 본다.
@@ -485,6 +485,32 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
- logging failure가 제품 flow를 실패시키지 않음
- consent가 필요한 analytics와 essential diagnostics를 분리
### 7.4 locale, message와 표시 값
RP-08부터 locale은 presentation-owned React context다. server/application
state에 번역된 문자열을 저장하거나 query key에 locale을 넣는 것은 응답 자체가
locale별 데이터인 경우에만 허용한다. 공통 UI copy 변경 때문에 query cache를
복제하지 않는다.
```text
API timestamp/number/failure kind
-> schema + mapper (의미 값 유지)
-> application result
-> presentation controller
-> useLocale().date/number/message
```
- message key는 `MessageKey` union이며 interpolation은 key별 tuple type이다.
- unknown external key는 `resolveMessage`에 전달해도 raw key가 표시되지 않는다.
- backend `message`는 diagnostic input일 수 있지만 사용자 copy가 아니다.
- form validation code는 `ParameterlessMessageKey` allowlist로 mapping한다.
- date의 기본 timezone은 UTC이고 제품 timezone은 presentation 호출자가
명시한다.
- pseudo/RTL locale state는 local interaction state이며 persistence와 server
synchronization을 기본 제공하지 않는다.
- key rename은 typed canonical key를 먼저 이동하고 runtime alias에 migration
기간을 둔다.
## 8. 폼 표준
VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
@@ -584,4 +610,5 @@ backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
- 상태 종류별 소유권이 테스트와 문서에서 확인된다.
- token은 UI와 일반 storage/store에 노출되지 않는다.
- failure와 validation의 각 계층이 typed mapper로 분리된다.
- common UI copy, locale formatter와 direction이 typed i18n facade를 통과한다.
- query/mutation/form recipe만으로 새 기능을 만들 수 있다.
+26 -14
View File
@@ -47,23 +47,23 @@
- Chromium, Firefox, WebKit
- 등록 라우트의 자동 접근성 검사
이 기반은 유효하지만 아직 디자인 시스템 플랫폼 전체는 아니다.
RP-07 구현 이후 이 기반은 저장소 내부 디자인 시스템 플랫폼 계약을 충족한다.
아래 목록은 구현 전 공백과 현재 해결 상태를 함께 보존한다.
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
breakpoint가 계약으로 닫혀 있지 않다.
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. 앱 셸 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
class를 다시 작성하는 곳이 있다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
5. `theme.css` 하나가 token, layout, primitive, pattern 스타일을 모두 소유한다.
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. page template은 준비됐지만 RP-07 design-system public entry와 token/pattern
계층으로 정리해야 한다.
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를 제공한다.
따라서 기존 구성요소는 폐기하지 않고 아래 목표 계층으로 이동·확장한다.
@@ -534,8 +534,9 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
## 9. 국제화 계약
디자인 시스템 primitive는 한국어 문구를 내부 기본값으로 숨기지 않는다.
접근성 label이나 오류 문구가 필요하면 명시적인 prop 또는 message key를 받는다.
RP-08에서 디자인 시스템 primitive의 닫기, alert, toast, 글자 수 같은 기본
문구는 `useLocale()` typed catalog로 이동했다. 제품 의미를 가진 label은 여전히
명시적인 prop으로 받으며 primitive가 feature message key를 소유하지 않는다.
필수 국제화 기반:
@@ -551,6 +552,17 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- pseudo-locale
- RTL story와 E2E smoke
현재 구현 위치와 실패 정책:
- `presentation/i18n/catalog.ts`: 137개 canonical common key와 locale parity
- `message-contract.ts`: key별 interpolation, fallback과 compatibility alias
- `formatters.ts`: UTC-default date, number, relative time, list, plural/select
- `locale-provider.tsx`: document `lang/dir`과 React consumer API
- missing key/parameter와 formatter 예외: 빈 문자열이나 raw key 대신 안전한
fallback
- `en-XA`: 긴 문자열/320px reflow, `ar-EG`: logical layout/RTL keyboard smoke
- backend raw message와 translated HTML: 정적 negative gate에서 거절
금지 패턴:
- 번역 문장 중간에 JSX 문자열을 연결한다.
@@ -559,7 +571,7 @@ Radix는 overlay와 primitive composition을 중심으로 평가할 수 있다.
- route title, navigation label, toast message를 JSX literal로 분산한다.
- 번역 누락 시 빈 문자열을 렌더링한다.
Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
RP-10의 Storybook toolbar에서 최소한 다음 조합을 전환할 수 있어야 한다.
- `ko-KR`, light
- `en-US`, light
@@ -562,6 +562,30 @@ 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
완료 증거로 사용하지 않는다.
RP-08의 국제화 실행 경로는 `check:i18n`, `check:i18n:fixture`,
`check:types:fixture:i18n-key`, `check:types:fixture:i18n-params`,
`tests/unit/i18n-contract.test.ts`, `tests/component/locale-platform.test.tsx`
`tests/e2e/i18n.spec.js`다.
- unit: locale catalog/placeholder parity, safe fallback/alias, pseudo expansion,
direction과 UTC date/number/relative/list/plural/select
- component: document `lang/dir`, RTL tab arrow semantics, direction-aware
pagination과 modal Drawer
- E2E: 320px pseudo reflow와 compact RTL shell/Drawer/focus restore
- negative: common UI locale literal, backend message JSX render, raw translated
HTML, unknown key와 잘못된 interpolation parameter
`ar-EG` catalog가 영어 smoke copy를 재사용하는 것은 RTL behavior 검증용이며
번역 품질 PASS가 아니다. 실제 locale catalog에는 언어 담당 승인 evidence가
추가되어야 한다.
### 10.1 Component behavior
- native role/name/value
+1
View File
@@ -76,6 +76,7 @@ export default [
"artifacts/**",
"tests/fixtures/typecheck/**",
"tests/fixtures/architecture/forbidden/**",
"tests/fixtures/i18n/forbidden/**",
"tests/fixtures/security/forbidden/**",
],
},
+8
View File
@@ -15,6 +15,10 @@
"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:i18n": "node scripts/check-i18n.mjs",
"check:i18n:fixture": "node scripts/check-i18n.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",
@@ -27,6 +31,9 @@
"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",
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
"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",
@@ -58,6 +65,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:
+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`,
);
+108
View File
@@ -0,0 +1,108 @@
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 { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
const fixtureMode = process.argv.includes("--fixture");
const failures = [];
const sourceExtensions = /\.(?:js|jsx|mjs|ts|tsx|mts)$/;
const koreanLiteral = /[가-힣]/;
const rawFailureRender =
/(?<!\$)\{\s*(?:failure|error|response|backend)(?:\?\.|\.)[\w?.]*message\s*\}/;
/** @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 (sourceExtensions.test(entry.name)) result.push(target);
}
return result;
}
/** @param {string} template */
function placeholders(template) {
return [...template.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)]
.map((match) => match[1])
.sort();
}
if (!fixtureMode) {
const canonicalKeys = Object.keys(KO_MESSAGES).sort();
for (const [locale, catalog] of Object.entries(MESSAGE_CATALOGS)) {
const readableCatalog =
/** @type {Readonly<Record<string, string>>} */ (catalog);
const canonicalCatalog =
/** @type {Readonly<Record<string, string>>} */ (KO_MESSAGES);
const keys = Object.keys(catalog).sort();
if (JSON.stringify(keys) !== JSON.stringify(canonicalKeys)) {
failures.push(`${locale} catalog keys do not match ko-KR`);
}
for (const key of canonicalKeys) {
if (
JSON.stringify(placeholders(readableCatalog[key] ?? "")) !==
JSON.stringify(placeholders(canonicalCatalog[key] ?? ""))
) {
failures.push(`${locale}:${key} interpolation parameters do not match`);
}
}
}
if (Object.keys(EN_MESSAGES).length !== canonicalKeys.length) {
failures.push("en-US catalog key count does not match ko-KR");
}
}
const commonDirectories = [
"src/presentation/boundaries",
"src/presentation/components",
"src/presentation/design-system",
"src/presentation/forms",
"src/presentation/layouts",
"src/presentation/routes",
"src/presentation/templates",
];
const sources = fixtureMode
? await listSourceFiles("tests/fixtures/i18n/forbidden")
: (
await Promise.all(commonDirectories.map((directory) => listSourceFiles(directory)))
).flat();
for (const file of sources) {
const source = await readFile(file, "utf8");
if (koreanLiteral.test(source)) {
failures.push(`hardcoded common user-facing locale literal in ${file}`);
}
if (rawFailureRender.test(source)) {
failures.push(`raw backend/error message rendered in ${file}`);
}
if (source.includes("dangerouslySetInnerHTML")) {
failures.push(`untrusted HTML interpolation boundary in ${file}`);
}
}
const report = {
schemaVersion: 1,
mode: fixtureMode ? "negative-fixture" : "source",
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
messageKeyCount: Object.keys(KO_MESSAGES).length,
checkedFiles: sources.length,
failures,
passed: failures.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
fixtureMode
? "artifacts/quality/i18n-fixture.json"
: "artifacts/quality/i18n.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
process.exit(1);
}
process.stdout.write(
`I18n contract: ${report.messageKeyCount} keys across ${report.localeCount} locales PASS\n`,
);
+10
View File
@@ -89,6 +89,12 @@ export function createInstalledFeatureInputs(_context: FeatureContext) {
}
`;
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({}),
"en-US": Object.freeze({}),
});
`;
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true });
@@ -135,6 +141,10 @@ await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-adapters.ts"),
emptyAdapters,
);
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-messages.js"),
emptyMessages,
);
/** @type {string[]} */
const residue = [];
@@ -0,0 +1,10 @@
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.js";
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
}),
"en-US": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["en-US"],
}),
});
@@ -6,7 +6,7 @@ import {
export type ReferenceResourceView = Readonly<{
resourceId: string;
title: string;
createdAtLabel: string | null;
createdAt: string | null;
optimistic?: boolean;
}>;
@@ -44,14 +44,10 @@ export function mapReferenceOperation(
export function toReferenceView(
resource: ReferenceResource,
formatDate: (value: Date) => string = (value) =>
new Intl.DateTimeFormat("ko-KR").format(value),
): ReferenceResourceView {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAtLabel: resource.createdAt
? formatDate(new Date(resource.createdAt))
: null,
createdAt: resource.createdAt,
});
}
@@ -0,0 +1,22 @@
export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
"en-US": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
});
@@ -1,11 +1,15 @@
import { Link } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { DetailPage } from "../../../presentation/templates/index.js";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useLocale } from "../../../presentation/i18n/index.js";
import { useReferenceDetail } from "./use-reference-feature.js";
export default function ReferenceResourceDetailPage() {
const { date, message } = useLocale();
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
@@ -28,7 +32,11 @@ export default function ReferenceResourceDetailPage() {
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>{resource.createdAtLabel ?? "표시 정보 없음"}</dd>
<dd>
{resource.createdAt
? date(new Date(resource.createdAt))
: message("common.noDisplayValue")}
</dd>
</dl>
) : (
<p> .</p>
@@ -1,17 +1,17 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../../presentation/components/ui/button.jsx";
import {
Button,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/forms/index.js";
import { FormPage } from "../../../presentation/templates/index.js";
} from "../../../presentation/design-system/index.js";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
@@ -1,8 +1,10 @@
import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
import { CollectionPage } from "../../../presentation/templates/index.js";
import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/templates/index.js";
import { StatusPage } from "../../../presentation/design-system/index.js";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
@@ -70,7 +70,7 @@ export function useReferenceFeature() {
{
resourceId: `optimistic:${command.name}`,
title: command.name,
createdAtLabel: null,
createdAt: null,
optimistic: true,
},
];
@@ -1,3 +1,5 @@
import { formatMessage } from "../i18n/index.js";
/**
* @param {{
* kind?: string,
@@ -18,32 +20,36 @@ export function BootErrorShell({
}) {
return (
<main role="alert">
<h1>애플리케이션을 시작할 없습니다.</h1>
<h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
<dl>
<dt>오류</dt>
<dt>{formatMessage("ko-KR", "boot.field.error")}</dt>
<dd>{kind}</dd>
<dt>코드</dt>
<dt>{formatMessage("ko-KR", "boot.field.code")}</dt>
<dd>{code}</dd>
{buildId && (
<>
<dt>빌드</dt>
<dt>{formatMessage("ko-KR", "boot.field.build")}</dt>
<dd>{buildId}</dd>
</>
)}
{configSchemaVersion && (
<>
<dt>설정 스키마</dt>
<dt>{formatMessage("ko-KR", "boot.field.configSchema")}</dt>
<dd>{configSchemaVersion}</dd>
</>
)}
{releaseId && (
<>
<dt>릴리스</dt>
<dt>{formatMessage("ko-KR", "boot.field.release")}</dt>
<dd>{releaseId}</dd>
</>
)}
</dl>
<p>지원 참조: {supportReference}</p>
<p>
{formatMessage("ko-KR", "boot.supportReference", {
reference: supportReference,
})}
</p>
</main>
);
}
@@ -3,6 +3,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { useLocale } from "../i18n/index.js";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
@@ -71,27 +72,44 @@ export class ChunkRecoveryBoundary extends Component<Props, State> {
const { error, recovery, reason } = this.state;
if (error && !isChunkLoadFailure(error)) throw error;
if (error && recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
.
</section>
);
return <ChunkRecoverySurface recovery="checking" />;
}
if (error && recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
.
</section>
);
return <ChunkRecoverySurface recovery="reload-requested" />;
}
if (error && recovery === "support") {
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1> .</h1>
<p> .</p>
</section>
);
return <ChunkRecoverySurface recovery="support" reason={reason} />;
}
return this.props.children;
}
}
function ChunkRecoverySurface({
recovery,
reason,
}: Readonly<{
recovery: Exclude<State["recovery"], "idle">;
reason?: string;
}>) {
const { message } = useLocale();
if (recovery === "checking") {
return (
<section className="ui-page" aria-live="polite" aria-busy="true">
{message("chunk.checking")}
</section>
);
}
if (recovery === "reload-requested") {
return (
<section className="ui-page" aria-live="polite">
{message("chunk.reloadOnce")}
</section>
);
}
return (
<section className="ui-page" role="alert" data-recovery-reason={reason}>
<h1>{message("chunk.failure.title")}</h1>
<p>{message("chunk.failure.description")}</p>
</section>
);
}
@@ -1,4 +1,5 @@
import { Component } from "react";
import { formatMessage } from "../i18n/index.js";
/**
* @typedef {{
@@ -57,9 +58,9 @@ export class RenderErrorBoundary extends Component {
return (
this.props.fallback ?? (
<section role="alert">
<p>error.render_failure</p>
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}>
retry
{formatMessage("ko-KR", "action.retry")}
</button>
</section>
)
+32 -14
View File
@@ -2,19 +2,22 @@ import { useId } from "react";
import { errorMessage } from "./error-copy.js";
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
export function LoadingSurface({ label }) {
const { message } = useLocale();
const accessibleLabel = label ?? message("async.loading");
return (
<section
className="state-surface state-surface--loading"
aria-busy="true"
aria-label={label}
aria-label={accessibleLabel}
aria-live="polite"
aria-atomic="true"
>
<div className="ui-skeleton" aria-hidden="true" />
<span className="visually-hidden">{label}</span>
<span className="visually-hidden">{accessibleLabel}</span>
</section>
);
}
@@ -27,13 +30,14 @@ export function LoadingSurface({ label = "불러오는 중" }) {
* }} props
*/
export function EmptySurface({
title = "표시할 항목이 없습니다.",
title,
description,
action,
}) {
const { message } = useLocale();
return (
<section className="ui-empty state-surface" aria-live="polite">
<h2>{title}</h2>
<h2>{title ?? message("async.empty")}</h2>
{description ? <p>{description}</p> : null}
{action}
</section>
@@ -49,13 +53,14 @@ export function EmptySurface({
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
const { locale, message } = useLocale();
const messageId = useId();
const actionLabels = Object.freeze({
retry: "다시 시도",
reauth: "로그인",
navigate: "안전한 화면으로 이동",
"reload-once": "한 번 새로고침",
"contact-support": "지원 정보 확인",
retry: message("action.retry"),
reauth: message("action.reauth"),
navigate: message("action.navigateSafe"),
"reload-once": message("action.reloadOnce"),
"contact-support": message("action.contactSupport"),
});
return (
<section
@@ -64,7 +69,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
aria-labelledby={messageId}
data-message-key={userMessageKey}
>
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
<h2 id={messageId}>{errorMessage(userMessageKey, locale)}</h2>
{action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button>
)}
@@ -88,6 +93,7 @@ export function AsyncSurface({
onRetry,
onResolveConflict,
}) {
const { message } = useLocale();
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
@@ -104,12 +110,24 @@ export function AsyncSurface({
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator ? (
<div role="status" aria-live="polite">
<span>{state.indicator}</span>
<span>
{message(
state.indicator === "stale-degraded"
? "async.staleDegraded"
: state.indicator === "mutation-conflict"
? "async.mutationConflict"
: state.indicator === "mutation-pending"
? "async.mutationPending"
: "async.refreshing",
)}
</span>
{state.indicator === "stale-degraded" && onRetry ? (
<Button onClick={onRetry}>다시 시도</Button>
<Button onClick={onRetry}>{message("action.retry")}</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
<Button onClick={onResolveConflict}>
{message("action.resolveConflict")}
</Button>
) : null}
</div>
) : null}
+4 -24
View File
@@ -1,26 +1,6 @@
const ERROR_MESSAGES = Object.freeze({
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
});
import { resolveMessage } from "../i18n/index.js";
/** @param {string} messageKey */
export function errorMessage(messageKey) {
const messages = /** @type {Readonly<Record<string, string>>} */ (
ERROR_MESSAGES
);
return messages[messageKey] ?? "요청을 완료하지 못했습니다.";
/** @param {string} messageKey @param {string} [locale] */
export function errorMessage(messageKey, locale = "ko-KR") {
return resolveMessage(locale, messageKey);
}
+16 -12
View File
@@ -1,4 +1,5 @@
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
/**
* @param {{
@@ -30,12 +31,13 @@ function StateSurface({
/** @param {{ onSignIn?: () => void }} props */
export function AuthRequiredSurface({ onSignIn }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="401 · 인증 필요"
title="로그인이 필요합니다."
description="세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다."
actionLabel="로그인 시작"
eyebrow={message("access.auth.eyebrow")}
title={message("access.auth.title")}
description={message("access.auth.description")}
actionLabel={message("action.signIn")}
onAction={onSignIn}
/>
);
@@ -43,12 +45,13 @@ export function AuthRequiredSurface({ onSignIn }) {
/** @param {{ onNavigate?: () => void }} props */
export function ForbiddenSurface({ onNavigate }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="403 · 권한 없음"
title="접근할 수 없습니다."
description="권한을 확인하거나 접근 가능한 화면으로 이동하세요."
actionLabel="안전한 화면으로 이동"
eyebrow={message("access.forbidden.eyebrow")}
title={message("access.forbidden.title")}
description={message("access.forbidden.description")}
actionLabel={message("action.navigateSafe")}
onAction={onNavigate}
tone="warning"
/>
@@ -57,12 +60,13 @@ export function ForbiddenSurface({ onNavigate }) {
/** @param {{ onNavigate?: () => void }} props */
export function NotFoundSurface({ onNavigate }) {
const { message } = useLocale();
return (
<StateSurface
eyebrow="404 · 찾을 수 없음"
title="요청한 화면이 없습니다."
description="주소를 확인하거나 시작 화면으로 돌아가세요."
actionLabel="시작 화면으로 이동"
eyebrow={message("access.notFound.eyebrow")}
title={message("access.notFound.title")}
description={message("access.notFound.description")}
actionLabel={message("action.goHome")}
onAction={onNavigate}
/>
);
+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,456 @@
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
import { useLocale } from "../../i18n/index.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) {
const { message } = useLocale();
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 ?? message("action.alertCloseNamed", { 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 { message } = useLocale();
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");
}
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
queueMicrotask(() => {
if (previousFocus?.isConnected) previousFocus.focus();
});
}, [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 ?? message("action.closeNamed", { 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,411 @@
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";
import { useLocale } from "../../i18n/index.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 { message } = useLocale();
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)
: message("form.remaining", { count: 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,199 @@
import { useId, useRef, useState } from "react";
import { useLocale } from "../../i18n/index.js";
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();
const { direction } = useLocale();
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, direction === "rtl" ? -1 : 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, direction === "rtl" ? 1 : -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);
const { direction } = useLocale();
const PreviousDirectionalIcon =
direction === "rtl" ? NextIcon : PreviousIcon;
const NextDirectionalIcon = direction === "rtl" ? PreviousIcon : NextIcon;
return (
<nav aria-label={label} className="ui-pagination">
<IconButton
accessibleName={previousLabel}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
variant="secondary"
>
<PreviousDirectionalIcon />
</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"
>
<NextDirectionalIcon />
</IconButton>
</nav>
);
}
@@ -0,0 +1,444 @@
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";
import { useLocale } from "../../i18n/index.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;
}>) {
const { message } = useLocale();
return (
<section
aria-label={message("toast.region")}
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 { message } = useLocale();
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={message("action.closeNamed", { title: 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(
+141 -7
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로 닫고 포커스를 복원합니다.">
<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>
+3 -1
View File
@@ -1,6 +1,7 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import { useLocale } from "../i18n/index.js";
import type {
FieldErrors,
FieldName,
@@ -31,6 +32,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const { message } = useLocale();
const {
fieldErrors,
formErrors = [],
@@ -50,7 +52,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}> .</h2>
<h2 id={headingId}>{message("form.errorSummary")}</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
+24 -12
View File
@@ -1,4 +1,8 @@
import type { ApiFailure } from "../../contracts/errors.js";
import {
formatMessage,
type ParameterlessMessageKey,
} from "../i18n/index.js";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
@@ -23,24 +27,32 @@ export type MappedValidationFailure<Values extends FormValues> = Readonly<{
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "필수 입력값입니다.",
too_small: "입력값이 너무 짧습니다.",
too_big: "입력값이 너무 깁니다.",
invalid_type: "입력 형식을 확인해 주세요.",
invalid_format: "입력 형식을 확인해 주세요.",
invalid_value: "허용된 값을 선택해 주세요.",
REQUIRED: "form.validation.required",
too_small: "form.validation.tooSmall",
too_big: "form.validation.tooBig",
invalid_type: "form.validation.invalidType",
invalid_format: "form.validation.invalidFormat",
invalid_value: "form.validation.invalidValue",
});
export function validationMessage(code: string): string {
return (
type MessageResolver = (key: ParameterlessMessageKey) => string;
const defaultMessage: MessageResolver = (key) => formatMessage("ko-KR", key);
export function validationMessage(
code: string,
message: MessageResolver = defaultMessage,
): string {
return message(
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"입력값을 확인해 주세요."
"form.validation.unknown",
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
allowedFields: readonly FieldName<Values>[],
message: MessageResolver = defaultMessage,
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
@@ -51,15 +63,15 @@ export function mapValidationFailureToFields<Values extends FormValues>(
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push("입력값을 다시 확인해 주세요.");
formErrors.push(message("form.validation.retry"));
}
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);
fieldErrors[name] ??= validationMessage(issue.code, message);
} else {
formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
formErrors.push(message("form.validation.unknownField"));
}
}
return Object.freeze({
+13 -4
View File
@@ -8,6 +8,10 @@ import {
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
useLocale,
type ParameterlessMessageKey,
} from "../i18n/index.js";
import {
mapValidationFailureToFields,
@@ -37,6 +41,7 @@ export function useAppForm<
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const { message } = useLocale();
const {
schema,
defaultValues,
@@ -142,13 +147,14 @@ export function useAppForm<
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
message,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? ["입력 구성을 다시 확인해 주세요."]
? [message("form.validation.configuration")]
: [],
);
setTouched(new Set(allowedServerFields));
@@ -178,6 +184,7 @@ export function useAppForm<
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
message,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
@@ -185,11 +192,11 @@ export function useAppForm<
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
message("form.conflict"),
]);
setResult("conflict");
} else {
setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
setFormErrors([message("form.unavailable")]);
setResult("unavailable");
}
return outcome;
@@ -203,6 +210,7 @@ export function useAppForm<
defaultValues,
focusFirstError,
mapToCommand,
message,
resetOnSuccess,
schema,
submit,
@@ -231,6 +239,7 @@ export function useAppForm<
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
message: (key: ParameterlessMessageKey) => string,
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
@@ -238,7 +247,7 @@ function issuesToFieldErrors<Values extends FormValues>(
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code);
errors[name] ??= validationMessage(issue.code, message);
}
return Object.freeze(errors);
}
@@ -3,6 +3,7 @@ import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { useLocale } from "../i18n/index.js";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
@@ -33,19 +34,20 @@ export function useDirtyNavigationGuard(when: boolean) {
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
const { message } = useLocale();
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title="저장하지 않은 변경이 있습니다."
description="이 화면을 떠나면 입력한 내용이 사라집니다."
title={message("form.unsaved.title")}
description={message("form.unsaved.description")}
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
{message("action.continueEditing")}
</Button>
<Button variant="danger" onClick={props.guard.leave}>
{message("action.discardAndLeave")}
</Button>
</>
}
+304
View File
@@ -0,0 +1,304 @@
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js";
const PLATFORM_KO_MESSAGES = {
"common.unavailable": "요청한 문구를 표시할 수 없습니다.",
"common.processing": "처리 중…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "표시 정보 없음",
"action.retry": "다시 시도",
"action.reauth": "로그인",
"action.navigateSafe": "안전한 화면으로 이동",
"action.reloadOnce": "한 번 새로고침",
"action.contactSupport": "지원 정보 확인",
"action.resolveConflict": "충돌 해결",
"action.continueEditing": "계속 작성",
"action.discardAndLeave": "변경 버리고 이동",
"action.signIn": "로그인 시작",
"action.recoverSession": "세션 복구",
"action.signOut": "로그아웃",
"action.goHome": "홈으로 이동",
"action.closeNamed": "{title} 닫기",
"action.alertCloseNamed": "{title} 알림 닫기",
"shell.skipToContent": "본문으로 건너뛰기",
"shell.menu": "메뉴",
"shell.closeMenu": "메뉴 닫기",
"shell.sidebar": "사이드바",
"shell.primaryNavigation": "주요 탐색",
"shell.theme": "색상 테마",
"shell.theme.system": "시스템 테마",
"shell.theme.light": "라이트 테마",
"shell.theme.dark": "다크 테마",
"shell.locale": "언어",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "인증됨",
"shell.session.unauthenticated": "로그인 전",
"shell.session.recoveryPending": "복구 대기",
"shell.session.integrationFailed": "연동 필요",
"shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.",
"route.APP_HOME.navigation": "홈",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_UI.navigation": "UI 구성요소",
"route.EXAMPLES_UI.title": "UI 구성요소",
"route.EXAMPLES_STATES.navigation": "화면 상태",
"route.EXAMPLES_STATES.title": "화면 상태",
"route.EXAMPLES_AUTH.navigation": "인증 연동",
"route.EXAMPLES_AUTH.title": "인증 연동",
"route.NOT_FOUND.navigation": "찾을 수 없음",
"route.NOT_FOUND.title": "페이지를 찾을 수 없습니다.",
"async.loading": "불러오는 중",
"async.empty": "표시할 항목이 없습니다.",
"async.refreshing": "최신 정보를 확인하고 있습니다.",
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
"async.mutationConflict": "다른 변경과 충돌했습니다.",
"access.auth.eyebrow": "401 · 인증 필요",
"access.auth.title": "로그인이 필요합니다.",
"access.auth.description":
"세션을 시작한 뒤 이전 작업을 안전하게 계속할 수 있습니다.",
"access.forbidden.eyebrow": "403 · 권한 없음",
"access.forbidden.title": "접근할 수 없습니다.",
"access.forbidden.description":
"권한을 확인하거나 접근 가능한 화면으로 이동하세요.",
"access.notFound.eyebrow": "404 · 찾을 수 없음",
"access.notFound.title": "요청한 화면이 없습니다.",
"access.notFound.description":
"주소를 확인하거나 시작 화면으로 돌아가세요.",
"route.loading": "화면을 준비하고 있습니다.",
"route.loadingNamed": "{title} 로딩 중",
"route.failure.title": "화면을 표시하지 못했습니다.",
"route.failure.description":
"잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요.",
"route.invalid.title": "올바르지 않은 주소입니다.",
"route.invalid.description": "주소의 경로 또는 검색 조건을 확인해 주세요.",
"route.invalid.action": "안전한 탐색 링크를 사용해 주세요.",
"route.auth.integration.title": "로그인 연동이 필요합니다.",
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.required.title": "세션이 필요합니다.",
"route.auth.recovering.description":
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
"route.auth.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
"chunk.failure.title": "화면 자산을 복구하지 못했습니다.",
"chunk.failure.description":
"문제가 계속되면 배포 상태와 지원 참조 정보를 확인해 주세요.",
"page.notFound.title": "페이지를 찾을 수 없습니다.",
"page.notFound.description":
"주소를 확인하거나 준비된 시작 화면으로 돌아가세요.",
"template.breadcrumb": "현재 위치",
"template.relatedInformation": "관련 정보",
"template.searchAndFilter": "검색과 필터",
"template.pagination": "페이지 탐색",
"template.summary": "요약 정보",
"template.dangerActions": "위험 작업",
"template.supportReference": "지원 참조: {reference}",
"form.errorSummary": "입력 내용을 확인해 주세요.",
"form.unsaved.title": "저장하지 않은 변경이 있습니다.",
"form.unsaved.description": "이 화면을 떠나면 입력한 내용이 사라집니다.",
"form.validation.required": "필수 입력값입니다.",
"form.validation.tooSmall": "입력값이 너무 짧습니다.",
"form.validation.tooBig": "입력값이 너무 깁니다.",
"form.validation.invalidType": "입력 형식을 확인해 주세요.",
"form.validation.invalidFormat": "입력 형식을 확인해 주세요.",
"form.validation.invalidValue": "허용된 값을 선택해 주세요.",
"form.validation.unknown": "입력값을 확인해 주세요.",
"form.validation.retry": "입력값을 다시 확인해 주세요.",
"form.validation.unknownField":
"서버가 확인하지 못한 입력 항목이 있습니다.",
"form.validation.configuration": "입력 구성을 다시 확인해 주세요.",
"form.conflict":
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
"form.unavailable": "저장하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"form.remaining": "{count}자 남음",
"toast.region": "알림",
"error.network_unreachable": "네트워크에 연결할 수 없습니다.",
"error.request_timeout": "요청 시간이 초과되었습니다.",
"error.auth_required": "계속하려면 로그인이 필요합니다.",
"error.auth_integration_failure": "로그인 연동을 사용할 수 없습니다.",
"error.forbidden": "이 작업을 수행할 권한이 없습니다.",
"error.not_found": "요청한 항목을 찾을 수 없습니다.",
"error.rate_limited": "요청이 많습니다. 잠시 후 다시 시도해 주세요.",
"error.server_failure": "요청을 완료하지 못했습니다.",
"error.chunk_load_failure": "새 화면 파일을 불러오지 못했습니다.",
"error.build_mismatch": "현재 화면과 활성 빌드가 일치하지 않습니다.",
"error.config_mismatch": "런타임 설정 버전이 현재 화면과 일치하지 않습니다.",
"error.api_contract_mismatch": "API 계약 버전이 현재 화면과 일치하지 않습니다.",
"error.release_mismatch": "현재 화면과 활성 릴리스가 일치하지 않습니다.",
"error.asset_mismatch": "화면 자산 구성이 현재 릴리스와 일치하지 않습니다.",
"error.render_failure": "화면을 표시하지 못했습니다.",
"error.unknown_failure": "예상하지 못한 문제가 발생했습니다.",
"boot.failure.title": "애플리케이션을 시작할 수 없습니다.",
"boot.field.error": "오류",
"boot.field.code": "코드",
"boot.field.build": "빌드",
"boot.field.configSchema": "설정 스키마",
"boot.field.release": "릴리스",
"boot.supportReference": "지원 참조: {reference}",
} as const;
export const KO_MESSAGES = {
...PLATFORM_KO_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["ko-KR"],
} as const;
export type MessageKey = keyof typeof KO_MESSAGES;
const PLATFORM_EN_MESSAGES = {
"common.unavailable": "This message is unavailable.",
"common.processing": "Processing…",
"common.appName": "Frontend Skeleton",
"common.noDisplayValue": "No display value",
"action.retry": "Try again",
"action.reauth": "Sign in",
"action.navigateSafe": "Go to an available page",
"action.reloadOnce": "Reload once",
"action.contactSupport": "View support information",
"action.resolveConflict": "Resolve conflict",
"action.continueEditing": "Continue editing",
"action.discardAndLeave": "Discard and leave",
"action.signIn": "Start sign-in",
"action.recoverSession": "Recover session",
"action.signOut": "Sign out",
"action.goHome": "Go home",
"action.closeNamed": "Close {title}",
"action.alertCloseNamed": "Dismiss {title} alert",
"shell.skipToContent": "Skip to content",
"shell.menu": "Menu",
"shell.closeMenu": "Close menu",
"shell.sidebar": "Sidebar",
"shell.primaryNavigation": "Primary navigation",
"shell.theme": "Color theme",
"shell.theme.system": "System theme",
"shell.theme.light": "Light theme",
"shell.theme.dark": "Dark theme",
"shell.locale": "Language",
"shell.locale.ko": "한국어",
"shell.locale.en": "English",
"shell.locale.pseudo": "Pseudo",
"shell.locale.rtl": "RTL smoke",
"shell.session.authenticated": "Authenticated",
"shell.session.unauthenticated": "Signed out",
"shell.session.recoveryPending": "Recovery pending",
"shell.session.integrationFailed": "Integration required",
"shell.session.actionFailed": "The session action could not be completed.",
"route.APP_HOME.navigation": "Home",
"route.APP_HOME.title": "Clean Architecture Frontend",
"route.EXAMPLES_UI.navigation": "UI components",
"route.EXAMPLES_UI.title": "UI components",
"route.EXAMPLES_STATES.navigation": "Screen states",
"route.EXAMPLES_STATES.title": "Screen states",
"route.EXAMPLES_AUTH.navigation": "Authentication",
"route.EXAMPLES_AUTH.title": "Authentication",
"route.NOT_FOUND.navigation": "Not found",
"route.NOT_FOUND.title": "Page not found",
"async.loading": "Loading",
"async.empty": "There are no items to display.",
"async.refreshing": "Checking for the latest information.",
"async.staleDegraded": "Showing previously loaded information.",
"async.mutationPending": "Saving changes.",
"async.mutationConflict": "The change conflicts with another update.",
"access.auth.eyebrow": "401 · Authentication required",
"access.auth.title": "Sign-in is required.",
"access.auth.description":
"Start a session to safely continue the previous task.",
"access.forbidden.eyebrow": "403 · Access denied",
"access.forbidden.title": "You cannot access this page.",
"access.forbidden.description":
"Check your permissions or go to an available page.",
"access.notFound.eyebrow": "404 · Not found",
"access.notFound.title": "The requested page does not exist.",
"access.notFound.description": "Check the address or return to the start page.",
"route.loading": "Preparing the page.",
"route.loadingNamed": "Loading {title}",
"route.failure.title": "The page could not be displayed.",
"route.failure.description":
"Try again shortly. If the problem continues, check the support reference.",
"route.invalid.title": "This address is invalid.",
"route.invalid.description": "Check the path and search parameters.",
"route.invalid.action": "Use a safe navigation link.",
"route.auth.integration.title": "Sign-in integration is required.",
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.",
"route.auth.required.title": "A session is required.",
"route.auth.recovering.description":
"Run recovery to continue checking the existing session.",
"route.auth.required.description":
"This protected route demonstrates the authentication integration seam.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.",
"chunk.failure.title": "The page assets could not be recovered.",
"chunk.failure.description":
"If the problem continues, check deployment status and support information.",
"page.notFound.title": "Page not found.",
"page.notFound.description": "Check the address or return to the start page.",
"template.breadcrumb": "Current location",
"template.relatedInformation": "Related information",
"template.searchAndFilter": "Search and filters",
"template.pagination": "Pagination",
"template.summary": "Summary information",
"template.dangerActions": "Dangerous actions",
"template.supportReference": "Support reference: {reference}",
"form.errorSummary": "Review the entered information.",
"form.unsaved.title": "You have unsaved changes.",
"form.unsaved.description":
"The entered information will be lost if you leave this page.",
"form.validation.required": "This field is required.",
"form.validation.tooSmall": "This value is too short.",
"form.validation.tooBig": "This value is too long.",
"form.validation.invalidType": "Check the input type.",
"form.validation.invalidFormat": "Check the input format.",
"form.validation.invalidValue": "Choose an allowed value.",
"form.validation.unknown": "Check the entered value.",
"form.validation.retry": "Review the entered values.",
"form.validation.unknownField":
"The server reported an unrecognized input field.",
"form.validation.configuration": "Review the input configuration.",
"form.conflict":
"Another update conflicts with this change. Your input has been preserved.",
"form.unavailable": "The change could not be saved. Try again shortly.",
"form.remaining": "{count} characters remaining",
"toast.region": "Notifications",
"error.network_unreachable": "The network is unavailable.",
"error.request_timeout": "The request timed out.",
"error.auth_required": "Sign in to continue.",
"error.auth_integration_failure": "Sign-in integration is unavailable.",
"error.forbidden": "You are not allowed to perform this action.",
"error.not_found": "The requested item could not be found.",
"error.rate_limited": "Too many requests were made. Try again shortly.",
"error.server_failure": "The request could not be completed.",
"error.chunk_load_failure": "The new page assets could not be loaded.",
"error.build_mismatch": "The active build does not match this page.",
"error.config_mismatch": "The runtime configuration does not match this page.",
"error.api_contract_mismatch": "The API contract does not match this page.",
"error.release_mismatch": "The active release does not match this page.",
"error.asset_mismatch": "The page assets do not match this release.",
"error.render_failure": "The page could not be displayed.",
"error.unknown_failure": "An unexpected problem occurred.",
"boot.failure.title": "The application could not start.",
"boot.field.error": "Error",
"boot.field.code": "Code",
"boot.field.build": "Build",
"boot.field.configSchema": "Configuration schema",
"boot.field.release": "Release",
"boot.supportReference": "Support reference: {reference}",
} as const satisfies Record<keyof typeof PLATFORM_KO_MESSAGES, string>;
export const EN_MESSAGES = {
...PLATFORM_EN_MESSAGES,
...INSTALLED_MESSAGE_CATALOGS["en-US"],
} as const satisfies Record<MessageKey, string>;
export const MESSAGE_CATALOGS = Object.freeze({
"ko-KR": KO_MESSAGES,
"en-US": EN_MESSAGES,
"ar-EG": EN_MESSAGES,
});
+96
View File
@@ -0,0 +1,96 @@
import { normalizeLocale, type SupportedLocale } from "./message-contract.js";
const FORMAT_FALLBACK = "—";
export type DateFormatStyle = "short" | "medium" | "long";
export function formatDate(
locale: SupportedLocale,
value: Date | number,
options: Readonly<{
dateStyle?: DateFormatStyle;
timeZone?: string;
}> = {},
): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return FORMAT_FALLBACK;
return new Intl.DateTimeFormat(normalizeLocale(locale), {
dateStyle: options.dateStyle ?? "medium",
timeZone: options.timeZone ?? "UTC",
}).format(date);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatNumber(
locale: SupportedLocale,
value: number,
options: Readonly<{
style?: "decimal" | "percent";
maximumFractionDigits?: number;
}> = {},
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.NumberFormat(normalizeLocale(locale), options).format(value);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatRelativeTime(
locale: SupportedLocale,
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string {
try {
if (!Number.isFinite(value)) return FORMAT_FALLBACK;
return new Intl.RelativeTimeFormat(normalizeLocale(locale), {
numeric: "auto",
}).format(value, unit);
} catch {
return FORMAT_FALLBACK;
}
}
export function formatList(
locale: SupportedLocale,
values: readonly string[],
): string {
try {
return new Intl.ListFormat(normalizeLocale(locale), {
style: "long",
type: "conjunction",
}).format(values);
} catch {
return values.join(", ");
}
}
export function selectPlural(
locale: SupportedLocale,
value: number,
choices: Readonly<
Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
>,
): string {
try {
if (!Number.isFinite(value)) return choices.other;
return choices[
new Intl.PluralRules(normalizeLocale(locale)).select(value)
] ?? choices.other;
} catch {
return choices.other;
}
}
export function selectMessage(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string {
return choices[value] ?? choices.other;
}
export { FORMAT_FALLBACK };
+29
View File
@@ -0,0 +1,29 @@
export { LocaleProvider, useLocale } from "./locale-provider.js";
export {
catalogKeys,
fallbackMessage,
formatMessage,
localeDirection,
MESSAGE_KEY_ALIASES,
messagePlaceholders,
normalizeLocale,
resolveMessage,
SUPPORTED_LOCALES,
} from "./message-contract.js";
export type {
MessageArguments,
MessageParameters,
ParameterlessMessageKey,
SupportedLocale,
TextDirection,
} from "./message-contract.js";
export type { MessageKey } from "./catalog.js";
export {
FORMAT_FALLBACK,
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
+118
View File
@@ -0,0 +1,118 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
formatDate,
formatList,
formatNumber,
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
import {
formatMessage,
localeDirection,
normalizeLocale,
resolveMessage,
type MessageArguments,
type SupportedLocale,
type TextDirection,
} from "./message-contract.js";
import type { MessageKey } from "./catalog.js";
type LocaleContextValue = Readonly<{
locale: SupportedLocale;
direction: TextDirection;
setLocale(locale: SupportedLocale): void;
message<Key extends MessageKey>(
key: Key,
...args: MessageArguments<Key>
): string;
resolve(key: string): string;
date(
value: Date | number,
options?: Parameters<typeof formatDate>[2],
): string;
number(value: number, options?: Parameters<typeof formatNumber>[2]): string;
relativeTime(
value: number,
unit: Intl.RelativeTimeFormatUnit,
): string;
list(values: readonly string[]): string;
plural(
value: number,
choices: Parameters<typeof selectPlural>[2],
): string;
select(
value: string,
choices: Readonly<Record<string, string> & { other: string }>,
): string;
}>;
const DEFAULT_LOCALE = "ko-KR" as const;
const DEFAULT_CONTEXT: LocaleContextValue = Object.freeze({
locale: DEFAULT_LOCALE,
direction: "ltr",
setLocale() {},
message: (key, ...args) => formatMessage(DEFAULT_LOCALE, key, ...args),
resolve: (key) => resolveMessage(DEFAULT_LOCALE, key),
date: (value, options) => formatDate(DEFAULT_LOCALE, value, options),
number: (value, options) => formatNumber(DEFAULT_LOCALE, value, options),
relativeTime: (value, unit) =>
formatRelativeTime(DEFAULT_LOCALE, value, unit),
list: (values) => formatList(DEFAULT_LOCALE, values),
plural: (value, choices) => selectPlural(DEFAULT_LOCALE, value, choices),
select: (value, choices) => selectMessage(value, choices),
});
const LocaleContext = createContext<LocaleContextValue>(DEFAULT_CONTEXT);
export function LocaleProvider({
children,
initialLocale = "ko-KR",
}: Readonly<{
children: React.ReactNode;
initialLocale?: SupportedLocale;
}>) {
const [locale, setLocaleState] = useState<SupportedLocale>(() =>
normalizeLocale(initialLocale),
);
const direction = localeDirection(locale);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = direction;
}, [direction, locale]);
const value = useMemo<LocaleContextValue>(
() => ({
locale,
direction,
setLocale: setLocaleState,
message: (key, ...args) => formatMessage(locale, key, ...args),
resolve: (key) => resolveMessage(locale, key),
date: (dateValue, options) => formatDate(locale, dateValue, options),
number: (numberValue, options) =>
formatNumber(locale, numberValue, options),
relativeTime: (relativeValue, unit) =>
formatRelativeTime(locale, relativeValue, unit),
list: (values) => formatList(locale, values),
plural: (pluralValue, choices) =>
selectPlural(locale, pluralValue, choices),
select: (selectValue, choices) => selectMessage(selectValue, choices),
}),
[direction, locale],
);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
export function useLocale(): LocaleContextValue {
return useContext(LocaleContext);
}
+135
View File
@@ -0,0 +1,135 @@
import {
EN_MESSAGES,
KO_MESSAGES,
MESSAGE_CATALOGS,
type MessageKey,
} from "./catalog.js";
export const SUPPORTED_LOCALES = Object.freeze([
"ko-KR",
"en-US",
"en-XA",
"ar-EG",
] as const);
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export type TextDirection = "ltr" | "rtl";
export type MessageParameters = Readonly<{
"action.closeNamed": { title: string };
"action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string };
"template.supportReference": { reference: string };
"form.remaining": { count: number };
"boot.supportReference": { reference: string };
}>;
export type ParameterlessMessageKey = Exclude<
MessageKey,
keyof MessageParameters
>;
export type MessageArguments<Key extends MessageKey> =
Key extends keyof MessageParameters
? [parameters: MessageParameters[Key]]
: [parameters?: never];
const PLACEHOLDER_PATTERN = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g;
const FALLBACK_LOCALE = "ko-KR" as const;
export const MESSAGE_KEY_ALIASES = Object.freeze({
"action.login": "action.reauth",
"async.pending": "common.processing",
} as const satisfies Readonly<Record<string, MessageKey>>);
export function normalizeLocale(locale: string): SupportedLocale {
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const language = locale.split("-")[0]?.toLowerCase();
if (language === "ko") return "ko-KR";
if (language === "ar") return "ar-EG";
if (language === "en") return "en-US";
return FALLBACK_LOCALE;
}
export function localeDirection(locale: SupportedLocale): TextDirection {
return locale === "ar-EG" ? "rtl" : "ltr";
}
export function messagePlaceholders(template: string): readonly string[] {
return Object.freeze(
[...template.matchAll(PLACEHOLDER_PATTERN)].map((match) => match[1] ?? ""),
);
}
export function resolveMessage(
locale: string,
key: string,
parameters?: Readonly<Record<string, string | number>>,
): string {
const normalized = normalizeLocale(locale);
const sourceLocale: keyof typeof MESSAGE_CATALOGS =
normalized === "en-XA" ? "en-US" : normalized;
const catalog =
MESSAGE_CATALOGS[sourceLocale] ??
MESSAGE_CATALOGS[FALLBACK_LOCALE];
const fallback = KO_MESSAGES["common.unavailable"];
const canonicalKey =
MESSAGE_KEY_ALIASES[key as keyof typeof MESSAGE_KEY_ALIASES] ?? key;
const template = catalog[canonicalKey as MessageKey] ?? fallback;
const placeholders = messagePlaceholders(template);
if (
placeholders.some(
(placeholder) =>
parameters?.[placeholder] === undefined ||
parameters?.[placeholder] === null,
)
) {
return fallback;
}
const formatted = template.replace(
PLACEHOLDER_PATTERN,
(_, placeholder: string) => String(parameters?.[placeholder] ?? ""),
);
return normalized === "en-XA" ? pseudoLocalize(formatted) : formatted;
}
export function formatMessage<Key extends MessageKey>(
locale: string,
key: Key,
...args: MessageArguments<Key>
): string {
return resolveMessage(
locale,
key,
args[0] as Readonly<Record<string, string | number>> | undefined,
);
}
function pseudoLocalize(value: string): string {
const expanded = value.replace(/[A-Za-z]/g, (character) => {
const replacements: Readonly<Record<string, string>> = {
a: "á",
e: "é",
i: "í",
o: "ó",
u: "ú",
A: "Á",
E: "É",
I: "Í",
O: "Ó",
U: "Ú",
};
return replacements[character] ?? character;
});
return `${expanded} ···]`;
}
export function catalogKeys(
locale: "ko-KR" | "en-US" | "ar-EG",
): MessageKey[] {
return Object.keys(MESSAGE_CATALOGS[locale]).sort() as MessageKey[];
}
export function fallbackMessage() {
return EN_MESSAGES["common.unavailable"];
}
+86 -59
View File
@@ -5,20 +5,29 @@ import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
import {
Button,
Drawer,
IconButton,
MenuIcon,
Select,
} from "../design-system/index.js";
import { useLocale } from "../i18n/index.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
const SESSION_LABELS = Object.freeze({
authenticated: "인증됨",
unauthenticated: "로그인 전",
"recovery-pending": "복구 대기",
"integration-failed": "연동 필요",
const SESSION_MESSAGE_KEYS = Object.freeze({
authenticated: "shell.session.authenticated",
unauthenticated: "shell.session.unauthenticated",
"recovery-pending": "shell.session.recoveryPending",
"integration-failed": "shell.session.integrationFailed",
});
export function AppShell() {
const location = useLocation();
const { sessionState, beginSignIn, signOut, recover } = useSession();
const { preference, setPreference } = useTheme();
const { locale, setLocale, message } = useLocale();
const [navigationOpen, setNavigationOpen] = useState(false);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
@@ -27,16 +36,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);
@@ -59,38 +58,41 @@ export function AppShell() {
const sessionActionLabel =
sessionState === "authenticated"
? "로그아웃"
? message("action.signOut")
: sessionState === "recovery-pending"
? "세션 복구"
: "로그인";
? message("action.recoverSession")
: message("action.reauth");
const integrationAvailable = sessionState !== "integration-failed";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">
본문으로 건너뛰기
{message("shell.skipToContent")}
</a>
<header className="app-shell__header">
<button
<IconButton
accessibleName={message("shell.menu")}
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
{message("common.appName")}
</NavLink>
<div className="app-shell__session">
<label className="visually-hidden" htmlFor="theme-preference">
색상 테마
</label>
<select
<Select
className="theme-selector"
id="theme-preference"
label={message("shell.theme")}
options={[
{ value: "system", label: message("shell.theme.system") },
{ value: "light", label: message("shell.theme.light") },
{ value: "dark", label: message("shell.theme.dark") },
]}
value={preference}
onChange={(event) =>
setPreference(
@@ -99,37 +101,75 @@ export function AppShell() {
),
)
}
>
<option value="system">시스템 테마</option>
<option value="light">라이트 테마</option>
<option value="dark">다크 테마</option>
</select>
/>
<Select
className="locale-selector"
id="locale-preference"
label={message("shell.locale")}
options={[
{ value: "ko-KR", label: message("shell.locale.ko") },
{ value: "en-US", label: message("shell.locale.en") },
{ value: "en-XA", label: message("shell.locale.pseudo") },
{ value: "ar-EG", label: message("shell.locale.rtl") },
]}
value={locale}
onChange={(event) =>
setLocale(
/** @type {import("../i18n/index.js").SupportedLocale} */ (
event.currentTarget.value
),
)
}
/>
<span className="session-status" data-state={sessionState}>
{SESSION_LABELS[sessionState]}
{message(SESSION_MESSAGE_KEYS[sessionState])}
</span>
{integrationAvailable ? (
<button
className="ui-button ui-button--compact"
type="button"
<Button
disabled={sessionActionPending}
onClick={() => void runSessionAction()}
size="compact"
>
{sessionActionPending ? "처리 중…" : sessionActionLabel}
</button>
{sessionActionPending
? message("common.processing")
: sessionActionLabel}
</Button>
) : null}
</div>
{sessionActionFailed ? (
<p className="app-shell__session-error" role="alert">
세션 작업을 완료하지 못했습니다.
{message("shell.session.actionFailed")}
</p>
) : null}
</header>
<aside
className="app-shell__sidebar"
data-open={navigationOpen}
aria-label="사이드바"
aria-label={message("shell.sidebar")}
>
<nav id="primary-navigation" aria-label="주요 탐색">
<PrimaryNavigation id="primary-navigation" />
</aside>
<Drawer
closeLabel={message("shell.closeMenu")}
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
title={message("shell.menu")}
>
<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 }) {
const { resolve, message } = useLocale();
return (
<nav id={id} aria-label={message("shell.primaryNavigation")}>
<ul className="app-navigation">
{NAVIGATION_ROUTES.map((definition) => (
<li key={definition.routeId}>
@@ -140,24 +180,11 @@ export function AppShell() {
end={definition.path === "/"}
to={definition.path}
>
{definition.navigationLabel}
{resolve(`route.${definition.routeId}.navigation`)}
</NavLink>
</li>
))}
</ul>
</nav>
</aside>
{navigationOpen ? (
<button
className="app-shell__scrim"
type="button"
aria-label="메뉴 닫기"
onClick={() => setNavigationOpen(false)}
/>
) : null}
<main className="app-shell__content" id="main-content" tabIndex={-1}>
<Outlet />
</main>
</div>
);
}
+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([
+6 -4
View File
@@ -1,18 +1,20 @@
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 { useLocale } from "../i18n/index.js";
export default function NotFoundPage() {
const { message } = useLocale();
return (
<section className="ui-page">
<PageHeader
title="페이지를 찾을 수 없습니다."
description="주소를 확인하거나 준비된 시작 화면으로 돌아가세요."
title={message("page.notFound.title")}
description={message("page.notFound.description")}
/>
<div>
<Link className="ui-button" to={routePath("APP_HOME")}>
홈으로 이동
{message("action.goHome")}
</Link>
</div>
</section>
+41 -21
View File
@@ -28,7 +28,8 @@ import {
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { PageHeader } from "../components/page-header.jsx";
import { Button, PageHeader } from "../design-system/index.js";
import { LocaleProvider, useLocale } from "../i18n/index.js";
import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
@@ -54,6 +55,8 @@ export function useRouteInput(): ParsedRouteInput {
}
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
const { message, resolve } = useLocale();
const title = resolve(`route.${definition.routeId}.title`);
return (
<section
className="ui-page route-loading"
@@ -62,8 +65,10 @@ function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
data-surface={definition.loadingSurface}
>
<div className="ui-skeleton" aria-hidden="true" />
<p> .</p>
<span className="visually-hidden">{definition.title} </span>
<p>{message("route.loading")}</p>
<span className="visually-hidden">
{message("route.loadingNamed", { title })}
</span>
</section>
);
}
@@ -73,35 +78,41 @@ function RouteFailureSurface({
}: {
definition?: RouteDefinition;
}) {
const { message } = useLocale();
return (
<section
className="ui-page"
data-surface={definition?.errorSurface ?? "route-boundary"}
>
<PageHeader
title="화면을 표시하지 못했습니다."
description="잠시 후 다시 시도해 주세요. 문제가 계속되면 운영 지원 참조 정보를 확인하세요."
title={message("route.failure.title")}
description={message("route.failure.description")}
/>
</section>
);
}
function InvalidRouteSurface({ code }: { code: string }) {
const { message } = useLocale();
return (
<section className="ui-page" data-surface="invalid-route">
<PageHeader
title="올바르지 않은 주소입니다."
description="주소의 경로 또는 검색 조건을 확인해 주세요."
title={message("route.invalid.title")}
description={message("route.invalid.description")}
/>
<p data-route-error={code}> .</p>
<p data-route-error={code}>{message("route.invalid.action")}</p>
</section>
);
}
function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
const location = useLocation();
const { message, resolve } = useLocale();
useEffect(() => {
document.title = `${definition.title} · Frontend Skeleton`;
document.title = message("route.documentTitle", {
title: resolve(`route.${definition.routeId}.title`),
appName: message("common.appName"),
});
const main = document.getElementById("main-content");
main?.focus({ preventScroll: true });
try {
@@ -111,7 +122,7 @@ function RouteLifecycle({ definition }: { definition: RouteDefinition }) {
} catch {
// Non-browser test hosts may not implement scrolling.
}
}, [definition, location.key, location.pathname]);
}, [definition, location.key, location.pathname, message, resolve]);
return null;
}
@@ -146,6 +157,7 @@ function ProtectedRoute({
}) {
const location = useLocation();
const { sessionState, beginSignIn, recover } = useSession();
const { message } = useLocale();
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const decision = decideRouteAccess(routeId, sessionState);
@@ -173,8 +185,8 @@ function ProtectedRoute({
return (
<section className="ui-page" data-surface="auth-integration-required">
<PageHeader
title="로그인 연동이 필요합니다."
description="외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다."
title={message("route.auth.integration.title")}
description={message("route.auth.integration.description")}
/>
</section>
);
@@ -183,24 +195,30 @@ function ProtectedRoute({
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={recovering ? "세션을 복구하고 있습니다." : "세션이 필요합니다."}
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? "기존 세션 확인을 계속하려면 복구를 실행하세요."
: "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다."
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<button
className="ui-button"
type="button"
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending ? "처리 중…" : recovering ? "세션 복구" : "로그인 시작"}
</button>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
.
{message("shell.session.actionFailed")}
</p>
) : null}
</section>
@@ -300,10 +318,12 @@ export function AppRouter({
[basename, buildId],
);
return (
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
);
}
+466 -70
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;
@@ -186,17 +131,36 @@
margin-inline-start: auto;
}
.theme-selector {
min-height: 2.25rem;
.theme-selector,
.locale-selector {
display: block;
}
.theme-selector .ui-field__label,
.locale-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,
.locale-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;
}
.locale-selector .ui-field__input {
max-width: 7rem;
}
.app-shell__session-error {
position: absolute;
top: calc(100% + 0.25rem);
@@ -305,7 +269,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 +308,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 +408,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 +526,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 +598,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 +650,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 +658,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;
}
@@ -920,7 +1312,7 @@
padding-inline: 0.75rem;
}
.app-shell__menu-button {
.app-shell__menu-button.ui-icon-button {
display: inline-flex;
}
@@ -955,6 +1347,10 @@
display: none;
}
.app-shell__session .theme-selector {
display: none;
}
.app-shell__brand {
display: none;
}
+29 -7
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Button } from "../components/ui/button.jsx";
import { useLocale } from "../i18n/index.js";
export type PageHeading = Readonly<{
title: string;
@@ -36,10 +37,14 @@ export type PageTemplateSlots = Readonly<{
}>;
export function StandardPage(props: PageTemplateSlots) {
const { message } = useLocale();
return (
<article className="ui-page page-template page-template--standard">
{props.breadcrumb ? (
<nav className="page-template__breadcrumb" aria-label="현재 위치">
<nav
className="page-template__breadcrumb"
aria-label={message("template.breadcrumb")}
>
{props.breadcrumb}
</nav>
) : null}
@@ -64,7 +69,10 @@ export function StandardPage(props: PageTemplateSlots) {
>
<div className="page-template__content">{props.children}</div>
{props.aside ? (
<aside className="page-template__aside" aria-label="관련 정보">
<aside
className="page-template__aside"
aria-label={message("template.relatedInformation")}
>
{props.aside}
</aside>
) : null}
@@ -83,6 +91,7 @@ export function CollectionPage(
pagination?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage
{...props}
@@ -92,7 +101,7 @@ export function CollectionPage(
{props.toolbar ? (
<section
className="collection-page__toolbar"
aria-label="검색과 필터"
aria-label={message("template.searchAndFilter")}
>
{props.toolbar}
</section>
@@ -117,7 +126,10 @@ export function CollectionPage(
>
<div className="collection-page__results">{props.children}</div>
{props.pagination ? (
<nav className="collection-page__pagination" aria-label="페이지 탐색">
<nav
className="collection-page__pagination"
aria-label={message("template.pagination")}
>
{props.pagination}
</nav>
) : null}
@@ -132,16 +144,23 @@ export function DetailPage(
destructiveAction?: ReactNode;
}>,
) {
const { message } = useLocale();
return (
<StandardPage {...props}>
{props.metadata ? (
<section className="detail-page__metadata" aria-label="요약 정보">
<section
className="detail-page__metadata"
aria-label={message("template.summary")}
>
{props.metadata}
</section>
) : null}
<div className="detail-page__sections">{props.children}</div>
{props.destructiveAction ? (
<section className="detail-page__danger" aria-label="위험 작업">
<section
className="detail-page__danger"
aria-label={message("template.dangerActions")}
>
{props.destructiveAction}
</section>
) : null}
@@ -190,6 +209,7 @@ export function StatusPage(
supportReference?: string;
}>,
) {
const { message } = useLocale();
return (
<section
className={`ui-page page-template status-page status-page--${props.variant}`}
@@ -208,7 +228,9 @@ export function StatusPage(
) : null}
{props.supportReference ? (
<p className="status-page__support">
: <code>{props.supportReference}</code>
{message("template.supportReference", {
reference: props.supportReference,
})}
</p>
) : null}
</section>
+3 -1
View File
@@ -50,7 +50,9 @@ describe("async UI state matrix", () => {
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
expect(screen.getByText("existing content")).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
expect(screen.getByRole("status")).toHaveTextContent(
"최신 정보를 확인하고 있습니다.",
);
});
it("connects stale retry and conflict resolution to real callbacks", async () => {
@@ -60,7 +60,9 @@ describe("chunk recovery boundary classification", () => {
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(screen.getByRole("alert")).toHaveTextContent(
"화면을 표시하지 못했습니다.",
);
expect(recover).not.toHaveBeenCalled();
});
});
@@ -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: "알림" });
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);
});
});
+120
View File
@@ -0,0 +1,120 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import {
Button,
Drawer,
Pagination,
Tabs,
} from "../../src/presentation/design-system/index.js";
import {
LocaleProvider,
useLocale,
type SupportedLocale,
} from "../../src/presentation/i18n/index.js";
function LocaleHarness() {
const { direction, locale, message, setLocale } = useLocale();
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<>
<label htmlFor="test-locale">{message("shell.locale")}</label>
<select
id="test-locale"
onChange={(event) =>
setLocale(event.currentTarget.value as SupportedLocale)
}
value={locale}
>
<option value="ko-KR"></option>
<option value="en-US">English</option>
<option value="ar-EG">RTL</option>
</select>
<output>{`${locale}:${direction}`}</output>
<Button onClick={() => setDrawerOpen(true)}>
{message("shell.menu")}
</Button>
<Drawer
closeLabel={message("shell.closeMenu")}
onClose={() => setDrawerOpen(false)}
open={drawerOpen}
title={message("shell.menu")}
>
content
</Drawer>
</>
);
}
describe("locale provider runtime", () => {
it("switches copy and synchronizes the document language and direction", async () => {
const user = userEvent.setup();
render(
<LocaleProvider>
<LocaleHarness />
</LocaleProvider>,
);
expect(document.documentElement).toHaveAttribute("lang", "ko-KR");
await user.selectOptions(screen.getByLabelText("언어"), "en-US");
expect(screen.getByLabelText("Language")).toHaveValue("en-US");
expect(document.documentElement).toHaveAttribute("lang", "en-US");
expect(document.documentElement).toHaveAttribute("dir", "ltr");
await user.selectOptions(screen.getByLabelText("Language"), "ar-EG");
expect(document.documentElement).toHaveAttribute("lang", "ar-EG");
expect(document.documentElement).toHaveAttribute("dir", "rtl");
await user.click(screen.getByRole("button", { name: "Menu" }));
expect(screen.getByRole("dialog", { name: "Menu" })).toHaveAttribute(
"open",
);
});
it("reverses horizontal tab focus semantics in RTL", async () => {
const user = userEvent.setup();
render(
<LocaleProvider initialLocale="ar-EG">
<Tabs
activation="manual"
label="sections"
tabs={[
{ id: "one", label: "One", panel: "One panel" },
{ id: "two", label: "Two", panel: "Two panel" },
{ id: "three", label: "Three", panel: "Three panel" },
]}
/>
</LocaleProvider>,
);
const first = screen.getByRole("tab", { name: "One" });
first.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Three" })).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(first).toHaveFocus();
});
it("keeps pagination semantics while direction-aware icons remain decorative", () => {
render(
<LocaleProvider initialLocale="ar-EG">
<Pagination
label="pages"
nextLabel="Next page"
onChange={() => {}}
page={2}
pageCount={3}
pageLabel={(page) => `Page ${page}`}
previousLabel="Previous page"
/>
</LocaleProvider>,
);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeEnabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -86,7 +86,7 @@ describe("page template slot contracts", () => {
/>,
);
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
expect(screen.getByText("SAFE-123")).toBeVisible();
expect(screen.getByText(/SAFE-123/)).toBeVisible();
expect(document.body).not.toHaveTextContent("stack");
});
});
+4 -2
View File
@@ -28,7 +28,9 @@ describe("render recovery boundaries", () => {
</FeatureBoundary>,
);
expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure");
expect(screen.getByRole("alert")).toHaveTextContent(
"화면을 표시하지 못했습니다.",
);
expect(onRenderFailure).toHaveBeenCalledWith({
routeId: "APP_HOME",
buildId: "build-a",
@@ -83,7 +85,7 @@ describe("render recovery boundaries", () => {
</FeatureBoundary>,
);
shouldThrow = false;
await user.click(screen.getByRole("button", { name: "retry" }));
await user.click(screen.getByRole("button", { name: "다시 시도" }));
view.rerender(
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
<Recoverable />
+16
View File
@@ -40,3 +40,19 @@ test("@a11y reduced-motion policy disables long animation", async ({ page }) =>
.evaluate((body) => getComputedStyle(body).animationDuration);
expect(["0s", "0.00001s", "1e-05s"]).toContain(duration);
});
test("@a11y opened design-system dialog has no blocking violations", async ({
page,
}) => {
await page.goto("/examples/ui");
await page.getByRole("button", { name: "모달 열기" }).click();
const results = await new AxeBuilder({ page })
.include(".ui-dialog")
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
results.violations.filter((violation) =>
["critical", "serious"].includes(violation.impact ?? ""),
),
).toEqual([]);
});
+5
View File
@@ -53,8 +53,13 @@ test("provides an escape-dismissible mobile navigation", async ({ page }) => {
await menu.click();
await expect(menu).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
await expect(page.locator(".ui-drawer")).toHaveJSProperty("open", true);
expect(
await page.locator(".ui-drawer").evaluate((drawer) => drawer.matches(":modal")),
).toBe(true);
await page.keyboard.press("Escape");
await expect(menu).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("navigation", { name: "주요 탐색" })).toBeHidden();
await expect(menu).toBeFocused();
});
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
test("runs menu typeahead, tabs and duplicate toast interactions", async ({
page,
}) => {
await page.goto("/examples/ui");
const menuTrigger = page.getByRole("button", { name: "작업 메뉴" });
await menuTrigger.focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.type("toast");
const toastItem = page.getByRole("menuitem", { name: "Toast 표시" });
await expect(toastItem).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByText("예제가 저장되었습니다.")).toBeVisible();
await expect(menuTrigger).toBeFocused();
const tokenTab = page.getByRole("tab", { name: "토큰" });
await tokenTab.focus();
await page.keyboard.press("ArrowRight");
const primitiveTab = page.getByRole("tab", { name: "프리미티브" });
await expect(primitiveTab).toBeFocused();
await expect(primitiveTab).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("Enter");
await expect(primitiveTab).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tabpanel", { name: "프리미티브" }),
).toContainText("native semantics");
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
test("switches the shell locale and keeps pseudo-locale copy within compact layout", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 720 });
await page.goto("/");
const locale = page.getByRole("combobox", { name: "언어" });
await locale.selectOption("en-US");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.locator("html")).toHaveAttribute("dir", "ltr");
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await page.getByRole("combobox", { name: "Language" }).selectOption("en-XA");
await expect(page.locator("html")).toHaveAttribute("lang", "en-XA");
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await expect(page.getByRole("button", { name: /Ménú/ })).toBeVisible();
});
test("applies RTL to the shell and modal navigation without changing action semantics", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.getByRole("combobox", { name: "언어" }).selectOption("ar-EG");
await expect(page.locator("html")).toHaveAttribute("lang", "ar-EG");
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
const trigger = page.getByRole("button", { name: "Menu" });
await trigger.click();
await expect(page.getByRole("dialog", { name: "Menu" })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Primary navigation" }))
.toHaveCount(1);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
});
@@ -72,10 +72,10 @@ describe("reference feature boundary contracts", () => {
createdAt: "2026-07-26T00:00:00.000Z",
});
if (!("id" in model)) throw new Error("expected one model");
expect(toReferenceView(model, () => "formatted")).toEqual({
expect(toReferenceView(model)).toEqual({
resourceId: "reference-1",
title: "Example",
createdAtLabel: "formatted",
createdAt: "2026-07-26T00:00:00.000Z",
});
});
@@ -55,7 +55,7 @@ function inputWith(
value: {
resourceId: "created",
title: name,
createdAtLabel: null,
createdAt: null,
},
}),
getResource: async (resourceId) => ({
@@ -63,7 +63,7 @@ function inputWith(
value: {
resourceId,
title: "Detail",
createdAtLabel: null,
createdAt: null,
},
}),
...overrides,
@@ -90,7 +90,7 @@ describe("reference feature page states", () => {
{
resourceId: "reference-1",
title: "Loaded",
createdAtLabel: null,
createdAt: null,
},
],
});
@@ -191,7 +191,7 @@ describe("reference feature page states", () => {
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
createdAt: null,
},
],
})
@@ -209,7 +209,7 @@ describe("reference feature page states", () => {
{
resourceId: "recovered",
title: "Recovered",
createdAtLabel: null,
createdAt: null,
},
],
});
@@ -217,7 +217,9 @@ describe("reference feature page states", () => {
await screen.findByText("Existing");
await user.click(screen.getByRole("button", { name: "새로고침" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(
await screen.findByText("기존 정보를 표시하고 있습니다."),
).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(await screen.findByText("Recovered")).toBeVisible();
@@ -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";
+5
View File
@@ -0,0 +1,5 @@
export function UnsafeBackendFailure({
failure,
}: Readonly<{ failure: { message: string } }>) {
return <p>{failure.message}</p>;
}
+3
View File
@@ -0,0 +1,3 @@
export function HardcodedCommonAction() {
return <button type="button"> </button>;
}
+3
View File
@@ -0,0 +1,3 @@
export function UnsafeTranslatedMarkup({ value }: Readonly<{ value: string }>) {
return <p dangerouslySetInnerHTML={{ __html: value }} />;
}
+3
View File
@@ -0,0 +1,3 @@
import { IconButton, MenuIcon } from "../../../src/presentation/design-system/index.js";
export const InvalidIconButton = <IconButton><MenuIcon /></IconButton>;
+3
View File
@@ -0,0 +1,3 @@
import { formatMessage } from "../../../src/presentation/i18n/index.js";
formatMessage("ko-KR", "unknown.translation.key");
+5
View File
@@ -0,0 +1,5 @@
import { formatMessage } from "../../../src/presentation/i18n/index.js";
formatMessage("en-US", "route.documentTitle");
formatMessage("en-US", "route.documentTitle", { title: "Missing app name" });
formatMessage("en-US", "action.retry", { unexpected: "value" });
+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");
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
catalogKeys,
FORMAT_FALLBACK,
formatDate,
formatList,
formatMessage,
formatNumber,
formatRelativeTime,
localeDirection,
messagePlaceholders,
normalizeLocale,
resolveMessage,
selectMessage,
selectPlural,
} from "../../src/presentation/i18n/index.js";
import {
EN_MESSAGES,
KO_MESSAGES,
MESSAGE_CATALOGS,
} from "../../src/presentation/i18n/catalog.js";
describe("internationalization message contract", () => {
it("keeps every registered catalog and interpolation contract in parity", () => {
expect(catalogKeys("en-US")).toEqual(catalogKeys("ko-KR"));
expect(catalogKeys("ar-EG")).toEqual(catalogKeys("ko-KR"));
for (const key of catalogKeys("ko-KR")) {
expect(messagePlaceholders(EN_MESSAGES[key])).toEqual(
messagePlaceholders(KO_MESSAGES[key]),
);
}
expect(MESSAGE_CATALOGS["ar-EG"]).toBe(EN_MESSAGES);
});
it("formats typed parameters and expands the pseudo locale", () => {
expect(
formatMessage("en-US", "route.documentTitle", {
title: "Settings",
appName: "Skeleton",
}),
).toBe("Settings · Skeleton");
const pseudo = formatMessage("en-XA", "action.closeNamed", {
title: "Account",
});
expect(pseudo).toMatch(/^.+ ···]$/);
expect(pseudo.length).toBeGreaterThan("Close Account".length);
});
it("falls back safely for unknown locale, key and missing interpolation", () => {
expect(normalizeLocale("fr-FR")).toBe("ko-KR");
expect(resolveMessage("fr-FR", "action.retry")).toBe("다시 시도");
expect(resolveMessage("en-US", "action.login")).toBe("Sign in");
expect(resolveMessage("en-US", "backend.stack.trace")).toBe(
"요청한 문구를 표시할 수 없습니다.",
);
expect(resolveMessage("en-US", "action.closeNamed")).toBe(
"요청한 문구를 표시할 수 없습니다.",
);
expect(resolveMessage("en-US", "backend.stack.trace")).not.toContain(
"backend.stack.trace",
);
});
it("defines direction without inferring it in components", () => {
expect(localeDirection("ko-KR")).toBe("ltr");
expect(localeDirection("en-XA")).toBe("ltr");
expect(localeDirection("ar-EG")).toBe("rtl");
});
});
describe("internationalization formatter contract", () => {
const instant = new Date("2026-07-26T23:30:00.000Z");
it("uses an explicit UTC default and deterministic locale formatting", () => {
expect(formatDate("en-US", instant, { dateStyle: "short" })).toBe(
"7/26/26",
);
expect(
formatDate("en-US", instant, {
dateStyle: "short",
timeZone: "Asia/Seoul",
}),
).toBe("7/27/26");
expect(formatNumber("en-US", 1234.5)).toBe("1,234.5");
expect(formatRelativeTime("en-US", -1, "day")).toBe("yesterday");
expect(formatList("en-US", ["one", "two", "three"])).toBe(
"one, two, and three",
);
expect(selectPlural("en-US", 1, { one: "item", other: "items" })).toBe(
"item",
);
expect(
selectPlural("en-US", 3, { one: "item", other: "items" }),
).toBe("items");
expect(
selectMessage("pending", {
ready: "Ready",
pending: "Pending",
other: "Unknown",
}),
).toBe("Pending");
});
it("does not throw or expose invalid formatter input", () => {
expect(formatDate("en-US", Number.NaN)).toBe(FORMAT_FALLBACK);
expect(formatNumber("en-US", Number.POSITIVE_INFINITY)).toBe(
FORMAT_FALLBACK,
);
expect(
formatDate("en-US", instant, { timeZone: "not/a-time-zone" }),
).toBe(FORMAT_FALLBACK);
});
});