diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 05894475..c3f235e1 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -531,7 +531,7 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d ### `` -A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards). +A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends. ### `` diff --git a/resources/js/chips.js b/resources/js/chips.js index c9e1197c..8c4c7011 100644 --- a/resources/js/chips.js +++ b/resources/js/chips.js @@ -9,6 +9,12 @@ * A scrolling set marks the edges it can still scroll towards (`data-scroll-start`, * `data-scroll-end`), and the set fades those edges. Its row carries `wire:ignore.self`, so a * Livewire morph keeps the marks. + * + * A set is also one tab stop with a roving tabindex, and the arrow keys walk its controls, which is + * M3's chip keyboard table ("Arrows: moves focus between chips"; "only one chip can be in focus + * even though many can be selected"). Left and right follow the writing direction; the ring wraps, + * and Home and End go to its ends. The roving mark is re-applied whenever the set changes, because + * a Livewire morph rewrites the chips underneath it. */ const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])' @@ -143,15 +149,82 @@ document.addEventListener('alpine:init', () => { resize.observe(row) this.observers.push(() => resize.disconnect()) - const mutation = new MutationObserver(mark) - mutation.observe(row, { childList: true, subtree: true }) + const settle = () => { + mark() + this.rove() + } + + const mutation = new MutationObserver(settle) + mutation.observe(row, { childList: true, subtree: true, attributeFilter: ['tabindex', 'disabled'] }) this.observers.push(() => mutation.disconnect()) - mark() + settle() }, destroy() { this.observers.forEach((stop) => stop()) }, + + /** Everything in the set the keyboard can reach, in the order it is written. */ + controls() { + return [...this.$el.querySelectorAll(CONTROLS)] + }, + + /** One tab stop: the control focus is on, or the first one, is the only one Tab reaches. */ + rove(focused = null) { + const controls = this.controls() + + if (controls.length === 0) { + return + } + + const current = (focused !== null && controls.includes(focused) ? focused : null) ?? controls.find((control) => control.tabIndex === 0) ?? controls[0] + + // Only where it differs: the mutation observer watches tabindex, and writing it back + // every time would call this from its own changes. + for (const control of controls) { + const wanted = control === current ? 0 : -1 + + if (control.tabIndex !== wanted) { + control.tabIndex = wanted + } + } + }, + + key(event) { + if (event.ctrlKey || event.metaKey || event.altKey) { + return + } + + const controls = this.controls() + const index = controls.indexOf(document.activeElement) + + if (controls.length === 0 || index === -1) { + return + } + + const forwards = getComputedStyle(this.$el).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight' + const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight' + + let next = null + + if (event.key === forwards || event.key === 'ArrowDown') { + next = (index + 1) % controls.length + } else if (event.key === backwards || event.key === 'ArrowUp') { + next = (index - 1 + controls.length) % controls.length + } else if (event.key === 'Home') { + next = 0 + } else if (event.key === 'End') { + next = controls.length - 1 + } + + if (next === null) { + return + } + + event.preventDefault() + this.rove(controls[next]) + controls[next].focus() + }, })) }) diff --git a/resources/views/components/chip-set.blade.php b/resources/views/components/chip-set.blade.php index d334b72c..df0b765d 100644 --- a/resources/views/components/chip-set.blade.php +++ b/resources/views/components/chip-set.blade.php @@ -11,7 +11,11 @@ `scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow screen: the edge it can still scroll towards fades (resources/js/chips.js), and a chip reached - with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set. --}} + with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set. + + The set is one tab stop and the arrow keys move between the chips inside it, with Home and End + at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input + chip. --}} @props([ 'label' => null, @@ -42,13 +46,15 @@
{{ $slot }}
@else -
+
{{ $slot }}
@endif diff --git a/resources/views/components/chip.blade.php b/resources/views/components/chip.blade.php index 4a60efcd..a17c6cd1 100644 --- a/resources/views/components/chip.blade.php +++ b/resources/views/components/chip.blade.php @@ -35,9 +35,11 @@ Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens, InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall, small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an - icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The check grows in on the - fast spatial spring and fades in on the slow effects one; it shrinks on default effects and - fades on fast effects, as AnimatingChipContent does. --}} + icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The chip and the remove + button each catch presses over 48×48, which M3 asks for even though it means reaching past the + chip and over the label's own strip ("the target may extend beyond the visible chip + container"). The check grows in on the fast spatial spring and fades in on the slow effects + one; it shrinks on default effects and fades on fast effects, as AnimatingChipContent does. --}} @props([ 'type' => 'assist', @@ -247,7 +249,7 @@ 'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full', 'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled, 'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled, - 'after:absolute after:-inset-x-2 after:-inset-y-3.75', + 'after:absolute after:-inset-3.75', 'cursor-not-allowed' => $disabled, ]) > diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php index 02bce831..d984e6ff 100644 --- a/tests/Feature/Components/ChipTest.php +++ b/tests/Feature/Components/ChipTest.php @@ -186,7 +186,9 @@ it('gives a removable input chip a named remove button that calls wire:remove', ->toContain('wire:click="removeRecipient(3)"') ->toContain('x-on:click="removeChip($event)"') ->toContain('') - ->toContain('ps-2.75 pe-2'); + ->toContain('ps-2.75 pe-2') + // 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target. + ->toContain('after:absolute after:-inset-3.75'); }); it('runs an Alpine remove expression, or takes the chip off the page with neither', function () { @@ -219,10 +221,13 @@ it('groups chips in a wrapping row named by its label, with a hint', function () expect($html) ->toContain('role="group"') - ->toContain('data-chip-set class="flex flex-wrap gap-2"') + ->toContain('data-chip-set x-data="materialChipSet"') + ->toContain('flex flex-wrap gap-2') + // M3's chip keyboard: the set is one tab stop and the arrows walk it. + ->toContain('x-on:keydown="key($event)"') + ->toContain('x-on:focusin="rove($event.target)"') ->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types

") - ->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these

") - ->not->toContain('materialChipSet'); + ->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these

"); }); it('scrolls a chip set on one line with fading edges', function () {