Walk a chip set with the arrow keys
M3's chip keyboard table makes a set one tab stop that the arrows move through; ten filter chips cost ten Tab presses today. The set now keeps a roving tabindex — re-applied whenever a Livewire morph rewrites the chips — and walks left, right, Home and End, following the writing direction. The input chip's remove button also grows to a full 48x48 target, where it was 34px wide. Plan step 20, findings IN-13 and IN-14. 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
47ed4603d0
commit
dd3d8afdb9
@@ -531,7 +531,7 @@ 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).
|
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.
|
||||||
|
|
||||||
### `<x-form>`
|
### `<x-form>`
|
||||||
|
|
||||||
|
|||||||
+76
-3
@@ -9,6 +9,12 @@
|
|||||||
* 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. Its row carries `wire:ignore.self`, so a
|
||||||
* Livewire morph keeps the marks.
|
* Livewire morph keeps the marks.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* even though many can be selected"). Left and right follow the writing direction; the ring wraps,
|
||||||
|
* and Home and End go to its ends. The roving mark is re-applied whenever the set changes, because
|
||||||
|
* a Livewire morph rewrites the chips underneath it.
|
||||||
*/
|
*/
|
||||||
const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
|
const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
|
||||||
|
|
||||||
@@ -143,15 +149,82 @@ document.addEventListener('alpine:init', () => {
|
|||||||
resize.observe(row)
|
resize.observe(row)
|
||||||
this.observers.push(() => resize.disconnect())
|
this.observers.push(() => resize.disconnect())
|
||||||
|
|
||||||
const mutation = new MutationObserver(mark)
|
const settle = () => {
|
||||||
mutation.observe(row, { childList: true, subtree: true })
|
mark()
|
||||||
|
this.rove()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = new MutationObserver(settle)
|
||||||
|
mutation.observe(row, { childList: true, subtree: true, attributeFilter: ['tabindex', 'disabled'] })
|
||||||
this.observers.push(() => mutation.disconnect())
|
this.observers.push(() => mutation.disconnect())
|
||||||
|
|
||||||
mark()
|
settle()
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.observers.forEach((stop) => stop())
|
this.observers.forEach((stop) => stop())
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Everything in the set the keyboard can reach, in the order it is written. */
|
||||||
|
controls() {
|
||||||
|
return [...this.$el.querySelectorAll(CONTROLS)]
|
||||||
|
},
|
||||||
|
|
||||||
|
/** One tab stop: the control focus is on, or the first one, is the only one Tab reaches. */
|
||||||
|
rove(focused = null) {
|
||||||
|
const controls = this.controls()
|
||||||
|
|
||||||
|
if (controls.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (focused !== null && controls.includes(focused) ? focused : null) ?? controls.find((control) => control.tabIndex === 0) ?? controls[0]
|
||||||
|
|
||||||
|
// Only where it differs: the mutation observer watches tabindex, and writing it back
|
||||||
|
// every time would call this from its own changes.
|
||||||
|
for (const control of controls) {
|
||||||
|
const wanted = control === current ? 0 : -1
|
||||||
|
|
||||||
|
if (control.tabIndex !== wanted) {
|
||||||
|
control.tabIndex = wanted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
key(event) {
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = this.controls()
|
||||||
|
const index = controls.indexOf(document.activeElement)
|
||||||
|
|
||||||
|
if (controls.length === 0 || index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwards = getComputedStyle(this.$el).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||||
|
const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
|
||||||
|
|
||||||
|
let next = null
|
||||||
|
|
||||||
|
if (event.key === forwards || event.key === 'ArrowDown') {
|
||||||
|
next = (index + 1) % controls.length
|
||||||
|
} else if (event.key === backwards || event.key === 'ArrowUp') {
|
||||||
|
next = (index - 1 + controls.length) % controls.length
|
||||||
|
} else if (event.key === 'Home') {
|
||||||
|
next = 0
|
||||||
|
} else if (event.key === 'End') {
|
||||||
|
next = controls.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
this.rove(controls[next])
|
||||||
|
controls[next].focus()
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
|
|
||||||
`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: 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. --}}
|
with Tab scrolls 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
|
||||||
|
chip. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
@@ -42,13 +46,15 @@
|
|||||||
<div
|
<div
|
||||||
data-chip-set
|
data-chip-set
|
||||||
x-data="materialChipSet"
|
x-data="materialChipSet"
|
||||||
|
x-on:keydown="key($event)"
|
||||||
|
x-on:focusin="rove($event.target)"
|
||||||
wire:ignore.self
|
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)]"
|
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 }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div data-chip-set 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">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -35,9 +35,11 @@
|
|||||||
Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens,
|
Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens,
|
||||||
InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall,
|
InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall,
|
||||||
small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an
|
small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an
|
||||||
icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The check grows in on the
|
icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The chip and the remove
|
||||||
fast spatial spring and fades in on the slow effects one; it shrinks on default effects and
|
button each catch presses over 48×48, which M3 asks for even though it means reaching past the
|
||||||
fades on fast effects, as AnimatingChipContent does. --}}
|
chip and over the label's own strip ("the target may extend beyond the visible chip
|
||||||
|
container"). The check grows in on the fast spatial spring and fades in on the slow effects
|
||||||
|
one; it shrinks on default effects and fades on fast effects, as AnimatingChipContent does. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'type' => 'assist',
|
'type' => 'assist',
|
||||||
@@ -247,7 +249,7 @@
|
|||||||
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
|
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
|
||||||
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled,
|
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled,
|
||||||
'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
|
'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
|
||||||
'after:absolute after:-inset-x-2 after:-inset-y-3.75',
|
'after:absolute after:-inset-3.75',
|
||||||
'cursor-not-allowed' => $disabled,
|
'cursor-not-allowed' => $disabled,
|
||||||
])
|
])
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ it('gives a removable input chip a named remove button that calls wire:remove',
|
|||||||
->toContain('wire:click="removeRecipient(3)"')
|
->toContain('wire:click="removeRecipient(3)"')
|
||||||
->toContain('x-on:click="removeChip($event)"')
|
->toContain('x-on:click="removeChip($event)"')
|
||||||
->toContain('<input type="hidden" name="to[]" value="3" />')
|
->toContain('<input type="hidden" name="to[]" value="3" />')
|
||||||
->toContain('ps-2.75 pe-2');
|
->toContain('ps-2.75 pe-2')
|
||||||
|
// 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target.
|
||||||
|
->toContain('after:absolute after:-inset-3.75');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
|
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
|
||||||
@@ -219,10 +221,13 @@ it('groups chips in a wrapping row named by its label, with a hint', function ()
|
|||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('role="group"')
|
->toContain('role="group"')
|
||||||
->toContain('data-chip-set class="flex flex-wrap gap-2"')
|
->toContain('data-chip-set x-data="materialChipSet"')
|
||||||
|
->toContain('flex flex-wrap gap-2')
|
||||||
|
// M3's chip keyboard: the set is one tab stop and the arrows walk it.
|
||||||
|
->toContain('x-on:keydown="key($event)"')
|
||||||
|
->toContain('x-on:focusin="rove($event.target)"')
|
||||||
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
|
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
|
||||||
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>")
|
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
|
||||||
->not->toContain('materialChipSet');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scrolls a chip set on one line with fading edges', function () {
|
it('scrolls a chip set on one line with fading edges', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user