145 lines
13 KiB
Markdown
145 lines
13 KiB
Markdown
# Date Picker
|
||
|
||
> 현재 접근성 보완(2026-10-10): 흰 Trigger의 빈 값 안내는 기존 `--hm-sem-fg-secondary`를 사용한다. 기본 Theme에서 #6e6759/흰색이며 공통 placeholder Token과 Disabled opacity는 보존한다. 아래 과거 검증의 #999 실측은 당시 기록이다.
|
||
|
||
현재 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-secondary`, 길면 말줄임 |
|
||
| 날짜 표시 | 로컬 `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 수정 없음.
|