Date picker

Summary

A calendar popover anchored to a text input. ISO values (YYYY-MM-DD, or with time: true a HH:MM row and YYYY-MM-DDTHH:MM), full keyboard grid, Intl-localised month/day names, min/max bounds, and a dateRange helper that pairs two pickers so each constrains the other. Hand-rolled, with no third-party datepicker dependency. Opening never steals focus: the input stays typeable, and moves into the calendar when you want it.

When to use

FormUse it for
Single dateDeadlines, launch dates. Typed ISO input is parsed too.
time: trueScheduling to the minute (maintenance start).
min/maxBounded choices — out-of-range days render disabled.
Soma.dateRangeWindows: start caps the end's minimum and vice versa.
presetsOne-click range presets on a dateRange: Today, Last 7/30 days, This month, Last month, or your own list.

Examples

Basic (auto-init)

Click to pick from a calendar; typed ISO parses too. The week start follows the active locale (Monday where the browser can't say).

With initial value

A pre-populated ISO value is adopted. The calendar opens on that month with the day selected.

US convention — Sunday-first

firstDayOfWeek: 0 — the week starts on Sunday.

Listening for change

Last picked: (none)

Min / max bounds

Days outside 2026 render disabled; prev/next stop at the bounds; Today is a no-op while today is out of range.

Locale — French

Month + day names via Intl.DateTimeFormat('fr-FR'); first day of week inferred from the locale.

Date + time

Pick a day, then a 24h time. The popover stays open after the day so the time can still be tweaked. Step: 15 min.

Date range (paired pickers)

Maintenance window

The start caps the end's minimum and vice versa; out-of-range selections are prevented, not clamped.

Date range with presets

Reporting period

presets: true — open the start input: a rail of built-in ranges (Today, Last 7 days, Last 30 days, This month, Last month) sits at the inline-start of the calendar. One click fills both inputs and closes the popover.

Keyboard navigation

KeyAction
in the inputOpen (if needed) and move focus into the calendar grid.
/ ±1 day. Horizontal arrows follow visual direction, mirrored under RTL.
/ ±1 week.
Home / EndStart / end of the focused week.
PageUp / PageDown±1 month; with Shift, ±1 year.
Enter / SpaceSelect the focused day.
EscClose and return focus to the input — without reopening.

Focus moves with a roving tabindex; out-of-range days are skipped: focus halts rather than landing on a disabled cell. Day cells carry full spoken-date labels ("24 July 2026") and today is marked aria-current="date"; the input is an ARIA 1.2 editable combobox with a dialog popup.

Positioning & layers

The calendar is a native popover="manual" in the browser's top layer — it paints above every z-index on the page and above an open modal <dialog>, where it stays interactive. Manual, deliberately: the input you type into sits outside the popover, so an auto popover's light dismiss would close the calendar on every press into its own input. Esc, outside-click and close-on-scroll therefore stay Soma behaviour (identical on the no-popover fallback path); the platform contributes only the stacking.

Placement is below the input, aligned to its inline-start edge (so it mirrors to right-alignment under RTL), flipping above and shifting inward at viewport edges. Where the engine supports CSS anchor positioning that placement is pure CSS (position-area + position-try-fallbacks, anchored to the input) and the JS positioner stands down; elsewhere the JS math applies unchanged.

HTML

Auto-init: add the marker class on any text input. No JS needed:

<input class="soma-input soma-date-picker-input" type="text"
       placeholder="YYYY-MM-DD" autocomplete="off" />

Pre-populate the input. The value must be ISO (YYYY-MM-DD, or YYYY-MM-DDTHH:MM with time: true):

<input class="soma-input soma-date-picker-input" type="text"
       value="2026-07-24" autocomplete="off" />

Inside a Soma field:

<div class="soma-field">
  <label class="soma-field-label" for="launch">Launch date</label>
  <input class="soma-input soma-date-picker-input" id="launch"
         type="text" placeholder="YYYY-MM-DD" autocomplete="off" />
  <p class="soma-field-description">Click to pick from a calendar.</p>
</div>

The popover DOM is built by the component; there is nothing else to author.

CSS classes

ClassEffect
.soma-date-picker-inputMarker on the text input; triggers auto-init binding.
.soma-date-pickerPopover container (rendered by JS, not authored manually).
.soma-date-picker-headerTop row: month nav + title.
.soma-date-picker-prev / -nextPrevious / next month buttons; auto-disabled when the whole neighbouring month is out of range.
.soma-date-picker-titleThe "July 2026" title between the nav buttons (a polite live region).
.soma-date-picker-gridThe 6×7 day grid table.
.soma-date-picker-dayA day cell; carries its date as data-date="YYYY-MM-DD".
.soma-selected / .soma-today / .soma-other-monthState classes on day cells: the picked value, today (+ aria-current="date"), and leading/trailing days of neighbouring months. Out-of-range cells are natively disabled.
.soma-date-picker-timeTime row (only with time: true): the -hour and -minute selects.
.soma-date-picker-footerThe Today / Clear buttons (localised via Soma.i18n).
.soma-date-picker-presets / -presetThe range-preset rail and its buttons, rendered only when Soma.dateRange is configured with presets. The popover gains .soma-date-picker-has-presets and the calendar is wrapped in .soma-date-picker-main to form the second column.

JavaScript

Constructor + options

MemberDescription
Soma.datePicker(input, opts?)Get or create the singleton picker for an input element / selector.
opts.firstDayOfWeek0 = Sunday, 1 = Monday. When not set, falls back to the first day of the effective locale (via Intl.Locale().getWeekInfo()), else Monday.
opts.localeBCP-47 string (e.g. 'fr-FR', 'ja-JP'); localises month/day names via Intl.DateTimeFormat. Defaults to the document/library locale (Soma.i18n({locale})<html lang>). Tags Intl rejects fall back to English names instead of throwing.
opts.min / opts.maxDate or ISO string. Out-of-range days render disabled and ignore clicks; bounds are inclusive at day granularity.
opts.timetrue enables a 24h HH:MM row. Output format becomes YYYY-MM-DDTHH:MM and the popover stays open after a day is picked.
opts.timeStepMinute granularity for the time row. Default 15.

Instance methods

MemberDescription
.show() / .hide()Open / close the popover. Opening never steals focus — the input stays typeable.
.focusGrid()Move keyboard focus to the focused day cell (what in the input does).
.setValue(v)Set the value: a Date, an ISO string, or null to clear.
.getValue()The current value as a Date, or null when empty.
.setMin(v) / .setMax(v)Update a bound at runtime; the grid re-renders.
.on('change', fn) / .off('change', fn)Picked, typed or cleared. A typed ISO value fires on its native change commit (blur/Enter) when it differs from the current value, so typed and picked dates behave identically (range pairing included). e.detail = { value, date }; when cleared, value is '' and the date key is absent.
.destroy()Tear down listeners and remove the popover from the DOM.

Methods return the instance (apart from .getValue() and .destroy()) so calls chain. The underlying DOM event is a bubbling soma-date-picker-change CustomEvent dispatched on the input, usable directly with addEventListener for delegated listening.

Date range API

MemberDescription
Soma.dateRange(startInput, endInput, opts?)Pair two pickers: the start's value becomes the end's min, the end's becomes the start's max. Returns the façade below. Picker options (locale, time, outer min/max, …) apply to both sides.
opts.presets: trueRenders the built-in preset rail at the inline-start of the start picker's popover: Today, Last 7 days, Last 30 days, This month, Last month. Labels are localised via Soma.i18n (Today reuses the picker's own Today string); dates are computed when the preset is clicked, never at construction.
opts.presets: [{ label, start, end }]Custom preset list instead of the built-ins. start/end accept a Date or an ISO string; label is rendered verbatim (localising it is the caller's job).
.start / .endThe two underlying picker instances — use them for any per-side operation (show(), per-side events, …).
.getValue(){ start: Date | null, end: Date | null }.
.on('change', fn) / .off('change', fn)Either side changed; e.detail = { start, end }. (DOM event: bubbling soma-date-range-change on the start input.)
.destroy()Destroy both inner pickers and remove listeners.

Listen for date changes:

Soma.datePicker('#dp-listen').on('change', (e) => {
  console.log('picked:', e.detail.value);  // 'YYYY-MM-DD', or '' when cleared
  console.log('as Date:', e.detail.date);  // Date object
});

US convention, Sunday-first:

Soma.datePicker('#dp-sunday', { firstDayOfWeek: 0 });

Set / read the value programmatically:

const dp = Soma.datePicker('#dp-basic');

dp.setValue(new Date(2026, 6, 24));   // from a Date  → input "2026-07-24"
dp.setValue('2026-07-24');            // from an ISO string
const date = dp.getValue();           // → Date instance or null
dp.setValue(null);                    // clear

Bind a picker on an input rendered after page load (auto-init runs once, at DOMContentLoaded):

// The call is a get-or-create singleton — safe after any DOM update.
container.insertAdjacentHTML('beforeend', renderedRowWithDateInput);
Soma.datePicker(container.querySelector('.soma-date-picker-input'));

Custom validation on submit — read the parsed value:

document.getElementById('signup').addEventListener('submit', (e) => {
  const date = Soma.datePicker('#dob').getValue();
  if (!date) {
    e.preventDefault();
    Soma.toast({ body: 'Please pick a date', appearance: 'warning' });
  }
});

Min / max bounds

Set min / max at construction time (both accept a Date or an ISO string):

Soma.datePicker('#dob', {
  min: '1900-01-01',   // ISO string
  max: new Date(),     // Date instance — not in the future
});

For relative bounds (only the last 30 days, or the next 90), compute the Date with arithmetic:

// Today minus 30 days, up to today
const min = new Date();
min.setDate(min.getDate() - 30);
Soma.datePicker('#expense-date', { min, max: new Date() });

// Today through 90 days from now
const max = new Date();
max.setDate(max.getDate() + 90);
Soma.datePicker('#delivery', { min: new Date(), max });

Update bounds at runtime, useful when one input depends on another (this is exactly what dateRange automates):

const arrival = Soma.datePicker('#arrival');
const departure = Soma.datePicker('#departure');

arrival.on('change', (e) => {
  departure.setMin(e.detail.date || null);
});

Out-of-range days render with the native disabled attribute and aria-disabled="true". The prev/next month buttons disable themselves when the entire neighbouring month is out of range, and the Today footer button is a no-op while today is out of range.

Locales

Pass any BCP-47 locale string. Soma uses Intl.DateTimeFormat for month and day names and Intl.Locale().getWeekInfo() for the locale's first day of week (Monday as the fallback). Without an explicit option, the locale follows the document/library locale: set <html lang> or Soma.i18n({locale}) once and every picker follows (see i18n).

Soma.datePicker('#dob', { locale: 'fr-FR' });   // janvier · lun., mar., …
Soma.datePicker('#dob', { locale: 'ja-JP' });   // 1月 · 月, 火, …
Soma.datePicker('#dob', { locale: 'en-US' });   // English, Sunday-first

Override the locale's first day of week:

// French labels but Monday-first regardless of the locale default:
Soma.datePicker('#dob', { locale: 'fr-FR', firstDayOfWeek: 1 });

Date + time

Set time: true to add a 24h HH:MM row below the calendar. The output format becomes YYYY-MM-DDTHH:MM and the popover stays open after a day is picked so the time can still be adjusted:

Soma.datePicker('#reservation', {
  time: true,
  timeStep: 30,    // half-hour granularity (default is 15)
});

Both YYYY-MM-DD and YYYY-MM-DDTHH:MM parse as input values, so a date-only pre-fill works and the time defaults to midnight. The ISO input format is a data contract, not a display string.

Date range

Pair two date inputs so picking the start automatically constrains the end, and vice versa:

<input class="soma-input" id="trip-start" type="text" placeholder="Start" />
<input class="soma-input" id="trip-end"   type="text" placeholder="End" />

<script>
  const range = Soma.dateRange('#trip-start', '#trip-end');

  range.on('change', (e) => {
    console.log('range:', e.detail.start, '→', e.detail.end);
  });
</script>

Both inputs receive their own picker instance, accessible via range.start and range.end for per-side calls. Pass opts to apply the same configuration (locale, time, outer min/max) to both. Selections that would invert the window are prevented (the day renders disabled), never silently clamped.

Range presets

The classic enterprise range affordance: a rail of one-click ranges next to the calendar. Pass presets: true to a dateRange and the start picker's popover gains the built-in list at its inline-start (the calendar keeps its usual size; under RTL the rail mirrors for free):

Soma.dateRange('#report-start', '#report-end', { presets: true });

Built-ins: Today, Last 7 days, Last 30 days (both inclusive of today), This month and Last month (calendar-month bounds). Their dates are computed when the preset is clicked, never at construction — a dashboard tab left open overnight still fills in the right dates. Labels follow the active locale via Soma.i18n; the Today preset reuses the picker's own Today string.

Clicking a preset sets both inputs (ISO), fires each picker's normal soma-date-picker-change plus a single soma-date-range-change with both sides final, closes the popover, and returns focus to the start input. Existing cross-constraints are re-established from the new values, so a preset can never be rejected by the previous window.

Need different ranges? Pass an array instead of true; start/end take a Date or an ISO string:

Soma.dateRange('#report-start', '#report-end', {
  presets: [
    { label: 'Q1 2026', start: '2026-01-01', end: '2026-03-31' },
    { label: 'Q2 2026', start: '2026-04-01', end: '2026-06-30' },
    { label: 'Fiscal year', start: new Date(2025, 3, 1), end: new Date(2026, 2, 31) },
  ],
});

The rail renders only when the option is set; a plain dateRange popover is unchanged. Preset buttons are ordinary tab-reachable buttons: Tab into the rail, Enter applies, and Esc still closes the popover from anywhere inside it.

The live examples above, exactly as this page wires them:

Soma.datePicker('#dp-sunday', { firstDayOfWeek: 0 });
Soma.datePicker('#dp-listen').on('change', (e) => {
  document.getElementById('dp-listen-out').textContent = e.detail.value;
});
Soma.datePicker('#dp-bounded', { min: '2026-01-01', max: '2026-12-31' });
Soma.datePicker('#dp-locale', { locale: 'fr-FR' });
Soma.datePicker('#dp-time', { time: true, timeStep: 15 });
Soma.dateRange('#dp-start', '#dp-end');
Soma.dateRange('#dp-preset-start', '#dp-preset-end', { presets: true });