Skip to content

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.

React
tsx
import { PlDateRangePicker } from 'plass-ui';

<PlDateRangePicker label="Stay" startPlaceholder="Check in" endPlaceholder="Check out" />;
dart
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

PropTypeDefaultDescription
valuePlDateRange | nullThe chosen range. Use with onValueChange for a controlled picker
defaultValuePlDateRange | nullThe range the picker starts on
onValueChange(value: PlDateRange) => voidAlways called with an object. A cleared range is { start: null, end: null }
minDateDate | nullThe earliest day that may be chosen. Day-granular — the time is ignored
maxDateDate | nullThe latest day that may be chosen
shouldDisableDate(date: Date) => booleanBlocks individual days that are inside the range but still not available — weekends, holidays, a room that is already booked
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6Which day the week starts on. Defaults to whatever the locale says; 0 is Sunday
defaultMonthDateWhich month the calendar opens on when there is no value
monthCount1 | 22How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case
startPlaceholderReactNodeShown in each half of the trigger while that end is unchosen
endPlaceholderReactNodeSee startPlaceholder
presetsreadonly 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
elevationshared0 | 1 | 2 | 30Drop shadow depth of the trigger. The popup has its own, fixed at 3 — it genuinely floats
openbooleanWhether the popup is open. Use with onOpenChange to control it
defaultOpenbooleanfalseWhether the popup starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
localestringBCP 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
formatIntl.DateTimeFormatOptions{ dateStyle: 'medium' }How the trigger writes each end. Passed straight to Intl
placeholderReactNodeShown in the trigger while nothing is chosen
clearablebooleanfalseOffers the × that empties the control
closeOnSelectbooleantrueCloses the popup once both ends are chosen
labelsPartial<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
labelReactNodeLabel above the trigger
descriptionReactNodeHelper text below the trigger
errorReactNodeError message below. Its presence also turns the control invalid
invalidbooleanForces the invalid state without a message
startIconReactNodeThe glyph before the value. A calendar, or a clock, by default
fullWidthbooleanfalseStretches to the width of the container
readOnlybooleanfalseThe value is shown but cannot be changed, and the popup does not open
disabledbooleanfalseUnavailable
requiredbooleanfalseWhether a value must be chosen before the form is submitted
namestringIdentifies 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
PropTypeDefaultDescription
value * PlDateRangeThe chosen range. Never null — an empty one is PlDateRange.empty
onChangedValueChanged<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
openbool?Whether the popup is open. Use with onOpenChange to control it
onOpenChangedValueChanged<bool>?Called when the calendars should open or close
defaultMonthDateTime?Which month the calendar opens on when there is no value
minDateDateTime?The earliest day that may be chosen. Day-granular — the time is ignored
maxDateDateTime?The latest day that may be chosen
shouldDisableDatebool Function(DateTime date)?Blocks individual days that are inside the range but still not available — weekends, holidays, a room that is already booked
weekStartsOnPlassWeekday?Which day the week starts on. Defaults to whatever the locale says; 0 is Sunday
namesPlDateNamesPlDateNames.englishThe 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
labelsPlPickerLabelsPlPickerLabels.englishThe strings the picker says on its own behalf. Every one has an English default. The dates are never in here: Intl knows those
formatValueString 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
monthCountint2How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case
startPlaceholderWidget?Shown in each half of the trigger while that end is unchosen
endPlaceholderWidget?See startPlaceholder
presetsList<PlDateRangePreset>const []Shortcuts listed beside the calendars — "Last 7 days", "This month"
clearableboolfalseOffers the × that empties the control
closeOnSelectbooltrueCloses the popup once both ends are chosen
variantsharedPlassVariantPlassVariant.glassThe material of the trigger, drawn on PlTextField's shell. solid is the well cut into the sheet
sizesharedPlassSizePlassSize.mdHeight and type scale
colorsharedPlassColorPlassColor.primarySemantic colour role. Arbitrary colour values are not accepted
densitysharedPlassDensityPlassDensity.standardPadding only — never the height, never the type scale
elevationsharedint0Drop shadow depth of the trigger. The popup has its own, fixed at 3 — it genuinely floats
labelWidget?Label above the trigger
descriptionWidget?Helper text below the trigger
errorWidget?Error message below. Its presence also turns the control invalid
invalidbool?Forces the invalid state without a message
startIconWidget?The glyph before the value. A calendar, or a clock, by default
fullWidthboolfalseStretches to the width of the container
readOnlyboolfalseThe value is shown but cannot be changed, and the popup does not open
disabledboolfalseUnavailable
semanticLabelString?The name a screen reader gives a trigger with no visible label
focusNodeFocusNode?Drive focus from outside
autofocusboolfalseTakes 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

PropTypeDefaultDescription
start * Date | nullThe first day of the span
end * Date | nullThe last. null between the first click and the second: half a range is a real state
PropTypeDefaultDescription
startDateTime?The first day of the span
endDateTime?The last. null between the first click and the second: half a range is a real state

PlDateRangePreset

PropTypeDefaultDescription
label * ReactNodeWhat 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
PropTypeDefaultDescription
label * WidgetWhat 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.

React

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.

React

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.

React

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 }.

React

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-hidden and 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-hidden and drawn as generated content.
  • With name, two hidden inputs of that name carry the ends as local YYYY-MM-DD, so they arrive as FormData.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

ReactFlutterWhy
locale / formatnames / formatValueThe same trade PlDatePicker explains: there is no Intl in the framework.
value: PlDateRange | nullvalue: PlDateRange, never nullPlDateRange.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 functionbuild is always a functionA preset almost always depends on today, and the one shape that is always right is cheaper than two.
the hidden inputs, nameThere is no native form submission to be part of.
className, style, native attributesThere is no class list and no style attribute to pass through.

Released under the MIT License