Plan step 35: <x-list-detail>, following the reference's visible-panes table row by row - one pane below expanded, the detail replacing the list with a back button once something is selected; from 840px the list a fixed 360px (412px from 1200px) beside the detail, 24px apart. The selection binds with wire:model or x-model; below expanded focus moves to the detail and back() returns it to the item (resources/js/layout.js). Grid columns mirror in RTL, and the back arrow turns with them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
114 lines
4.9 KiB
JavaScript
114 lines
4.9 KiB
JavaScript
/**
|
|
* `materialListDetail`: the state of `<x-list-detail>`, and where focus goes when it changes.
|
|
*
|
|
* Which pane shows is CSS (resources/css/layout/list-detail.css), keyed on `data-md-selected`, so
|
|
* the first paint is right before this runs. What CSS cannot do is focus. Below `expanded` (840px)
|
|
* one pane shows, and a selection hides the list the focused item is in, which would drop focus on
|
|
* the page itself; so a selection there moves focus to the detail pane, and `back()` returns it to
|
|
* the item it came from (or the list's current item, or the list), as a dialog returns focus to its
|
|
* trigger (WAI-ARIA APG, Dialog (Modal) → Keyboard interaction). From `expanded` both panes are on
|
|
* screen side by side and focus stays where the person put it: M3 asks co-planar panes for a focus
|
|
* order that follows what is on screen, not for focus to jump
|
|
* (docs/reference/m3/foundations.md § Layout → Scaffold, Panes → Accessibility). A window crossing
|
|
* 840px with focus in the pane that is about to hide hands it to the one that stays.
|
|
*
|
|
* The item a selection came from is the last thing focused or clicked inside the list, recorded as
|
|
* it happens: by the time a watcher runs, the list may already be hidden and the browser may
|
|
* already have moved focus to the page.
|
|
*/
|
|
import { from } from './breakpoints.js'
|
|
|
|
/** A selection is anything but nothing: null, undefined, false and '' select nothing; 0 is an id. */
|
|
const chosen = (value) => value !== null && value !== undefined && value !== false && value !== ''
|
|
|
|
const FOCUSABLE = 'a[href], button, input, select, textarea, summary, [tabindex]'
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.data('materialListDetail', (selected = null) => ({
|
|
selected,
|
|
origin: null,
|
|
focused: null,
|
|
query: null,
|
|
onBreakpoint: null,
|
|
|
|
init() {
|
|
this.query = from('expanded')
|
|
this.onBreakpoint = () => this.handOver()
|
|
this.query.addEventListener('change', this.onBreakpoint)
|
|
|
|
this.$watch('selected', (value, previous) => {
|
|
if (chosen(value) === chosen(previous) || this.query.matches) {
|
|
return
|
|
}
|
|
|
|
this.$nextTick(() => (chosen(value) ? this.$refs.detail.focus() : this.returnFocus()))
|
|
})
|
|
},
|
|
|
|
destroy() {
|
|
this.query?.removeEventListener('change', this.onBreakpoint)
|
|
},
|
|
|
|
/** Whether anything is selected, for `data-md-selected`. */
|
|
get hasSelection() {
|
|
return chosen(this.selected)
|
|
},
|
|
|
|
/** Back to the list: a boolean selection becomes false, anything else null. */
|
|
back() {
|
|
this.selected = typeof this.selected === 'boolean' ? false : null
|
|
},
|
|
|
|
/** Remembers what inside the list a selection may come from. */
|
|
remember(event) {
|
|
const target = event.target instanceof Element ? event.target.closest(FOCUSABLE) : null
|
|
|
|
if (target && target !== this.$refs.list && this.$refs.list.contains(target)) {
|
|
this.origin = target
|
|
}
|
|
},
|
|
|
|
returnFocus() {
|
|
const list = this.$refs.list
|
|
const marked = list.querySelector('[aria-current]:not([aria-current="false"]), [aria-selected="true"]')
|
|
const current = marked && (marked.matches(FOCUSABLE) ? marked : marked.querySelector(FOCUSABLE))
|
|
const target = [this.origin, current].find((element) => element && element.isConnected && list.contains(element) && element.checkVisibility()) ?? list
|
|
|
|
target.focus()
|
|
},
|
|
|
|
/** Remembers the last thing focused inside the layout, and forgets it once focus leaves for another element. */
|
|
track(event) {
|
|
if (event.type === 'focusin') {
|
|
this.focused = event.target
|
|
} else if (event.relatedTarget && !this.$root.contains(event.relatedTarget)) {
|
|
this.focused = null
|
|
}
|
|
},
|
|
|
|
/**
|
|
* The window crossed `expanded`: focus in a pane that no longer shows moves to the one that
|
|
* does. The browser may already have dropped focus from the hidden element to the page by
|
|
* the time the media query reports, so the element that had it is the one remembered.
|
|
*/
|
|
handOver() {
|
|
if (this.query.matches) {
|
|
return
|
|
}
|
|
|
|
const active = document.activeElement
|
|
const source = active && active !== document.body ? active : this.focused
|
|
|
|
if (!source || (source !== active && source.checkVisibility())) {
|
|
return
|
|
}
|
|
|
|
if (this.hasSelection && this.$refs.list.contains(source)) {
|
|
this.$refs.detail.focus()
|
|
} else if (!this.hasSelection && this.$refs.detail.contains(source)) {
|
|
this.returnFocus()
|
|
}
|
|
},
|
|
}))
|
|
})
|