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
| Form | Use it for |
|---|---|
| Single date | Deadlines, launch dates. Typed ISO input is parsed too. |
time: true | Scheduling to the minute (maintenance start). |
min/max | Bounded choices — out-of-range days render disabled. |
Soma.dateRange | Windows: start caps the end's minimum and vice versa. |
presets | One-click range presets on a dateRange: Today, Last 7/30 days, This month, Last month, or your own list. |
Examples
Keyboard navigation
| Key | Action |
|---|---|
| ↓ in the input | Open (if needed) and move focus into the calendar grid. |
| ← / → | ±1 day. Horizontal arrows follow visual direction, mirrored under RTL. |
| ↑ / ↓ | ±1 week. |
| Home / End | Start / end of the focused week. |
| PageUp / PageDown | ±1 month; with Shift, ±1 year. |
| Enter / Space | Select the focused day. |
| Esc | Close 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
| Class | Effect |
|---|---|
.soma-date-picker-input | Marker on the text input; triggers auto-init binding. |
.soma-date-picker | Popover container (rendered by JS, not authored manually). |
.soma-date-picker-header | Top row: month nav + title. |
.soma-date-picker-prev / -next | Previous / next month buttons; auto-disabled when the whole neighbouring month is out of range. |
.soma-date-picker-title | The "July 2026" title between the nav buttons (a polite live region). |
.soma-date-picker-grid | The 6×7 day grid table. |
.soma-date-picker-day | A day cell; carries its date as data-date="YYYY-MM-DD". |
.soma-selected / .soma-today / .soma-other-month | State 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-time | Time row (only with time: true): the -hour and -minute selects. |
.soma-date-picker-footer | The Today / Clear buttons (localised via Soma.i18n). |
.soma-date-picker-presets / -preset | The 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
| Member | Description |
|---|---|
Soma.datePicker(input, opts?) | Get or create the singleton picker for an input element / selector. |
opts.firstDayOfWeek | 0 = Sunday, 1 = Monday. When not set, falls back to the first day of the effective locale (via Intl.Locale().getWeekInfo()), else Monday. |
opts.locale | BCP-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.max | Date or ISO string. Out-of-range days render disabled and ignore clicks; bounds are inclusive at day granularity. |
opts.time | true enables a 24h HH:MM row. Output format becomes YYYY-MM-DDTHH:MM and the popover stays open after a day is picked. |
opts.timeStep | Minute granularity for the time row. Default 15. |
Instance methods
| Member | Description |
|---|---|
.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
| Member | Description |
|---|---|
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: true | Renders 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 / .end | The 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 });