/** * `materialChip`: a removable ``. `materialChipSet`: a scrolling ``. * * 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() }, })) })