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
+35 -11
View File
@@ -10,8 +10,12 @@
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
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.
screen. M3's chips accessibility page asks that a row which overflows say so, so the edge it
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
at the ends — M3's chip keyboard table. Backspace and Delete still remove a focused input
@@ -43,15 +47,35 @@
@endif
@if ($scroll)
<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 x-data="materialChipSet" class="relative">
<div
data-chip-set
x-ref="row"
x-on:keydown="key($event)"
x-on:focusin="rove($event.target)"
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 }}
</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>
@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">
@@ -52,8 +52,9 @@
</x-chip-set>
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">
<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="Largest" name="sort[]" value="largest" />
<x-chip type="filter" label="Expiring soon" name="sort[]" value="expiring" />