PlTimePicker
A time of day, chosen from columns. Columns because they are the shape that answers what a time picker is actually asked.
import { PlTimePicker } from 'plass-ui';
<PlTimePicker label="Doors" placeholder="Pick a time" minuteStep={15} />;import 'package:plass_ui/plass_ui.dart';
PlTimePicker(
label: const Text('Doors'),
minuteStep: 15,
value: doors,
onChanged: (DateTime? next) => setState(() => doors = next),
);The columns lift themselves out of the tree, so a picker needs an Overlay above it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Date | null | — | The chosen time. A Date, so it carries a day as well — see referenceDate |
| defaultValue | Date | null | — | The time the picker starts on |
| onValueChange | (value: Date | null) => void | — | Called with the new value |
| referenceDate | Date | today | The day a chosen time is written onto while there is no value yet. Held still for as long as the picker is mounted, so a popup left open across midnight does not move the value onto a new day |
| minTime | Date | null | — | The earliest time of day that may be chosen. Only the clock is read |
| maxTime | Date | null | — | The latest time of day that may be chosen |
| hour12 | boolean | — | A 12-hour dial with an AM/PM column. Defaults to whatever the locale does |
| showSeconds | boolean | false | Adds the seconds column |
| hourStep | number | 1 | How far apart the rows of each column are |
| minuteStep | number | 1 | See hourStep |
| secondStep | number | 1 | See hourStep |
| shouldDisableTime | (value: Date, unit: TimeUnit) => boolean | — | Blocks individual rows. Called once per row per column with the instant that row would produce and the column it is in, so a rule may be as coarse as "no afternoons" or as fine as one minute |
| showNowButton | boolean | true | Offers the shortcut to the current time in the footer |
| 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 | { hour: 'numeric', minute: '2-digit' } | How the trigger writes the chosen time. Passed straight to Intl; seconds are added when showSeconds is on |
| placeholder | ReactNode | — | Shown in the trigger while nothing is chosen |
| clearable | boolean | false | Offers the × that empties the control |
| closeOnSelect | boolean | false | Closes the popup as soon as any column is touched. false by default, unlike PlDatePicker, because a time is two answers and closing after the first would make choosing 9:30 a matter of opening the popup twice |
| 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 HH:MM — HH:MM:SS when showSeconds is on |
| 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 time. A DateTime, so it carries a day as well |
| onChanged | ValueChanged<DateTime?>? | — | Called with the time that was chosen, or null when the picker is emptied |
| open | bool? | — | Whether the popup is open. Use with onOpenChange to control it |
| onOpenChanged | ValueChanged<bool>? | — | Called when the columns should open or close |
| referenceDate | DateTime? | now | The day a chosen time is written onto while there is no value yet. Held still for as long as the picker is mounted, so a popup left open across midnight does not move the value onto a new day |
| minTime | DateTime? | — | The earliest time of day that may be chosen. Only the clock is read |
| maxTime | DateTime? | — | The latest time of day that may be chosen |
| hour12 | bool | false | A 12-hour dial with an AM/PM column. A plain false where React takes it from the locale: there is no Intl here to ask, and the words it uses when on are PlDateNames.am and .pm |
| showSeconds | bool | false | Adds the seconds column |
| hourStep | int | 1 | How far apart the rows of each column are |
| minuteStep | int | 1 | See hourStep |
| secondStep | int | 1 | See hourStep |
| shouldDisableTime | bool Function(DateTime value, PlassTimeUnit unit)? | — | Blocks individual rows. Called once per row per column with the instant that row would produce and the column it is in, so a rule may be as coarse as "no afternoons" or as fine as one minute |
| names | PlDateNames | PlDateNames.english | Where AM and PM come from |
| labels | PlPickerLabels | PlPickerLabels.english | The strings the picker says on its own behalf. Every one has an English default. The dates are never in here: Intl knows those |
| formatValue | String Function(DateTime value)? | — | How the trigger writes the chosen time. Without it, H:MM — with seconds and a meridiem when those are on |
| placeholder | Widget? | — | Shown in the trigger while nothing is chosen |
| clearable | bool | false | Offers the × that empties the control |
| showNowButton | bool | true | Offers the shortcut to the current time in the footer |
| closeOnSelect | bool | false | Closes the popup as soon as any column is touched. false by default, unlike PlDatePicker, because a time is two answers and closing after the first would make choosing 9:30 a matter of opening the popup twice |
| 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 columns are 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: value with onChanged, and null is a picker with nothing chosen.
Everything a PlDatePicker says about locale and the absence of a date library holds here too. Intl is what decides whether the clock is on a 12-hour dial and what AM and PM are called.
Everything a PlDatePicker says about the absence of a date library holds here too. What differs is that nothing decides the dial for you: hour12 is a plain false by default, and the words it uses when it is on are PlDateNames.am and .pm.
Columns, not a dial
"Half past nine" is two glances at two columns. "Any time at all, on the hour" is a column you never touch. A clock face is prettier, needs a transform to read, and answers neither question faster, and this library does not put a transform on a control.
The chosen row in each column is scrolled into view once, on open. That is not decoration: a column of sixty minutes that opens at 00 while the value is 45 has hidden its own answer.
The bounds
This is the detail that separates a working time picker from a frustrating one. A bound is checked against the span a row stands for, not against one instant inside it.
With a minTime of 09:30, the hour 9 covers 09:00:00–09:59:59, which overlaps what is allowed, so it stays available, and the minute column is where 00 through 25 grey out. Comparing the whole candidate instead hides the 9 and makes half past nine unreachable.
The value
Not a string and not a number of minutes. Everything else in this library that carries a moment is a Date, and a bare time has nowhere to record that it crossed a daylight-saving boundary. referenceDate is the day a bare time is written onto, and it is held still for as long as the picker is mounted. A popup left open across midnight must not quietly move the value onto a new day.
Examples
hour12
Taken from the locale unless you say otherwise.
false unless you say otherwise: there is no Intl to ask what this region does.
A 12-hour dial reads 12, 1, 2 … 11 rather than 0, 1, 2, and gains an AM/PM column; a 24-hour one runs 00 to 23 and has none.
The step intervals
hourStep, minuteStep and secondStep decide how far apart the rows are. A booking that only takes quarter hours should say so with minuteStep={15} rather than by rejecting 09:07 after the fact.
minTime · maxTime · shouldDisableTime
minTime and maxTime read the clock only. The day on them is ignored. shouldDisableTime is called once per row per column with the instant that row would produce and the column it is in, so a rule may be as coarse as "no afternoons" or as fine as one minute.
closeOnSelect
false here, and true on a PlDatePicker. A day is one answer; a time is two, and closing after the first would make choosing 9:30 a matter of opening the popup twice.
Because the popup stays up while the columns are being read, there has to be something to press that means that is the one, so the footer carries a Done. Turning closeOnSelect on takes it away, since there is then nothing for it to do.
readOnly · disabled · error
Accessibility
- Each column is named after the unit it holds, and each row says whether it is the chosen one.
- A blocked row stays in its column and is announced as unavailable rather than being removed.
- Each column is a
role="listbox", and each row anoptioncarryingaria-selected; a blocked one carriesaria-disabledrather than the attribute. - Three unlabelled lists of numbers say nothing to a reader who is not looking at them, so a polite live region beside the columns reads the whole time back as one sentence whenever it changes.
- The chosen row in each column is brought into view inside its own column, by setting
scrollToprather than callingscrollIntoView, which walks every scrollable ancestor up to the document and, on the frame the popup opens, would scroll the page to the top to reveal a row that is about to move anyway. - The trigger is held open at the width of the longest time it could show. Those samples are
aria-hiddenand drawn as generated content. - With
name, a hidden input carries the value as a localHH:MM, the shape<input type="time">submits, so a server that already parses those needs no new code.
- Each column is a semantics container named after its unit, and each row is announced with the whole of what it means,
14 Hour, not14. - The trigger carries the time as its semantic value rather than folding it into its label.
- The live region beside the columns reads the whole time back whenever it changes.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
hour12 defaults to the locale's dial | it defaults to false | There is no Intl here to ask. PlDateNames.am / .pm supply the words. |
format: Intl.DateTimeFormatOptions | formatValue: String Function(DateTime) | The same trade PlDatePicker explains. |
role="listbox" and option | a named semantics container and rows that say what they mean | Flutter names the state on the node itself. |
the hidden input, name | — | There is no native form submission to be part of. |
className, style, native attributes | — | There is no class list and no style attribute to pass through. |