PlDateRangePicker
A span between two days. Two months side by side, and the band between the ends is drawn as the pointer moves, before the second click lands.
import { PlDateRangePicker } from 'plass-ui';
<PlDateRangePicker label="Stay" startPlaceholder="Check in" endPlaceholder="Check out" />;import 'package:plass_ui/plass_ui.dart';
PlDateRangePicker(
label: const Text('Stay'),
startPlaceholder: const Text('Check in'),
endPlaceholder: const Text('Check out'),
value: stay,
onChanged: (PlDateRange next) => setState(() => stay = next),
);The calendars lift themselves out of the tree, so a picker needs an Overlay above it.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | PlDateRange | null | — | The chosen range. Use with onValueChange for a controlled picker |
| defaultValue | PlDateRange | null | — | The range the picker starts on |
| onValueChange | (value: PlDateRange) => void | — | Always called with an object. A cleared range is { start: null, end: null } |
| 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 |
| monthCount | 1 | 2 | 2 | How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case |
| startPlaceholder | ReactNode | — | Shown in each half of the trigger while that end is unchosen |
| endPlaceholder | ReactNode | — | See startPlaceholder |
| presets | readonly PlDateRangePreset[] | — | Shortcuts listed beside the calendars — "Last 7 days", "This month" |
| 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 each end. Passed straight to Intl |
| 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 once both ends are 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. Two hidden inputs of the same name, so the two ends arrive as FormData.getAll(name) |
| 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 * | PlDateRange | — | The chosen range. Never null — an empty one is PlDateRange.empty |
| onChanged | ValueChanged<PlDateRange>? | — | Called with the new range, always as an object. It fires twice per selection: once with only a start, and once with both ends |
| open | bool? | — | Whether the popup is open. Use with onOpenChange to control it |
| onOpenChanged | ValueChanged<bool>? | — | Called when the calendars 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 whatever the locale says; 0 is Sunday |
| 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 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 value. A callback rather than React's Intl options; without it it is written out of names in its medium form |
| monthCount | int | 2 | How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case |
| startPlaceholder | Widget? | — | Shown in each half of the trigger while that end is unchosen |
| endPlaceholder | Widget? | — | See startPlaceholder |
| presets | List<PlDateRangePreset> | const [] | Shortcuts listed beside the calendars — "Last 7 days", "This month" |
| clearable | bool | false | Offers the × that empties the control |
| closeOnSelect | bool | true | Closes the popup once both ends are 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 calendars 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 value is never null. An empty range is PlDateRange.empty.
Everything a PlDatePicker says about locale, the header, the bounds and the absence of a date library holds here unchanged. This is that component with a second end.
PlDateRange
| Prop | Type | Default | Description |
|---|---|---|---|
| start * | Date | null | — | The first day of the span |
| end * | Date | null | — | The last. null between the first click and the second: half a range is a real state |
| Prop | Type | Default | Description |
|---|---|---|---|
| start | DateTime? | — | The first day of the span |
| end | DateTime? | — | The last. null between the first click and the second: half a range is a real state |
PlDateRangePreset
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | ReactNode | — | What the button says |
| value * | PlDateRange | (() => PlDateRange) | — | The range it stands for. A function when it depends on today, which is almost always: "the last 7 days" computed at module scope is wrong for anyone who left the tab open overnight |
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | Widget | — | What the button says |
| build * | PlDateRange Function() | — | The range it stands for. A callback rather than a value, and always: a preset almost always depends on today, and "the last 7 days" computed once at startup is wrong for anyone who left the app open overnight |
The value
Not a [Date, Date] tuple, and not two props. A range is one value: it is chosen in one gesture, cleared in one gesture and validated as a whole, and the two names are what stop a caller writing the end into the start.
Half a range is a real state, it is what the picker holds between the first press and the second, so the callback reports a range with only a start after the first press and the complete range after the second. A controlled caller is never handed a range mid-gesture that it did not ask for: the pending anchor lives inside the component, not in your form.
The preview band
The band is drawn between the anchor and whatever the pointer is currently over, before the second click lands. Without it the first click has no visible consequence, and the control looks broken for the second or so between the two.
Clicking backwards is not a mistake to be rejected. It is the same range typed in the other order, and it is committed as one.
Examples
monthCount
Two months is the default because a range that crosses a month boundary is the ordinary case, not the exception. A one-month picker turns that into a two-step navigation problem.
The two panels are one calendar in two halves: the left one has no forward stepper, the right one has no back stepper, and either header's month and year buttons move both. Where a stepper is not drawn, a hole its size is left, so the two headings stay on one centre line.
They also draw no outside days, and that is not a matter of taste: with both panels showing six full weeks, the 1st of August would appear twice (once as a trailing day of July and once as itself), and two cells with the same name in one popup is ambiguous to a pointer and outright broken to a screen reader.
presets
A named range beside the calendars, for the ones people actually pick. Give value as a function when it depends on today, which is almost always: "the last 7 days" computed at module scope is a range that would be wrong for anyone who left the tab open overnight.
minDate · maxDate · shouldDisableDate
The same three as a PlDatePicker, applied to both ends. A blocked day stays in the grid and keeps its place in the arrow-key path, and it does not take the range's tint: a blocked day wearing the band would be advertising that it is part of a range it cannot join.
Controlled
Pass value with onValueChange. The callback is always given an object, so there is no null range to guard against. An emptied picker is { start: null, end: null }.
Accessibility
- Both grids have a roving tab stop each, and the keyboard is
PlDatePicker's in full.
- Both grids are
role="grid". - Every cell's accessible name is the full date, and no date appears twice in the popup, which is what turning off the outside days buys.
- The footer says which end the next click will fill. The trigger says the same thing with its two halves, but the trigger is behind the popup while the popup is up, so the footer is the only place that can say it where it will be read.
- The arrow between the two halves of the trigger is
aria-hiddenand flips under RTL. - Each half of the trigger holds its own width open against every date it could show, so filling in the second end does not resize the first. Those samples are
aria-hiddenand drawn as generated content. - With
name, two hidden inputs of that name carry the ends as localYYYY-MM-DD, so they arrive asFormData.getAll(name).
- The trigger carries both ends as its semantic value rather than folding them into its label.
- The arrow between the two halves turns under RTL, so it always points from the first end to the second.
- The width samples are behind
ExcludeSemantics.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
locale / format | names / formatValue | The same trade PlDatePicker explains: there is no Intl in the framework. |
value: PlDateRange | null | value: PlDateRange, never null | PlDateRange.empty says it, and a non-nullable value is one fewer thing for a caller to guard. |
a preset's value may be a range or a function | build is always a function | A preset almost always depends on today, and the one shape that is always right is cheaper than two. |
the hidden inputs, 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. |