Date Picker
Localized calendar popover with full keyboard grid navigation and an ISO hidden form value.
Preview
<div class="w-full max-w-xs">
<x-ui.date-picker name="due_date" :placeholder="__('Pick a date')" />
</div>
Installation
php artisan ui:add date-picker
Note
This component ships an Alpine behavior module at
resources/js/ui/date-picker.js. Import it once from your bundle so it registers on alpine:init:
import './date-picker.js';
Registry contract
php artisan ui:add date-picker
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/date-picker.blade.php -
resources/js/ui/date-picker.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Date Picker (`date-picker`)
Localized calendar popover with full keyboard grid navigation and an ISO hidden form value.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add date-picker
```
## Usage
```blade
<div class="w-full max-w-xs">
<x-ui.date-picker name="due_date" :placeholder="__('Pick a date')" />
</div>
```
## Props
- `name` (mixed|null, default `null`) — Hidden input name; inherited from a wrapping field via @aware if omitted.
- `value` (string, default ``) — Selected ISO date.
- `placeholder` (string|null, default `null`) — Field text shown with no date selected. Null shows "Pick a date", or in typed mode the locale's numeric pattern (dd-mm-yyyy, mm/dd/yyyy).
- `locale` (mixed|null, default `null`) — Intl locale override.
- `firstDay` (int, default `1`) — First weekday shown in the calendar (0 = Sunday).
- `display` (string|null, default `null`) — How the date reads in the field: an Intl style (short, medium, long, full), numeric (the locale's digits with a 4-digit year) or a token pattern (e.g. dd/MM/yyyy). Null means medium, or numeric in typed mode.
- `typed` (bool, default `false`) — Makes the field a text input that accepts a typed date, with a calendar button beside it. The text parses on Enter or blur through the date-picker-parse hook, then the built-in parser (ISO, or day, month and year in the locale's order). The input is described by its accepted formats, and while typing a polite live line reads the date the text will become, through the same hook with detail.preview true; unparseable text is flagged on Enter or blur only.
- `min` (mixed|null, default `null`) — ISO date lower bound; also applied to a typed date.
- `max` (mixed|null, default `null`) — ISO date upper bound; also applied to a typed date.
- `disabled` (array, default `[]`) — ISO dates and/or weekday numbers to disable, passed to the calendar and applied to a typed date.
- `caption` (string, default `label`) — label or dropdown caption style, passed to the calendar.
- `showToday` (bool, default `false`) — Shows a Today link in the calendar.
- `clearable` (bool, default `false`) — Shows a clear control in the field once a date is chosen.
- `size` (sm|md|lg, default `md`) — Field height (the control-height tokens, 32 / 40 / 48px), inline padding and, in typed mode, the calendar button width; default is an alias for md. Use sm in a dense toolbar or filter row.
- `label` (string|null, default `null`) — Server-formatted text for value (e.g. Carbon isoFormat). Shown in the server HTML and while the value is the one it was rendered for; another date (a new pick, old() input) is formatted with Intl through display. A Livewire re-render with a new label updates the field.
## Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Collecting a single date in a form through a compact text field that opens a calendar popover.
- Needing the chosen date to post as a normalized ISO value regardless of the display format shown.
- Letting people type a date (18-09-2026, 2026-09-18) with the calendar one button away: set `typed`.
## Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Needing a start/end range; use date-range-picker instead.
- Needing the month grid inline with no field or popover; use calendar directly instead.
## Anti-patterns
- Forcing a picker when typing is faster
## Rules
- Use the `<brok:date-picker>` tag (or `<x-ui.date-picker>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/date-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/date-picker.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- `display` shapes what the field shows; the posted value stays ISO. --}}
<div class="grid w-full max-w-md gap-4">
<x-ui.field name="d1" label="Intl long">
<x-ui.date-picker name="d1" value="2026-09-18" display="long" />
</x-ui.field>
<x-ui.field name="d2" label="Pattern dd/MM/yyyy">
<x-ui.date-picker name="d2" value="2026-09-18" display="dd/MM/yyyy" />
</x-ui.field>
<x-ui.field name="d3" label="Pattern EEE d MMM yyyy, clearable, weekdays only">
<x-ui.date-picker name="d3" value="2026-09-18" display="EEE d MMM yyyy" clearable :disabled="[0, 6]" caption="dropdown" show-today />
</x-ui.field>
</div>
In Dialog
{{-- A date field at the foot of a dialog: with no room below, the calendar opens above the field and stays inside the dialog on a phone. --}}
<x-ui.dialog>
<x-ui.dialog.trigger class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">
{{ __('Log time') }}
</x-ui.dialog.trigger>
<x-ui.dialog.content size="sm">
<x-ui.dialog.close />
<x-ui.dialog.header>
<x-ui.dialog.title>{{ __('Log time') }}</x-ui.dialog.title>
<x-ui.dialog.description>{{ __('Record the hours for one day of the Website relaunch project.') }}</x-ui.dialog.description>
</x-ui.dialog.header>
<div class="mt-4 flex flex-col gap-4">
<x-ui.field name="hours" :label="__('Hours')">
<x-ui.input name="hours" value="2.5" inputmode="decimal" />
</x-ui.field>
<x-ui.field name="worked_on" :label="__('Date')">
<x-ui.date-picker name="worked_on" value="2026-09-28" :placeholder="__('Pick a date')" />
</x-ui.field>
</div>
</x-ui.dialog.content>
</x-ui.dialog>
{{-- `size` follows the control heights (sm 32px, md 40px, lg 48px). `label`
is server-formatted text for `value`: it shows until another date is
picked, then the picker formats with Intl through `display`. --}}
<div class="grid w-full max-w-md gap-4">
<x-ui.field name="s1" label="Small, in a filter row">
<x-ui.date-picker name="s1" size="sm" value="2026-09-18" />
</x-ui.field>
<x-ui.field name="s2" label="Default, with a server label">
<x-ui.date-picker name="s2" value="2026-09-18" label="Fri 18 Sep" />
</x-ui.field>
<x-ui.field name="s3" label="Large, typed, clearable">
<x-ui.date-picker name="s3" size="lg" typed clearable value="2026-09-18" />
</x-ui.field>
</div>
{{-- `typed` makes the field a text input: type 18-09-2026 or 2026-09-18 and
press Enter, or open the calendar with the button or Alt+ArrowDown. The
posted value stays ISO. The second field hands parsing to the app through
the date-picker-parse hook: "today" and "tomorrow" resolve there, anything
else falls through to the built-in parser. --}}
<div class="grid w-full max-w-md gap-4">
<x-ui.field name="invoice_date" label="Invoice date" hint="Type a date or pick one from the calendar.">
<x-ui.date-picker name="invoice_date" typed value="2026-09-18" />
</x-ui.field>
<x-ui.field name="follow_up" label="Follow up on" hint="Weekdays from today on. Also accepts today and tomorrow.">
<x-ui.date-picker
name="follow_up"
typed
clearable
:min="now()->toDateString()"
:disabled="[0, 6]"
x-on:date-picker-parse="
const word = $event.detail.text.trim().toLowerCase();
const offset = { today: 0, tomorrow: 1 }[word];
if (offset !== undefined) { const d = new Date(); d.setDate(d.getDate() + offset); $event.detail.value = d; }
"
/>
</x-ui.field>
</div>
Typed Async
{{-- An app parser that answers later (a server round trip) hands the picker a
promise through $event.detail.respondWith(). This stand-in answers after
800 ms: "next friday" resolves, anything else falls through to the
built-in parser. While it waits the field is busy and keeps its last
date; press Save straight after typing and the submit waits for the
answer. With Livewire: $event.detail.respondWith($wire.parseDate($event.detail.text)). --}}
<form
class="grid w-full max-w-md gap-4"
x-data="{ saved: null }"
x-on:submit.prevent="saved = new FormData($el).get('due_date') || '(empty)'"
>
<x-ui.field name="due_date" label="Due date" hint="Type a date or next friday, then save.">
<x-ui.date-picker
name="due_date"
typed
value="2026-09-18"
x-on:date-picker-parse="
const text = $event.detail.text.trim().toLowerCase();
$event.detail.respondWith(new Promise((resolve) => setTimeout(() => {
if (text !== 'next friday') { resolve(undefined); return; }
const d = new Date();
d.setDate(d.getDate() + ((12 - d.getDay()) % 7 || 7));
resolve(d);
}, 800)));
"
/>
</x-ui.field>
<div class="flex items-center gap-4">
<x-ui.button type="submit" variant="outline">Save</x-ui.button>
<p class="text-sm text-muted-foreground" data-slot="saved" aria-live="polite" x-text="saved === null ? '' : 'Saved ' + saved"></p>
</div>
</form>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Hidden input name; inherited from a wrapping field via @aware if omitted. |
| value | string | Selected ISO date. | |
| placeholder | string | null | null | Field text shown with no date selected. Null shows "Pick a date", or in typed mode the locale's numeric pattern (dd-mm-yyyy, mm/dd/yyyy). |
| locale | mixed | null | null | Intl locale override. |
| firstDay | int | 1 | First weekday shown in the calendar (0 = Sunday). |
| display | string | null | null | How the date reads in the field: an Intl style (short, medium, long, full), numeric (the locale's digits with a 4-digit year) or a token pattern (e.g. dd/MM/yyyy). Null means medium, or numeric in typed mode. |
| typed | bool | false | Makes the field a text input that accepts a typed date, with a calendar button beside it. The text parses on Enter or blur through the date-picker-parse hook, then the built-in parser (ISO, or day, month and year in the locale's order). The input is described by its accepted formats, and while typing a polite live line reads the date the text will become, through the same hook with detail.preview true; unparseable text is flagged on Enter or blur only. |
| min | mixed | null | null | ISO date lower bound; also applied to a typed date. |
| max | mixed | null | null | ISO date upper bound; also applied to a typed date. |
| disabled | array | [] | ISO dates and/or weekday numbers to disable, passed to the calendar and applied to a typed date. |
| caption | string | label | label or dropdown caption style, passed to the calendar. |
| showToday | bool | false | Shows a Today link in the calendar. |
| clearable | bool | false | Shows a clear control in the field once a date is chosen. |
| size | sm | md | lg | md | Field height (the control-height tokens, 32 / 40 / 48px), inline padding and, in typed mode, the calendar button width; default is an alias for md. Use sm in a dense toolbar or filter row. |
| label | string | null | null | Server-formatted text for value (e.g. Carbon isoFormat). Shown in the server HTML and while the value is the one it was rendered for; another date (a new pick, old() input) is formatted with Intl through display. A Livewire re-render with a new label updates the field. |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- A button-styled field opens a calendar popover (chrome=false) below it; picking a day closes the popover, updates the field label in the display format and writes the ISO value to a hidden input.
- Owns the popover's open/close, Escape and click-outside handling and returns focus to the trigger on close.
- Opens above the field when the calendar does not fit below it and there is more room above, measured against the viewport or the nearest clipping ancestor such as a scrolling dialog body; data-side on the panel reports the side. It shifts sideways to stay inside that box and never grows wider than the viewport minus 2rem, so it fits inside a dialog on a phone. It re-places on resize and scroll while open.
- Escape is consumed only while the calendar is open; a closed picker lets Escape reach a surrounding dialog or sheet.
- Typed mode (`typed`): the field is a text input plus a calendar button. Enter or blur parses the text; Alt+ArrowDown or the button opens the calendar, and picking a day writes the formatted date back into the input and returns focus to it. The hidden form value stays ISO yyyy-mm-dd.
- Typed mode parses with the built-in parser: ISO yyyy-mm-dd, or day, month and year in the locale's numeric order separated by - / . or spaces (18-09-2026 in nl, 9/18/2026 in en-US; a 2-digit year means 20yy; 8 digits in the same order also work).
- Parser hook: before the built-in parser runs, the root dispatches the cancelable, bubbling `date-picker-parse` event with detail { text, locale, value }. A listener that sets `detail.value` to an ISO date (or a Date; '' clears) decides the result; one that calls preventDefault() without a value rejects the text. Example: `<x-ui.date-picker typed @date-picker-parse="$event.detail.value = myParse($event.detail.text)" />`.
- Async parser: a listener that answers later (a server round trip) calls `$event.detail.respondWith(promise)`. The promise resolves to an ISO date or Date, '' (clear), null (reject) or undefined (built-in parser); a rejected promise also falls back to the built-in parser. While it is pending the root has aria-busy="true" and data-pending on the field, the form value keeps the last committed date, and a newer commit, a calendar pick, a clear or an inbound model value supersedes it. Livewire: `@date-picker-parse="$event.detail.respondWith($wire.parseDate($event.detail.text))"`.
- A submit of the surrounding form made while a parse is pending (typing, then pressing Save within one round trip) is held and replayed with the same submitter once the parse settles, so wire:submit and native submits see the parsed date. `date-picker-settled` ({ value, invalid }) bubbles from the root when a pending parse settles; `await window.uiDatePickerSettled(scope)` (or the Alpine `settled()` method) waits for every pending picker in scope, for a wire:click action outside a form.
- Typed text that does not parse, or a date outside min/max/disabled, marks the field invalid, keeps the text as typed, shows a message and empties the form value, so the form never posts a date the field does not show.
- Server label: with `label` the root carries data-label and data-label-value. While the value equals data-label-value the field shows that text (also before Alpine boots); any other value is formatted with Intl through `display`. A change to either attribute after a Livewire morph refreshes the field, but not over text being typed.
- Binds wire:model (Livewire) or x-model (Alpine) to the ISO date through x-modelable; an inbound date updates the label and the calendar.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Choose a date, time, or bounded contextual value.
Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Collecting a single date in a form through a compact text field that opens a calendar popover.
- Needing the chosen date to post as a normalized ISO value regardless of the display format shown.
- Letting people type a date (18-09-2026, 2026-09-18) with the calendar one button away: set `typed`.
Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Needing a start/end range; use date-range-picker instead.
- Needing the month grid inline with no field or popover; use calendar directly instead.
Use instead
- Direct typed entry with validation
Anti-patterns
- Forcing a picker when typing is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape
- Focus
managed
- The trigger exposes aria-haspopup="dialog" and aria-expanded; when wrapped by a field it self-wires aria-invalid/aria-describedby off the shared validation error bag.
- Typed mode: a wrapping field labels the text input (id = the field name). The calendar button is a named, tabbable button ("Choose date") with aria-haspopup="dialog" and aria-expanded. An invalid typed date sets aria-invalid on the input and points aria-describedby at a role="alert" message.
- Typed mode shows exactly one focus ring: the field draws it while the text input has focus, and the calendar button draws its own inset ring (the ring token, in light and dark) when it has keyboard focus, so the focused part is never ambiguous.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="date-picker-{{ $record->id }}">
<div class="w-full max-w-xs">
<x-ui.date-picker name="due_date" :placeholder="__('Pick a date')" />
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:date-picker
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
composite self-wires old()/aria-invalid/aria-describedby off the shared
$errors bag. Inert with no field ancestor and no own `name` —
fully backward-compatible. --}}
@aware([
'name' => null,
])
{{--
Props:
- display: how the chosen date reads in the field — an Intl style (short,
medium, long, full) or a pattern (d, dd, M, MM, MMM, MMMM, yy, yyyy, EEE,
EEEE; other characters print as is, e.g. "dd/MM/yyyy" or "EEE d MMM yyyy").
numeric prints the locale's day, month and 4-digit year in digits
(18-09-2026, 9/18/2026). The hidden value stays ISO yyyy-mm-dd whatever
the display. Default: medium, or numeric in typed mode.
- typed: the field is a text input you can type a date into, with a
calendar button beside it. The text parses on Enter or blur: ISO
yyyy-mm-dd, or day, month and year in the locale's order with any of
- / . or space between them (a 2-digit year means 20yy). Alt+ArrowDown
or the button opens the calendar. An app parser takes over through the
cancelable, bubbling `date-picker-parse` event: set
`$event.detail.value` to an ISO date ('' clears), or call
preventDefault() to reject the text. A parser that answers later (a
server round trip) calls `$event.detail.respondWith(promise)` instead:
the promise resolves to an ISO date, a Date, '' (clears), null (rejects)
or undefined (built-in parser). While it is pending the root carries
aria-busy="true", the form value keeps the last committed date, a
surrounding form's submit waits for it, and `date-picker-settled`
fires when it settles. `await window.uiDatePickerSettled(scope)` waits
for every pending picker in scope. Text that does not parse, or a date
outside min/max/disabled, marks the field invalid, keeps the text and
empties the form value. The input is described by the accepted formats,
and while the user types a polite live line below it reads the date the
text will become ("Friday 18 September 2026"), through the same parse
path with `detail.preview` true; it stays empty for empty or unparseable
text, which is flagged on Enter or blur only.
- min / max / disabled / caption / show-today: passed to the calendar
(and, in typed mode, applied to a typed date).
- clearable: a clear control in the field once a date is chosen.
- size: sm, md (default; `default` is an alias) or lg. Sets the field
height from the control-height tokens (32 / 40 / 48px), its inline
padding and the typed-mode calendar button width.
- label: server-formatted text for `value` (e.g. Carbon's
isoFormat('ddd D MMM')). The field shows it while the value is the one
it was rendered for and in the server HTML before Alpine boots; any
other date (a new pick, old() input) is formatted with Intl through
`display`. A Livewire re-render with a new label updates the field.
--}}
@props([
'name' => null,
'value' => '',
'placeholder' => null,
'locale' => null,
'firstDay' => 1,
'display' => null,
'typed' => false,
'min' => null,
'max' => null,
'disabled' => [],
'caption' => 'label',
'showToday' => false,
'clearable' => false,
'size' => 'md',
'label' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$trigger = $styles['field-trigger'];
$size = $styles['normalizeVariant']($size);
$size = $size === 'default' ? 'md' : $size;
$size = array_key_exists($size, $trigger['sizes']) ? $size : 'md';
// Typed mode draws its own field around a text input and a calendar
// button; these follow the same control-height and padding tokens as
// the field-trigger sizes. The clear control sits just inside the icon
// or button at the end of the field.
$typedSizes = [
'sm' => ['field' => 'h-control-h-sm', 'input' => 'ps-control-x-sm text-sm', 'button' => 'w-8', 'clear' => 'end-8'],
'md' => ['field' => 'h-control-h-md', 'input' => 'ps-control-x-md text-sm', 'button' => 'w-9', 'clear' => 'end-9'],
'lg' => ['field' => 'h-control-h-lg', 'input' => 'ps-control-x-lg text-base', 'button' => 'w-11', 'clear' => 'end-11'],
][$size];
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// old() repopulates the selected date after a failed redirect-back.
$resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
$typed = (bool) $typed;
$display = (string) ($display ?? ($typed ? 'numeric' : 'medium'));
$placeholder ??= $typed ? null : __('Pick a date');
// A server label belongs to the value it was rendered for; old() input
// after a failed redirect is another date and is formatted client-side.
$serverLabel = filled($label) && filled($value) ? (string) $label : null;
$serverLabelValue = $serverLabel !== null ? (string) $value : null;
$initialLabel = $serverLabel !== null && (string) $resolvedValue === $serverLabelValue ? $serverLabel : null;
@endphp
{{--
Date Picker (spec §17 + §19). A text field that opens a calendar popover.
Phase 15: the month grid is now the standalone `<x-ui.calendar>` component,
composed here with `:chrome="false"`. Date Picker owns the popover (open/
close, Escape, click-outside, focus return) and the form value; it listens
for the calendar's `change` event to update its label and hidden input. The
public API (`name`, `value`, `placeholder`, `locale`, `firstDay`) and the
`<x-ui.date-picker>` registry name are unchanged.
--}}
<div
x-data="uiDatePicker({ value: @js($resolvedValue), locale: @js($locale), display: @js($display), typed: @js($typed), min: @js($min), max: @js($max), disabled: @js(array_values((array) $disabled)), messages: { invalid: @js(__('Enter a date such as :example.')), unavailable: @js(__('That date is not available. Choose another date.')), formats: @js(__('Type a date as :format or :iso.')), format: @js(__('Type a date as :format.')) } })"
{{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
the same x-model contract) attach to the root. Inert without either. --}}
x-modelable="model"
data-slot="date-picker"
data-size="{{ $size }}"
@if ($serverLabel !== null) data-label="{{ $serverLabel }}" data-label-value="{{ $serverLabelValue }}" @endif
@if ($hasBagError) data-invalid="true" @endif
@if ($typed) x-id="['date-picker-message', 'date-picker-format', 'date-picker-preview']" x-bind:data-invalid="typedInvalid || @js($hasBagError) ? 'true' : null" x-bind:aria-busy="pending ? 'true' : null" @endif
{{-- Escape is consumed only while the calendar is open, so a closed
picker inside a dialog lets Escape close the dialog. --}}
@keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
@click.outside="close()"
{{-- Only the calendar's change event carries a detail; the typed
input's native change bubbles here too and is ignored. --}}
@change="$event.detail && onSelect($event.detail.value)"
{{ $attributes->except(['name'])->merge(['class' => 'relative w-full']) }}
>
@if (filled($fieldName))
<input type="hidden" name="{{ $fieldName }}" :value="value" />
@endif
@if ($typed)
{{-- Typed mode: a text field plus a calendar button. The input posts
nothing; the hidden input above carries the ISO value. --}}
<div
data-slot="date-picker-field"
x-ref="anchor"
:data-invalid="typedInvalid ? 'true' : null"
x-bind:data-pending="pending ? 'true' : null"
{{-- One focus indicator at a time: the field draws the ring while
its text input has focus; the calendar button draws its own
inset ring, so a focused button never shows two. --}}
class="flex w-full min-w-0 items-center rounded-md border bg-background text-foreground shadow-xs transition-colors ring-offset-background has-[input:focus]:ring-[length:var(--ring-width)] has-[input:focus]:ring-offset-[length:var(--ring-offset-width)] data-[pending=true]:cursor-progress motion-reduce:transition-none {{ $typedSizes['field'] }} {{ $hasBagError ? 'border-destructive ring-destructive' : 'border-input ring-ring' }} data-[invalid=true]:border-destructive data-[invalid=true]:ring-destructive"
>
<input
type="text"
autocomplete="off"
spellcheck="false"
data-slot="date-picker-input"
x-ref="input"
x-model="typed"
@if ($initialLabel !== null) value="{{ $initialLabel }}" @endif
@if (filled($awareName)) id="{{ $fieldName }}" @endif
@if (filled($placeholder)) placeholder="{{ $placeholder }}" @else x-bind:placeholder="hint" @endif
@if ($hasBagError) aria-invalid="true" @endif
x-bind:aria-invalid="typedInvalid || @js($hasBagError) ? 'true' : null"
x-bind:aria-describedby="[@js($describedBy), $id('date-picker-format'), typedInvalid ? $id('date-picker-message') : null].filter(Boolean).join(' ')"
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@input="updatePreview()"
@keydown.enter.prevent="commitTyped()"
@keydown.alt.down.prevent="commitTyped(); show()"
@blur="commitTyped()"
class="h-full min-w-0 flex-1 bg-transparent {{ $typedSizes['input'] }} text-foreground outline-none placeholder:text-muted-foreground"
/>
<button
type="button"
data-slot="date-picker-trigger"
x-ref="trigger"
aria-label="{{ __('Choose date') }}"
aria-haspopup="dialog"
:aria-expanded="open"
@click="toggle()"
class="inline-flex h-full {{ $typedSizes['button'] }} shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18M8 2v4M16 2v4" />
</svg>
</button>
</div>
{{-- The accepted formats, read with the field (aria-describedby). --}}
<span
data-slot="date-picker-format"
x-bind:id="$id('date-picker-format')"
x-text="formatHint"
class="sr-only"
></span>
{{-- A polite, live reading of the text while it is typed: the date it
will become, in full. Empty while the field is empty, unchanged
or not (yet) a date; an error shows only on Enter or blur. The
region stays in the tree so the first reading is announced. --}}
<p
data-slot="date-picker-preview"
x-bind:id="$id('date-picker-preview')"
x-text="preview"
aria-live="polite"
aria-atomic="true"
class="mt-field min-w-0 break-words text-sm text-muted-foreground empty:mt-0"
></p>
<p
data-slot="date-picker-message"
x-bind:id="$id('date-picker-message')"
x-show="typedInvalid"
x-cloak
x-text="message"
role="alert"
class="mt-field min-w-0 break-words text-sm font-medium text-destructive"
></p>
@else
<button
type="button"
data-slot="date-picker-trigger"
x-ref="trigger"
aria-haspopup="dialog"
:aria-expanded="open"
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@click="toggle()"
class="{{ $trigger['base'] }} {{ $trigger['sizes'][$size] }} {{ $trigger['states'][$hasBagError ? 'invalid' : 'default'] }}"
>
<span class="truncate" :class="label ? 'text-foreground' : 'text-muted-foreground'" x-text="label || @js((string) $placeholder)">{{ $initialLabel ?? (filled($resolvedValue) ? '' : $placeholder) }}</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground">
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18M8 2v4M16 2v4" />
</svg>
</button>
@endif
@if ($clearable)
<button
type="button"
data-slot="date-picker-clear"
x-show="value"
x-cloak
@click.stop="clear()"
aria-label="{{ __('Clear date') }}"
class="absolute {{ $typedSizes['clear'] }} top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
@endif
<div
data-slot="date-picker-content"
x-ref="panel"
:data-side="side"
role="dialog"
aria-modal="false"
aria-label="{{ __('Choose date') }}"
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
:class="side === 'top' ? 'bottom-full mb-1' : 'top-full mt-1'"
class="absolute start-0 z-dropdown w-72 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md motion-reduce:transition-none"
>
<x-ui.calendar
:value="$resolvedValue"
:locale="$locale"
:first-day="$firstDay"
:min="$min"
:max="$max"
:disabled="$disabled"
:caption="$caption"
:show-today="$showToday"
:chrome="false"
/>
</div>
</div>
/**
* Date Picker behavior (spec §17 + §19).
*
* Phase 15: the month-grid keyboard navigation now lives in the standalone
* `uiCalendar` module. This controller only owns the popover — open/close,
* Escape, click-outside and focus return — plus the displayed label and the
* form value. It listens for the composed calendar's `change` event (handled in
* markup via `@change="onSelect($event.detail.value)"`).
*
* Typed mode (`typed` prop) adds a text input: `commitTyped()` parses the text
* on Enter or blur. It first dispatches the cancelable, bubbling
* `date-picker-parse` event ({ text, locale, value: undefined }); a listener
* that sets `detail.value` to an ISO date ('' clears) decides the result, and
* one that calls preventDefault() without a value rejects the text. Otherwise
* `parseDate()` reads ISO yyyy-mm-dd or day, month and year in the locale's
* order.
*
* An asynchronous parser (a server round trip) calls
* `detail.respondWith(promise)`. Until the promise settles the picker is
* pending: `pending` is true, the root has aria-busy="true", the form value
* keeps the last committed date, and a submit of the surrounding form is held
* and replayed once it settles. The promise resolves to an ISO date, a Date,
* '' (clear), null (reject) or undefined (use the built-in parser); a rejected
* promise also falls back to the built-in parser. A newer commit supersedes an
* older pending one. `date-picker-settled` ({ value, invalid }) bubbles from
* the root when a pending parse settles, and `settled()` (or
* `window.uiDatePickerSettled(scope)` for every picker in scope) returns a
* promise for the committed value.
*
* Server label (`label` prop): the root carries `data-label` and
* `data-label-value`. While the value equals `data-label-value`, the field
* shows the server text instead of the Intl format; any other value is
* formatted with Intl. A MutationObserver picks up a new server label after
* a Livewire morph and refreshes the field.
*
* While the user types, `updatePreview()` runs the same parse (with
* `detail.preview` true) after a 200ms pause and shows the resulting date in
* full in a polite live region. It never marks the field invalid or pending.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
/**
* Format an ISO date for display. `display` is an Intl style (`short`, `medium`,
* `long`, `full`) or a pattern of tokens: d, dd, M, MM, MMM, MMMM, yy, yyyy,
* EEE, EEEE (any other character is printed as is, e.g. "dd/MM/yyyy").
*/
function formatIsoDate(iso, display, locale) {
if (!iso) return '';
const [y, m, d] = iso.split('-').map(Number);
const date = new Date(y, m - 1, d);
if (display === 'numeric') {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
}
if (['short', 'medium', 'long', 'full'].includes(display)) {
return new Intl.DateTimeFormat(locale, { dateStyle: display }).format(date);
}
const part = (options) => new Intl.DateTimeFormat(locale, options).format(date);
const tokens = {
yyyy: () => String(y),
yy: () => String(y).slice(-2),
MMMM: () => part({ month: 'long' }),
MMM: () => part({ month: 'short' }),
MM: () => String(m).padStart(2, '0'),
M: () => String(m),
dd: () => String(d).padStart(2, '0'),
d: () => String(d),
EEEE: () => part({ weekday: 'long' }),
EEE: () => part({ weekday: 'short' }),
};
return display.replace(/yyyy|yy|MMMM|MMM|MM|M|dd|d|EEEE|EEE/g, (token) => tokens[token]());
}
/** The order of day, month and year in the locale's numeric date: e.g. ['day', 'month', 'year']. */
function numericOrder(locale) {
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
.formatToParts(new Date(2026, 8, 18))
.map((part) => part.type)
.filter((type) => ['day', 'month', 'year'].includes(type));
} catch {
return ['day', 'month', 'year'];
}
}
function pad(n) {
return String(n).padStart(2, '0');
}
/** ISO yyyy-mm-dd for a real calendar date, or null (31-02 is not one). */
function toIso(year, month, day) {
if (!(year >= 1 && year <= 9999 && month >= 1 && month <= 12 && day >= 1)) return null;
const date = new Date(year, month - 1, day);
date.setFullYear(year);
if (date.getMonth() !== month - 1 || date.getDate() !== day) return null;
return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`;
}
/**
* The built-in parser: '' for empty text, an ISO date, or null when the text
* is not a date. Reads ISO yyyy-mm-dd (also yyyy/mm/dd), day, month and year
* in the locale's order separated by - / . or spaces (a 2-digit year means
* 20yy), and 8 digits in the same order (18092026).
*/
function parseDate(text, locale) {
const value = String(text ?? '').trim();
if (value === '') return '';
let parts = value.split(/[\s./-]+/).filter(Boolean);
const order = numericOrder(locale);
if (parts.length === 1 && /^\d{8}$/.test(parts[0])) {
const digits = parts[0];
const widths = order.map((type) => (type === 'year' ? 4 : 2));
let offset = 0;
parts = widths.map((width) => {
const part = digits.slice(offset, offset + width);
offset += width;
return part;
});
}
if (parts.length !== 3 || !parts.every((part) => /^\d+$/.test(part))) return null;
// A leading 4-digit part is always a year-first (ISO) date.
const fields = parts[0].length === 4 ? ['year', 'month', 'day'] : order;
const read = {};
fields.forEach((type, i) => { read[type] = parts[i]; });
if (read.year.length !== 2 && read.year.length !== 4) return null;
if (read.day.length > 2 || read.month.length > 2) return null;
const year = read.year.length === 2 ? 2000 + Number(read.year) : Number(read.year);
return toIso(year, Number(read.month), Number(read.day));
}
/**
* A parser's answer as an ISO date, '' (clear), null (not a date) or
* undefined (no answer: fall through). A valid Date is read in local time.
*/
function readParserValue(value) {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : toIso(value.getFullYear(), value.getMonth() + 1, value.getDate());
}
if (typeof value === 'string') {
if (value === '') return '';
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
return match ? toIso(Number(match[1]), Number(match[2]), Number(match[3])) : null;
}
return undefined;
}
/** Resolves once every typed date picker inside `scope` has no pending parse. */
window.uiDatePickerSettled = function uiDatePickerSettled(scope = document) {
const roots = [...(scope ?? document).querySelectorAll('[data-slot="date-picker"]')];
if (scope instanceof Element && scope.matches('[data-slot="date-picker"]')) roots.unshift(scope);
return Promise.all(roots.map((root) => window.Alpine?.$data(root)?.settled?.()));
};
/**
* The box the panel must stay inside: the viewport, narrowed by the nearest
* ancestor that clips its overflow (a dialog body, a scrolling card).
*/
function clipBounds(el) {
const bounds = {
top: 0,
left: 0,
bottom: document.documentElement.clientHeight,
right: document.documentElement.clientWidth,
};
for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
const style = getComputedStyle(node);
if (/(auto|scroll|hidden|clip)/.test(style.overflowX + style.overflowY)) {
const r = node.getBoundingClientRect();
bounds.top = Math.max(bounds.top, r.top);
bounds.left = Math.max(bounds.left, r.left);
bounds.bottom = Math.min(bounds.bottom, r.bottom);
bounds.right = Math.min(bounds.right, r.right);
break;
}
}
return bounds;
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiDatePicker', (config = {}) => ({
open: false,
value: config.value || '',
label: '',
locale: config.locale || document.documentElement.lang || 'en',
display: config.display || 'medium',
typedMode: Boolean(config.typed),
min: config.min || '',
max: config.max || '',
disabledDates: (config.disabled ?? []).filter((d) => typeof d === 'string'),
disabledWeekdays: (config.disabled ?? []).filter((d) => typeof d === 'number'),
messages: config.messages || {},
// Typed mode: the text in the field, whether it failed to parse or
// is not an available date, and the message that says so.
typed: '',
typedInvalid: false,
message: '',
// Typed mode with an async parser: the text being parsed, a counter
// that lets a newer commit supersede an older one, and the callers
// waiting in settled().
pending: false,
pendingText: null,
parseSeq: 0,
waiters: [],
holdSubmit: null,
// Typed mode: the live reading of the text being typed, and the
// counter and timer that keep only the newest reading.
preview: '',
previewSeq: 0,
previewTimer: null,
// Which side of the field the calendar opens on: below unless it
// would not fit there and there is more room above.
side: 'bottom',
stopTracking: null,
labelObserver: null,
init() {
this.label = this.format(this.value);
this.typed = this.label;
// A server label (data-label for data-label-value) can change on a
// Livewire morph; refresh the shown text when it does.
this.labelObserver = new MutationObserver(() => this.refreshServerLabel());
this.labelObserver.observe(this.$root, { attributes: true, attributeFilter: ['data-label', 'data-label-value'] });
if (this.typedMode) {
// Capture on the document runs before the form's own submit
// handlers (wire:submit included), so a submit made while a
// parse is pending is held and replayed once it settles.
this.holdSubmit = (event) => {
const form = event.target;
if (!this.pending || !(form instanceof HTMLFormElement) || !form.contains(this.$root)) return;
event.preventDefault();
event.stopImmediatePropagation();
const submitter = event.submitter;
this.settled().then(() => setTimeout(() => {
if (!form.isConnected) return;
form.requestSubmit(submitter?.isConnected && submitter.form === form ? submitter : undefined);
}));
};
document.addEventListener('submit', this.holdSubmit, true);
}
},
/** A promise for the committed value once no parse is pending. */
settled() {
if (!this.pending) return Promise.resolve(this.value);
return new Promise((resolve) => this.waiters.push(resolve));
},
/** End the pending state and wake whoever waits for it. */
settle() {
const wasPending = this.pending;
this.pending = false;
this.pendingText = null;
const waiters = this.waiters.splice(0);
waiters.forEach((resolve) => resolve(this.value));
if (wasPending) {
this.$root.dispatchEvent(new CustomEvent('date-picker-settled', {
detail: { value: this.value, invalid: this.typedInvalid },
bubbles: true,
}));
}
},
/** A calendar pick, a clear or an inbound model value wins over a pending parse. */
cancelPending() {
this.parseSeq++;
if (this.pending) this.settle();
},
/** Placeholder for typed mode: the locale's numeric order, e.g. dd-mm-yyyy. */
get hint() {
const sample = formatIsoDate('2026-09-18', 'numeric', this.locale);
return sample.replace('2026', 'yyyy').replace('09', 'mm').replace('18', 'dd');
},
/** The accepted formats, for the input's description. */
get formatHint() {
const iso = 'yyyy-mm-dd';
const local = this.hint;
const template = local === iso ? this.messages.format : this.messages.formats;
return String(template || '').replace(':format', local).replace(':iso', iso);
},
/**
* Typed mode, on input: read the text through the same parse path as
* a commit (the `date-picker-parse` event with `detail.preview` true,
* `respondWith()` included) after a short pause, and show the date it
* would become. Empty, unchanged or unparseable text shows nothing:
* the error waits for Enter or blur.
*/
updatePreview() {
if (!this.typedMode) return;
this.cancelPreview();
const seq = this.previewSeq;
this.previewTimer = setTimeout(() => {
const text = this.typed;
if (String(text ?? '').trim() === '' || (text === this.label && !this.typedInvalid)) return;
const show = (result) => {
if (seq !== this.previewSeq) return;
this.preview = result && !this.isUnavailable(result) ? formatIsoDate(result, 'full', this.locale) : '';
};
const parsed = this.parseTyped(text, true);
if (parsed instanceof Promise) parsed.then(show);
else show(parsed);
}, 200);
},
/** Drop the live reading and any reading still on its way. */
cancelPreview() {
clearTimeout(this.previewTimer);
this.previewTimer = null;
this.previewSeq++;
this.preview = '';
},
isUnavailable(iso) {
if (this.min && iso < this.min) return true;
if (this.max && iso > this.max) return true;
if (this.disabledDates.includes(iso)) return true;
const [y, m, d] = iso.split('-').map(Number);
return this.disabledWeekdays.includes(new Date(y, m - 1, d).getDay());
},
/**
* Parse text through the `date-picker-parse` hook, then the built-in
* parser. Returns an ISO date, '' (empty), null (not a date), or a
* promise of one when a listener called `detail.respondWith()`.
*/
parseTyped(text, preview = false) {
let answer = null;
const detail = {
text,
locale: this.locale,
// True for the live reading while typing; a parser may skip
// costly work then and answer undefined (built-in parser).
preview,
value: undefined,
respondWith: (promise) => { answer = Promise.resolve(promise); },
};
const event = new CustomEvent('date-picker-parse', { detail, bubbles: true, cancelable: true });
this.$root.dispatchEvent(event);
if (answer) {
return answer.then(
(value) => {
if (value === null || value === false) return null;
const read = readParserValue(value);
return read === undefined ? parseDate(text, this.locale) : read;
},
() => parseDate(text, this.locale),
);
}
const read = readParserValue(detail.value);
if (read !== undefined) return read;
if (event.defaultPrevented) return null;
return parseDate(text, this.locale);
},
/** Enter, blur or Alt+ArrowDown on the typed field: adopt the date or flag the text. */
commitTyped() {
if (!this.typedMode) return;
this.cancelPreview();
const text = this.typed;
// The text already being parsed waits for that answer.
if (this.pending && text === this.pendingText) return;
const seq = ++this.parseSeq;
// The shown label (in any display) needs no parse.
if (text === this.label && !this.typedInvalid) {
this.settle();
return;
}
const parsed = this.parseTyped(text);
if (parsed instanceof Promise) {
this.pending = true;
this.pendingText = text;
parsed.then((result) => {
if (seq !== this.parseSeq) return;
this.applyParsed(result, text);
this.settle();
});
return;
}
this.applyParsed(parsed, text);
this.settle();
},
/** Adopt a parse result for `text`, or flag the text as not a date. */
applyParsed(parsed, text) {
if (parsed === null || (parsed !== '' && this.isUnavailable(parsed))) {
const example = formatIsoDate(`${new Date().getFullYear()}-12-31`, this.display, this.locale);
this.message = parsed === null
? String(this.messages.invalid || '').replace(':example', example)
: String(this.messages.unavailable || '');
this.typedInvalid = true;
// The form never posts a date the field does not show.
this.value = '';
this.label = '';
return;
}
this.typedInvalid = false;
this.message = '';
this.value = parsed;
this.label = this.format(parsed);
// Text typed since the parse started stays for the next commit.
if (this.typed === text) this.typed = this.label;
this.syncCalendar(parsed);
},
/** Hand the composed calendar a date so its selection and view follow. */
syncCalendar(iso) {
const calendar = this.$root.querySelector('[data-slot="calendar"]');
const data = calendar && window.Alpine ? window.Alpine.$data(calendar) : null;
if (data && 'model' in data) data.model = iso;
},
/** Where focus returns: the typed input, or the trigger button. */
focusField() {
(this.$refs.input || this.$refs.trigger)?.focus();
},
destroy() {
this.labelObserver?.disconnect();
this.untrack();
clearTimeout(this.previewTimer);
if (this.holdSubmit) document.removeEventListener('submit', this.holdSubmit, true);
},
/**
* Measure the field, the panel and the box it must stay inside, then
* pick the side and shift the panel sideways so it stays inside a
* dialog or the viewport on a narrow phone.
*/
place() {
const panel = this.$refs.panel;
const trigger = this.$refs.anchor || this.$refs.trigger;
if (!panel || !trigger || !this.open) return;
const margin = 8;
const gap = 4;
const bounds = clipBounds(this.$root);
const field = trigger.getBoundingClientRect();
const below = bounds.bottom - field.bottom;
const above = field.top - bounds.top;
this.side = panel.offsetHeight + gap + margin > below && above > below ? 'top' : 'bottom';
// Layout box, not the bounding rect: the enter transition scales it.
panel.style.translate = '';
const left = this.$root.getBoundingClientRect().left + panel.offsetLeft;
const right = left + panel.offsetWidth;
let shift = 0;
if (right > bounds.right - margin) shift = bounds.right - margin - right;
if (left + shift < bounds.left + margin) shift = bounds.left + margin - left;
panel.style.translate = shift === 0 ? '' : `${Math.round(shift)}px 0`;
},
track() {
this.untrack();
const handler = () => this.place();
window.addEventListener('resize', handler);
window.addEventListener('scroll', handler, { capture: true, passive: true });
this.stopTracking = () => {
window.removeEventListener('resize', handler);
window.removeEventListener('scroll', handler, { capture: true });
};
},
untrack() {
this.stopTracking?.();
this.stopTracking = null;
},
/**
* Modelable value (`x-modelable="model"` on the root): the ISO date or
* ''. An inbound value (Livewire, `x-model`) refreshes the label.
*/
get model() {
return this.value;
},
set model(v) {
const next = v == null ? '' : String(v);
if (next === this.value) return;
this.cancelPending();
this.cancelPreview();
this.value = next;
this.label = next === '' ? '' : this.format(next);
this.typed = this.label;
this.typedInvalid = false;
// The composed calendar keeps its own selection and view; hand it
// the new date through its modelable value so the grid follows.
this.syncCalendar(next);
},
/**
* The text for an ISO date: the server label when it was rendered for
* this date, otherwise the Intl/pattern format.
*/
format(iso) {
const { label, labelValue } = this.$root.dataset;
if (iso && label && labelValue === iso) return label;
return formatIsoDate(iso, this.display, this.locale);
},
/** Re-read the server label after the root's data-label changed. */
refreshServerLabel() {
if (!this.value || this.typedInvalid || this.pending) return;
const next = this.format(this.value);
if (next === this.label) return;
const editing = this.typedMode && this.$refs.input && document.activeElement === this.$refs.input && this.typed !== this.label;
this.label = next;
if (!editing) this.typed = next;
},
toggle() {
this.open ? this.close() : this.show();
},
show() {
this.open = true;
// Place the panel, then move focus into the calendar's roving date.
this.$nextTick(() => {
this.place();
this.track();
this.$root.querySelector('[data-slot="calendar"] [data-date][tabindex="0"]')?.focus({ preventScroll: true });
});
},
close() {
this.open = false;
this.untrack();
},
closeAndFocus() {
if (!this.open) {
return;
}
this.close();
this.$nextTick(() => this.focusField());
},
clear() {
this.cancelPending();
this.cancelPreview();
this.value = '';
this.label = '';
this.typed = '';
this.typedInvalid = false;
this.$nextTick(() => this.focusField());
},
/** Driven by the calendar's `change` event. */
onSelect(iso) {
if (!iso) {
return;
}
this.cancelPending();
this.cancelPreview();
this.value = iso;
this.label = this.format(iso);
this.typed = this.label;
this.typedInvalid = false;
this.close();
this.$nextTick(() => this.focusField());
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.9.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0