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:
co-authored by
Claude Opus 5
parent
b4f1e005de
commit
b5c5be1fd7
@@ -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()
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -29,3 +29,4 @@ import './tabs.js'
|
||||
import './app-bar.js'
|
||||
import './navigation.js'
|
||||
import './toolbar.js'
|
||||
import './layout.js'
|
||||
|
||||
Reference in New Issue
Block a user