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>`
|
### `<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
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user