From 0074f9bd5c272ff55d8232a3352d7d965d419034 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:13:00 +0200 Subject: [PATCH] Finish the keyboards, and say a state once Shift+M and Shift+Y reach the month and year dropdowns, which is M3's date picker keyboard table; Home and End go to the ends of an open combobox list, and stay the field's own caret keys when it is closed. The password reveal drops aria-pressed and keeps only its flipping label, so a screen reader hears the state once rather than twice and inverted. The radio's `inline` now says that M3 cautions against a row and wants an option chosen on load. Plan step 20, findings IN-22, IN-23, IN-28 and IN-29. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/js/datepicker.js | 29 +++++++++++++++++-- resources/views/components/choices.blade.php | 16 ++++++++-- .../views/components/datepicker.blade.php | 6 ++-- resources/views/components/password.blade.php | 6 ++-- resources/views/components/radio.blade.php | 7 ++++- tests/Browser/FieldsTest.php | 2 +- tests/Feature/Components/ChoicesTest.php | 3 ++ tests/Feature/Components/DatepickerTest.php | 8 +++++ tests/Feature/Components/FieldTest.php | 4 ++- 10 files changed, 69 insertions(+), 14 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index e449690f..30784477 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -564,7 +564,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire M3 selection controls on native inputs; the whole row is the label. - `` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). -- `` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. +- `` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads. - `` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`. ```blade diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js index 84c57f86..cc439c99 100644 --- a/resources/js/datepicker.js +++ b/resources/js/datepicker.js @@ -18,8 +18,11 @@ * * 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. + * Enter selects and confirms, Escape closes and hands focus back to the field. M3's own table says + * Home and End "move to the first day of the month"; the APG behaviour is the stronger web + * convention and is what this keeps. Shift+M and Shift+Y are M3's, and reach the month and year + * dropdowns. Focus never leaves the `min`–`max` span, so a disabled day cannot be chosen from the + * keyboard either. */ import { upTo } from './breakpoints.js' @@ -576,11 +579,33 @@ document.addEventListener('alpine:init', () => { return config.range ? value.start : value }, + /** + * The month or the year dropdown, whichever of them this presentation draws: the docked + * picker has one of each, the modal picker a single button that opens the years. + */ + menuButton(view) { + const buttons = [...this.$refs.dialog.querySelectorAll('[data-datepicker-menu-button]')].filter((button) => button.getClientRects().length > 0) + + return buttons.find((button) => button.dataset.datepickerMenuButton === view) ?? buttons[0] ?? null + }, + gridKey(event) { const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl' const focused = this.focused const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7 + // M3's keyboard table: Shift+M moves to the month dropdown, Shift+Y to the year one. + if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && ['M', 'Y'].includes(event.key.toUpperCase())) { + const button = this.menuButton(event.key.toUpperCase() === 'M' ? 'months' : 'years') + + if (button) { + event.preventDefault() + button.focus() + } + + return + } + const target = { ArrowRight: () => addDays(focused, rtl ? -1 : 1), ArrowLeft: () => addDays(focused, rtl ? 1 : -1), diff --git a/resources/views/components/choices.blade.php b/resources/views/components/choices.blade.php index b485af4f..3e7087a3 100644 --- a/resources/views/components/choices.blade.php +++ b/resources/views/components/choices.blade.php @@ -7,8 +7,10 @@ you are free" is seven filter chips, not a dropdown opened seven times. `single` makes them choice chips, one on at a time. - **`searchable`**, for a list too long to lay out — four hundred time zones. A text field - that filters as you type, with the matches in M3's menu under it: the arrow keys move, - Enter chooses, Escape puts the field back. One value only. The list is a popover in the top + that filters as you type, with the matches in M3's menu under it: the arrow keys move, Home + and End go to the ends of the open list (with it closed they are the field's own and move the + caret), Enter chooses, Escape puts the field back — WAI-ARIA's combobox keyboard, since M3 + has no combobox. One value only. The list is a popover in the top layer, placed by CSS anchor positioning, so a card's clipping never cuts it off. `icon`, `variant` and `placeholder` are the field's. @@ -104,6 +106,14 @@ this.active = (this.active + step + this.filtered.length) % this.filtered.length; this.$nextTick(() => this.reveal()); }, + // The APG combobox puts Home and End at the ends of the open popup. With the popup + // closed they are the text field's own, and move the caret. + jump(event, last) { + if (! this.open || this.filtered.length === 0) return; + event.preventDefault(); + this.active = last ? this.filtered.length - 1 : 0; + this.$nextTick(() => this.reveal()); + }, choose(option) { if (! option || option.disabled) return; this.value = option.value; @@ -139,6 +149,8 @@ x-on:input="open = true; active = 0" x-on:keydown.arrow-down.prevent="move(1)" x-on:keydown.arrow-up.prevent="move(-1)" + x-on:keydown.home="jump($event, false)" + x-on:keydown.end="jump($event, true)" x-on:keydown.enter.prevent="choose(filtered[active])" x-on:keydown.escape="close()" x-on:keydown.tab="close()" diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php index 9d9bc1d9..033fec61 100644 --- a/resources/views/components/datepicker.blade.php +++ b/resources/views/components/datepicker.blade.php @@ -286,7 +286,7 @@