공통 UI primitive props 계약 — U01 v1
- 계획: U01 / #1289, v0.18.0 총괄.
- 버전:
UI_PRIMITIVES_CONTRACT_VERSION = 1(src/react/contracts/ui-primitives-props.ts). 소비하는 G03 core 버전은 1이다. - 규범: 디자인 전달 계약. 표현/배선의 전달 입력은 이 문서이며 테스트 하니스는 규범이 아니다.
- 표현 담당 U01은 아래 props와 시각 상태를 소유한다. 기능 담당 A12/A13은 입력 판정, A08/A10·S 계열은 저장 단계 판정을 소유한다. 그 결과만
ViewModel로 주입한다. 기존 core/DTO·화면 props는 변경하지 않는다. - 스타일·소유권·예외: 공통 시각 기반.
경계와 도입 범위
contracts/ui-primitives-props.ts는 React를 모르는 표시 모델만 선언한다. DOM 이벤트·ReactNode·ref를 포함한 실제 컴포넌트 props는 ui/shared/primitives/*.tsx에 선언한다. ViewModel 브랜드는 feature mapper에서 부여하고 UI 안에서 DTO를 캐스팅하거나 브랜드를 새로 붙이지 않는다. 버튼의 children·다이얼로그 제목 같은 UI 구조 props에는 도메인 DTO를 넣지 않는다.
| 입력 | 기능 담당이 내려주는 결론 | UI가 하지 않는 일 |
|---|---|---|
UiInputModel | label, raw, hint?, G03 InputCell과 같은 state; invalid만 error 필수 | parse/정규화/단위 계산/빈 값→0 변환/저장 가능 판정 |
UiPersistenceModel | 저장 단계와 전송 보류/차단, 통계 대기 여부 | 영수증·세대 비교/online 감지/낙관적 성공 승격 |
UiStateModel | loading/empty/error와 제목·설명 | 빈 결과 추측/인증 오류와 데이터 오류 합치기/재조회 실행 |
| intent callback | onValueChange, onCommit, onDismiss, onClick, onPress | RPC·controller·storage import/Promise 완료를 저장 성공으로 해석 |
이번 대표 연결은 DataErrorGate 재시도 버튼과 AppErrorScreen 두 버튼이다. 기존 클래스·배치·문구·콜백을 보존한다. 입력·다이얼로그·저장 상태의 전체 화면 채택은 U02/U03 단계다. 기존 모바일 UiModal과 데스크톱 DkScrim은 아직 유지한다.
버튼
UiButton은 native button 속성·ARIA 이름·children·className·onClick을 전달한다. 기본 type="button", 폼 제출은 명시적인 type="submit"이다. 아이콘만 있는 버튼은 기능을 설명하는 aria-label을 호출자가 준다. 링크 이동에는 button 대신 native anchor를 쓴다.
| 트리거 | 상태 변화/intent | 복귀 경로 |
|---|---|---|
| click / Enter / Space, 사용 가능 | native click으로 onClick 1회 | 호출자가 새 상태를 주입 |
disabled=true | native disabled, Tab 순서에서 제외 | 호출자가 disabled 해제 |
pending=true | aria-disabled, aria-busy; click·폼 제출·상위 click 전파 차단, 현재 포커스 유지 | 호출자가 pending 해제 |
pending은 실행 중인 행위의 상태다. 기기 저장 후 서버 동기화가 대기 중이라는 이유로 모든 버튼을 pending으로 잠그지 않는다. 중복 명령 방지는 controller 책임이며 이 버튼은 서버 멱등성을 대신하지 않는다. pending 중 버튼을 제거하거나 이름을 빈 문자열로 바꾸지 않는다. 결과 안내는 별도 상태 영역으로 전달한다.
입력·검증·IME
UiInput은 native text/search input과 label/hint/error를 묶는다. 숫자도 inputMode="decimal" 또는 numeric인 text로 받고 원문을 보존한다. model.raw가 유일한 value다. empty/typing/valid/invalid 판정은 G03 입력 셀 §6과 A12/A13 binding이 소유한다.
| 트리거 | 상태 변화/intent | 복귀 경로 |
|---|---|---|
| input change | onValueChange(raw); "", "0", "12.", 한글을 변환 없이 전달 | binding이 raw/state/model을 다시 주입 |
| composition start/end | 조합 이벤트 그대로 전달; UI의 조합 중 flag는 키보드 보호용 | end의 raw를 binding이 판단; UI가 검색/저장을 실행하지 않음 |
조합 중 Enter / isComposing / keyCode 229 | onCommit·폼 제출·상위 Enter 동작 차단 | 조합 종료 후 별도 Enter 가능 |
일반 Enter + onCommit | 외부 onKeyDown이 취소하지 않았다면 commit intent 1회, implicit submit 차단 | binding이 검증·다음 포커스/저장 여부 결정 |
| readOnly/disabled | commit 발화 없음; readOnly는 선택·복사 허용 | 호출자가 해제 |
| invalid model | aria-invalid, aria-errormessage, label/hint/error ID 연결 | 수정된 model이 오면 error 관계도 제거 |
id 생략 시 React useId로 연결한다. 여러 인스턴스에서 중복 id를 지정하지 않는다. 외부 aria-describedby도 hint/error와 함께 보존한다. 오류 문구는 사용자용 문장으로 mapper가 준다. raw 오류 객체·서버 메시지·request payload를 그대로 노출하지 않는다. typing 상태를 빨간 오류로 그리지 않는다.
입력마다 assertive live region을 만들지 않는다. 제출 실패 때 binding이 첫 invalid 입력에 초점을 옮기고 요약 한 곳을 알린다. 숫자 확정·blur rollback·자동 선택·검색 debounce는 이 primitive에 없다. 기존 자동 숫자 선택 정책을 이식할 때는 A12/A13이 충돌 없이 배선한다.
다이얼로그·포커스
UiDialog는 native <dialog>.showModal()을 쓰는 신규 modal 전용이다. 제목으로 accessible name, 선택적 짧은 설명으로 description을 제공한다. 긴 구조화된 내용 전체를 description에 중복 연결하지 않는다. showModal을 지원하는 브라우저가 전제이며 지원하지 않는 WebView에는 임의 nonmodal fallback을 만들지 않는다(U06/N01 검증).
| 트리거 | 상태 변화/intent | 복귀 경로 |
|---|---|---|
open: false → true | 브라우저 top layer, 배경 inert; 내부 initialFocusRef 또는 제목에 초점 | 표준 Tab 순환 |
| Tab / Shift+Tab | 보이는 enabled 요소의 끝/처음에서 내부 순환; custom widget이 preventDefault한 키는 존중 | 다이얼로그 안 유지 |
| Escape | onDismiss("escape"), native 자동 close는 방지 | 소유자가 open=false로 닫거나 확인 절차 유지 |
| IME 조합 중 Escape | dismiss 없음, 조합 취소만 | 조합 종료 후 Escape |
| 닫기 버튼 | onDismiss("close_button") | 위와 같음 |
| 배경 클릭 | 자동 dismiss 없음 | 닫기 버튼/Escape 사용 |
| open=false 또는 unmount | close; 지정 returnFocusRef 또는 열기 직전 요소로 복귀 | 사라질 트리면 호출자가 살아남는 복귀 ref를 제공 |
초기 ref와 복귀 ref 객체는 안정적으로 유지한다. 열 때 복귀 대상을 캡처한다. 사라졌거나 inert인 요소에 focus하지 않는다. 파괴적 확인은 initialFocusRef를 안전한 취소 동작에 두고 confirm에는 autofocus하지 않는다. positive tabindex는 사용하지 않는다. virtualized list에서 opener가 사라지는 경우 U04가 안정된 복귀 대상으로 연결한다.
이 API의 open은 UI 표현 상태다. 저장·삭제·뒤로가기 정책은 callback 소유자가 결정한다. 자식에서 dialog.close(), form method="dialog", 외부 open 속성 제어를 하지 않는다. 한 기능은 하나의 overlay 계열을 사용하며 기존 document Escape 리스너/앱 루트 포털과 신규 top-layer modal을 중첩하지 않는다. overlay 스택·native back gesture·owner 전환 닫힘은 앱 shell 소유이고 U02/U03에서 이식할 때 함께 배선한다.
loading·empty·error
UiState({ model, action? })의 kind 선택은 기능 담당이 한다. loading은 role=status, empty는 조용한 일반 콘텐츠, action 후 error는 role=alert다. 제목·설명만 알리고 재시도 버튼을 alert 영역 안에 반복 포함하지 않는다. action은 명시된 callback만 호출한다. loading의 live text에 aria-busy=true를 붙여 알림을 보류하지 않는다. 갱신 중인 콘텐츠 영역의 busy 표시는 그 영역 소유자가 관리한다.
데이터 조회 실패를 로그인 화면으로 바꾸지 않는다. 기존 degraded 화면을 무조건 error로 승격하지 않는다. 네트워크 재시도 중 기존 데이터 유지/empty 선택은 resource snapshot과 화면 계약이 결정한다.
저장 단계·동기화 안내
UiPersistenceStatus는 아래 표의 고정 의미만 그린다. G03 §15의 stage 판정은 controller에서 끝낸다.
| 주입 값 | 제목 | 설명의 의미 |
|---|---|---|
| device_persisted + pending | 기기에 보관됨 | 서버 대기, 통계는 동기화 후 반영 |
| device_persisted + held | 기기에 보관됨 | 로그인 확인 뒤 동기화, 통계 미반영 |
| device_persisted + blocked | 동기화 실패 | 기기에 보관된 기록 확인·명시 복구 |
| server_committed + stats pending | 서버 저장 완료 | 통계 반영 대기 |
| server_committed + stats not_applicable | 서버 저장 완료 | 통계 대상이 아닌 저장(계획 등) |
| stats_published | 통계 반영 완료 | 읽기 모델이 저장 기록을 반영함 |
| unknown | 저장 상태 확인 필요 | 결과를 아직 확인하지 못함; 성공 아님 |
상태 영역은 role=status, polite, atomic이다. 색/아이콘만으로 구별하지 않는다. 텍스트는 플랫폼 공통이며 모양은 플랫폼이 소유한다. unknown을 오류로 단정하거나 server_committed로 올리지 않는다. not_applicable은 receipt 정책의 결과를 binding이 주입하며 UI가 requested version=0을 추측하지 않는다.
이것은 표시 내용 계약이며 새 toast 발화 정책이 아니다. 완료 기록 쓰기 파이프라인 §4·§7의 기기 적재 즉시 진행·서버 성공 무음·영구 거부 때 기록 배지/명시 복구를 유지한다. 저장 도중 일반 네트워크 실패를 갑자기 modal error로 띄우지 않는다. 아직 기기에 적재되지 않았다면 device_persisted를 주지 않는다.
검증과 인계
- 자동 Node 검사:
tests/react/uiPrimitives.test.mjs— 버튼 행위·7가지 저장 표시·상태 알림. 기존 frontend/contracts import boundary가 새로운 shared 파일도 검사한다. - 실제 브라우저:
tests/browser/u01/primitives.spec.mjs— 두 viewport에서 raw·IME·Enter·pending·Tab·Escape·unmount·초기/복귀 focus·오류 연결·대표 재시도·CSS scope·긴 dialog. 실행법은 테스트 안내. - U02/U03: mapper에서
ViewModel을 만들고 의미론 앵커(#input/#persistence 등)를 배선 PR에 적는다. fixture가 아닌 실제 storage/receipt/resource와 연결된 매퍼 행동 검사를 추가한다. - U07: 시각 계약의 selector 목록, 레이어·예외 소유자를 소비한다. 실제 화면 이식 뒤 기존 primitive 삭제를 판단한다.
- U06: 실기기 VoiceOver/TalkBack·IME·native back·200% 글자 확대·overlay/포털 상호작용·긴 목록 복귀를 최종 검증한다. 이 문서가 모든 기존 화면의 검증 완료를 뜻하지 않는다.
CHANGE-NOTES: 신규 intent onValueChange/onCommit — 계약 #input, 신규 intent onDismiss — 계약 #dialog, 상태·저장 표시 — 계약 #states/#persistence.