Add the M3 date pickers
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s

Docked, modal and modal input date pickers for single dates and ranges,
with locale month names, week start and typed format from Intl, min and
max, and the APG grid keyboard. Completes Phase 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:48:56 +02:00
co-authored by Claude Opus 5
parent b8239dd215
commit f4be9848e2
13 changed files with 2370 additions and 1 deletions
@@ -463,6 +463,27 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
### `<x-datepicker>`
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
```blade
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
```
| Prop | Default | |
|---|---|---|
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
| `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
### `<x-timepicker>`
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 1223 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
+508
View File
@@ -0,0 +1,508 @@
/*
* M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php).
*
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
* Both are 360px of surface-container-high at elevation 3.
*
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
* DateInput.kt; the docked picker's from material-web's md-comp-date-picker-docked tokens (v0_192).
* Apache-2.0.
*
* [data-datepicker] the root: the field, its support line, the dialog
* [data-datepicker-support] the hint, a server error, or what is wrong with the typed date
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
* [data-datepicker-surface]
* [data-datepicker-header] modal only: title, headline, switch
* [data-datepicker-calendar]
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
* or a month and a year stepper (docked, data-docked)
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
* data-today, data-selected, data-start, data-end,
* data-between, data-outside, data-blank, aria-disabled
* [data-datepicker-years] the modal year grid
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
* [data-datepicker-entry] the text field(s) of the modal input
* [data-datepicker-actions] Cancel and OK
*
* Every state layer is the content colour at 8% on hover (only where a pointer can hover) and 10% on
* focus and press; keyboard focus adds the package's 3px secondary ring.
*/
@layer components {
/* ---- The support line under the field ------------------------------------------------------ */
/* The padding is on the lines, not the box, so a box with nothing to say takes no room. */
[data-datepicker-support] {
--datepicker-pad: 1rem;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-datepicker-support] > * {
padding: 0.25rem var(--datepicker-pad) 0;
}
[data-datepicker-support][data-size="sm"] {
--datepicker-pad: 0.75rem;
}
[data-datepicker-support][data-size="xs"] {
--datepicker-pad: 0.625rem;
}
[data-datepicker-error] {
color: var(--md-sys-color-error);
}
/* ---- The dialog ---------------------------------------------------------------------------- */
[data-datepicker-picker] {
inset: auto;
width: 22.5rem;
max-width: calc(100vw - 1rem);
max-height: calc(100dvh - 1rem);
margin: 0;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
}
[data-datepicker-picker]:popover-open {
top: anchor(bottom);
left: anchor(left);
margin-block: 0.25rem;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 1;
transition-property: opacity;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@starting-style {
[data-datepicker-picker]:popover-open {
opacity: 0;
}
}
[data-datepicker-picker]:modal {
inset: 0;
margin: auto;
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
[data-datepicker-picker]:modal {
opacity: 0;
scale: 0.95;
}
}
[data-datepicker-picker]:modal::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-datepicker-surface] {
display: flex;
flex-direction: column;
max-height: inherit;
overflow-x: hidden;
overflow-y: auto;
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
outline: none;
}
[data-datepicker-picker][data-presentation="modal"] [data-datepicker-surface] {
border-radius: var(--md-sys-shape-corner-xl);
}
/* ---- The modal header ---------------------------------------------------------------------- */
[data-datepicker-header] {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: none;
min-height: 7.5rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-header][data-range] {
min-height: 8rem;
}
[data-datepicker-title] {
padding: 1rem 0.75rem 0 1.5rem;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
}
[data-datepicker-headline-row] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.75rem 0.75rem 1.5rem;
}
[data-datepicker-headline] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
}
[data-datepicker-header][data-range] [data-datepicker-headline] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
/* ---- Month and year navigation -------------------------------------------------------------- */
[data-datepicker-nav] {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding-inline: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-nav][data-docked] {
height: 4rem;
padding-inline: 0.25rem;
}
[data-datepicker-stepper],
[data-datepicker-arrows] {
display: flex;
align-items: center;
}
/* While a year or month list is open the arrows keep their place but are gone, as androidx hides them. */
[data-datepicker-arrows][data-concealed] {
visibility: hidden;
}
[data-datepicker-menu-button] {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding-inline: 0.75rem 0.5rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
white-space: nowrap;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-nav][data-docked] [data-datepicker-menu-button] {
padding-inline: 0.5rem 0.25rem;
gap: 0.25rem;
}
[data-datepicker-menu-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-menu-button][aria-expanded="true"] [data-datepicker-menu-arrow] {
rotate: 180deg;
}
/* ---- State layers shared by days, years, menu buttons and list rows ------------------------ */
[data-datepicker-menu-button]::before,
[data-datepicker-day] > span::before,
[data-datepicker-year]::before,
[data-datepicker-option]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--datepicker-layer, currentColor);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-datepicker-menu-button]:hover::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):hover > span::before,
[data-datepicker-year]:hover::before,
[data-datepicker-option]:not([aria-disabled="true"]):hover::before {
opacity: 0.08;
}
}
[data-datepicker-menu-button]:focus-visible::before,
[data-datepicker-menu-button]:active::before,
[data-datepicker-day]:focus-visible > span::before,
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):active > span::before,
[data-datepicker-year]:focus-visible::before,
[data-datepicker-year]:active::before,
[data-datepicker-option]:focus-visible::before,
[data-datepicker-option]:not([aria-disabled="true"]):active::before {
opacity: 0.1;
}
[data-datepicker-menu-button]:focus-visible,
[data-datepicker-day]:focus-visible > span,
[data-datepicker-year]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-datepicker-option]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
/* ---- The days ----------------------------------------------------------------------------- */
[data-datepicker-grid] {
width: calc(100% - 1.5rem);
margin-inline: 0.75rem;
border-collapse: collapse;
table-layout: fixed;
}
[data-datepicker-grid] th {
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
}
[data-datepicker-day] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
height: 3rem;
padding: 0;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: center;
cursor: pointer;
outline: none;
isolation: isolate;
-webkit-tap-highlight-color: transparent;
}
[data-datepicker-day] > span {
position: relative;
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
margin: auto;
border-radius: var(--md-sys-shape-corner-full);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-datepicker-day][data-blank] {
cursor: default;
}
/* A docked picker shows the neighbouring months' days, quieter. */
[data-datepicker-day][data-outside] {
color: var(--md-sys-color-on-surface-variant);
}
[data-datepicker-day][data-today] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
}
[data-datepicker-day][data-today] > span {
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
/* A range's band: secondary-container behind the days between its ends, from the middle of each end. */
[data-datepicker-day][data-between]::before,
[data-datepicker-day][data-start]::before,
[data-datepicker-day][data-end]::before {
content: "";
position: absolute;
inset-block: 0.25rem;
inset-inline: 0;
z-index: -1;
background-color: var(--md-sys-color-secondary-container);
}
[data-datepicker-day][data-start]::before {
inset-inline-start: 50%;
}
[data-datepicker-day][data-end]::before {
inset-inline-end: 50%;
}
[data-datepicker-day][data-between] {
--datepicker-layer: var(--md-sys-color-on-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-datepicker-day][data-selected] {
--datepicker-layer: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary);
}
[data-datepicker-day][data-selected] > span {
background-color: var(--md-sys-color-primary);
box-shadow: none;
}
[data-datepicker-day][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-day][aria-disabled="true"][data-today] > span {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- The modal year grid ----------------------------------------------------------------- */
[data-datepicker-years] {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-content: start;
justify-items: center;
row-gap: 1rem;
height: 20.9375rem;
padding: 0.5rem 0.75rem;
overflow-y: auto;
overscroll-behavior: contain;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-year] {
--datepicker-layer: var(--md-sys-color-on-surface-variant);
position: relative;
width: 4.5rem;
height: 2.25rem;
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
outline: none;
isolation: isolate;
scroll-margin-block: 0.5rem;
}
[data-datepicker-year][data-current] {
--datepicker-layer: var(--md-sys-color-primary);
color: var(--md-sys-color-primary);
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
}
[data-datepicker-year][aria-selected="true"] {
--datepicker-layer: var(--md-sys-color-on-primary);
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
box-shadow: none;
}
/* ---- The docked month and year lists ------------------------------------------------------ */
[data-datepicker-menu] {
height: 21.5rem;
padding-block: 0.5rem;
overflow-y: auto;
overscroll-behavior: contain;
border-top: 1px solid var(--md-sys-color-outline-variant);
}
[data-datepicker-option] {
--datepicker-layer: var(--md-sys-color-on-surface);
position: relative;
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
height: 3rem;
padding-inline: 1rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
text-align: start;
cursor: pointer;
outline: none;
isolation: isolate;
}
[data-datepicker-option][aria-selected="true"] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-datepicker-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-datepicker-check] {
color: var(--md-sys-color-on-surface);
}
[data-datepicker-option]:not([aria-selected="true"]) [data-datepicker-check] {
visibility: hidden;
}
/* ---- The modal input ------------------------------------------------------------------------ */
[data-datepicker-entry] {
padding: 0.625rem 1.5rem 1rem;
}
[data-datepicker-entry-fields][data-range] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
/* ---- Cancel and OK ---------------------------------------------------------------------------- */
[data-datepicker-actions] {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.25rem 0.375rem 0.5rem 0.75rem;
}
}
+1
View File
@@ -26,6 +26,7 @@
@import './components/menu.css';
@import './components/selection.css';
@import './components/search.css';
@import './components/datepicker.css';
@import './components/timepicker.css';
@import './components/tabs.css';
@import './components/app-bar.css';
+830
View File
@@ -0,0 +1,830 @@
/**
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
* date pickers on one `<dialog>`.
*
* The dialog is shown two ways. Docked, it is a `popover="manual"` placed under the field by CSS
* anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact
* window) it is opened with `showModal()`. Everything a person sees in it is drawn by Alpine from
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
* it is open.
*
* Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight
* saving change can move a day; only "today" is read in the browser's own zone. Month and weekday
* names, the week's first day and the typed format come from `Intl` for the locale the server
* passes (the application's).
*
* The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to
* the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects,
* Enter selects and confirms, Escape closes and hands focus back to the field. Focus never leaves
* the `min``max` span, so a disabled day cannot be chosen from the keyboard either.
*/
const COMPACT = '(max-width: 39.99rem)'
// CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`.
const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ')
const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.split(' ')
const YEARS_FROM = 1900
const YEARS_TO = 2100
function pad(number, length = 2) {
return String(number).padStart(length, '0')
}
/** A UTC timestamp for a calendar day; `setUTCFullYear` so years below 100 are not read as 19xx. */
function utc(year, month, day) {
const date = new Date(0)
date.setUTCFullYear(year, month - 1, day)
return date
}
function iso(date) {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/** The ISO string, or null when it is not a real calendar day. */
function valid(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof value === 'string' ? value : '')
if (!match) {
return null
}
const date = utc(+match[1], +match[2], +match[3])
return date.getUTCMonth() + 1 === +match[2] && date.getUTCDate() === +match[3] ? match[0] : null
}
function parts(value) {
return value.split('-').map(Number)
}
function addDays(value, days) {
const [year, month, day] = parts(value)
return iso(utc(year, month, day + days))
}
/** The same day in another month, or that month's last day when it is shorter. */
function addMonths(value, months) {
const [year, month, day] = parts(value)
const first = utc(year, month + months, 1)
const length = utc(first.getUTCFullYear(), first.getUTCMonth() + 2, 0).getUTCDate()
return iso(utc(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(day, length)))
}
function localToday() {
const now = new Date()
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** 0 for Sunday … 6 for Saturday. */
function firstDayOfWeek(locale) {
try {
const tag = new Intl.Locale(locale)
const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo
if (info?.firstDay) {
return info.firstDay % 7
}
const region = tag.maximize().region
if (WEEK_STARTS_SUNDAY.includes(region)) {
return 0
}
if (WEEK_STARTS_SATURDAY.includes(region)) {
return 6
}
} catch {
// An unknown tag falls through to Monday, ISO 8601's first day.
}
return 1
}
/**
* The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one
* delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has
* no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`.
*/
function inputFormat(locale) {
let pattern = ''
try {
pattern = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' })
.formatToParts(utc(2026, 11, 22))
.map((part) => ({ year: 'y', month: 'M', day: 'd', literal: part.value })[part.type] ?? '')
.join('')
.replace(/[^dMy/\-.]/g, '')
.replace(/d{1,2}/g, 'dd')
.replace(/M{1,2}/g, 'MM')
.replace(/y{1,4}/g, 'yyyy')
.replace('My', 'M/y')
.replace(/\.$/, '')
} catch {
pattern = ''
}
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
pattern = 'yyyy-MM-dd'
}
return {
pattern,
placeholder: pattern.toUpperCase(),
order: pattern.replace(/[^dMy]/g, '').replace('dd', 'd').replace('MM', 'M').replace('yyyy', 'y'),
}
}
function normaliseRange(value) {
return { start: valid(value?.start), end: valid(value?.end) }
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialDatepicker', (config) => ({
value: config.value,
open: false,
presentation: 'docked',
typing: false,
view: 'days',
draft: null,
focused: null,
shown: null,
text: '',
fieldError: '',
entry: '',
entryEnd: '',
entryError: '',
today: localToday(),
compact: false,
refocus: true,
init() {
const locale = config.locale || document.documentElement.lang || 'en'
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
this.firstDay = firstDayOfWeek(locale)
this.format = inputFormat(locale)
this.numbers = new Intl.NumberFormat(locale, { useGrouping: false })
this.formats = {
monthYear: format({ year: 'numeric', month: 'long' }),
month: format({ month: 'short' }),
monthLong: format({ month: 'long' }),
year: format({ year: 'numeric' }),
headline: format({ year: 'numeric', month: 'short', day: 'numeric' }),
long: format({ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weekdayNarrow: format({ weekday: 'narrow' }),
weekdayLong: format({ weekday: 'long' }),
}
this.min = valid(config.min)
this.max = valid(config.max)
// androidx's 19002100, narrowed to the years `min` and `max` leave open.
this.yearsFrom = this.min ? parts(this.min)[0] : Math.min(YEARS_FROM, this.max ? parts(this.max)[0] : YEARS_FROM)
this.yearsTo = this.max ? parts(this.max)[0] : Math.max(YEARS_TO, this.yearsFrom)
this.text = this.display(this.current())
this.moveTo(this.start() ?? this.today, false)
this.$watch('value', () => {
if (document.activeElement !== this.$refs.input) {
this.text = this.display(this.current())
this.fieldError = ''
}
})
const query = window.matchMedia(COMPACT)
this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches))
},
// ---- Values --------------------------------------------------------------------------
/** The bound value, cleaned: an ISO string or null, or `{ start, end }` for a range. */
current() {
return config.range ? normaliseRange(this.value) : valid(this.value)
},
/** Writes only a change, so leaving a field untouched sends Livewire nothing. */
write(value) {
const next = config.range ? { start: value?.start ?? null, end: value?.end ?? null } : (value ?? null)
if (JSON.stringify(next) !== JSON.stringify(this.current())) {
this.value = next
}
},
/** A date as the field shows and takes it: `13.09.2026`. */
typed(value) {
if (!value) {
return ''
}
const [year, month, day] = parts(value)
return this.format.pattern.replace('yyyy', pad(year, 4)).replace('MM', pad(month)).replace('dd', pad(day))
},
display(value) {
if (!config.range) {
return this.typed(value)
}
if (!value.start && !value.end) {
return ''
}
return `${this.typed(value.start)} ${this.typed(value.end)}`
},
/**
* Reads typed digits in the locale's order: `13.9.2026`, `13/09/2026` and `13092026` are the
* same day in `de`. Returns an ISO string, or null for text that is not a date.
*/
parse(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
let values = groups
if (groups.length === 1 && groups[0].length === 8) {
let offset = 0
values = [...this.format.order].map((unit) => {
const length = unit === 'y' ? 4 : 2
const piece = groups[0].slice(offset, offset + length)
offset += length
return piece
})
}
if (values.length !== 3) {
return null
}
const units = Object.fromEntries([...this.format.order].map((unit, index) => [unit, values[index]]))
if (units.y.length !== 4 || units.M.length > 2 || units.d.length > 2) {
return null
}
return valid(`${units.y}-${pad(+units.M)}-${pad(+units.d)}`)
},
parseRange(text) {
const groups = String(text ?? '').match(/\d+/g) ?? []
if (groups.length === 2 && groups.every((group) => group.length === 8)) {
return { start: this.parse(groups[0]), end: this.parse(groups[1]) }
}
if (groups.length === 6) {
return { start: this.parse(groups.slice(0, 3).join(' ')), end: this.parse(groups.slice(3).join(' ')) }
}
return null
},
/** androidx's DateInputValidator: the pattern, then min and max, then the year range. */
problem(value) {
if (!value) {
return config.strings.pattern.replace(':pattern', this.format.placeholder)
}
if (this.disabled(value)) {
return config.strings.notAllowed.replace(':date', this.formats.headline.format(utc(...parts(value))))
}
const year = parts(value)[0]
if (year < this.yearsFrom || year > this.yearsTo) {
return config.strings.yearRange.replace(':start', this.numbers.format(this.yearsFrom)).replace(':end', this.numbers.format(this.yearsTo))
}
return ''
},
disabled(value) {
return (this.min !== null && value < this.min) || (this.max !== null && value > this.max)
},
clamp(value) {
if (this.min !== null && value < this.min) {
return this.min
}
return this.max !== null && value > this.max ? this.max : value
},
// ---- The field ----------------------------------------------------------------------
/** Typing into the docked field: a complete, allowed date is written at once. */
typeInField() {
const text = this.text.trim()
const value = config.range ? this.parseRange(text) : this.parse(text)
const complete = config.range ? value?.start && value?.end : value
if (!complete) {
return
}
const problem = this.fieldProblem(value)
if (problem === '') {
this.fieldError = ''
this.write(value)
this.follow(config.range ? value.start : value)
}
},
/** Leaving the field, or Enter: the text is the value, or it says what is wrong with it. */
commitField() {
const text = this.text.trim()
if (text === '') {
this.fieldError = ''
if (config.range ? this.current().start || this.current().end : this.current()) {
this.write(null)
}
return
}
const value = config.range ? this.parseRange(text) : this.parse(text)
const problem = this.fieldProblem(value)
this.fieldError = problem
if (problem === '') {
this.write(value)
this.text = this.display(config.range ? normaliseRange(value) : value)
}
},
fieldProblem(value) {
if (!config.range) {
return this.problem(value)
}
if (!value) {
return config.strings.pattern.replace(':pattern', `${this.format.placeholder} ${this.format.placeholder}`)
}
return this.problem(value.start) || this.problem(value.end) || (value.start > value.end ? config.strings.invalidRange : '')
},
/** An open calendar follows a date typed into the field. */
follow(value) {
if (this.open && value) {
this.draft = this.current()
this.moveTo(value, false)
}
},
// ---- Opening and closing --------------------------------------------------------------
show(focusInside = true) {
if (this.open || config.disabled || config.readonly) {
return
}
const value = this.current()
this.today = localToday()
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
this.typing = config.mode === 'input'
this.view = 'days'
this.draft = value
this.moveTo(this.start() ?? this.today, false)
this.entryError = ''
this.fillEntry()
this.refocus = true
this.open = true
const dialog = this.$refs.dialog
if (this.presentation === 'docked') {
dialog.showPopover()
} else {
dialog.showModal()
}
if (focusInside || this.presentation === 'modal') {
this.settled(() => this.focusInside())
}
},
/**
* Runs a step once what it needs is on screen. `x-show` reveals an element a frame after the
* state changes (a timeout in a hidden tab), and a hidden element cannot take focus.
*/
settled(callback) {
this.$nextTick(() => (document.visibilityState === 'visible' ? requestAnimationFrame : setTimeout)(callback))
},
focusInside() {
if (this.typing) {
this.$refs.entry?.focus()
} else {
this.focusDay()
}
},
/** Closes without keeping what was picked. */
cancel(refocus = true) {
if (!this.open) {
return
}
this.refocus = refocus
this.close()
},
close() {
const dialog = this.$refs.dialog
this.open = false
this.view = 'days'
if (dialog.matches(':popover-open')) {
dialog.hidePopover()
}
if (dialog.open) {
dialog.close()
}
if (this.refocus) {
this.$refs.input.focus()
}
},
/** OK: the picked date (or typed one) becomes the value. */
confirm() {
if (this.typing && !this.takeEntry()) {
return
}
this.write(this.draft)
this.text = this.display(this.current())
this.fieldError = ''
this.refocus = true
this.close()
},
/** A press outside a docked picker, or focus leaving it, puts it away. */
leave(event) {
if (this.open && this.presentation === 'docked' && event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.cancel(false)
}
},
// ---- Picking --------------------------------------------------------------------------
pick(value) {
if (!value || this.disabled(value)) {
return
}
this.focused = value
this.shown = value.slice(0, 8) + '01'
if (!config.range) {
this.draft = value
return
}
const { start, end } = this.draft ?? { start: null, end: null }
this.draft = start && !end && value >= start ? { start, end: value } : { start: value, end: null }
},
complete() {
return config.range ? Boolean(this.draft?.start && this.draft?.end) : Boolean(this.draft)
},
choose(cell) {
if (cell.blank || cell.disabled) {
return
}
this.pick(cell.value)
this.focusDay()
},
focusDay() {
this.settled(() => this.$refs.dialog.querySelector('[data-datepicker-day][tabindex="0"]')?.focus())
},
/** Focuses a day (inside min and max) and shows its month. */
moveTo(value, focus = true) {
this.focused = this.clamp(value)
this.shown = this.focused.slice(0, 8) + '01'
if (focus) {
this.focusDay()
}
},
/** The single date, or a range's start. */
start() {
const value = this.current()
return config.range ? value.start : value
},
gridKey(event) {
const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl'
const focused = this.focused
const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7
const target = {
ArrowRight: () => addDays(focused, rtl ? -1 : 1),
ArrowLeft: () => addDays(focused, rtl ? 1 : -1),
ArrowDown: () => addDays(focused, 7),
ArrowUp: () => addDays(focused, -7),
Home: () => addDays(focused, -column),
End: () => addDays(focused, 6 - column),
PageUp: () => addMonths(focused, event.shiftKey ? -12 : -1),
PageDown: () => addMonths(focused, event.shiftKey ? 12 : 1),
}[event.key]
if (target) {
event.preventDefault()
this.moveTo(target())
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
this.pick(focused)
if (event.key === 'Enter' && this.complete()) {
this.confirm()
}
}
},
/** The previous or next month (or year) buttons; focus stays on the button. */
step(months) {
if (!this.canStep(months)) {
return
}
this.moveTo(addMonths(this.focused, months), false)
},
monthAllowed(year, month) {
const first = `${pad(year, 4)}-${pad(month)}-01`
const last = iso(utc(year, month + 1, 0))
return year >= this.yearsFrom && year <= this.yearsTo && !(this.max !== null && first > this.max) && !(this.min !== null && last < this.min)
},
canStep(months) {
const [year, month] = parts(addMonths(this.shown, months))
return this.monthAllowed(year, month)
},
// ---- Years and months ---------------------------------------------------------------
/** Opens the year grid (or a docked picker's month or year list), or goes back to the days. */
toggleView(view) {
this.view = this.view === view ? 'days' : view
if (this.view === 'days') {
this.focusDay()
return
}
this.settled(() => {
const list = [...this.$refs.dialog.querySelectorAll('[data-datepicker-list]')].find((each) => each.getClientRects().length > 0)
const option = list?.querySelector('[aria-selected="true"]') ?? list?.querySelector('[role="option"]')
option?.scrollIntoView({ block: 'center' })
option?.focus()
})
},
/** The focused day moves to the chosen year and month, kept inside min and max. */
showMonthOf(year, month) {
const [focusedYear, focusedMonth] = parts(this.focused)
this.moveTo(addMonths(this.focused, (year - focusedYear) * 12 + month - focusedMonth))
this.view = 'days'
},
/** Arrows, Home and End in a year or month list; the options are buttons, so Enter and Space are the browser's. */
listKey(event, columns = 1) {
const options = [...event.currentTarget.querySelectorAll('[role="option"]')]
const index = options.indexOf(document.activeElement)
const move = {
ArrowDown: columns,
ArrowUp: -columns,
ArrowRight: columns > 1 ? 1 : 0,
ArrowLeft: columns > 1 ? -1 : 0,
}[event.key]
let next = null
if (move) {
next = options[Math.min(options.length - 1, Math.max(0, index + move))]
} else if (event.key === 'Home') {
next = options[0]
} else if (event.key === 'End') {
next = options.at(-1)
}
if (next) {
event.preventDefault()
next.focus()
next.scrollIntoView({ block: 'nearest' })
}
},
// ---- Text entry in the dialog --------------------------------------------------------
fillEntry() {
if (config.range) {
this.entry = this.typed(this.draft?.start)
this.entryEnd = this.typed(this.draft?.end)
} else {
this.entry = this.typed(this.draft)
}
},
/** As it is typed, a whole and allowed date becomes the draft, so the headline follows it. */
typeEntry() {
const read = (text) => {
const value = this.parse(text)
return value && this.problem(value) === '' ? value : null
}
this.entryError = ''
this.draft = config.range ? { start: read(this.entry), end: read(this.entryEnd) } : read(this.entry)
},
/** Reads the dialog's text fields into the draft; false, with the reason shown, when it cannot. */
takeEntry() {
if (!config.range) {
if (this.entry.trim() === '') {
this.draft = null
this.entryError = ''
return true
}
const value = this.parse(this.entry)
this.entryError = this.problem(value)
if (this.entryError !== '') {
return false
}
this.draft = value
return true
}
const start = this.entry.trim() === '' ? null : this.parse(this.entry)
const end = this.entryEnd.trim() === '' ? null : this.parse(this.entryEnd)
this.entryError = (this.entry.trim() !== '' ? this.problem(start) : '')
|| (this.entryEnd.trim() !== '' ? this.problem(end) : '')
|| (start && end && start > end ? config.strings.invalidRange : '')
if (this.entryError !== '') {
return false
}
this.draft = { start, end }
return true
},
toggleTyping() {
if (this.typing) {
if (!this.takeEntry()) {
return
}
const start = config.range ? this.draft.start : this.draft
this.moveTo(start ?? this.focused, false)
} else {
this.fillEntry()
this.entryError = ''
}
this.typing = !this.typing
this.view = 'days'
this.settled(() => this.focusInside())
},
// ---- What the template draws ---------------------------------------------------------
get weekdays() {
return Array.from({ length: 7 }, (_, index) => {
// 1 January 2023 was a Sunday.
const date = utc(2023, 1, 1 + ((this.firstDay + index) % 7))
return { narrow: this.formats.weekdayNarrow.format(date), long: this.formats.weekdayLong.format(date) }
})
},
get weeks() {
const [year, month] = parts(this.shown)
const first = utc(year, month, 1)
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
const range = config.range ? (this.draft ?? { start: null, end: null }) : null
const outside = this.presentation === 'docked'
return Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, column) => {
const date = utc(year, month, 1 - offset + row * 7 + column)
const value = iso(date)
const inMonth = date.getUTCMonth() + 1 === month
const start = range ? value === range.start : value === this.draft
const end = range ? value === range.end : false
return {
value,
blank: !inMonth && !outside,
outside: !inMonth,
label: this.numbers.format(date.getUTCDate()),
name: this.formats.long.format(date),
disabled: this.disabled(value),
today: value === this.today,
selected: start || end,
start: range !== null && start && Boolean(range.end) && range.end !== range.start,
end: range !== null && end && range.end !== range.start,
between: range !== null && Boolean(range.start && range.end) && value > range.start && value < range.end,
focused: inMonth && value === this.focused,
}
}))
},
get years() {
const shown = parts(this.shown)[0]
const current = parts(this.today)[0]
return Array.from({ length: this.yearsTo - this.yearsFrom + 1 }, (_, index) => {
const year = this.yearsFrom + index
return { value: year, label: this.numbers.format(year), selected: year === shown, current: year === current }
})
},
get months() {
const [year, shown] = parts(this.shown)
return Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: this.formats.monthLong.format(utc(2023, index + 1, 1)),
selected: index + 1 === shown,
disabled: !this.monthAllowed(year, index + 1),
}))
},
get monthYear() {
return this.shown ? this.formats.monthYear.format(utc(...parts(this.shown))) : ''
},
get monthLabel() {
return this.shown ? this.formats.month.format(utc(...parts(this.shown))) : ''
},
get yearLabel() {
return this.shown ? this.formats.year.format(utc(...parts(this.shown))) : ''
},
/** M3's headline: the chosen date, or what the picker is for until there is one. */
get headline() {
const date = (value) => (value ? this.formats.headline.format(utc(...parts(value))) : null)
if (config.range) {
return `${date(this.draft?.start) ?? config.strings.start} ${date(this.draft?.end) ?? config.strings.end}`
}
return date(this.draft) ?? (this.typing ? config.strings.entered : config.strings.selected)
},
get placeholder() {
return config.range ? `${this.format.placeholder} ${this.format.placeholder}` : this.format.placeholder
},
get serialised() {
return config.range ? this.current() : this.current() ?? ''
},
}))
})
+1
View File
@@ -20,6 +20,7 @@ import './carousel.js'
import './chips.js'
import './field.js'
import './search.js'
import './datepicker.js'
import './timepicker.js'
import './slider.js'
import './tabs.js'
@@ -0,0 +1,447 @@
{{-- A date field with M3's date pickers: docked under the field, modal, or modal text input.
`mode` picks M3's three:
- `docked` (the default): a text field that takes a typed date in the locale's numeric format
(`13.09.2026` in `de`, `09/13/2026` in `en-US`), with the calendar dropping open under it —
on a press of the field, its calendar button, or ArrowDown. On a compact window (below
`sm`) the calendar opens as the modal picker instead, where a docked one would not fit.
- `modal`: the field only shows the date; pressing it (or Enter, Space, ArrowDown) opens the
calendar in a dialog, with a pencil to switch to typing.
- `input`: the same dialog, opened on its text field, with a calendar icon to switch back.
The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the
value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed
into the docked field is the value as soon as it is a whole, allowed date.
`wire:model` stores `Y-m-d` strings (`x-model` without Livewire). `range` picks a start and an
end, bound as one array, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null)
one property rather than two, because a `wire:model` names one property, Livewire sends a
range as one update so `after_or_equal:period.start` validates against the end it came
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
Month and weekday names, the first day of the week and the typed format come from `Intl` for
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
`config` becomes `min`, `max`, `range` and `mode`.
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
DatePicker.kt, DateRangePicker.kt and DateInput.kt; the docked picker's values from
material-web's md-comp-date-picker-docked tokens, v0_192, as Compose has none; all Apache-2.0):
surface-container-high at elevation 3, 360px wide, the extra-large corner (modal) or large
(docked), 40px days in 48px cells, today outlined in primary, the chosen day in primary and a
range's middle in secondary-container. The styles are resources/css/components/datepicker.css. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'size' => 'md',
'variant' => null,
'mode' => 'docked',
'range' => false,
'min' => null,
'max' => null,
'value' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
$messages = $model !== null && isset($errors)
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
: [];
$locale = str_replace('_', '-', app()->getLocale());
$toIso = function (mixed $date): ?string {
if ($date instanceof \DateTimeInterface) {
return $date->format('Y-m-d');
}
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/', $date, $match) === 1 && checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
return substr($date, 0, 10);
}
return null;
};
// Rendered as the bound property already says, so the field does not change once Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $range
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
: $toIso($current);
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
$pattern = 'yyyy-MM-dd';
if (class_exists(\IntlDateFormatter::class)) {
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
$pattern = $candidate;
}
}
$typed = fn (?string $date): string => $date === null ? '' : strtr($pattern, ['yyyy' => substr($date, 0, 4), 'MM' => substr($date, 5, 2), 'dd' => substr($date, 8, 2)]);
$display = $range
? ($current['start'] === null && $current['end'] === null ? '' : $typed($current['start']).' '.$typed($current['end']))
: $typed($current);
$title = $range ? __('Select dates') : __('Select date');
$inputTitle = $range ? __('Enter dates') : __('Select date');
$fieldName = $attributes->get('name');
$invalid = $messages !== [];
$config = [
'locale' => $locale,
'mode' => $mode,
'range' => (bool) $range,
'min' => $toIso($min),
'max' => $toIso($max),
'disabled' => (bool) $attributes->get('disabled'),
'readonly' => (bool) $attributes->get('readonly'),
'strings' => [
'selected' => __('Selected date'),
'entered' => __('Entered date'),
'start' => __('Start date'),
'end' => __('End date'),
'pattern' => __('Date does not match expected pattern: :pattern'),
'yearRange' => __('Date out of expected year range :start - :end'),
'notAllowed' => __('Date not allowed: :date'),
'invalidRange' => __('Invalid date range input'),
],
];
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialDatepicker({
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
...@js($config),
})"
@if ($model === null) x-modelable="value" @endif
x-on:focusout="leave($event)"
x-on:pointerdown.outside="open && presentation === 'docked' && cancel(false)"
data-datepicker
>
<div style="anchor-name: {{ $anchor }}">
<x-field
:$id
:$label
:$icon
:$size
:$variant
:data-invalid="$invalid ? '' : null"
:data-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
>
<input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
x-ref="input"
id="{{ $id }}"
type="text"
role="combobox"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
aria-describedby="{{ $id }}-support"
@if ($invalid) aria-invalid="true" @endif
x-bind:aria-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? 'true' : null"
autocomplete="off"
value="{{ $display }}"
placeholder=" "
class="field-control"
x-model="text"
@if ($mode === 'docked')
x-bind:placeholder="placeholder"
x-on:input="typeInField()"
x-on:change="commitField()"
x-on:keydown.enter="commitField()"
x-on:click="show(false)"
x-on:keydown.arrow-down.prevent="open ? focusInside() : show()"
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); cancel(); }"
@else
readonly
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
@endif
/>
<x-slot:trailing>
<button
type="button"
class="field-trailing field-button"
aria-label="{{ $title }}"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
x-bind:aria-expanded="open.toString()"
x-on:click="open ? cancel() : show()"
@if ($mode !== 'docked') tabindex="-1" @endif
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle
>
<x-icon name="calendar_today" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-field>
</div>
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
<p x-cloak x-show="fieldError !== ''" x-text="fieldError" role="alert" data-datepicker-error></p>
@if ($invalid)
<div x-show="fieldError === ''" role="alert" data-datepicker-error>
@foreach ($messages as $message)
<p>{{ $message }}</p>
@endforeach
</div>
@elseif (filled($hint))
<p x-show="fieldError === ''">{{ $hint }}</p>
@endif
</div>
@if (filled($fieldName))
@if ($range)
<input type="hidden" name="{{ $fieldName }}[start]" value="{{ $current['start'] }}" x-bind:value="serialised.start ?? ''" />
<input type="hidden" name="{{ $fieldName }}[end]" value="{{ $current['end'] }}" x-bind:value="serialised.end ?? ''" />
@else
<input type="hidden" name="{{ $fieldName }}" value="{{ $current }}" x-bind:value="serialised" />
@endif
@endif
<dialog
wire:ignore
x-ref="dialog"
id="{{ $id }}-picker"
popover="manual"
aria-label="{{ $title }}"
style="position-anchor: {{ $anchor }}"
x-bind:data-presentation="presentation"
x-on:cancel.prevent="cancel()"
x-on:click.self="presentation === 'modal' && cancel()"
x-on:keydown.escape.prevent.stop="cancel()"
data-datepicker-picker
>
<div tabindex="-1" data-datepicker-surface>
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
<div data-datepicker-headline-row>
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
<span x-show="! typing">
<x-button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
<span x-show="typing">
<x-button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
</span>
</div>
</div>
<div x-show="! typing" data-datepicker-calendar>
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
<div data-datepicker-nav x-show="presentation === 'modal'">
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="monthYear"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
</span>
</div>
<div data-datepicker-nav data-docked x-show="presentation === 'docked'">
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
</span>
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('months')"
x-bind:aria-expanded="(view === 'months').toString()"
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
>
<span x-text="monthLabel"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
</span>
</span>
<span data-datepicker-stepper>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
</span>
<button
type="button"
data-datepicker-menu-button
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
>
<span x-text="yearLabel"></span>
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
</button>
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
<x-button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
</span>
</span>
</div>
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-datepicker-grid>
<thead>
<tr>
<template x-for="weekday in weekdays" :key="weekday.long">
<th scope="col" x-bind:abbr="weekday.long" x-text="weekday.narrow"></th>
</template>
</tr>
</thead>
<tbody>
<template x-for="(week, row) in weeks" :key="row">
<tr>
<template x-for="(cell, column) in week" :key="column">
<td
role="gridcell"
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
x-bind:aria-label="cell.blank ? null : cell.name"
x-bind:aria-selected="! cell.blank && (cell.selected || cell.between) ? 'true' : null"
x-bind:aria-disabled="! cell.blank && cell.disabled ? 'true' : null"
x-bind:aria-current="! cell.blank && cell.today ? 'date' : null"
x-bind:data-value="cell.blank ? null : cell.value"
x-bind:data-blank="cell.blank ? '' : null"
x-bind:data-outside="cell.outside ? '' : null"
x-bind:data-today="cell.today ? '' : null"
x-bind:data-selected="cell.selected ? '' : null"
x-bind:data-start="cell.start ? '' : null"
x-bind:data-end="cell.end ? '' : null"
x-bind:data-between="cell.between ? '' : null"
x-on:click="choose(cell)"
data-datepicker-day
><span x-text="cell.blank ? '' : cell.label"></span></td>
</template>
</tr>
</template>
</tbody>
</table>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'modal'" x-on:keydown="listKey($event, 3)" data-datepicker-list data-datepicker-years>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-bind:data-current="year.current ? '' : null"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
x-text="year.label"
data-datepicker-year
></button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Months') }}" x-show="view === 'months' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="month in months" :key="month.value">
<button
type="button"
role="option"
x-bind:aria-selected="month.selected.toString()"
x-bind:aria-disabled="month.disabled ? 'true' : null"
x-bind:tabindex="month.selected ? 0 : -1"
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
data-datepicker-option
>
<x-icon name="check" data-datepicker-check />
<span x-text="month.label"></span>
</button>
</template>
</div>
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
<template x-for="year in years" :key="year.value">
<button
type="button"
role="option"
x-bind:aria-selected="year.selected.toString()"
x-bind:tabindex="year.selected ? 0 : -1"
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
data-datepicker-option
>
<x-icon name="check" data-datepicker-check />
<span x-text="year.label"></span>
</button>
</template>
</div>
</div>
<div x-show="typing" data-datepicker-entry>
<div @if ($range) data-range @endif data-datepicker-entry-fields>
<x-field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-ref="entry"
x-model="entry"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-field>
@if ($range)
<x-field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
<input
id="{{ $id }}-entry-end"
type="text"
autocomplete="off"
placeholder=" "
x-bind:placeholder="format.placeholder"
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
aria-describedby="{{ $id }}-entry-support"
x-model="entryEnd"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
/>
</x-field>
@endif
</div>
<div id="{{ $id }}-entry-support" data-datepicker-support>
<p x-show="entryError !== ''" x-text="entryError" role="alert" data-datepicker-error></p>
</div>
</div>
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
<x-button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
<x-button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
</div>
</div>
</dialog>
</div>
+1
View File
@@ -21,6 +21,7 @@
@include('livewire-material::showcase.sections.fields')
@include('livewire-material::showcase.sections.chips')
@include('livewire-material::showcase.sections.sliders')
@include('livewire-material::showcase.sections.pickers')
@include('livewire-material::showcase.sections.timepickers')
@include('livewire-material::showcase.sections.bars')
@include('livewire-material::showcase.sections.data')
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
@@ -0,0 +1,71 @@
@php
$examples = [
'docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Starts on" variant="filled" icon="event" x-model="starts" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(starts)"></code></p>
</div>
</div>
BLADE,
'modal and modal input' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Delivery" mode="input" x-model="delivery" hint="Opens on a text field" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(delivery)"></code></p>
</div>
</div>
BLADE,
'range' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
<div class="grid content-start gap-4">
<x-datepicker label="Trip" range x-model="trip" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p>
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
</div>
</div>
BLADE,
'limits, errors and states' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<div class="grid content-start gap-4">
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
<x-datepicker label="Required" required />
</div>
<div class="grid content-start gap-4">
<x-datepicker label="Disabled" value="2026-09-13" disabled />
<x-datepicker label="Read-only" value="2026-09-13" readonly />
<div class="flex flex-wrap items-center gap-3">
<x-datepicker size="sm" aria-label="From" class="w-48" />
</div>
</div>
</div>
BLADE,
];
@endphp
<section id="pickers" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Date pickers</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-datepicker&gt;</code> docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>