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
+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()
},
}))
})