Plan step 16. `navigation.js` takes its standard-rail threshold from
`from('expanded')` (840px) instead of the 64rem it had, and `navigation.css`'s
three 64rem queries follow, so the whole expanded class gets the in-layout
collapsible rail M3 asks for rather than one that opens over a scrim (N-06);
its two 40rem queries become 37.5rem, the compact/medium boundary the bar's own
container query already used (N-07). `search.js` and `datepicker.js` swap their
39.99rem media strings for `upTo('medium')`, so the full-screen search view and
the modal date picker end at 600px, not 640px.
The time picker's landscape layout stops keying on width at all: M3 swaps it on
orientation and viewport height, so it is now landscape plus a window too short
for the upright dial (35rem, the dialog's own height), and the two dial-shrink
rules key on height alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
100 lines
3.2 KiB
JavaScript
100 lines
3.2 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.
|
|
*/
|
|
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-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 = upTo('medium')
|
|
|
|
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()
|
|
}
|
|
},
|
|
}))
|
|
})
|