Add chips, choices and search
tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s

M3's assist, filter, input and suggestion chips with chip sets; choices
as filter chips or a searchable combobox whose list is an anchored
popover; and the search bar that opens into a docked or full-screen
search view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:07:59 +02:00
co-authored by Claude Opus 5
parent 7f92f1cb29
commit 0fee2a0952
21 changed files with 2061 additions and 2 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* `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.
*/
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 mutation = new MutationObserver(mark)
mutation.observe(row, { childList: true, subtree: true })
this.observers.push(() => mutation.disconnect())
mark()
},
destroy() {
this.observers.forEach((stop) => stop())
},
}))
})
+2
View File
@@ -17,4 +17,6 @@ import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
import './carousel.js'
import './chips.js'
import './field.js'
import './search.js'
+99
View File
@@ -0,0 +1,99 @@
/**
* `materialSearch`: the behaviour of `<x-search>` — a search bar that opens into a search view.
*
* Focus or a press on the bar opens the view; Escape, the back arrow, a press outside, focus
* leaving the search, or choosing a result closes it. ArrowDown from the input moves into the
* results and the arrow keys walk them; ArrowUp past the first result returns to the input. On a
* compact window (below `sm`) the view is full screen and modal: focus stays in it and the page
* behind does not scroll. The results themselves are the caller's, rendered by Livewire into the
* view as the query changes.
*/
const COMPACT = '(max-width: 39.99rem)'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]'
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
// coming to search.
const RETURN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSearch', (docked = false) => ({
open: false,
compact: false,
closedAt: -Infinity,
init() {
const query = window.matchMedia(COMPACT)
this.compact = query.matches
query.addEventListener('change', (event) => (this.compact = event.matches))
},
get fullScreen() {
return this.open && this.compact && !docked
},
show() {
this.open = true
},
focused() {
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
this.show()
}
},
close(refocus = false) {
this.open = false
this.closedAt = performance.now()
if (refocus) {
this.$refs.input.focus()
}
},
clear() {
const input = this.$refs.input
input.value = ''
input.dispatchEvent(new Event('input', { bubbles: true }))
input.focus()
},
results() {
return [...this.$refs.view.querySelectorAll(FOCUSABLE)].filter((element) => element.getClientRects().length > 0)
},
step(delta) {
const results = this.results()
const index = results.indexOf(document.activeElement)
if (results.length === 0) {
return
}
if (index === -1) {
results[delta > 0 ? 0 : results.length - 1].focus()
} else if (index + delta < 0) {
this.$refs.input.focus()
} else {
results[Math.min(index + delta, results.length - 1)].focus()
}
},
choose(event) {
if (event.target.closest(CHOOSES)) {
this.close()
}
},
leave(event) {
if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
this.close()
}
},
}))
})