PlDatePicker
One day, chosen from a calendar. The trigger is a PlTextField's shell wearing a calendar glyph, so a date field and the fields beside it are the same object.
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),
);The calendar lifts itself out of the tree, so a picker needs an Overlay above it, WidgetsApp with a navigator and MaterialApp both provide one.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Date | null | — | The chosen day. Use with onValueChange for a controlled picker |
| defaultValue | Date | null | — | The day the picker starts on |
| onValueChange | (value: Date | null) => void | — | Called with the new value |
| precision | 'day' | 'month' | 'year' | 'day' | How far down the picker goes: a day, a month or a year. The grid for that unit is the last one, and a month picker has no day grid at all. The value is always the start of what was chosen — the 1st of the month, or the 1st of January |
| minDate | Date | null | — | The earliest day that may be chosen. Day-granular — the time is ignored |
| maxDate | Date | null | — | The latest day that may be chosen |
| shouldDisableDate | (date: Date) => boolean | — | Blocks individual days that are inside the range but still not available — weekends, holidays, a room that is already booked |
| weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | Which day the week starts on. Defaults to whatever the locale says; 0 is Sunday |
| defaultMonth | Date | — | Which month the calendar opens on when there is no value |
| showTodayButton | boolean | true | Offers the shortcut to today in the footer — to this month or this year, as precision says |
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | The material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth of the trigger. The popup has its own, fixed at 3 — it genuinely floats |
| open | boolean | — | Whether the popup is open. Use with onOpenChange to control it |
| defaultOpen | boolean | false | Whether the popup starts open |
| onOpenChange | (open: boolean) => void | — | Called when the popup opens or closes |
| locale | string | — | BCP 47 tag deciding the month and weekday names, the order of the header's two buttons, and how the trigger writes the value. Defaults to the browser's |
| format | Intl.DateTimeFormatOptions | { dateStyle: 'medium' } | How the trigger writes the chosen date. Passed straight to Intl; the default follows precision |
| placeholder | ReactNode | — | Shown in the trigger while nothing is chosen |
| clearable | boolean | false | Offers the × that empties the control |
| closeOnSelect | boolean | true | Closes the popup as soon as a day is chosen |
| labels | Partial<PlPickerLabels> | — | The strings the picker says on its own behalf. Every one has an English default. The dates are never in here: Intl knows those |
| label | ReactNode | — | Label above the trigger |
| description | ReactNode | — | Helper text below the trigger |
| error | ReactNode | — | Error message below. Its presence also turns the control invalid |
| invalid | boolean | — | Forces the invalid state without a message |
| startIcon | ReactNode | — | The glyph before the value. A calendar, or a clock, by default |
| fullWidth | boolean | false | Stretches to the width of the container |
| readOnly | boolean | false | The value is shown but cannot be changed, and the popup does not open |
| disabled | boolean | false | Unavailable |
| required | boolean | false | Whether a value must be chosen before the form is submitted |
| name | string | — | Identifies the field when a form is submitted, as a local YYYY-MM-DD — never as UTC. YYYY-MM and YYYY at the two shorter precisions |
| classNames | { label?, control?, description?, error?: string } | — | Classes on the parts a className does not reach. control is the part a reader acts on |
| Prop | Type | Default | Description |
|---|---|---|---|
| value * | DateTime? | — | The chosen day, or null for none. Controlled, like every other input in the package |
| onChanged | ValueChanged<DateTime?>? | — | Called with the day that was chosen, or null when the picker is emptied |
| precision | PlDatePickerPrecision | PlDatePickerPrecision.day | How far down the picker goes: a day, a month or a year. The grid for that unit is the last one, and a month picker has no day grid at all. The value is always the start of what was chosen — the 1st of the month, or the 1st of January |
| open | bool? | — | Whether the popup is open. Use with onOpenChange to control it |
| onOpenChanged | ValueChanged<bool>? | — | Called when the calendar should open or close |
| defaultMonth | DateTime? | — | Which month the calendar opens on when there is no value |
| minDate | DateTime? | — | The earliest day that may be chosen. Day-granular — the time is ignored |
| maxDate | DateTime? | — | The latest day that may be chosen |
| shouldDisableDate | bool Function(DateTime date)? | — | Blocks individual days that are inside the range but still not available — weekends, holidays, a room that is already booked |
| weekStartsOn | PlassWeekday? | — | Which day the week starts on. Defaults to what names says |
| names | PlDateNames | PlDateNames.english | The month and weekday names the calendar draws, and the order the header writes them in. **This is what a locale string is in the React build**: there is no Intl in the framework, so the words arrive as an object |
| labels | PlPickerLabels | PlPickerLabels.english | The words the picker says about itself. Every one has an English default |
| formatValue | String Function(DateTime value)? | — | How the trigger writes the day. A callback rather than React's Intl options; without it the value is written out of names at whatever precision asked for — the medium day, the month and year, or the bare year |
| placeholder | Widget? | — | Shown in the trigger while nothing is chosen |
| clearable | bool | false | Offers the × that empties the control |
| showTodayButton | bool | true | Offers the shortcut to today in the footer — to this month or this year, as precision says |
| closeOnSelect | bool | true | Closes the popup as soon as a day is chosen |
| variantshared | PlassVariant | PlassVariant.glass | The material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet |
| sizeshared | PlassSize | PlassSize.md | Height and type scale |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth of the trigger. The popup has its own, fixed at 3 — it genuinely floats |
| label | Widget? | — | Label above the trigger |
| description | Widget? | — | Helper text below the trigger |
| error | Widget? | — | Error message below. Its presence also turns the control invalid |
| invalid | bool? | — | Forces the invalid state without a message |
| startIcon | Widget? | — | The glyph before the value. A calendar, or a clock, by default |
| fullWidth | bool | false | Stretches to the width of the container |
| readOnly | bool | false | The value is shown but cannot be changed, and the popup does not open |
| disabled | bool | false | Unavailable |
| semanticLabel | String? | — | The name a screen reader gives a trigger with no visible label |
| focusNode | FocusNode? | — | Drive focus from outside |
| autofocus | bool | false | Takes focus as it is inserted into the tree |
Every native <div> attribute passes straight through to the field wrapper. color is excluded because it collides with the color in the table above, defaultValue because the picker spells it as a value rather than a DOM attribute, and children because the calendar is the component.
A className lands on the stack that holds the label, the control and the two lines under it. classNames reaches the four parts inside it: label, control (the trigger) description and error.
The picker is controlled, like every other input in the package: value with onChanged, and null is a picker with nothing chosen.
names is the one parameter with no React counterpart, and the next section says why.
PlDateNames
PlDateNames is not in the React package yet.
| Prop | Type | Default | Description |
|---|---|---|---|
| months | List<String> | English | The twelve month names in full, January first |
| monthsShort | List<String> | English | The same twelve, abbreviated. What the month grid draws |
| weekdays | List<String> | English | The seven weekday names in full, **Sunday first**, whatever the week is drawn as starting on — rotating them is the calendar’s job |
| weekdaysShort | List<String> | English | The same seven, abbreviated. What the column headers draw — abbreviated rather than narrow, because narrow gives S M T W T F S in English |
| am | String | 'AM' | The first half of the day, for a 12-hour clock |
| pm | String | 'PM' | And the second |
| monthBeforeYear | bool | true | Whether the header writes the month before the year. It is two buttons rather than one string, so it cannot print what a formatter gives it and has to be told which comes first |
| firstDayOfWeek | PlassWeekday | PlassWeekday.sunday | Which day the week starts on in this language. A picker’s own weekStartsOn overrides it |
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
No date library, and no translation files
The pickers add nothing to your dependency tree. Everything they do is either Date arithmetic, which is a dozen lines, or Intl, which the platform already ships and which knows more about month names in more languages than any bundled table ever will. A component library that quietly added date-fns, or worse, picked a side in the dayjs / luxon / Temporal argument on its consumer's behalf. Would have made a decision that was not its to make.
That is also the whole of the localisation story. There is no per-language module to import and register: locale is a BCP 47 tag, and from it Intl supplies the month names, the weekday names, AM and PM, which day the week starts on, the order of the header's two buttons, and how the trigger writes the date. A project that ships in twelve languages pays nothing for eleven of them.
This is also the one place the two packages genuinely part company. The browser hands React an Intl that already knows what July is called in every language, so a BCP 47 tag is enough. The Flutter framework ships nothing of the kind, and a package that pulled package:intl in to fill the gap would be making a dependency decision on its consumer's behalf, the same trade PlProgressLinear's formatValue already refuses.
So the words arrive as a PlDateNames: English by default, so a picker works with no setup at all, and three lines of DateFormat for an app that already depends on 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)),
),
)The only strings left over are the ones on the picker's own buttons ("Today", "Previous month", "Choose a year"), because neither platform has an opinion about those. They are one labels object with English defaults.
You cannot type into it
This is deliberate. Parsing a date out of free text is locale-dependent in a way that cannot be done honestly without a date library, and a field that understands 27/7/26 in one browser and not the next is worse than one that never claimed to. The trigger is a button, exactly as a PlSelect's is, and the calendar is where the answer comes from.
Examples
The header
A picker that only steps a month at a time puts a birthday thirty years back a hundred and eighty clicks away. So the month name and the year are each a button that opens a grid of its own, twelve months, then twelve years at a time. Any month of the year on screen is two clicks; any year at all is three.
All three views are the same width and the same height, so switching between them never resizes the popup under the pointer that opened it. The day grid is always six weeks for the same reason: a February that needs four rows and a March that needs six would move every cell as you stepped between them.
precision
A birthday is a day, a card's expiry is a month and a model year is a year. precision says which, and the calendar opens on the grid for that unit. A month picker's month grid is the last grid, and there is no day grid under it at all. Asking someone which day of December 2027 their card expires is asking a question that will be answered wrongly.
The value is still a Date, normalised to the start of what was chosen: the 1st of the month, or the 1st of January. minDate and maxDate are then read at the same precision, so a minDate of 15 July leaves July pickable on a month picker and hands back 1 July, a bound on a control that returns a month is a bound on months. shouldDisableDate is day-granular and is not consulted at all.
The trigger's default format follows along, and so does the footer's shortcut: "This month" and "This year" rather than "Today".
Names and labels
locale is a BCP 47 tag, and the month and weekday names, AM/PM, which day the week starts on, the order of the header's two buttons and the trigger's own format all come from it.
names carries all of that, and monthBeforeYear is the part that is easiest to overlook.
2026년 7월 in Korean, July 2026 in English. The two buttons swap places rather than being printed in a fixed order, because a header in the wrong order reads as broken to exactly the readers it is wrong for.
minDate · maxDate · shouldDisableDate
minDate and maxDate are day-granular: the time of day on them is ignored, because the bound is about which days exist. shouldDisableDate is for the days inside the range that still are not available, weekends, holidays, a room that is already booked.
A blocked day stays in the grid rather than vanishing, and it is not a disabled button: it keeps its place in the arrow-key path, so a reader arrowing across a month does not fall into a hole at every blocked day.
How the trigger writes it
format is passed straight to Intl.DateTimeFormat, so { dateStyle: 'full' } and { year: 'numeric', month: 'long' } both work.
formatValue is a callback, for the reason above. Without it the day is written out of names in its medium form; PlDateNames.spell is the long one the cells already use.
Whatever it says, the trigger is held open at the width of the longest date it could ever hold, so choosing the 1st after the 28th does not shrink the field out from under the pointer that chose it.
readOnly · disabled · error
error also turns the picker invalid, which re-points the whole colour family at danger, the edge, the ring and the message turn over together. invalid does the same without a message.
A readOnly picker keeps its value and its focus but will not open: what it holds is something to read, and a calendar whose every cell was inert would be a menu of nothing.
Controlled
Pass value with onValueChange. The value is a Date at local midnight, or at whatever time of day it already carried: choosing a new day changes the day and leaves the clock alone, so a picker bound to a field that also holds a time does not silently reset it every time the date is corrected.
null is a value a controlled picker legitimately holds. It is what an emptied one is.
Accessibility
- The grid has one roving tab stop, so Tab leaves the calendar rather than walking forty-two cells. That is the pattern the ARIA date-picker practice describes.
- The grid is a
role="grid"ofgridcells. - ← → ↑ ↓ move by a day and a week, Home and End go to the ends of the week, and PageUp / PageDown move by a month, a year with Shift. Running off an edge steps the calendar rather than stopping.
- A blocked day carries
aria-disabledrather than thedisabledattribute, so it stays in the arrow-key path and is still announced, as unavailable.
- A blocked day keeps its focus node and is announced as unavailable, for the same reason: a reader arrowing across a month must not fall into a hole at every one of them.
- The trigger is a button that carries the chosen day as its value rather than folding it into its label, which is what a
PlSelectalready does: the label names the field and the value says what is in it. - The width samples are behind
ExcludeSemantics, so nothing extra is read out.
- Every cell's accessible name is the full date, never the bare number:
Monday 27 July 2026, fromIntl, in the picker's own locale. - Today carries
aria-current="date"and a dot rather than a ring, because the ring belongs to the focus indicator and two rings in one cell is a cell saying nothing. - The weekday headers are
columnheaders labelled with the full name, so a screen reader hears "Monday" where a sighted reader sees "Mon". - With
name, a hidden input carries the value as a localYYYY-MM-DD, or asYYYY-MMandYYYYat the two shorter precisions, which is what a native<input type="month">submits. NevertoISOString(): a picker in Seoul would submit the day before the one on screen. - The trigger is held open at the width of the longest date it could show. Those samples are
aria-hiddenand drawn as generated content, so nothing extra is read out or found by find-in-page. - The popup is portalled to the end of
<body>and its positioner carries.plass-portal, which is where a host that scopes a CSS reset can hang the same reset.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
locale, a BCP 47 tag | names, a PlDateNames | The framework ships no Intl, and pulling package:intl in would be a dependency decision made on the consumer's behalf. English is the default, so a picker still works with no setup. |
format: Intl.DateTimeFormatOptions | formatValue: String Function(DateTime) | The same trade, for the same reason. |
value / defaultValue / onValueChange | value / onChanged | Flutter's own controls are controlled, and its name for the callback. |
the hidden input, name, required | — | There is no native form submission to be part of. |
the header's controls are the picker's size | one step down the ladder | A month name is July in one language and септември in the next, and the row has seven cells to fit inside. Both buttons truncate rather than overflow. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |