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:
co-authored by
Claude Fable 5.1
parent
549d96b0dc
commit
c8357cfd4d
@@ -531,7 +531,9 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d
|
||||
|
||||
### `<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>`
|
||||
|
||||
|
||||
+18
-5
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>'))
|
||||
->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('<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 () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user