DatePicker
달력에서 하루를 고릅니다. 월 이름과 연도가 각각 자기 그리드를 여는 버튼이라, 어느 달이든 두 번, 어느 해든 세 번이면 닿습니다.
import { DatePicker } from 'neba';
<DatePicker label="출고일" placeholder="날짜를 고르세요" clearable />;Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'outline' | 'text' | 'outline' | 표면의 무게. TextField·Select와 같은 셸입니다 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일. 달력의 한 칸도 같은 사다리를 씁니다 — md는 32px |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | 트리거의 그림자 깊이. 팝업은 자기 그림자를 따로 가집니다 |
| value | Date | null | — | 선택된 날. onValueChange와 함께 제어 컴포넌트로 씁니다 |
| defaultValue | Date | null | — | 초기 값 |
| onValueChange | (value: Date | null) => void | — | 선택이 바뀔 때 |
| defaultMonth | Date | — | 값이 없을 때 달력이 열리는 달. 기본값은 이번 달 |
| minDate | Date | null | — | 고를 수 있는 가장 이른 날. 날짜 단위로만 비교하므로 시각은 무시됩니다 |
| maxDate | Date | null | — | minDate의 반대쪽 끝 |
| shouldDisableDate | (date: Date) => boolean | — | 범위 안이지만 고를 수 없는 날 — 주말, 공휴일, 이미 예약된 방. 셀은 목록에 남은 채 비활성이 됩니다 |
| weekStartsOn공통 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | 주가 시작하는 요일. 일요일이 0입니다. 기본값은 로케일이 말하는 것 |
| showTodayButton | boolean | true | 푸터에 오늘로 가는 단축 버튼 |
| open | boolean | — | 팝업이 열려 있는지. onOpenChange와 함께 제어 컴포넌트로 씁니다 |
| defaultOpen | boolean | false | 처음에 열린 채로 시작 |
| onOpenChange | (open: boolean) => void | — | 열리거나 닫힐 때 |
| locale | string | — | BCP 47 태그. 월·요일 이름, 헤더의 연/월 버튼 순서, 트리거의 표기를 정합니다. 기본값은 브라우저의 로케일 |
| format | Intl.DateTimeFormatOptions | — | 트리거가 값을 쓰는 방식. Intl에 그대로 넘어갑니다 |
| placeholder | ReactNode | — | 아무것도 고르지 않았을 때 트리거에 보이는 내용 |
| clearable | boolean | false | 값을 비우는 ×를 트리거에 답니다 |
| closeOnSelect | boolean | true | 날을 고르는 즉시 팝업을 닫습니다. 물어본 것이 하나뿐이므로 기본값이 true입니다 |
| labels | Partial<PickerLabels> | — | 스크린 리더가 듣는 문자열들. 열여덟 개가 한 벌이라 프롭 하나로 받습니다 — 날짜 이름은 여기 없고 Intl이 만듭니다 |
| name | string | — | 폼 제출 시의 필드 이름. 값은 YYYY-MM-DD로, UTC가 아니라 로컬 기준입니다 |
| fullWidth | boolean | false | 컨테이너 너비만큼 확장 |
| label | ReactNode | — | 컨트롤과 연결되는 라벨. Base UI Field가 묶어 줍니다 |
| description | ReactNode | — | 보조 설명 |
| error | ReactNode | — | 오류 메시지. 값이 있으면 invalid 상태도 함께 켜지고 색 계열이 danger로 넘어갑니다 |
| invalid | boolean | !!error | 메시지 없이 invalid만 켭니다. 외부 폼 라이브러리가 유효성을 가질 때 |
| readOnly | boolean | false | 값은 보이지만 바꿀 수 없음. 색과 가장자리는 유지한 채 채도만 빠집니다 |
| disabled | boolean | false | 사용 불가. 색 계열을 버리고 중립 회색이 됩니다 |
값은 Date입니다
문자열도, 타임스탬프도, 별도의 래퍼 객체도 아닙니다. Date는 플랫폼이 이미 가진 것이고, Intl이 포맷하는 것이며, 호출하는 쪽이 쓰는 어떤 라이브러리로든 한 줄이면 변환되는 것입니다.
날짜 라이브러리는 일부러 쓰지 않았습니다. Neba의 유일한 런타임 의존성은 Base UI이고, date-fns를 슬그머니 끌어오는 — 더 나쁘게는 dayjs/luxon/Temporal 논쟁에서 소비자를 대신해 편을 드는 — 컴포넌트 라이브러리는 자기 것이 아닌 결정을 내린 것입니다. 여기 있는 산술은 열댓 줄이고, 이름은 Intl이 만듭니다. 어떤 내장 테이블보다 더 많은 언어의 월 이름을 알고 있으니까요.
모든 비교는 타임스탬프가 아니라 로컬 달력의 날을 기준으로 합니다. 달력의 하루는 선 위의 한 순간이 아니라 사람이 벽에서 보고 있는 무언가이고, 그래서 서울의 피커와 상파울루의 피커가 똑같이 27이라고 쓰인 칸에 불이 들어옵니다. 폼이 제출하는 hidden input이 로컬 기준으로 2026-07-27을 쓰는 것도 같은 이유입니다 — 같은 값에 toISOString()을 부르면 2026-07-26이 나오고, 그것이 날짜 피커가 저지를 수 있는 가장 비싼 버그입니다.
뷰가 셋입니다
한 번에 한 달씩만 넘어가는 피커는 30년 전 생일을 180번의 클릭 뒤에 둡니다. 그래서 헤더에는 라벨이 아니라 버튼 두 개가 있습니다.
- 월 이름은 12개월 그리드를 열고,
- 연도는 12년 그리드를 엽니다. 이때 스테퍼는 한 페이지씩 움직입니다.
연도를 고르면 날짜 뷰가 아니라 월 뷰로 넘어갑니다. 방금 어느 해인지 말했으니, 다음 질문은 어느 달인가이기 때문입니다. 두 버튼은 로케일이 쓰는 순서로 놓입니다 — 영어에서는 July 2026, 한국어에서는 2026년 7월. 세 뷰는 너비도 높이도 같으므로, 뷰를 바꾼다고 해서 방금 팝업을 연 포인터 밑에서 크기가 달라지는 일이 없습니다.
예시
Variant
TextField와 같은 세 가지 무게를, 같은 셸 위에 그립니다. 날짜 필드만 주변 필드와 높이·반경·색이 다른 폼은 디자인된 것이 아니라 조립된 것처럼 보입니다.
크기
달력 한 칸은 컨트롤 사다리 위에 있습니다. md에서 32px — md Button이고 md TextField입니다. 폼 옆에 놓인 달력은 그 폼의 그리드 위에 있습니다.
범위
minDate와 maxDate는 날짜 단위로 비교하므로, 27일 09:00이 최댓값이어도 27일은 여전히 고를 수 있습니다. 범위 안에 있지만 그래도 쓸 수 없는 날은 shouldDisableDate가 막습니다.
막힌 칸은 그리드에 그대로 남고, disabled 속성이 아니라 aria-disabled로 자기를 알립니다. disabled 버튼은 탭 순서와 함께 그리드의 방향키 경로에서도 빠지므로, 그렇게 하면 읽는 사람이 막힌 날마다 구멍에 빠지게 됩니다.
상태
트리거에 직접 입력할 수 없는 이유
트리거는 텍스트 입력이 아니라 버튼입니다. Select의 것과 정확히 같습니다.
자유 텍스트에서 날짜를 파싱하는 일은 날짜 라이브러리 없이는 정직하게 해낼 수 없을 만큼 로케일에 매여 있습니다. 어떤 브라우저에서는 27/7/26을 알아듣고 다음 브라우저에서는 못 알아듣는 필드 — 또는 절반의 독자에게는 그것을 12월 7일로 읽는 필드 — 는 애초에 그런 척하지 않은 필드보다 나쁩니다. 답은 달력에서 나오고, 뷰 세 개가 그 달력을 키보드가 아쉽지 않을 만큼 빠르게 만듭니다.
키보드
| 키 | 하는 일 |
|---|---|
Space / Enter | 달력을 엽니다. 그리드가 선택된 날 위에서 포커스를 받습니다 |
← → ↑ ↓ | 하루 또는 한 주씩 이동하고, 가장자리에서는 달을 넘깁니다 |
Home / End | 주의 처음 또는 끝으로 |
PageUp / PageDown | 한 달씩 — Shift와 함께면 1년씩 |
Escape | 고르지 않고 닫습니다 |
그리드의 탭 정지점은 하나뿐입니다. Tab은 42개의 칸을 걷지 않고 그리드를 빠져나갑니다.
접근성
- 그리드는
role="gridcell"버튼들로 이루어진role="grid"이고, 각 칸의 이름은 숫자가 아니라 완전한 날짜입니다 —27이 아니라2026년 7월 27일 월요일. - 선택된 날은
aria-selected를, 오늘은aria-current="date"와 숫자 아래의 점을 답니다. 색만으로 말하면 일부 독자에게만 말하는 것이기 때문입니다. label이 트리거의 이름이 되고,description과error는aria-describedby로 연결됩니다.- 팝업은
<body>끝으로 포털됩니다. positioner에 붙는neba-portal은 CSS 리셋을 서브트리에 한정해 둔 앱을 위한 고리입니다.