Skip to content

DatePicker ​

A calendar date, chosen from a month. The value is a YYYY-MM-DD string, '' for none, because a calendar date isn't an instant: a Date at local midnight, read back in UTC, is the day before, and a string has no time zone to be a day out in.

tsx
/**
 * A start and a due date, each bounded by the other.
 *
 * The values are `YYYY-MM-DD` strings, so nothing here can be a day out
 * in another time zone. Start can't be after due, and due can't be
 * before start: each passes its value to the other as `max` or `min`,
 * and the calendar keeps its cursor inside, greys the days outside and
 * refuses them.
 *
 * Neither needs a pointer. Tab to a trigger and press Enter: the arrows
 * move a day or a week, PageUp and PageDown a month (with Shift, a year),
 * Home and End the week's ends, Enter chooses and Escape closes.
 */
export function Schedule(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const start = internalState('2026-10-05');
  const due = internalState('2026-10-23');

  const length = combineLatest([start, due]).pipe(
    map(([from, to]) => {
      if (from === '' || to === '') {
        return 'Pick both dates to see how long it runs';
      }
      const days = (Date.parse(to) - Date.parse(from)) / 86_400_000;
      return `${days} day${days === 1 ? '' : 's'}`;
    })
  );

  return (
    <column gap={14} padding={20} width={percent(100)} height={percent(100)}>
      <DatePicker label="Start date" value={start} max={due} onChange={next => (start.value = next)} />
      <DatePicker label="Due date" value={due} min={start} onChange={next => (due.value = next)} />
      <text text={length} fontSize={12} color="textMuted" />
    </column>
  );
}

Each date bounds the other through min and max. Open Due date and press Down: the cursor moves a week, and Enter takes it. Open Start date and press PageDown: a month on is past the due date, so the cursor stops there.

Props ​

PropTypeDefaultWhat it does
valuestringnoneThe date, YYYY-MM-DD or '', when the application owns it.
defaultValuestringnoneThe date to start on, when the picker owns it.
onChange(value: string) => voidnoneCalled with the date chosen, or '' from Clear.
labelstring''Drawn above the trigger, and the name of the trigger and the calendar.
labelHiddenbooleanfalseThe label still names the trigger but isn't drawn.
placeholderstring'No date'Drawn in the trigger with no date.
descriptionstring''Shown under the trigger and read after its name.
errorstring''What's wrong, shown in place of the description; implies invalid.
invalidbooleanfalseDraws the border in danger and adds the invalid state.
requiredbooleanfalseAdds the required state. It enforces nothing.
disabledbooleanfalseRefuses presses and keys, and greys the trigger.
min, maxstring''The earliest and latest dates that can be chosen. Days outside are greyed and refused.
weekStartnumber1The first day of a week: 0 for Sunday, 1 for Monday.
clearablebooleantrueOffers Clear, to go back to no date.
todaystringlocal dateThe date that counts as today. For a spec or a demo that must show the same month.
localestringruntime'sA language tag for the names of days and months.
refUiNodeRefnoneReceives the trigger.

min and max are followed while the calendar is open, not worked out once for each month shown, so a bound that moves, a due date's min following its start, dims and announces the right days at once.

In a narrow row the trigger gives way, as a select does: a long date or placeholder is cut short with an ellipsis before the chevron instead of running past the border. A value that isn't a real date shows the placeholder, muted the same as an empty one, rather than text that looks chosen.

The date arithmetic is exported for an application that works in the same strings: parseIsoDate, isoDate, todayIso, addDays, addMonths, monthGrid and formatDate.

Keyboard ​

On the trigger, Enter, Space or Down opens the calendar on the chosen date, or on today when there is none. The calendar is a dialog that traps focus, and focus starts in the grid of days. It opens under the trigger, lined up with the trigger's start edge: its left in left-to-right text and its right under textDirection="rtl".

KeyWhat it does
Left, RightThe day before or after
Up, DownThe same day a week before or after
Home, EndThe first or last day of the week
PageUp, PageDownThe same day a month before or after
With ShiftA year before or after
Enter, SpaceChooses the day and closes
EscapeCloses without choosing
TabThe month buttons, the grid, then Today and Clear

Left and Right follow where the days are drawn. Under textDirection="rtl" the week runs from the right, so Left is the day after and Right the day before; Up and Down are weeks either way.

The cursor never leaves min and max; a move past either stops on it. Its ring is drawn in the focus ring's colour, so it still shows on the chosen day, whose fill is the accent. A month move keeps the day where the month has it and takes the month's last day where it doesn't. Closing, for any reason, hands focus back to the trigger.

Semantics ​

The trigger is a combobox, expanded or collapsed, whose value is the date read out in full ("Tuesday, October 20, 2026"). The calendar is a dialog holding a grid named for its month; its rows are rows, the weekday names columnheaders, and the days gridcells named in full and marked selected when chosen. The grid holds focus and names the day under the cursor as its activeDescendant, so a screen reader reads each day as the arrows reach it. The month name above the grid is a polite live region, so turning the page is announced.