From f4be9848e29d61f85dcb30501be3f05ecd8daebb Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 08:48:56 +0200 Subject: [PATCH] Add the M3 date pickers 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) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- NOTICE | 8 + docs/plans/livewire-material.md | 18 + .../livewire-material-development/SKILL.md | 21 + resources/css/components/datepicker.css | 508 +++++++++++ resources/css/material.css | 1 + resources/js/datepicker.js | 830 ++++++++++++++++++ resources/js/material.js | 1 + .../views/components/datepicker.blade.php | 447 ++++++++++ resources/views/showcase/index.blade.php | 1 + resources/views/showcase/layout.blade.php | 2 +- .../views/showcase/sections/pickers.blade.php | 71 ++ tests/Browser/DatepickerTest.php | 298 +++++++ tests/Feature/Components/DatepickerTest.php | 165 ++++ 13 files changed, 2370 insertions(+), 1 deletion(-) create mode 100644 resources/css/components/datepicker.css create mode 100644 resources/js/datepicker.js create mode 100644 resources/views/components/datepicker.blade.php create mode 100644 resources/views/showcase/sections/pickers.blade.php create mode 100644 tests/Browser/DatepickerTest.php create mode 100644 tests/Feature/Components/DatepickerTest.php diff --git a/NOTICE b/NOTICE index 38fc591e..9bffbd5b 100644 --- a/NOTICE +++ b/NOTICE @@ -21,6 +21,14 @@ M3 Expressive carousel keylines (resources/js/carousel.js) Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file. Copyright The Android Open Source Project. Apache License 2.0. +M3 date pickers (resources/views/components/datepicker.blade.php, resources/js/datepicker.js, +resources/css/components/datepicker.css) + Token values and layout from androidx Compose Material 3 (DatePickerModalTokens, + DateInputModalTokens, DatePicker.kt, DateRangePicker.kt, DateInput.kt; the input-format + derivation ported from datePatternAsInputFormat), commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, + and the docked picker's values from @material/web's md-comp-date-picker-docked tokens (v0_192). + Copyright The Android Open Source Project; Copyright Google LLC. Apache License 2.0. + M3 time picker (resources/js/timepicker.js, resources/css/components/timepicker.css) Behaviour and geometry ported from androidx Compose Material 3 (TimePicker.kt, TimePickerDialog.kt, tokens/TimePickerTokens.kt, tokens/TimeInputTokens.kt), as recorded at diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 4386ea78..9e369782 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -493,6 +493,24 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker` (dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar. +**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own +worktrees. What changed from the step above: + +- **`` is one `` for every mode**: docked opens it as a + `popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input + with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in + UTC; only "today" reads the browser's zone. The typed format is androidx's + `datePatternAsInputFormat` from `Intl` (`de` → `dd.MM.yyyy`). **A range binds one array**, + `['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start` + validates against the matching end, and all its errors land on one field. Docked values come from + material-web's docked tokens, which Compose lacks. +- **`` follows current Compose, not older Android**: a 24-hour dial puts 00–11 outside + and 12–23 inside, and the period selector is two separate toggles. The selector angle is a + registered custom property, so one transition turns the line and moves the handle. +- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from + `$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS. +- **A Livewire public property named `$slot` renders empty** in the component's view. + ### Phase 8 — Navigation 28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky, diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 3c202962..a1b5e73b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -463,6 +463,27 @@ M3 Expressive's slider on native ``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. +### `` + +M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire). + +```blade + + + +``` + +| 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. + ### `` 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 12–23 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. diff --git a/resources/css/components/datepicker.css b/resources/css/components/datepicker.css new file mode 100644 index 00000000..f55d5c8d --- /dev/null +++ b/resources/css/components/datepicker.css @@ -0,0 +1,508 @@ +/* + * M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php). + * + * One `` 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 ; 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; + } +} diff --git a/resources/css/material.css b/resources/css/material.css index 69a0cb8b..ff0ea197 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -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'; diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js new file mode 100644 index 00000000..e5dc673e --- /dev/null +++ b/resources/js/datepicker.js @@ -0,0 +1,830 @@ +/** + * `materialDatepicker`: the behaviour of `` — M3's docked, modal and modal-input + * date pickers on one ``. + * + * 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 1900–2100, 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() ?? '' + }, + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index cb248e1f..ac2640e7 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -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' diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php new file mode 100644 index 00000000..9b5074b4 --- /dev/null +++ b/resources/views/components/datepicker.blade.php @@ -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 + +
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 +> +
+ + 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 + /> + + + + + +
+ +
+

+ + @if ($invalid) +
+ @foreach ($messages as $message) +

{{ $message }}

+ @endforeach +
+ @elseif (filled($hint)) +

{{ $hint }}

+ @endif +
+ + @if (filled($fieldName)) + @if ($range) + + + @else + + @endif + @endif + + +
+
+

{{ $title }}

+ +
+

+ + + + + + + +
+
+ +
+ + +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + + + + @if ($range) + + + + @endif +
+ +
+

+
+
+ +
+ + +
+
+
+
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 8853ccfb..af8c3308 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -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') diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index fb77bd73..05be5076 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/pickers.blade.php b/resources/views/showcase/sections/pickers.blade.php new file mode 100644 index 00000000..ab552e0c --- /dev/null +++ b/resources/views/showcase/sections/pickers.blade.php @@ -0,0 +1,71 @@ +@php + $examples = [ + 'docked' => <<<'BLADE' +
+
+ +

Bound value:

+
+ +
+ +

Bound value:

+
+
+ BLADE, + 'modal and modal input' => <<<'BLADE' +
+
+ +

Bound value:

+
+ +
+ +

Bound value:

+
+
+ BLADE, + 'range' => <<<'BLADE' +
+
+ +

Bound value:

+
+ +
+ +

Bound value:

+
+
+ BLADE, + 'limits, errors and states' => <<<'BLADE' +
+
+ + +
+ +
+ + +
+ +
+
+
+ BLADE, + ]; +@endphp + +
+

Date pickers

+ +

+ <x-datepicker> — 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. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php new file mode 100644 index 00000000..adb8faff --- /dev/null +++ b/tests/Browser/DatepickerTest.php @@ -0,0 +1,298 @@ + '2026-09-13', 'end' => '2026-09-15']; + + public int $renders = 0; + + public function touch(): void + { + $this->renders++; + } + + public function render(): string + { + return <<<'BLADE' +
+

expires: {{ $expires }}

+

birthday: {{ $birthday }}

+

delivery: {{ $delivery }}

+

trip: {{ json_encode($trip) }}

+

renders: {{ $renders }}

+ + + + + +
+ BLADE; + } +} + +function dateProbe(string $locale = 'en') +{ + Livewire::component('date-probe', DateProbe::class); + + Route::middleware('web')->get('/date-probe/{locale}', function (string $locale) { + app()->setLocale($locale); + + return Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE); + }); + + return visit("/date-probe/{$locale}")->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +/** A picker's day cell, by its ISO date. */ +function day(string $picker, string $date): string +{ + return "#{$picker}-picker [data-value=\"{$date}\"]"; +} + +function focusedDay(string $date): string +{ + return "document.activeElement.dataset.value === '{$date}'"; +} + +it('opens the docked picker under its field and walks the grid from the keyboard', function () { + $picker = "document.querySelector('#expires-field-picker')"; + + $page = dateProbe() + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'") + ->assertAttribute('#expires-field', 'aria-expanded', 'true') + ->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()") + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14')); + $page->keys(':focus', 'ArrowDown')->assertScript(focusedDay('2026-09-21')); + $page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-26')); + $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-20')); + $page->keys(':focus', 'ArrowUp')->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'PageDown') + ->assertScript(focusedDay('2026-10-13')) + ->assertScript("document.querySelector('#expires-field-month').textContent === 'October 2026'"); + + $page->keys(':focus', 'Enter') + ->assertSeeIn('#expires', '2026-10-13') + ->assertScript("! {$picker}.matches(':popover-open')") + ->assertScript("document.activeElement.id === 'expires-field'") + ->assertValue('#expires-field', '10/13/2026'); +}); + +it('keeps focus and choice inside min and max', function () { + $page = dateProbe() + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript(focusedDay('2026-09-13')); + + // Playwright will not press an aria-disabled cell, so the press is dispatched. + $page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true') + ->script("document.querySelector('".day('expires-field', '2026-09-09')."').click()"); + + $page->assertScript("! document.querySelector('".day('expires-field', '2026-09-09')."').hasAttribute('data-selected')") + ->assertScript("document.querySelector('".day('expires-field', '2026-09-13')."').hasAttribute('data-selected')"); + + $page->keys(':focus', 'PageUp')->assertScript(focusedDay('2026-09-10')); + $page->keys(':focus', 'ArrowLeft')->assertScript(focusedDay('2026-09-10')); + $page->keys(':focus', 'Shift+PageDown')->assertScript(focusedDay('2026-10-20')); + + $page->keys(':focus', 'Enter')->assertSeeIn('#expires', '2026-10-20'); +}); + +it('takes a date typed into the docked field once it is whole, and says when it cannot', function () { + $page = dateProbe() + ->type('#expires-field', '09/15/2026') + ->assertSeeIn('#expires', '2026-09-15'); + + $page->type('#expires-field', '13/45/2026') + ->keys('#expires-field', 'Enter') + ->assertSee('Date does not match expected pattern: MM/DD/YYYY') + ->assertAttribute('#expires-field', 'aria-invalid', 'true') + ->assertSeeIn('#expires', '2026-09-15'); + + $page->type('#expires-field', '09/01/2026') + ->keys('#expires-field', 'Enter') + ->assertSee('Date not allowed: Sep 1, 2026') + ->assertSeeIn('#expires', '2026-09-15'); +}); + +it('opens the modal picker, jumps through the year grid and confirms with OK', function () { + $dialog = "document.querySelector('#birthday-field-picker')"; + + $page = dateProbe() + ->click('#birthday-field') + ->assertScript("{$dialog}.matches(':modal')") + ->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 17, 2000') + ->click('#birthday-field-picker [data-datepicker-nav]:not([data-docked]) [data-datepicker-menu-button]') + ->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-datepicker-years]')).display !== 'none'") + ->assertScript("document.activeElement.textContent.trim() === '2000'"); + + $page->click('#birthday-field-picker [data-datepicker-year]:has-text("1990")') + ->assertSeeIn('#birthday-field-picker [data-datepicker-nav]', 'May 1990') + ->click(day('birthday-field', '1990-05-03')) + ->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 3, 1990') + ->assertSeeIn('#birthday', '2000-05-17'); + + $page->click('#birthday-field-picker [data-datepicker-confirm]') + ->assertSeeIn('#birthday', '1990-05-03') + ->assertScript("! {$dialog}.open"); +}); + +it('closes on Escape without keeping the draft and hands focus back to the field', function () { + $dialog = "document.querySelector('#birthday-field-picker')"; + + $page = dateProbe(); + + $page->script("document.querySelector('#birthday-field').focus()"); + + $page->keys('#birthday-field', 'Enter') + ->assertScript("{$dialog}.open") + ->assertScript(focusedDay('2000-05-17')); + + $page->keys(':focus', 'ArrowRight') + ->keys(':focus', 'Space') + ->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 18, 2000'); + + $page->keys(':focus', 'Escape') + ->assertScript("! {$dialog}.open") + ->assertScript("document.activeElement.id === 'birthday-field'") + ->assertSeeIn('#birthday', '2000-05-17'); + + // The docked picker too. + $page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-datepicker-toggle]').focus()"); + + $page->keys(':focus', 'Enter') + ->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')") + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'Escape') + ->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')") + ->assertScript("document.activeElement.id === 'expires-field'"); +}); + +it('reads the modal input in the locale\'s format and explains a date it cannot take', function () { + $dialog = "document.querySelector('#delivery-field-picker')"; + + $page = dateProbe() + ->click('#delivery-field') + ->assertScript("{$dialog}.matches(':modal')") + ->assertScript("document.activeElement.id === 'delivery-field-entry'") + ->assertSeeIn('#delivery-field-picker [data-datepicker-headline]', 'Entered date'); + + $page->type('#delivery-field-entry', '2026-03-07') + ->click('#delivery-field-picker [data-datepicker-confirm]') + ->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY') + ->assertAttribute('#delivery-field-entry', 'aria-invalid', 'true') + ->assertScript("{$dialog}.open"); + + $page->type('#delivery-field-entry', '12/24/2025') + ->keys('#delivery-field-entry', 'Enter') + ->assertSeeIn('#delivery-field-entry-support', 'Date not allowed: Dec 24, 2025') + ->assertScript("{$dialog}.open"); + + $page->type('#delivery-field-entry', '3/7/2026') + ->assertSeeIn('#delivery-field-picker [data-datepicker-headline]', 'Mar 7, 2026') + ->keys('#delivery-field-entry', 'Enter') + ->assertSeeIn('#delivery', '2026-03-07') + ->assertScript("! {$dialog}.open") + ->assertValue('#delivery-field', '03/07/2026'); +}); + +it('picks a range: a start, an end, and the days between', function () { + $page = dateProbe() + ->click('[aria-controls="trip-field-picker"][data-datepicker-toggle]') + ->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true'); + + $page->click(day('trip-field', '2026-09-20')) + ->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-selected')") + ->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-between')"); + + $page->click(day('trip-field', '2026-09-24')) + ->assertScript("[...document.querySelectorAll('#trip-field-picker [data-between]')].map((cell) => cell.dataset.value).join() === '2026-09-21,2026-09-22,2026-09-23'") + ->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-end')") + ->assertSeeIn('#trip', '2026-09-15'); + + $page->click('#trip-field-picker [data-datepicker-confirm]') + ->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') + ->assertValue('#trip-field', '09/20/2026 – 09/24/2026'); +}); + +it('follows the locale: German weeks start on Monday, American ones on Sunday', function () { + $firstWeekday = "document.querySelector('#expires-field-picker thead th').getAttribute('abbr')"; + + $page = dateProbe('de') + ->assertValue('#expires-field', '13.09.2026') + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript("{$firstWeekday} === 'Montag'") + ->assertScript("document.querySelector('#expires-field-month').textContent === 'September 2026'") + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-10')); + $page->keys(':focus', 'Escape'); + + $page->type('#expires-field', '20.9.2026') + ->assertSeeIn('#expires', '2026-09-20') + ->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY'); + + dateProbe('en_US') + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript("{$firstWeekday} === 'Sunday'"); +}); + +it('stays open, where it was, through a Livewire render', function () { + $page = dateProbe() + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14')); + + $page->script('window.eval("Livewire.first().touch()")'); + + $page->assertSeeIn('#renders', '1') + ->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')") + ->assertAttribute('#expires-field', 'aria-expanded', 'true') + ->assertScript("document.querySelector('".day('expires-field', '2026-09-14')."').tabIndex === 0"); + + $page->keys(day('expires-field', '2026-09-14'), 'Enter')->assertSeeIn('#expires', '2026-09-14'); + + $page->click('#birthday-field') + ->script('window.eval("Livewire.first().touch()")'); + + $page->assertSeeIn('#renders', '2') + ->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')"); +}); + +it('opens a docked picker as a modal one on a compact window', function () { + dateProbe() + ->resize(400, 800) + ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') + ->assertScript("document.querySelector('#expires-field-picker').matches(':modal')") + ->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'"); +}); diff --git a/tests/Feature/Components/DatepickerTest.php b/tests/Feature/Components/DatepickerTest.php new file mode 100644 index 00000000..c8910a48 --- /dev/null +++ b/tests/Feature/Components/DatepickerTest.php @@ -0,0 +1,165 @@ + + */ +function datepickerConfig(string $html): array +{ + preg_match("/\.\.\.JSON\.parse\('(.+?)'\)/", $html, $match); + + return json_decode(json_decode('"'.$match[1].'"'), true, flags: JSON_THROW_ON_ERROR); +} + +it('draws a docked date field whose combobox controls a popover picker', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('data-datepicker') + ->toContain('') + ->toContain('role="combobox"') + ->toContain('aria-haspopup="dialog"') + ->toContain('aria-controls="expires-picker"') + ->toContain('aria-expanded="false"') + ->toContain('aria-describedby="expires-support"') + ->toContain('Type a date or pick one') + ->toContain('toContain('id="expires-picker"') + ->toContain('popover="manual"') + ->toContain('wire:ignore') + ->toContain('aria-label="Select date"') + ->toContain('role="grid"') + ->toContain('data-datepicker-toggle') + ->toContain('x-modelable="value"') + ->toContain('x-on:input="typeInField()"') + ->not->toMatch('/]*\sreadonly[\s>]/') + ->not->toContain('aria-invalid="true"') + ->and(datepickerConfig($html))->toMatchArray(['mode' => 'docked', 'range' => false, 'min' => null, 'max' => null, 'locale' => 'en']) + ->and($html)->toContain('style="anchor-name: --material-datepicker-expires"') + ->toContain('style="position-anchor: --material-datepicker-expires"'); +}); + +it('makes the field a read-only trigger for the modal and modal input pickers', function (string $mode) { + $html = (string) $this->blade('', ['mode' => $mode]); + + expect($html) + ->toMatch('/]*\sreadonly[\s>]/') + ->toContain('x-on:keydown.space.prevent="show()"') + ->not->toContain('x-on:input="typeInField()"') + ->and(datepickerConfig($html)['mode'])->toBe($mode); +})->with(['modal', 'input']); + +it('falls back to the docked picker for an unknown mode', function () { + expect(datepickerConfig((string) $this->blade(''))['mode'])->toBe('docked'); +}); + +it('hands min, max and the application locale to the picker as Y-m-d', function () { + app()->setLocale('de_CH'); + + $config = datepickerConfig((string) $this->blade( + '', + ['min' => CarbonImmutable::parse('2026-09-13 08:30')], + )); + + expect($config)->toMatchArray(['min' => '2026-09-13', 'max' => '2026-12-31', 'locale' => 'de-CH']) + ->and($config['strings']['pattern'])->toBe('Date does not match expected pattern: :pattern') + ->and(datepickerConfig((string) $this->blade(''))) + ->toMatchArray(['min' => null, 'max' => null]); +}); + +it('shows the value in the locale\'s numeric format before Alpine starts', function () { + expect((string) $this->blade('')) + ->toContain('value="09/13/2026"') + ->toContain('setLocale('de'); + + expect((string) $this->blade('')) + ->toContain('value="13.09.2026 – 20.09.2026"') + ->toContain('name="trip[start]" value="2026-09-13"') + ->toContain('name="trip[end]" value="2026-09-20"') + ->toContain('aria-label="Select dates"'); +}); + +it('puts class on the root and every other attribute on the text field', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('class="w-60"') + ->toContain('data-variant="filled"') + ->toContain('data-size="sm"') + ->toMatch('/]*required[^>]*id="due"/s') + ->toMatch('/]*disabled[^>]*id="due"/s') + ->and(datepickerConfig($html)['disabled'])->toBeTrue(); +}); + +it('entangles the value with its Livewire property and renders it as the property says', function () { + Livewire::component('datepicker-probe', new class extends Component + { + public ?string $expires = '2026-09-13'; + + /** @var array{start: ?string, end: ?string} */ + public array $trip = ['start' => '2026-09-01', 'end' => null]; + + public function render(): string + { + return <<<'BLADE' +
+ + +
+ BLADE; + } + }); + + $html = Livewire::test('datepicker-probe')->html(); + + expect($html) + ->toContain(".entangle('expires').live") + ->toContain(".entangle('trip')") + ->not->toContain('x-modelable') + ->not->toContain('wire:model') + ->toContain('value="09/13/2026"') + ->toContain('value="09/01/2026 – "'); +}); + +it('replaces the hint with the errors for the property and its start and end', function () { + Livewire::component('datepicker-errors', new class extends Component + { + public ?string $expires = null; + + /** @var array{start: ?string, end: ?string} */ + public array $trip = ['start' => null, 'end' => null]; + + public function mount(): void + { + $this->addError('expires', 'Choose a date in the future.'); + $this->addError('trip.end', 'The end must follow the start.'); + } + + public function render(): string + { + return <<<'BLADE' +
+ + +
+ BLADE; + } + }); + + $html = Livewire::test('datepicker-errors')->html(); + + expect($html) + ->toContain('Choose a date in the future.') + ->toContain('The end must follow the start.') + ->not->toContain('Pick a day') + ->toContain('aria-invalid="true"') + ->toContain('data-datepicker-error') + ->and(substr_count($html, 'data-invalid=""'))->toBe(2); +});