/** * `materialListDetail`: the state of ``, 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() } }, })) })