From fb29169d44ce3b06f3b13c02ac2d044f5676526f Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 17:58:15 +0200 Subject: [PATCH] Let the date picker take a first day of the week and a format An application that lets each person choose when their week starts and how dates are written needs the picker to follow that choice rather than the locale. takes week-start, 0 (Sunday) to 6 (Saturday), and format, dd, MM and yyyy each once around one delimiter (dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what Intl derives for firstDayOfWeek() and inputFormat(), so the field, the server-rendered value, the typed-date reader (year first too), the calendar's columns and weekday header, Home and End, the dialog's text fields and ranges all follow them. Month and weekday names stay the locale's, and wire:model still stores Y-m-d. Values that are neither are ignored, and without the props nothing changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 5 +- resources/js/datepicker.js | 34 +++-- .../views/components/datepicker.blade.php | 24 ++- .../views/showcase/sections/pickers.blade.php | 15 +- tests/Browser/DatepickerTest.php | 140 ++++++++++++++++++ tests/Feature/Components/DatepickerTest.php | 21 +++ 6 files changed, 222 insertions(+), 17 deletions(-) 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"')