PlDatePicker
달력에서 하루를 고릅니다. trigger는 달력 글리프를 단 PlTextField의 껍데기 그대로라, 날짜 field와 그 옆의 field들이 하나의 물건으로 읽힙니다.
import { PlDatePicker } from 'plass-ui';
<PlDatePicker label="Departure" placeholder="Pick a day" minDate={new Date()} />;import 'package:plass_ui/plass_ui.dart';
PlDatePicker(
label: const Text('Departure'),
placeholder: const Text('Pick a day'),
minDate: DateTime.now(),
value: departure,
onChanged: (DateTime? next) => setState(() => departure = next),
);달력은 트리 밖으로 자기를 들어 올리므로 picker 위에 Overlay가 필요합니다. navigator가 있는 WidgetsApp과 MaterialApp 둘 다 하나씩 제공합니다.
Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value | Date | null | — | 선택된 날. onValueChange와 함께 controlled로 씁니다 |
| defaultValue | Date | null | — | uncontrolled일 때 시작하는 날 |
| onValueChange | (value: Date | null) => void | — | 값이 바뀔 때 호출됩니다 |
| precision | 'day' | 'month' | 'year' | 'day' | 어디까지 내려가는지 — 일, 월, 연. 그 단위의 그리드에서 바로 확정되고, month picker에는 일 그리드가 아예 없습니다. 값은 언제나 그 단위의 시작(1일, 1월 1일)입니다 |
| minDate | Date | null | — | 고를 수 있는 가장 이른 날. 일 단위입니다 — 시각은 무시됩니다 |
| maxDate | Date | null | — | 고를 수 있는 가장 늦은 날 |
| shouldDisableDate | (date: Date) => boolean | — | 범위 안이지만 그래도 쓸 수 없는 날을 막습니다 — 주말, 공휴일, 이미 예약된 방 |
| weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | 한 주가 시작하는 요일. 기본은 locale이 말하는 대로이고, 0이 일요일입니다 |
| defaultMonth | Date | — | 값이 없을 때 달력이 열리는 달 |
| showTodayButton | boolean | true | 푸터에 오늘로 가는 지름길을 둡니다. precision에 따라 "이번 달", "올해"가 됩니다 |
| variant공통 | 'solid' | 'glass' | 'ghost' | 'glass' | trigger의 재질. PlTextField와 같은 껍데기를 씁니다. solid는 시트에 파인 우물 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | trigger의 그림자 깊이. 팝업은 3으로 고정입니다 — 팝업은 정말로 페이지 위에 떠 있습니다 |
| open | boolean | — | 팝업이 열려 있는지. onOpenChange와 함께 controlled로 씁니다 |
| defaultOpen | boolean | false | 팝업이 열린 채로 시작할지 |
| onOpenChange | (open: boolean) => void | — | 팝업이 열리고 닫힐 때 호출됩니다 |
| locale | string | — | BCP 47 태그. 월과 요일 이름, 헤더 두 버튼의 순서, trigger가 날짜를 쓰는 방식을 정합니다. 기본은 브라우저의 것 |
| format | Intl.DateTimeFormatOptions | { dateStyle: 'medium' } | trigger가 날짜를 쓰는 방식. Intl로 그대로 넘어갑니다. 기본값은 precision을 따릅니다 |
| placeholder | ReactNode | — | 아무것도 고르지 않았을 때 trigger에 보이는 내용 |
| clearable | boolean | false | 값을 비우는 ×를 보여 줍니다 |
| closeOnSelect | boolean | true | 날을 고르는 즉시 팝업을 닫습니다 |
| labels | Partial<PlPickerLabels> | — | picker가 스스로 말하는 문자열들. 전부 영어 기본값이 있습니다. 날짜 이름은 여기 없습니다 — 그건 Intl이 압니다 |
| label | ReactNode | — | trigger 위 라벨 |
| description | ReactNode | — | trigger 아래 보조 설명 |
| error | ReactNode | — | 오류 메시지. 존재 자체가 invalid 상태를 만듭니다 |
| invalid | boolean | — | 메시지 없이 invalid로 만듭니다 |
| startIcon | ReactNode | — | 값 앞의 글리프. 기본은 달력(또는 시계)입니다 |
| fullWidth | boolean | false | 컨테이너 너비만큼 확장 |
| readOnly | boolean | false | 값은 보이지만 바꿀 수 없고, 팝업도 열리지 않습니다 |
| disabled | boolean | false | 사용 불가 |
| required | boolean | false | 폼 제출 전에 값이 있어야 하는지 |
| name | string | — | 폼 제출 시 필드를 식별합니다. YYYY-MM-DD로, UTC가 아니라 로컬로 보냅니다. precision이 짧으면 YYYY-MM, YYYY입니다 |
| classNames | { label?, control?, description?, error?: string } | — | className이 닿지 않는 부분에 붙는 class. control은 실제로 조작하는 부분입니다 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | DateTime? | — | 선택된 날, 또는 없으면 null. 패키지의 다른 모든 입력과 마찬가지로 controlled입니다 |
| onChanged | ValueChanged<DateTime?>? | — | 고른 날과 함께 호출됩니다. 비우면 null입니다 |
| precision | PlDatePickerPrecision | PlDatePickerPrecision.day | 어디까지 내려가는지 — 일, 월, 연. 그 단위의 그리드에서 바로 확정되고, month picker에는 일 그리드가 아예 없습니다. 값은 언제나 그 단위의 시작(1일, 1월 1일)입니다 |
| open | bool? | — | 팝업이 열려 있는지. onOpenChange와 함께 controlled로 씁니다 |
| onOpenChanged | ValueChanged<bool>? | — | 달력이 열리거나 닫혀야 할 때 호출됩니다 |
| defaultMonth | DateTime? | — | 값이 없을 때 달력이 열리는 달 |
| minDate | DateTime? | — | 고를 수 있는 가장 이른 날. 일 단위입니다 — 시각은 무시됩니다 |
| maxDate | DateTime? | — | 고를 수 있는 가장 늦은 날 |
| shouldDisableDate | bool Function(DateTime date)? | — | 범위 안이지만 그래도 쓸 수 없는 날을 막습니다 — 주말, 공휴일, 이미 예약된 방 |
| weekStartsOn | PlassWeekday? | — | 한 주가 시작하는 요일. 기본은 names가 말하는 대로입니다 |
| names | PlDateNames | PlDateNames.english | 달력이 그리는 월과 요일 이름, 그리고 헤더가 그것들을 쓰는 순서. **React의 locale 문자열에 해당합니다** — 프레임워크에 Intl이 없으므로 단어를 객체로 받습니다 |
| labels | PlPickerLabels | PlPickerLabels.english | picker가 스스로 말하는 문자열들. 전부 영어 기본값이 있습니다 |
| formatValue | String Function(DateTime value)? | — | trigger가 날짜를 쓰는 방식. React의 Intl 옵션 대신 콜백입니다. 빼면 precision이 요구한 만큼 names로 씁니다 — medium 형식의 날, 월과 연, 또는 연도만 |
| placeholder | Widget? | — | 아무것도 고르지 않았을 때 trigger에 보이는 내용 |
| clearable | bool | false | 값을 비우는 ×를 보여 줍니다 |
| showTodayButton | bool | true | 푸터에 오늘로 가는 지름길을 둡니다. precision에 따라 "이번 달", "올해"가 됩니다 |
| closeOnSelect | bool | true | 날을 고르는 즉시 팝업을 닫습니다 |
| variant공통 | PlassVariant | PlassVariant.glass | trigger의 재질. PlTextField와 같은 껍데기를 씁니다. solid는 시트에 파인 우물 |
| size공통 | PlassSize | PlassSize.md | 높이와 타입 스케일 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | int | 0 | trigger의 그림자 깊이. 팝업은 3으로 고정입니다 — 팝업은 정말로 페이지 위에 떠 있습니다 |
| label | Widget? | — | trigger 위 라벨 |
| description | Widget? | — | trigger 아래 보조 설명 |
| error | Widget? | — | 오류 메시지. 존재 자체가 invalid 상태를 만듭니다 |
| invalid | bool? | — | 메시지 없이 invalid로 만듭니다 |
| startIcon | Widget? | — | 값 앞의 글리프. 기본은 달력(또는 시계)입니다 |
| fullWidth | bool | false | 컨테이너 너비만큼 확장 |
| readOnly | bool | false | 값은 보이지만 바꿀 수 없고, 팝업도 열리지 않습니다 |
| disabled | bool | false | 사용 불가 |
| semanticLabel | String? | — | 보이는 label이 없는 trigger를 스크린 리더가 부를 이름 |
| focusNode | FocusNode? | — | 포커스를 밖에서 제어할 때 넘깁니다 |
| autofocus | bool | false | 트리에 들어가면서 포커스를 가져갑니다 |
나머지 <div> 속성은 field 래퍼로 그대로 통과합니다. color는 위 표의 color와 겹쳐서, defaultValue는 DOM 속성이 아니라 값으로 쓰기 때문에, children은 달력이 곧 컴포넌트이기 때문에 제외했습니다.
className은 label과 control, 그 아래 두 줄을 함께 담는 stack에 붙습니다. 그 안쪽 네 부분에 닿는 것이 classNames입니다: label, control(트리거), description, error.
picker는 패키지의 다른 모든 입력과 마찬가지로 controlled입니다. value와 onChanged를 함께 주고, null은 아무것도 고르지 않은 picker입니다.
React에 대응하는 것이 없는 유일한 파라미터가 names이고, 그 이유는 다음 절에 있습니다.
PlDateNames
React 패키지에는 아직 PlDateNames가 없습니다.
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| months | List<String> | English | 월 이름 열둘, 1월부터 |
| monthsShort | List<String> | English | 같은 열둘의 약칭. 월 그리드가 그립니다 |
| weekdays | List<String> | English | 요일 이름 일곱, **일요일부터**. 어느 요일로 시작해 그리든 회전은 달력의 몫입니다 |
| weekdaysShort | List<String> | English | 같은 일곱의 약칭. 열 머리글이 그립니다 — narrow가 아니라 short인 건 영어의 narrow가 S M T W T F S이기 때문입니다 |
| am | String | 'AM' | 12시간제의 오전 |
| pm | String | 'PM' | 그리고 오후 |
| monthBeforeYear | bool | true | 헤더가 월을 연도보다 먼저 쓰는지. 헤더는 문자열 하나가 아니라 버튼 둘이라 formatter가 준 것을 그대로 찍을 수 없고, 어느 쪽이 먼저인지 들어야 합니다 |
| firstDayOfWeek | PlassWeekday | PlassWeekday.sunday | 이 언어에서 한 주가 시작하는 요일. picker의 weekStartsOn이 우선합니다 |
공유 축(variant size color density elevation)이 라이브러리 전체에서 무엇을 뜻하는지는 prop 규약에 있습니다.
날짜 라이브러리도, 번역 파일도 없습니다
picker들은 의존성 트리에 아무것도 더하지 않습니다. 하는 일은 열두 줄짜리 Date 연산이거나, 플랫폼이 이미 싣고 있고 어떤 번들 테이블보다 더 많은 언어의 월 이름을 아는 Intl입니다. date-fns를 조용히 끌어오는(더 나쁘게는 dayjs / luxon / Temporal 논쟁에서 소비자 대신 편을 드는) 컴포넌트 라이브러리라면 자기 것이 아닌 결정을 내린 셈입니다.
로캘 이야기도 그것이 전부입니다. import하고 등록할 언어별 모듈이 없습니다. locale은 BCP 47 태그이고, 거기서부터 Intl이 월 이름, 요일 이름, 오전/오후, 한 주가 시작하는 요일, 헤더 두 버튼의 순서, trigger가 날짜를 쓰는 방식을 전부 제공합니다. 열두 언어로 출시하는 프로젝트가 열한 개에 대해 내는 비용이 0입니다.
두 패키지가 진짜로 갈라지는 유일한 지점이기도 합니다. 브라우저는 React에게 7월을 모든 언어로 뭐라 부르는지 이미 아는 Intl을 건네주므로 BCP 47 태그 하나면 충분합니다. Flutter 프레임워크는 그런 것을 싣고 있지 않고, 그 공백을 메우려고 package:intl을 끌어오는 패키지는 소비자 대신 의존성을 정하는 것입니다. PlProgressLinear의 formatValue가 이미 거절한 것과 같은 거래입니다.
그래서 단어들은 PlDateNames로 옵니다. 기본값이 영어라 아무 설정 없이도 picker가 작동하고, 이미 package:intl에 의존하는 앱이라면 세 줄이면 됩니다.
PlDateNames(
months: List<String>.generate(
12,
(int i) => DateFormat.MMMM(locale).format(DateTime(2021, i + 1)),
),
monthsShort: List<String>.generate(
12,
(int i) => DateFormat.MMM(locale).format(DateTime(2021, i + 1)),
),
weekdays: List<String>.generate(
7,
(int i) => DateFormat.EEEE(locale).format(DateTime(2021, 8, i + 1)),
),
weekdaysShort: List<String>.generate(
7,
(int i) => DateFormat.E(locale).format(DateTime(2021, 8, i + 1)),
),
)남는 문자열은 picker 자신의 버튼에 적히는 것들("Today", "Previous month", "Choose a year") 뿐입니다. 어느 플랫폼도 그것들에 대해서는 의견이 없기 때문입니다. 영어 기본값이 붙은 labels 객체 하나입니다.
직접 입력할 수 없습니다
의도한 것입니다. 자유 텍스트에서 날짜를 파싱하는 일은 날짜 라이브러리 없이는 정직하게 할 수 없을 만큼 로캘에 의존하고, 어떤 브라우저에서는 27/7/26을 알아듣고 다음 브라우저에서는 못 알아듣는 field는 애초에 그런 척하지 않은 field보다 나쁩니다. trigger는 PlSelect의 것과 똑같이 버튼이고, 답은 달력에서 나옵니다.
Examples
헤더
한 번에 한 달씩만 넘기는 picker는 30년 전 생일을 180번의 클릭 너머에 둡니다. 그래서 월 이름과 연도가 각각 자기 그리드를 여는 버튼 입니다. 열두 달, 그다음 한 번에 열두 해. 화면에 보이는 연도의 어느 달이든 두 번, 어느 해든 세 번입니다.
세 뷰는 너비도 높이도 같아서, 뷰를 바꿔도 그것을 연 포인터 아래에서 팝업 크기가 변하지 않습니다. 날짜 그리드가 언제나 여섯 주인 것도 같은 이유입니다. 네 줄이면 되는 2월과 여섯 줄이 필요한 3월 사이를 넘길 때마다 모든 칸이 움직이기 때문입니다.
precision
생일은 날이고, 카드 만료는 월이고, 연식은 연도입니다. precision이 그중 무엇인지를 정하고, 달력은 그 단위의 그리드에서 열립니다. month picker의 월 그리드가 마지막 그리드이고, 그 아래에 날짜 그리드는 아예 없습니다. 2027년 12월 며칠에 카드가 만료되느냐고 묻는 것은 잘못 답해질 질문을 던지는 일입니다.
값은 그대로 Date이고, 고른 단위의 시작으로 맞춰집니다(그 달의 1일, 또는 1월 1일. minDate와 maxDate도 같은 정밀도로 읽힙니다. minDate가 7월 15일이어도 month picker에서 7월은 그대로 고를 수 있고 7월 1일이 돌아옵니다). 월을 돌려주는 컨트롤의 경계는 월의 경계이기 때문입니다. shouldDisableDate는 일 단위라 아예 참조되지 않습니다.
trigger의 기본 format도 따라가고, 푸터의 지름길도 마찬가지입니다. "Today"가 아니라 "This month", "This year"가 됩니다.
이름과 라벨
locale은 BCP 47 태그이고, 월과 요일 이름, 오전/오후, 한 주가 시작하는 요일, 헤더 두 버튼의 순서, trigger 자신의 형식까지 전부 거기서 나옵니다.
names가 그것을 전부 담고, 그중 가장 놓치기 쉬운 것이 monthBeforeYear입니다.
한국어에서는 2026년 7월, 영어에서는 July 2026. 고정된 순서로 찍는 대신 두 버튼이 자리를 바꿉니다. 순서가 틀린 헤더는 그것이 틀린 바로 그 독자에게 고장으로 읽히기 때문입니다.
minDate · maxDate · shouldDisableDate
minDate와 maxDate는 일 단위 입니다. 거기 붙은 시각은 무시됩니다(그 경계는 어떤 날이 존재하는가에 대한 것이기 때문입니다. shouldDisableDate는 범위 안이지만 그래도 쓸 수 없는 날들을 위한 것입니다). 주말, 공휴일, 이미 예약된 방.
막힌 날은 사라지지 않고 그리드에 남고, disabled 버튼이 아닙니다. 화살표 경로에서 자기 자리를 지키므로, 한 달을 화살표로 훑는 독자가 막힌 날마다 구멍에 빠지지 않습니다.
trigger가 쓰는 방식
format은 Intl.DateTimeFormat으로 그대로 넘어갑니다. { dateStyle: 'full' }도 { year: 'numeric', month: 'long' }도 됩니다.
formatValue는 위의 이유로 콜백입니다. 주지 않으면 names의 medium 형식으로 씁니다. 칸들이 이미 쓰고 있는 긴 형식은 PlDateNames.spell입니다.
무엇이라 쓰든 trigger는 담을 수 있는 가장 긴 날짜의 너비로 붙잡혀 있습니다. 28일 뒤에 1일을 골라도, 그것을 고른 포인터 아래에서 field가 줄어들지 않습니다.
readOnly · disabled · error
error는 picker를 invalid로도 만들고, 그러면 색 계열 전체가 danger로 옮겨 갑니다. 테두리와 ring과 메시지가 함께 넘어갑니다. invalid는 메시지 없이 같은 일을 합니다.
readOnly picker는 값과 포커스를 유지하되 열리지 않습니다. 그것이 담고 있는 것은 읽을 것이고, 모든 칸이 죽어 있는 달력은 아무것도 없는 메뉴입니다.
Controlled
value를 onValueChange와 함께 주세요. 값은 로컬 자정의 Date이거나, 이미 갖고 있던 시각 그대로의 Date입니다. 새 날을 고르면 날짜만 바뀌고 시계는 그대로 남으므로, 시각도 함께 담는 필드에 묶인 picker가 날짜를 고칠 때마다 시각을 조용히 초기화하지 않습니다.
null은 controlled picker가 정당하게 가질 수 있는 값입니다. 비워진 picker가 그것입니다.
Accessibility
- 그리드에는 roving tab stop이 하나 라서, Tab은 칸 마흔둘을 걷는 대신 달력을 빠져나갑니다. ARIA date picker 관행이 설명하는 패턴입니다.
- 그리드는
gridcell들로 이루어진role="grid"입니다. - ← → ↑ ↓는 하루와 한 주씩, Home과 End는 그 주의 양 끝으로, PageUp / PageDown은 한 달씩(Shift와 함께면 한 해씩) 움직입니다. 가장자리를 벗어나면 멈추는 대신 달력이 넘어갑니다.
- 막힌 날은
disabled속성이 아니라aria-disabled를 답니다. 그래서 화살표 경로에 남고, 사용할 수 없다고 읽힙니다.
- 막힌 날도 자기 focus node를 지키고 사용할 수 없다고 읽힙니다. 같은 이유입니다. 한 달을 화살표로 훑는 독자가 그때마다 구멍에 빠지면 안 됩니다.
- trigger는 고른 날을 label에 접어 넣는 대신 value 로 지닌 버튼입니다.
PlSelect가 이미 하고 있는 것과 같습니다. label은 field의 이름을 말하고 value는 그 안에 무엇이 있는지를 말합니다. - 너비를 잡아 주는 샘플들은
ExcludeSemantics뒤에 있어서 더 읽히는 것이 없습니다.
- 모든 칸의 접근성 이름은 숫자 하나가 아니라 날짜 전체 입니다: picker 자신의 로캘로,
Intl에서 나온2026년 7월 27일 월요일. - 오늘은
aria-current="date"와 링이 아닌 점을 답니다. 링은 포커스 표시의 몫이고, 한 칸 안의 링 둘은 아무 말도 하지 않는 칸이기 때문입니다. - 요일 헤더는 전체 이름을 라벨로 단
columnheader입니다. 눈으로 보는 독자가 "월"을 볼 때 스크린리더는 "월요일"을 듣습니다. name이 있으면 hidden input이 값을 로컬YYYY-MM-DD로 담습니다. precision이 짧으면YYYY-MM,YYYY이고, 이는 네이티브<input type="month">가 제출하는 형태와 같습니다.toISOString()은 절대 아닙니다. 서울의 picker라면 화면에 보이는 날의 전날을 제출하게 됩니다.- trigger는 담을 수 있는 가장 긴 날짜의 너비로 붙잡혀 있습니다. 그 샘플들은
aria-hidden이고 generated content로 그려지므로, 더 읽히는 것도 페이지 내 검색에 걸리는 것도 없습니다. - 팝업은
<body>끝으로 portal되고 positioner가.plass-portal을 답니다. CSS 리셋을 subtree에 한정한 호스트가 같은 리셋을 걸 수 있는 자리입니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
BCP 47 태그인 locale | PlDateNames인 names | 프레임워크에 Intl이 없고, package:intl을 끌어오는 것은 소비자 대신 의존성을 정하는 일입니다. 기본값이 영어라 설정 없이도 picker는 작동합니다. |
format: Intl.DateTimeFormatOptions | formatValue: String Function(DateTime) | 같은 이유의 같은 거래입니다. |
value / defaultValue / onValueChange | value / onChanged | Flutter의 컨트롤은 controlled이고, 콜백 이름도 그쪽 것입니다. |
hidden input, name, required | — | 참여할 네이티브 form 제출이 없습니다. |
헤더의 컨트롤이 picker의 size | 사다리 한 단 아래 | 월 이름은 어떤 언어에서는 July이고 다음 언어에서는 септември인데, 그 줄은 칸 일곱 개 안에 들어가야 합니다. 두 버튼 다 넘치는 대신 잘립니다. |
className, style, 네이티브 속성 | — | 통과시킬 class 목록도 style 속성도 없습니다. |