Files
HMDesign/docs/components/date-picker.md
T

143 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Date Picker
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6, §7), Token 구조 원칙은 `테마-토큰-가이드.md`를 따른다. 이 문서는 Date Picker 고유의 API·Token·상태·결정만 기록한다.
## 1. 상태와 성격
| 항목 | 내용 |
|---|---|
| 성격 | **Reusable component, Popover + Calendar 조합** (새 날짜 engine 없음) |
| 구현 상태 | 구현 완료 (single-date, 현재 HM 규격) |
| 기준일 | 2026-10-02 |
| 기반 | `Popover`(public API) + `Calendar`(public API) 조합. shadcn 공식 Date Picker는 예시 조합이다 |
| 대표 Story | `Components/Search & Date/Date Picker` → `All` (이름 "Date Picker") |
| 보류 | 아래 §11 |
## 2. 적용 근거
- **참고 근거**: 외부 ui_viz Reference (ZIP 미포함)의 기존 테스트 규격에는 Date Picker가 이름뿐이고 규격이 없다. 참고할 수치가 없었다.
- **Component로 만든 이유**: 서비스의 모든 날짜 선택이 같은 trigger 텍스트 형식, 선택 후 닫힘, 값 처리, focus 복귀를 필요로 해 반복 사용이 확실하다고 판단했다. 값 처리(`value`/`defaultValue`/`onValueChange`)는 패키지가 소유한다.
- **구현 방식**: Popover(Portal, collision, Escape, focus 복귀)와 Calendar(날짜 모델, 키보드)를 그대로 조합한다. Popover 내부 구현을 복제하지 않았다.
- **Trigger**: Button이 아니라 Input/Select의 field language를 따르는 필드형 버튼이다. Button/Input/Select Token은 참조하지 않고 `--hm-date-picker-*`로 관리한다.
- **보류**: §11.
## 3. 기본 규격 (현재 HM 규격)
| 항목 | 값 |
|---|---|
| Size | `large`(기본, 안쪽 42px + 1px border = 44px, 16 / 24), `medium`(안쪽 32px = 34px, 14 / 20) — Select와 같은 두 크기 |
| Trigger | 폭 100%(부모 폭), padding-x 12px, 1px `--hm-sem-stroke-input-default`, radius 4px, 흰 면, 오른쪽 달력 아이콘 18px `--hm-sem-fg-secondary` |
| 값 / placeholder | 선택값은 `--hm-sem-fg-primary`, placeholder("날짜 선택")는 `--hm-sem-fg-placeholder`, 길면 말줄임 |
| 날짜 표시 | 로컬 `Date`의 연·월·일 → `YYYY.MM.DD` (예: `2026.01.09`, `2026.10.06`). `format` prop으로 교체 |
| Popup | Popover 면(흰 면, 1px pale border, 4px radius, 그림자)에 Popover padding 0, 폭 `auto`로 Calendar에 맞춤 |
## 4. 상태 규격
| 상태 | 값 |
|---|---|
| Empty / Selected | `data-empty`가 있으면 placeholder 색 |
| Focus / Open | 테두리 전체가 `--hm-sem-stroke-input-focus`(ring 없음, Input·Select와 같은 방식). 열림은 Radix `data-state="open"` |
| Invalid (`aria-invalid="true"`) | Trigger border 1px `--hm-sem-action-destructive`. 새 `invalid` / `error` / `status` prop 없음 |
| Invalid + Keyboard Focus (`:focus-visible`) | Destructive border 유지 + Focus Semantic 3px / 50% outer ring |
| Invalid + Open | Trigger Error border 유지. Calendar Selected는 기존 Active, 날짜 Keyboard Focus는 기존 Focus Semantic 유지 |
| Disabled | opacity 0.6(이 컴포넌트 값, 전역 Disabled 규칙으로 승격 안 함), cursor not-allowed, 열리지 않음 |
| 선택 후 | 값 갱신, Popover 닫힘, focus가 trigger로 복귀 |
## 5. API
export: `DatePicker`, type `DatePickerSize`.
- `value`/`defaultValue`(Date), `onValueChange(date)`, `placeholder`, `size` `"medium" | "large"`(기본 `large`), `disabled`, `format(date) => string`, `calendarProps`(`disabled`, `startMonth`, `endMonth`, `locale`, `captionLayout`, `weekStartsOn`, `today`), 그리고 `button` props(`id`, `aria-label`, `className`…).
- 선택은 `required`라 선택된 날짜를 다시 눌러도 해제되지 않는다(해제/clear UI는 없음).
- **접근성**: Trigger는 `button`에 Radix Popover의 `aria-haspopup="dialog"`, `aria-expanded`, 팝업은 `role="dialog"`. 접근 가능한 이름은 `<label htmlFor=id>`(Story) 또는 `aria-label`을 소비자가 준다. 선택값은 버튼 텍스트로 읽힌다.
- Popover가 열릴 때 Radix 기본은 첫 tabbable(이전 달 버튼)에 focus하므로, DatePicker가 `onOpenAutoFocus`로 **Calendar의 tab stop(선택일, 없으면 오늘)에 focus**를 보낸다.
- 범위(range) Date Picker는 만들지 않았다(필요가 명확해질 때 추가).
### 값 소유권과 외부 초기화
- `controlled?: boolean`은 선택적 값 소유권 설정이다. `true`이면 `value`가 `undefined`여도 Controlled이며, 부모 값만 표시한다. `defaultValue`는 표시값을 대신하지 않는다. 사용자 선택은 기존 `(Date | undefined) => void` 콜백으로 전달하며, 부모가 값을 반영하지 않으면 표시값은 유지된다.
- `controlled={false}`이면 내부 state가 소유권을 가진다. `value`는 사용하지 않고 `defaultValue`를 최초 마운트 시에만 적용한다. 이후 `defaultValue` 변경은 초기화가 아니다. 사용자 선택은 내부 state를 갱신하고 콜백을 호출한다.
- **Legacy 호환**: `controlled`를 생략하면 기존 `value !== undefined` 판정을 유지한다. 날짜가 있으면 Controlled, `undefined`이면 Uncontrolled이다. 이 방식으로 외부 값을 `undefined`로 바꾸면 과거 내부 값/defaultValue가 다시 표시될 수 있다. 기존 소비 코드의 동작은 변경하지 않았다.
- 신규 Controlled 사용과 FilterBar 연동은 `controlled={true}`를 사용한다. `controlled` 옵션과 값 소유권 모드는 마운트 중 변경하지 않는 사용 계약을 권장한다. Controlled에서 `defaultValue`를 함께 전달해도 부모 `value`가 우선한다.
```tsx
const [date, setDate] = useState<Date | undefined>()
<DatePicker controlled={true} value={date} onValueChange={setDate} />
// 외부 날짜 지정/갱신: setDate(new Date(2026, 9, 6))
// 외부 초기화: setDate(undefined) — key remount 불필요
```
외부 값 갱신/초기화는 `onValueChange`를 호출하지 않는다. FilterBar의 부모는 날짜 state와 조회 조건을 함께 초기화한다. 선택값 clear는 팝업 닫기나 달력의 현재 월/focus 초기화를 의미하지 않으며, 새 clear UI를 추가하지 않는다. `controlled`를 생략한 Legacy 호출의 remount 우회는 이 신규 계약과 구분한다.
## 6. Dependency
새 dependency 없음(`Calendar`의 `react-day-picker`, `Popover`의 Radix를 그대로 사용). 날짜 표시는 `Intl.DateTimeFormat`, 값은 native `Date`이며 별도 date 라이브러리를 추가하지 않았다.
## 7. Token
Component Mapping 위치: `packages/ui/src/theme/date-picker.css` (`--hm-date-picker-*`). Trigger만 스타일하며 Popover와 Calendar의 스타일은 중복 정의하지 않는다. 선택일 색은 Calendar Selected(`--hm-sem-state-active`)를 그대로 상속하며 Date Picker 전용 selected 색이나 scope override는 없다.
`height-md|lg`, `border-width`, `padding-x`, `gap`, `radius`, `font-size|line-height-md|lg`, `background`, `text`, `placeholder`, `border-color`, `focus-border-color`, `icon-size|color`, `disabled-opacity`. Semantic/Theme palette를 참조하며 `--hm-popover-*`와 `--hm-calendar-*`는 참조하지 않는다. 기본 모양은 `@layer components`에 두어 소비자의 `className`(폭, 높이)이 이긴다.
**Popover 결합 지점**: Popup의 padding과 폭을 `PopoverContent`의 inline style로 `--hm-popover-padding: 0px`, `--hm-popover-width: auto`를 지정해 Popover의 token 값을 인스턴스 단위로 덮는다. Popover 코드를 바꾸지 않는 선택이며, Popover token 이름에 대한 약한 결합이다.
## 8. 조합한 컴포넌트에서 유지한 항목
Popover: Portal, collision 처리, Escape, 바깥 클릭 닫힘, trigger focus 복귀, `role=dialog`. Calendar: 키보드(방향키, Enter/Space, PageUp/Down), disabled 날짜, 월 이동, locale(기본 한국어).
## 9. Storybook
- 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(값 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정.
- verification: `Empty`, `Selected`, `Controlled`.
## 10. 사용자 확정 결정
- 2026-10-02: ui-viz는 참고 자료이며 최종 기준은 현재 HM 구현과 이 문서다(작업 지시). 개별 수치에 대한 별도 결정은 없다. Date Picker는 실제 소비 API가 필요한 Component로 만든다(작업 지시).
- 2026-10-06 Visual QA: Trigger 기본 formatter만 `YYYY.MM.DD`로 변경. Calendar 날짜 셀·caption·locale, custom `format`은 유지.
## 11. 미확정 / 보류
- 기본 표시 형식은 `YYYY.MM.DD`로 확정했다. 다른 형식은 `format`으로 교체한다.
- 선택 해제(clear) 버튼, 직접 입력(typing) 입력, Range Date Picker, 시간 선택은 만들지 않았다.
- 타임존: native `Date`의 로컬 시간 기준이다.
- Popup의 방향/collision은 Popover 기본(`align="start"`, 아래쪽 우선).
- 좁은 화면 전용 picker(바텀시트 등)는 만들지 않았다.
## 12. 관련 파일
```
packages/ui/src/components/ui/date-picker.tsx
packages/ui/src/theme/date-picker.css
packages/ui/src/theme.css (import)
packages/ui/src/index.ts (export)
src/components/date-picker.stories.tsx
```
## 13. 검증 상태
- 2026-10-08 Controlled 초기화: 실제 소스를 직접 묶은 격리 Headless Edge 환경에서 수정 전 과거 값 재표시를 재현했다. `controlled={true}`의 undefined → A → B → undefined, 최초 Controlled 값 clear, 부모 선택 거부, 외부 값 갱신, 콜백 횟수, Legacy/defaultValue/명시적 Uncontrolled, 열린 팝업 clear, 키보드 날짜 선택·focus 복귀를 확인했다. 소스 기준 영향 범위 타입 검사 통과. 공용 dist / Storybook 빌드 및 전체 시각 회귀는 수행하지 않았다.
- 2026-10-06 Visual QA: 1280/1024/768/390px에서 `2026.10.06`, `2026.01.09`, custom override, Korean caption, 키보드 날짜 선택과 focus 복귀 통과. `typecheck`, `build`, `build-storybook` 통과. 아래 2026-10-02 interaction의 긴 한국어 날짜 표시는 당시 기록이다.
- `npm run typecheck`, `npm run build`: 통과(2026-10-02).
- 실제 interaction(Browser pane): trigger에서 Enter로 열림(`aria-haspopup=dialog`, `aria-expanded` true), 선택된 날짜/오늘에 focus, ArrowRight 2 → 3, Enter로 선택하면 trigger 텍스트가 `2026년 10월 3일`로 바뀌고 Popover가 닫히며 focus가 trigger로 복귀(controlled 값 `2026. 10. 3.`). 다시 열면 선택일(3)에 focus, Escape로 닫혀도 값은 유지되고 trigger로 복귀, 날짜 클릭(20)으로도 선택과 닫힘. Disabled trigger는 클릭해도 열리지 않음.
- computed style: Large 44px / 16·24, Medium 34px / 14, border `rgb(222,219,210)`, radius 4px, placeholder `rgb(153,153,153)`, Disabled opacity 0.6·cursor not-allowed.
- Responsive(390×844): trigger 폭 320px, popup 264px(왼쪽 23px)이 viewport 안에 들어가고 페이지 가로 overflow 없음.
- 수정한 문제: 처음에 `PopoverContent`에 `data-slot="date-picker-content"`를 넘겨 Popover의 `data-slot`을 덮어쓰는 바람에 Popover 스타일(윤곽, 그림자)이 빠졌다. 제거했다.
- 미확인: Disabled dates가 popup 안에서 선택 불가한 것(Calendar 문서의 검증으로 갈음), 포인터 hover 시각, 좁은 viewport의 collision flip, `calendarProps.locale` 교체.
- 숨김 `InvalidFocus` Story (`tags: ["verification"]`)를 추가하여 기본 Sidebar는 유지한다.
### Invalid + Focus 검증 (2026-10-07)
- Focus ring: `--ring` → `--hm-sem-stroke-input-focus` → `--hm-color-border-focus`. Invalid border: 기존 `--hm-sem-action-destructive`. 새 Runtime Role / Production Error prop / 메시지 컴포넌트 없음.
- Chromium computed style: Invalid border `rgb(211, 47, 47)`, Invalid + Keyboard Focus box-shadow `color(srgb 0.14902 0.4 0.317647 / 0.5) 0px 0px 0px 3px`, `outline-style: none`. Invalid 단독은 `box-shadow: none`.
- Theme Designer UI에서 Focus `#c75b00`, Destructive `#d41466`, Active `#007f73`, Accent `#ffd36d`로 분리. Invalid border `rgb(212, 20, 102)` + Focus shadow `color(srgb 0.780392 0.356863 0 / 0.5) 0px 0px 0px 3px` 확인.
- `npm run typecheck`, `npm run build`, `npm run build-storybook` 통과. 기존 문서 예시 CSS warning 및 Storybook chunk 크기 / plugin timing warning 유지.
- 검증 범위: Chromium 환경. 다른 브라우저의 렌더링은 별도 확인이 필요하다.
- 숨김 `InvalidFocus`의 실제 Trigger DOM `aria-invalid="true"` 확인. Trigger Invalid border computed 1px, Focus 전후 shadow none → 3px Focus ring. 대표 `All` Default / Selected / Medium / Disabled 유지.
- Enter / Space open, ArrowRight 날짜 이동, Enter 선택 후 닫힘 및 Trigger focus return, Escape close / focus return, Tab / Shift+Tab 확인. 2026.10.06 → 2026.10.07 선택값 표시 확인.
- Calendar Selected `rgb(0, 127, 115)`(Active), 선택 날짜 Focus `color(srgb 0.780392 0.356863 0 / 0.5) 0px 0px 0px 3px` 유지. 일반 Calendar 대표 Story의 Single 및 Range 끝점은 Active, Range 중간 면은 Accent 45% 유지. Calendar CSS/API 수정 없음.