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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:13:00 +02:00
co-authored by Claude Fable 5.1
parent 856b6f476b
commit 0074f9bd5c
10 changed files with 69 additions and 14 deletions
@@ -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.
- `<x-checkbox label hint right indeterminate />``indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group.
- `<x-radio label wire:model :options inline />` — 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.
- `<x-toggle label hint right icons />` — 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
+27 -2
View File
@@ -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),
+14 -2
View File
@@ -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()"
@@ -286,7 +286,7 @@
<div data-datepicker-nav x-show="presentation === 'modal'">
<button
type="button"
data-datepicker-menu-button
data-datepicker-menu-button="years"
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
@@ -308,7 +308,7 @@
</span>
<button
type="button"
data-datepicker-menu-button
data-datepicker-menu-button="months"
x-on:click="toggleView('months')"
x-bind:aria-expanded="(view === 'months').toString()"
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
@@ -327,7 +327,7 @@
</span>
<button
type="button"
data-datepicker-menu-button
data-datepicker-menu-button="years"
x-on:click="toggleView('years')"
x-bind:aria-expanded="(view === 'years').toString()"
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
@@ -1,7 +1,9 @@
{{-- A password field, with M3's trailing eye to show what was typed.
The eye flips the control's `type` and nothing else, so the value never leaves the field and a
password manager still sees a password input on load. Its label says what pressing it will do.
password manager still sees a password input on load. Its label says what pressing it will do
M3's own pattern for an interactive trailing icon ("Show password" / "Hide password") — and it
carries no `aria-pressed`, which would say the state a second time and the other way round.
`label`, `hint`, `icon`, `variant`, `size`; every other attribute reaches the `<input>`
(`autocomplete="current-password"`, `wire:model`). --}}
@@ -39,9 +41,7 @@
type="button"
x-on:click="shown = ! shown"
x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))"
x-bind:aria-pressed="shown"
aria-label="{{ __('Show password') }}"
aria-pressed="false"
class="field-trailing field-button"
data-field-reveal
>
+6 -1
View File
@@ -7,7 +7,12 @@
(600px, M3's compact/medium boundary — compact always stacks), and
`value` for the option chosen when the page loads (with `wire:model`, Livewire sets it).
Errors are read from the bag under the `wire:model` name
(resources/css/components/selection.css). --}}
(resources/css/components/selection.css).
M3 stacks radio buttons vertically and cautions against a row at any width, so `inline` is for
a group of two or three short labels that would waste a column, not the shape to reach for.
It also asks for five options or fewer and for one of them to be chosen when the page loads:
give the group a `value` (or a bound property that already holds one). --}}
@props([
'label' => null,
+1 -1
View File
@@ -116,7 +116,7 @@ it('shows and hides a password', function () {
->type('#password-field', 'secret')
->click('[data-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-field-reveal]', 'aria-pressed', 'true')
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
->click('[data-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'password'");
});
+3
View File
@@ -52,6 +52,9 @@ it('draws a searchable combobox with a popover list', function () {
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
->toContain('x-modelable="value"')
->toContain('field-arrow')
// WAI-ARIA's combobox keyboard, Home and End included.
->toContain('x-on:keydown.home="jump($event, false)"')
->toContain('x-on:keydown.end="jump($event, true)"')
->toContain('Nothing matches');
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
@@ -54,6 +54,14 @@ it('makes the field a read-only trigger for the modal and modal input pickers',
->and(datepickerConfig($html)['mode'])->toBe($mode);
})->with(['modal', 'input']);
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
expect($html)
->toContain('data-datepicker-menu-button="months"')
->toContain('data-datepicker-menu-button="years"');
});
it('falls back to the docked picker for an unknown mode', function () {
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" mode="wheel" />'))['mode'])->toBe('docked');
});
+3 -1
View File
@@ -93,7 +93,9 @@ it('lets a password be shown and hidden again', function () {
->not->toContain('type="text"')
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
->toContain('data-field-reveal')
->toContain('aria-pressed="false"')
->toContain('aria-label="Show password"')
// The flipping label already says the state; aria-pressed would say it again, inverted.
->not->toContain('aria-pressed')
->toContain('autocomplete="current-password"');
});