-
Notifications
You must be signed in to change notification settings - Fork 159
a11y(2.4.3): inert-based focus containment + restore; drop trap from native dialogs #3598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,64 +9,81 @@ export const getFocusableElements = (node: HTMLElement) => | |
| !(element.getAttribute('tabindex') === '-1'), | ||
| ); | ||
|
|
||
| export const focusTrap = (node: HTMLElement, enabled: boolean) => { | ||
| let firstFocusable: HTMLElement; | ||
| let lastFocusable: HTMLElement; | ||
| // Make everything outside `node`'s ancestor chain `inert` — removing it from | ||
| // the focus order, tab sequence, pointer events, and the accessibility tree. | ||
| // Walks up to <body>, marking the siblings at each level. Returns a function | ||
| // that clears only the elements this call inerted (pre-existing `inert` state | ||
| // is left untouched). This delegates containment to the platform rather than | ||
| // re-implementing it with keydown wrapping. | ||
| const inertBackground = (node: HTMLElement): (() => void) => { | ||
| const inerted: HTMLElement[] = []; | ||
| let current: HTMLElement = node; | ||
|
|
||
| const onKeydown = (event: KeyboardEvent) => { | ||
| if (event.key === 'Tab') { | ||
| if (event.shiftKey) { | ||
| if (document.activeElement === firstFocusable) { | ||
| lastFocusable.focus(); | ||
| event.preventDefault(); | ||
| } | ||
| } else if (document.activeElement === lastFocusable) { | ||
| firstFocusable.focus(); | ||
| event.preventDefault(); | ||
| while (current !== document.body && current.parentElement) { | ||
| for (const sibling of Array.from(current.parentElement.children)) { | ||
| if ( | ||
| sibling !== current && | ||
| sibling instanceof HTMLElement && | ||
| !sibling.inert | ||
| ) { | ||
| sibling.inert = true; | ||
| inerted.push(sibling); | ||
| } | ||
| } | ||
| current = current.parentElement; | ||
| } | ||
|
|
||
| return () => { | ||
| for (const element of inerted) element.inert = false; | ||
| }; | ||
| }; | ||
|
|
||
| export const focusTrap = (node: HTMLElement, enabled: boolean) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL Svelte runs action setup top-to-bottom, because if we change the order of |
||
| let active = false; | ||
| let releaseBackground: () => void = () => {}; | ||
| let previouslyFocused: HTMLElement | null = null; | ||
|
|
||
| const setFocus = (fromObserver: boolean = false) => { | ||
| if (enabled === false) return; | ||
| const activate = () => { | ||
| if (active) return; | ||
| active = true; | ||
|
|
||
| const focusable = getFocusableElements(node); | ||
| firstFocusable = focusable[0]; | ||
| lastFocusable = focusable[focusable.length - 1]; | ||
| previouslyFocused = | ||
| document.activeElement instanceof HTMLElement | ||
| ? document.activeElement | ||
| : null; | ||
|
|
||
| if (!fromObserver) firstFocusable?.focus(); | ||
| releaseBackground = inertBackground(node); | ||
|
|
||
| firstFocusable?.addEventListener('keydown', onKeydown); | ||
| lastFocusable?.addEventListener('keydown', onKeydown); | ||
| const [firstFocusable] = getFocusableElements(node); | ||
| (firstFocusable ?? node).focus(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: Might be nice to add |
||
| }; | ||
|
|
||
| const cleanUp = () => { | ||
| firstFocusable?.removeEventListener('keydown', onKeydown); | ||
| lastFocusable?.removeEventListener('keydown', onKeydown); | ||
| }; | ||
| const deactivate = () => { | ||
| if (!active) return; | ||
| active = false; | ||
|
|
||
| const onChange = ( | ||
| mutationRecords: MutationRecord[], | ||
| observer: MutationObserver, | ||
| ) => { | ||
| if (mutationRecords.length) { | ||
| cleanUp(); | ||
| setFocus(true); | ||
| releaseBackground(); | ||
| releaseBackground = () => {}; | ||
|
|
||
| if (previouslyFocused && document.body.contains(previouslyFocused)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we provide a way to pass a fallback or default here? That way if whatever opened the focus trap re-renders (e.g. a |
||
| previouslyFocused.focus(); | ||
| } | ||
| return observer; | ||
| previouslyFocused = null; | ||
| }; | ||
| const observer = new MutationObserver(onChange); | ||
| observer.observe(node, { childList: true, subtree: true }); | ||
|
|
||
| setFocus(); | ||
| if (enabled) activate(); | ||
|
|
||
| return { | ||
| update(newArgs: boolean) { | ||
| enabled = newArgs; | ||
| newArgs ? setFocus() : cleanUp(); | ||
| if (enabled) { | ||
| activate(); | ||
| } else { | ||
| deactivate(); | ||
| } | ||
| }, | ||
| destroy() { | ||
| cleanUp(); | ||
| deactivate(); | ||
| }, | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't hurt to add a test for overlapping traps (e.g. if there is a
Maximizablecomponent inside aDrawercomponent).