Files
Andreas Reinhold / reiniandClaude Sonnet 5 f04e31b3aa Rewrite the card without Tailwind
Plan step 36 (containment group): <x-card>'s class lists move into
resources/css/components/card.css, keyed on data-md-card (per variant),
data-md-card-figure/-body/-header/-heading/-title/-subtitle/-menu/
-content/-actions. A row (data-md-list-row) renders the shared
md-state-layer and md-focus-ring classes for its tint and ring
(foundation/interaction.css) instead of copying their rules; card.css
adds only the per-state elevation the shared class has no opinion on
(ElevatedCardTokens.kt/FilledCardTokens.kt/OutlinedCardTokens.kt), the
press/hover exclusion for a card's own nested buttons, and the ring for
a non-actionable row whose focus lands on its opener rather than the
card. data-md-dragged is read automatically by the shared class's own
16% tint; card.css only adds its elevation levels 3/4.

Hooks renamed data-list-row -> data-md-list-row, data-list-open ->
data-md-list-open, data-list-actionable -> data-md-list-actionable,
data-dragged -> data-md-dragged, updated in the same commit:
resources/js/search.js and list-rows.js's consumers, table.css's
comment, the showcase (index, data and containment sections),
tests/Browser/ContainmentTest.php, and the development skill's card
and table examples.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 20:30:14 +02:00

177 lines
6.7 KiB
JavaScript

/**
* `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 `medium`, 600px) the view is full screen and modal: focus stays in it and
* the page behind does not scroll — M3 docks the view from medium upwards. The results themselves
* are the caller's, rendered by Livewire into the view as the query changes.
*
* Because the results arrive from the server, nothing in the page tells a screen reader they are
* there; M3 asks that it be told. A MutationObserver counts the list items whenever the view's DOM
* settles and writes "N results" into the polite live region the view renders, which is the one
* announcement M3's search accessibility page names. With a `suggestions` slot, whichever of the
* two lists is on screen is the one counted, and the suggestions are named as such.
*
* `trigger` is M3's entry point: the bar itself, or `icon` — a single search icon button that
* expands into the full-screen view wherever the window is wide enough to dock, because an icon
* button has nowhere to dock under.
*/
import { upTo } from './breakpoints.js'
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-md-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
// A Livewire morph replaces the results in several mutations; wait for the batch to end before
// counting, so the live region speaks once.
const SETTLE_MS = 120
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
open: false,
compact: false,
closedAt: -Infinity,
announcement: '',
// What is in the field, which is what tells suggestions from results.
query: '',
observer: null,
settle: null,
init() {
const media = upTo('medium')
this.compact = media.matches
media.addEventListener('change', (event) => (this.compact = event.matches))
// Alpine registers x-ref as it walks the children, which is after this runs.
this.$nextTick(() => {
this.query = this.$refs.input?.value ?? ''
this.observer = new MutationObserver(() => this.countLater())
this.observer.observe(this.$refs.view, { childList: true, subtree: true, characterData: true })
})
this.$watch('open', () => this.countLater())
},
destroy() {
this.observer?.disconnect()
clearTimeout(this.settle)
},
countLater() {
clearTimeout(this.settle)
this.settle = setTimeout(() => this.count(), SETTLE_MS)
},
count() {
if (! this.open || ! this.$refs.view) {
this.announcement = ''
return
}
// Suggestions and results never show together; count whichever one is on screen.
const list = [...this.$refs.view.querySelectorAll('[data-md-search-results], [data-md-search-suggestions]')]
.find((element) => element.getClientRects().length > 0)
const items = list ? list.querySelectorAll('[role="listitem"], li') : []
const total = items.length || (list ? this.results().length : 0)
const suggesting = Boolean(list?.hasAttribute('data-md-search-suggestions'))
this.announcement = total === 0
? (announce.none ?? '')
: total === 1
? ((suggesting ? announce.suggestionOne : announce.one) ?? '')
: ((suggesting ? announce.suggestionMany : announce.many) ?? '').replace(':count', total)
},
get fullScreen() {
// An icon button has nothing to dock under, so M3's icon entry point always expands.
return this.open && (trigger === 'icon' || (this.compact && !docked))
},
show() {
this.open = true
},
/** M3's search-icon entry point: the button opens the view and hands over focus. */
expand() {
this.show()
this.$nextTick(() => requestAnimationFrame(() => this.$refs.input?.focus()))
},
/** Every keystroke: the view opens, and the query decides suggestions or results. */
typed(event) {
this.query = event.target.value
this.show()
},
focused() {
if (performance.now() - this.closedAt > RETURN_GUARD_MS) {
this.show()
}
},
close(refocus = false) {
this.open = false
this.closedAt = performance.now()
if (refocus) {
// Back to whatever opened the view: the icon button, or the field itself. A tick
// later, because the icon button is only on screen again once the view has closed.
const back = this.$refs.trigger ?? this.$refs.input
this.$nextTick(() => back.focus())
}
},
clear() {
const input = this.$refs.input
input.value = ''
this.query = ''
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()
}
},
}))
})