Add the list-detail canonical layout

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:51:13 +02:00
co-authored by Claude Opus 5
parent b4f1e005de
commit b5c5be1fd7
9 changed files with 578 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
/**
* `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()
}
},
}))
})
+1
View File
@@ -29,3 +29,4 @@ import './tabs.js'
import './app-bar.js'
import './navigation.js'
import './toolbar.js'
import './layout.js'