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:
co-authored by
Claude Fable 5.1
parent
856b6f476b
commit
0074f9bd5c
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user