Skip to content

DatePicker

달력에서 하루를 고릅니다. 월 이름과 연도가 각각 자기 그리드를 여는 버튼이라, 어느 달이든 두 번, 어느 해든 세 번이면 닿습니다.

tsx
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 | 30트리거의 그림자 깊이. 팝업은 자기 그림자를 따로 가집니다
valueDate | null선택된 날. onValueChange와 함께 제어 컴포넌트로 씁니다
defaultValueDate | null초기 값
onValueChange(value: Date | null) => void선택이 바뀔 때
defaultMonthDate값이 없을 때 달력이 열리는 달. 기본값은 이번 달
minDateDate | null고를 수 있는 가장 이른 날. 날짜 단위로만 비교하므로 시각은 무시됩니다
maxDateDate | nullminDate의 반대쪽 끝
shouldDisableDate(date: Date) => boolean범위 안이지만 고를 수 없는 날 — 주말, 공휴일, 이미 예약된 방. 셀은 목록에 남은 채 비활성이 됩니다
weekStartsOn공통0 | 1 | 2 | 3 | 4 | 5 | 6주가 시작하는 요일. 일요일이 0입니다. 기본값은 로케일이 말하는 것
showTodayButtonbooleantrue푸터에 오늘로 가는 단축 버튼
openboolean팝업이 열려 있는지. onOpenChange와 함께 제어 컴포넌트로 씁니다
defaultOpenbooleanfalse처음에 열린 채로 시작
onOpenChange(open: boolean) => void열리거나 닫힐 때
localestringBCP 47 태그. 월·요일 이름, 헤더의 연/월 버튼 순서, 트리거의 표기를 정합니다. 기본값은 브라우저의 로케일
formatIntl.DateTimeFormatOptions트리거가 값을 쓰는 방식. Intl에 그대로 넘어갑니다
placeholderReactNode아무것도 고르지 않았을 때 트리거에 보이는 내용
clearablebooleanfalse값을 비우는 ×를 트리거에 답니다
closeOnSelectbooleantrue날을 고르는 즉시 팝업을 닫습니다. 물어본 것이 하나뿐이므로 기본값이 true입니다
labelsPartial<PickerLabels>스크린 리더가 듣는 문자열들. 열여덟 개가 한 벌이라 프롭 하나로 받습니다 — 날짜 이름은 여기 없고 Intl이 만듭니다
namestring폼 제출 시의 필드 이름. 값은 YYYY-MM-DD로, UTC가 아니라 로컬 기준입니다
fullWidthbooleanfalse컨테이너 너비만큼 확장
labelReactNode컨트롤과 연결되는 라벨. Base UI Field가 묶어 줍니다
descriptionReactNode보조 설명
errorReactNode오류 메시지. 값이 있으면 invalid 상태도 함께 켜지고 색 계열이 danger로 넘어갑니다
invalidboolean!!error메시지 없이 invalid만 켭니다. 외부 폼 라이브러리가 유효성을 가질 때
readOnlybooleanfalse값은 보이지만 바꿀 수 없음. 색과 가장자리는 유지한 채 채도만 빠집니다
disabledbooleanfalse사용 불가. 색 계열을 버리고 중립 회색이 됩니다

값은 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입니다. 폼 옆에 놓인 달력은 그 폼의 그리드 위에 있습니다.

범위

minDatemaxDate는 날짜 단위로 비교하므로, 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이 트리거의 이름이 되고, descriptionerroraria-describedby로 연결됩니다.
  • 팝업은 <body> 끝으로 포털됩니다. positioner에 붙는 neba-portal은 CSS 리셋을 서브트리에 한정해 둔 앱을 위한 고리입니다.

Released under the MIT License