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

13 KiB
Raw Blame History

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가 우선한다.
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 수정 없음.