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
231 lines
8.9 KiB
JavaScript
231 lines
8.9 KiB
JavaScript
/**
|
|
* `materialChip`: a removable `<x-chip type="input">`. `materialChipSet`: a scrolling `<x-chip-set>`.
|
|
*
|
|
* Removing always goes through the chip's remove button, so a Backspace or Delete runs exactly
|
|
* what a press on it runs — its `wire:click` (from `wire:remove`), its Alpine expression, or the
|
|
* chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
|
|
* 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.
|
|
*
|
|
* 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"])'
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.data('materialChip', () => ({
|
|
init() {
|
|
const button = this.removeButton()
|
|
const label = this.$root.querySelector('[data-chip-label]')
|
|
|
|
// A chip rendered by x-for has no label on the server: its button carries the translated
|
|
// words with a `:label` placeholder, filled in once Alpine has written the label.
|
|
if (button?.dataset.chipRemove && label) {
|
|
this.$nextTick(() => {
|
|
const text = label.textContent.trim()
|
|
|
|
if (text !== '' && !button.hasAttribute('aria-label')) {
|
|
button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
removeButton() {
|
|
return this.$root.querySelector('[data-chip-remove]')
|
|
},
|
|
|
|
removeOnKey(event) {
|
|
if ((event.key !== 'Backspace' && event.key !== 'Delete') || event.ctrlKey || event.metaKey || event.altKey) {
|
|
return
|
|
}
|
|
|
|
const button = this.removeButton()
|
|
|
|
if (!button || button.disabled) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
this.handOffFocus(event.key === 'Backspace')
|
|
button.click()
|
|
},
|
|
|
|
removeChip() {
|
|
if (this.$root.contains(document.activeElement)) {
|
|
this.handOffFocus(false)
|
|
}
|
|
},
|
|
|
|
handOffFocus(backwards) {
|
|
const chip = this.$root
|
|
const set = chip.closest('[data-chip-set]') ?? chip.parentElement
|
|
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
|
|
const index = chips.indexOf(chip)
|
|
const before = chips.slice(0, index).reverse()
|
|
const after = chips.slice(index + 1)
|
|
const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
|
|
|
|
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
|
|
const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
|
|
|
|
if (target) {
|
|
target.focus()
|
|
this.holdFocus(target, set)
|
|
|
|
return
|
|
}
|
|
}
|
|
},
|
|
|
|
// Livewire morphs without lookahead: removing a chip re-creates every keyed chip after it,
|
|
// and the focused one goes with them. Until the person moves focus themselves (or ten
|
|
// seconds pass), whenever the set changes and focus has fallen away, focus the same
|
|
// control again — the element itself, or the chip with its `wire:key` after a morph.
|
|
holdFocus(target, set) {
|
|
const key = target.closest('[data-chip]')?.getAttribute('wire:key')
|
|
const onRemove = target.hasAttribute('data-chip-remove')
|
|
|
|
const current = () => {
|
|
if (target.isConnected || !key) {
|
|
return target.isConnected ? target : null
|
|
}
|
|
|
|
const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
|
|
|
|
return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
|
|
}
|
|
|
|
const observer = new MutationObserver(() => {
|
|
const active = document.activeElement
|
|
const control = current()
|
|
|
|
if (!control || (active && active !== document.body && active !== control)) {
|
|
stop()
|
|
} else if (active !== control) {
|
|
control.focus()
|
|
}
|
|
})
|
|
|
|
const timer = setTimeout(() => stop(), 10000)
|
|
|
|
const stop = () => {
|
|
observer.disconnect()
|
|
clearTimeout(timer)
|
|
}
|
|
|
|
observer.observe(set, { childList: true, subtree: true })
|
|
},
|
|
}))
|
|
|
|
window.Alpine.data('materialChipSet', () => ({
|
|
observers: [],
|
|
|
|
init() {
|
|
const row = this.$el
|
|
|
|
const mark = () => {
|
|
// scrollLeft runs negative towards the end in a right-to-left row.
|
|
const travelled = Math.abs(row.scrollLeft)
|
|
const room = row.scrollWidth - row.clientWidth
|
|
|
|
row.toggleAttribute('data-scroll-start', travelled > 1)
|
|
row.toggleAttribute('data-scroll-end', room - travelled > 1)
|
|
}
|
|
|
|
// A filter chip's check changes its width without resizing the row.
|
|
for (const type of ['scroll', 'transitionend']) {
|
|
row.addEventListener(type, mark, { passive: true })
|
|
this.observers.push(() => row.removeEventListener(type, mark))
|
|
}
|
|
|
|
const resize = new ResizeObserver(mark)
|
|
resize.observe(row)
|
|
this.observers.push(() => resize.disconnect())
|
|
|
|
const settle = () => {
|
|
mark()
|
|
this.rove()
|
|
}
|
|
|
|
const mutation = new MutationObserver(settle)
|
|
mutation.observe(row, { childList: true, subtree: true, attributeFilter: ['tabindex', 'disabled'] })
|
|
this.observers.push(() => mutation.disconnect())
|
|
|
|
settle()
|
|
},
|
|
|
|
destroy() {
|
|
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()
|
|
},
|
|
}))
|
|
})
|