diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 0c730a80..79ca943f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -592,6 +592,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` + ``` | Prop | Default | | @@ -603,8 +604,10 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` | `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) | | `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date | +| `week-start` | `null` | the first day of the week, `0` (Sunday) to `6` (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored | +| `format` | `null` | the typed and displayed format instead of the locale's: `dd`, `MM` and `yyyy`, each once, around one delimiter (`.`, `/`, `-`) — `dd.MM.yyyy`, `dd/MM/yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`. The field, the dialog's text fields, a range and the error message follow it; `wire:model` still stores `Y-m-d`. Anything else is ignored | -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. +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()` (the last two unless `week-start` and `format` say otherwise — for a per-person setting). 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. ### `` diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js index 992067e6..b298da32 100644 --- a/resources/js/datepicker.js +++ b/resources/js/datepicker.js @@ -11,7 +11,9 @@ * 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). + * passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday + * to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and + * `format` below then follows those. * * 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, @@ -82,8 +84,12 @@ function localToday() { return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` } -/** 0 for Sunday … 6 for Saturday. */ -function firstDayOfWeek(locale) { +/** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */ +function firstDayOfWeek(locale, weekStart = null) { + if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) { + return weekStart + } + try { const tag = new Intl.Locale(locale) const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo @@ -111,9 +117,16 @@ function firstDayOfWeek(locale) { /** * 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`. + * no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen` + * pattern of the same shape (each unit once, one delimiter) replaces the locale's. */ -function inputFormat(locale) { +function inputFormat(locale, chosen = null) { + const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '') + + if (units && new Set([units[1], units[3], units[4]]).size === 3) { + return formatOf(chosen) + } + let pattern = '' try { @@ -131,10 +144,11 @@ function inputFormat(locale) { pattern = '' } - if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) { - pattern = 'yyyy-MM-dd' - } + return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd') +} +/** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */ +function formatOf(pattern) { return { pattern, placeholder: pattern.toUpperCase(), @@ -169,8 +183,8 @@ document.addEventListener('alpine: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.firstDay = firstDayOfWeek(locale, config.weekStart ?? null) + this.format = inputFormat(locale, config.format ?? null) this.numbers = new Intl.NumberFormat(locale, { useGrouping: false }) this.formats = { monthYear: format({ year: 'numeric', month: 'long' }), diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php index b8985a7a..115dd65e 100644 --- a/resources/views/components/datepicker.blade.php +++ b/resources/views/components/datepicker.blade.php @@ -27,8 +27,14 @@ 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`. + `app()->getLocale()` (resources/js/datepicker.js). An application that lets each person choose + overrides the last two: `week-start` is the first day of the week, 0 (Sunday) to 6 (Saturday), + and `format` the typed and displayed format, `dd`, `MM` and `yyyy` in any order around one + delimiter (`.`, `/` or `-`: `dd.MM.yyyy`, `MM/dd/yyyy`, `yyyy-MM-dd`). The field, the typed-date + reader, the calendar's columns and weekday header, Home and End, and the dialog's text fields + all follow them; the names stay the locale's, and `wire:model` still stores `Y-m-d`. A value + that is neither (`week-start="7"`, `format="d.M.yy"`) is ignored, as `null` is. 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 @@ -50,6 +56,8 @@ 'max' => null, 'value' => null, 'clearable' => false, + 'weekStart' => null, + 'format' => null, ]) @php @@ -63,6 +71,10 @@ ? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($errorKey), $errors->get($errorKey.'.*')]))) : []; $locale = str_replace('_', '-', app()->getLocale()); + $weekStart = (is_int($weekStart) || is_string($weekStart)) && preg_match('/^[0-6]\z/', (string) $weekStart) === 1 ? (int) $weekStart : null; + $format = is_string($format) && preg_match('/^(dd|MM|yyyy)([.\/-])(dd|MM|yyyy)\2(dd|MM|yyyy)\z/', $format, $units) === 1 && count(array_unique([$units[1], $units[3], $units[4]])) === 3 + ? $format + : null; $toIso = function (mixed $date): ?string { if ($date instanceof \DateTimeInterface) { @@ -85,9 +97,9 @@ ? ['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)) { + // The typed format: `format`, or as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl. + $pattern = $format ?? 'yyyy-MM-dd'; + if ($format === null && 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) { @@ -110,6 +122,8 @@ 'range' => (bool) $range, 'min' => $toIso($min), 'max' => $toIso($max), + 'weekStart' => $weekStart, + 'format' => $format, 'disabled' => (bool) $attributes->get('disabled'), 'readonly' => (bool) $attributes->get('readonly'), 'strings' => [ diff --git a/resources/views/showcase/sections/pickers.blade.php b/resources/views/showcase/sections/pickers.blade.php index 71f8c1d2..91b1a9a3 100644 --- a/resources/views/showcase/sections/pickers.blade.php +++ b/resources/views/showcase/sections/pickers.blade.php @@ -39,6 +39,19 @@ BLADE, + 'First day of the week and format' => <<<'BLADE' +
+
+ +

Bound value:

+
+ +
+ +

Bound value:

+
+
+ BLADE, 'Limits, errors and states' => <<<'BLADE'
@@ -62,7 +75,7 @@

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. + <x-datepicker> — docked, modal and modal input; single dates and ranges. Month and weekday names, the first day of the week and the typed format follow the application's locale; week-start and format set the last two for someone who chose their own.

@foreach ($examples as $title => $code) diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index 6e70b44d..70b0666c 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -69,6 +69,62 @@ function dateProbe(string $locale = 'en') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); } +class DateFormatProbe extends Component +{ + public ?string $sunday = '2026-09-13'; + + public ?string $iso = '2026-09-13'; + + public ?string $dotted = '2026-09-13'; + + /** @var array{start: ?string, end: ?string} */ + public array $span = ['start' => '2026-09-13', 'end' => '2026-09-15']; + + public function render(): string + { + return <<<'BLADE' +
+

sunday: {{ $sunday }}

+

iso: {{ $iso }}

+

dotted: {{ $dotted }}

+

span: {{ json_encode($span) }}

+ + + + + +
+ BLADE; + } +} + +function dateFormatProbe(string $locale = 'en') +{ + Livewire::component('date-format-probe', DateFormatProbe::class); + + Route::middleware('web')->get('/date-format-probe/{locale}', function (string $locale) { + app()->setLocale($locale); + + return Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE); + }); + + return visit("/date-format-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 { @@ -311,3 +367,87 @@ it('empties a date, or both ends of a range, with its clear button', function () ->assertSeeIn('#trip', '{"start":null,"end":null}') ->assertValue('#trip-field', ''); }); + +it('starts the week on the day week-start names, whatever the locale says', function () { + $page = dateFormatProbe('de') + ->assertValue('#sunday-field', '13.09.2026') + ->click('[aria-controls="sunday-field-picker"][data-datepicker-toggle]') + ->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'") + ->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.value === '2026-08-30'") + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-19')); + $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'Enter') + ->assertSeeIn('#sunday', '2026-09-13') + ->assertValue('#sunday-field', '13.09.2026'); +}); + +it('shows and reads a year-first format and still binds Y-m-d', function () { + $page = dateFormatProbe() + ->assertValue('#iso-field', '2026-09-13') + ->assertAttribute('#iso-field', 'placeholder', 'YYYY-MM-DD') + ->type('#iso-field', '2026-10-01') + ->assertSeeIn('#iso', '2026-10-01'); + + $page->type('#iso-field', '10/02/2026') + ->keys('#iso-field', 'Enter') + ->assertSee('Date does not match expected pattern: YYYY-MM-DD') + ->assertSeeIn('#iso', '2026-10-01'); + + $page->click('[aria-controls="iso-field-picker"][data-datepicker-toggle]') + ->assertScript(focusedDay('2026-10-01')); + + $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02')); + + $page->keys(':focus', 'Enter') + ->assertSeeIn('#iso', '2026-10-02') + ->assertValue('#iso-field', '2026-10-02'); +}); + +it('reads the dialog\'s text field in the given format, not the locale\'s', function () { + $dialog = "document.querySelector('#dotted-field-picker')"; + + $page = dateFormatProbe() + ->assertValue('#dotted-field', '13.09.2026') + ->click('#dotted-field') + ->assertScript("{$dialog}.matches(':modal')") + ->assertScript("document.activeElement.id === 'dotted-field-entry'") + ->assertValue('#dotted-field-entry', '13.09.2026') + ->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY'); + + $page->type('#dotted-field-entry', '2026-10-01') + ->keys('#dotted-field-entry', 'Enter') + ->assertSeeIn('#dotted-field-entry-support', 'Date does not match expected pattern: DD.MM.YYYY') + ->assertScript("{$dialog}.open"); + + $page->type('#dotted-field-entry', '1.10.2026') + ->assertSeeIn('#dotted-field-picker [data-datepicker-headline]', 'Oct 1, 2026') + ->keys('#dotted-field-entry', 'Enter') + ->assertSeeIn('#dotted', '2026-10-01') + ->assertScript("! {$dialog}.open") + ->assertValue('#dotted-field', '01.10.2026'); +}); + +it('lays out and shows a range in the given first day and format', function () { + $dialog = "document.querySelector('#span-field-picker')"; + + $page = dateFormatProbe()->assertValue('#span-field', '13/09/2026 – 15/09/2026'); + + $page->script("document.querySelector('#span-field').focus()"); + + $page->keys('#span-field', 'Enter') + ->assertScript("{$dialog}.matches(':modal')") + ->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'") + ->assertScript(focusedDay('2026-09-13')); + + $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12')); + $page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18')); + + $page->click(day('span-field', '2026-09-20')) + ->click(day('span-field', '2026-09-24')) + ->click('#span-field-picker [data-datepicker-confirm]') + ->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}') + ->assertValue('#span-field', '20/09/2026 – 24/09/2026'); +}); diff --git a/tests/Feature/Components/DatepickerTest.php b/tests/Feature/Components/DatepickerTest.php index 750d1ee2..e1752b17 100644 --- a/tests/Feature/Components/DatepickerTest.php +++ b/tests/Feature/Components/DatepickerTest.php @@ -72,6 +72,27 @@ it('hands min, max and the application locale to the picker as Y-m-d', function ->toMatchArray(['min' => null, 'max' => null]); }); +it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () { + app()->setLocale('de'); + + $html = (string) $this->blade(''); + + expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de']) + ->and($html)->toContain('value="2026-09-13"') + ->and(datepickerConfig((string) $this->blade(''))) + ->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy']) + ->and((string) $this->blade('')) + ->toContain('value="13/09/2026 – 20/09/2026"'); + + foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) { + expect(datepickerConfig((string) $this->blade(""))) + ->toMatchArray(['weekStart' => null, 'format' => null]); + } + + expect(datepickerConfig((string) $this->blade('')))->toMatchArray(['weekStart' => null, 'format' => null]) + ->and((string) $this->blade(''))->toContain('value="13.09.2026"'); +}); + it('shows the value in the locale\'s numeric format before Alpine starts', function () { expect((string) $this->blade('')) ->toContain('value="09/13/2026"')