From c8357cfd4dfbd372c6610b1df3fe5324a96493df Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:43:38 +0200 Subject: [PATCH] Give a scrolling chip set a scroll button at each edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 § Chips, Accessibility asks that a row which overflows horizontally carry a visible affordance; `` 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 4 +- resources/js/chips.js | 23 ++++++++-- resources/views/components/chip-set.blade.php | 46 ++++++++++++++----- .../views/showcase/sections/chips.blade.php | 3 +- tests/Feature/Components/ChipTest.php | 23 ++++++++++ 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 11745975..13c21a42 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -531,7 +531,9 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d ### `` -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. ### `` diff --git a/resources/js/chips.js b/resources/js/chips.js index 8c4c7011..2b1e86c5 100644 --- a/resources/js/chips.js +++ b/resources/js/chips.js @@ -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 diff --git a/resources/views/components/chip-set.blade.php b/resources/views/components/chip-set.blade.php index df0b765d..9857bf7f 100644 --- a/resources/views/components/chip-set.blade.php +++ b/resources/views/components/chip-set.blade.php @@ -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) -
- {{ $slot }} +
+
+ {{ $slot }} +
+ + {{-- 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) + + @endforeach
@else
diff --git a/resources/views/showcase/sections/chips.blade.php b/resources/views/showcase/sections/chips.blade.php index 42de4e12..8e6da8da 100644 --- a/resources/views/showcase/sections/chips.blade.php +++ b/resources/views/showcase/sections/chips.blade.php @@ -52,8 +52,9 @@ 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. --}}
- + diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php index d984e6ff..8f8d57e7 100644 --- a/tests/Feature/Components/ChipTest.php +++ b/tests/Feature/Components/ChipTest.php @@ -234,12 +234,35 @@ it('scrolls a chip set on one line with fading edges', function () { expect((string) $this->blade('')) ->toContain('aria-label="Sort"') ->toContain('x-data="materialChipSet"') + ->toContain('x-ref="row"') ->toContain('wire:ignore.self') ->toContain('overflow-x-auto') ->toContain('data-scroll-start:[--chip-fade-start:1.5rem]') ->not->toContain('flex-wrap'); }); +it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () { + $html = (string) $this->blade(''); + + 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('')) + ->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 () { $component = new class extends Component {