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
@@ -531,7 +531,9 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d
### `<x-chip-set>` ### `<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). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends. 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). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends.
With `scroll`, M3's overflow affordance is drawn for you: the edge the row can still scroll towards fades, and where the pointer is fine (a mouse, no swipe to reach for) a small button sits over each fading edge and scrolls the row by most of its width. The buttons are pointer-only — not tab stops — because the arrow keys already walk every chip and scroll each one clear of both the fade and the buttons.
### `<x-form>` ### `<x-form>`
+18 -5
View File
@@ -7,8 +7,11 @@
* after it (Delete), because a focused element that disappears leaves focus on the body. * 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`, * 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 * `data-scroll-end`), and the set fades those edges and where the pointer is fine, so there is no
* Livewire morph keeps the marks. * 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 * 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 * 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', () => ({ window.Alpine.data('materialChipSet', () => ({
observers: [], observers: [],
row: null,
init() { 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 = () => { const mark = () => {
// scrollLeft runs negative towards the end in a right-to-left row. // 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. */ /** Everything in the set the keyboard can reach, in the order it is written. */
controls() { 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. */ /** 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 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' const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
let next = null let next = null
+35 -11
View File
@@ -10,8 +10,12 @@
its items: `kinds` and `kinds.*`) replaces the hint. its items: `kinds` and `kinds.*`) replaces the hint.
`scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a narrow `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 screen. M3's chips accessibility page asks that a row which overflows say so, so the edge it
with Tab scrolls clear of the fade. Removing a focused input chip moves focus within the set. can still scroll towards fades (resources/js/chips.js) and, where the pointer is fine and
there is no swipe to reach for, a button sits over each fading edge and scrolls the row by
most of its width. The buttons are pointer affordances only `tabindex="-1"` and
`aria-hidden`, because the arrow keys already walk every chip and scroll each one 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 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 at the ends M3's chip keyboard table. Backspace and Delete still remove a focused input
@@ -43,15 +47,35 @@
@endif @endif
@if ($scroll) @if ($scroll)
<div <div x-data="materialChipSet" class="relative">
data-chip-set <div
x-data="materialChipSet" data-chip-set
x-on:keydown="key($event)" x-ref="row"
x-on:focusin="rove($event.target)" x-on:keydown="key($event)"
wire:ignore.self x-on:focusin="rove($event.target)"
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)]" wire:ignore.self
> class="peer -mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 pointer-fine:scroll-px-10 [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 }} >
{{ $slot }}
</div>
{{-- After the row, so each button can ask whether the row can still scroll its way. --}}
@foreach (['start' => 'chevron_left', 'end' => 'chevron_right'] as $edge => $glyph)
<button
type="button"
tabindex="-1"
aria-hidden="true"
data-chip-scroll="{{ $edge }}"
x-on:click="nudge('{{ $edge }}')"
@class([
'absolute inset-y-0 my-auto hidden size-8 place-items-center rounded-corner-full bg-surface-container-high text-on-surface-variant shadow-elevation-1',
'start-0 peer-data-scroll-start:pointer-fine:grid' => $edge === 'start',
'end-0 peer-data-scroll-end:pointer-fine:grid' => $edge === 'end',
])
>
<x-livewire-material::icon :name="$glyph" class="size-4.5 rtl:-scale-x-100" optical="20" />
</button>
@endforeach
</div> </div>
@else @else
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" 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">
@@ -52,8 +52,9 @@
</x-chip-set> </x-chip-set>
BLADE, BLADE,
'A set that scrolls' => <<<'BLADE' 'A set that scrolls' => <<<'BLADE'
{{-- M3 asks an overflowing chip row for a visible affordance: the edge it can still scroll towards fades, and on a mouse a button sits over it. --}}
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<x-chip-set label="Sort and filter" scroll> <x-chip-set label="Sort and filter" hint="Drag, or use the arrow keys" scroll>
<x-chip type="filter" label="Newest" name="sort[]" value="newest" :selected="true" /> <x-chip type="filter" label="Newest" name="sort[]" value="newest" :selected="true" />
<x-chip type="filter" label="Largest" name="sort[]" value="largest" /> <x-chip type="filter" label="Largest" name="sort[]" value="largest" />
<x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" /> <x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" />
+23
View File
@@ -234,12 +234,35 @@ it('scrolls a chip set on one line with fading edges', function () {
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>')) expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->toContain('aria-label="Sort"') ->toContain('aria-label="Sort"')
->toContain('x-data="materialChipSet"') ->toContain('x-data="materialChipSet"')
->toContain('x-ref="row"')
->toContain('wire:ignore.self') ->toContain('wire:ignore.self')
->toContain('overflow-x-auto') ->toContain('overflow-x-auto')
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]') ->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
->not->toContain('flex-wrap'); ->not->toContain('flex-wrap');
}); });
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html)
->toContain('data-chip-scroll="start"')
->toContain('data-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"')
->toContain('x-on:click="nudge(\'end\')"')
// Only while the row can still scroll that way, and only where the pointer is fine.
->toContain('peer-data-scroll-start:pointer-fine:grid')
->toContain('peer-data-scroll-end:pointer-fine:grid')
// A pointer affordance, not a tab stop: the arrows already walk every chip.
->toContain('tabindex="-1"')
->toContain('aria-hidden="true"')
// A chip the keyboard reaches clears the buttons as well as the fade.
->toContain('pointer-fine:scroll-px-10');
// A wrapping set has no edge to scroll towards.
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
->not->toContain('data-chip-scroll');
});
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () { it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
$component = new class extends Component $component = new class extends Component
{ {