Walk a chip set with the arrow keys

M3's chip keyboard table makes a set one tab stop that the arrows move
through; ten filter chips cost ten Tab presses today. The set now keeps a
roving tabindex — re-applied whenever a Livewire morph rewrites the chips
— and walks left, right, Home and End, following the writing direction.
The input chip's remove button also grows to a full 48x48 target, where
it was 34px wide.

Plan step 20, findings IN-13 and IN-14.

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:05:41 +02:00
co-authored by Claude Fable 5.1
parent 47ed4603d0
commit dd3d8afdb9
5 changed files with 100 additions and 14 deletions
@@ -531,7 +531,7 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d
### `<x-chip-set>`
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.
### `<x-form>`
+76 -3
View File
@@ -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()
},
}))
})
@@ -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 @@
<div
data-chip-set
x-data="materialChipSet"
x-on:keydown="key($event)"
x-on:focusin="rove($event.target)"
wire:ignore.self
class="-mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
>
{{ $slot }}
</div>
@else
<div data-chip-set class="flex flex-wrap gap-2">
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" class="flex flex-wrap gap-2">
{{ $slot }}
</div>
@endif
+6 -4
View File
@@ -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,
])
>
+9 -4
View File
@@ -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('<input type="hidden" name="to[]" value="3" />')
->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</p>")
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>")
->not->toContain('materialChipSet');
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
});
it('scrolls a chip set on one line with fading edges', function () {