Give a scrolling chip set a scroll button at each edge

M3 § Chips, Accessibility asks that a row which overflows horizontally carry a
visible affordance; `<x-chip-set scroll>` faded its edges and left it there
(plan step 24, audit docs/audits/m3-alignment/inputs.md § Missing). Where the
pointer is fine, and so there is no swipe to reach for, a button now sits over
each fading edge and scrolls the row by most of its width. They are pointer
affordances only — no tab stops, since the arrow keys already walk every chip —
and the row's scroll padding grows on a fine pointer so a chip the keyboard
reaches clears the buttons as well as the fade. The scroller became a ref,
because the buttons stand outside it.

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:43:38 +02:00
co-authored by Claude Fable 5.1
parent 549d96b0dc
commit c8357cfd4d
5 changed files with 81 additions and 18 deletions
+18 -5
View File
@@ -7,8 +7,11 @@
* after it (Delete), because a focused element that disappears leaves focus on the body.
*
* 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.
* `data-scroll-end`), and the set fades those edges and — where the pointer is fine, so there is no
* swipe — puts a button over each of them, which is the visible affordance M3's chips accessibility
* page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the
* marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref,
* because the buttons stand outside the scroller.
*
* 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
@@ -126,9 +129,11 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialChipSet', () => ({
observers: [],
row: null,
init() {
const row = this.$el
// A scrolling set hangs its buttons outside the scroller, so the row is a ref there.
const row = (this.row = this.$refs.row ?? this.$el)
const mark = () => {
// scrollLeft runs negative towards the end in a right-to-left row.
@@ -167,7 +172,15 @@ document.addEventListener('alpine:init', () => {
/** Everything in the set the keyboard can reach, in the order it is written. */
controls() {
return [...this.$el.querySelectorAll(CONTROLS)]
return [...this.row.querySelectorAll(CONTROLS)]
},
/** A scroll button: most of a row's width towards one of its ends. */
nudge(towards) {
const rtl = getComputedStyle(this.row).direction === 'rtl'
const step = Math.max(this.row.clientWidth * 0.8, 120)
this.row.scrollBy({ left: (towards === 'start' ? -1 : 1) * (rtl ? -1 : 1) * step, behavior: 'smooth' })
},
/** One tab stop: the control focus is on, or the first one, is the only one Tab reaches. */
@@ -203,7 +216,7 @@ document.addEventListener('alpine:init', () => {
return
}
const forwards = getComputedStyle(this.$el).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
const forwards = getComputedStyle(this.row).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
let next = null