Files
Andreas Reinhold / reiniandClaude Opus 5 55cf8ceed7 Draw the chip set without Tailwind
<x-chip-set> renders data-md-chip-set (data-md-scroll), its row, label,
hint, errors and scroll buttons as data-md-* attributes, and its row,
fade mask, scroll padding and pointer-fine buttons move into
components/chip-set.css on tokens (plan step 36). chips.js marks the
row data-md-scroll-start and data-md-scroll-end. Browser tests cover the
scroll buttons in LTR and RTL and the arrow keys with a roving tab stop
that scrolls the focused chip clear of the button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:54:41 +02:00

244 lines
9.7 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-md-scroll-start`,
* `data-md-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
* 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-md-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.mdChipRemove && label) {
this.$nextTick(() => {
const text = label.textContent.trim()
if (text !== '' && !button.hasAttribute('aria-label')) {
button.setAttribute('aria-label', button.dataset.mdChipRemove.replace(':label', text))
}
})
}
},
removeButton() {
return this.$root.querySelector('[data-md-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-md-chip-set-row]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-md-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-md-chip-remove')
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
const target = (onRemove && other.querySelector('[data-md-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-md-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-md-chip-remove')
const current = () => {
if (target.isConnected || !key) {
return target.isConnected ? target : null
}
const chip = [...set.querySelectorAll('[data-md-chip]')].find((other) => other.getAttribute('wire:key') === key)
return chip && (onRemove ? chip.querySelector('[data-md-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: [],
row: null,
init() {
// 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.
const travelled = Math.abs(row.scrollLeft)
const room = row.scrollWidth - row.clientWidth
row.toggleAttribute('data-md-scroll-start', travelled > 1)
row.toggleAttribute('data-md-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.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. */
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.row).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()
},
}))
})