-
-
Notifications
You must be signed in to change notification settings - Fork 242
feat: manage focus for accessible click/context popups #613
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: master
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 | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,176 @@ | ||||||||||
| import type * as React from 'react'; | ||||||||||
|
|
||||||||||
| const TABBABLE_SELECTOR = | ||||||||||
| 'a[href], button, input, select, textarea, [tabindex]:not([tabindex^="-"])'; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Subtree cannot contain tab stops the browser will use. | ||||||||||
| * @see https://github.com/KittyGiraudel/a11y-dialog/blob/4674ff3e4d626430a028a64969328e339c533ce8/src/dom-utils.ts | ||||||||||
| */ | ||||||||||
| function canHaveTabbableChildren(el: HTMLElement): boolean { | ||||||||||
| if (el.shadowRoot && el.getAttribute('tabindex') === '-1') { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| return !el.matches(':disabled, [hidden], [inert]'); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function isNonVisibleForInteraction(el: HTMLElement): boolean { | ||||||||||
| if ( | ||||||||||
| el.matches('details:not([open]) *') && | ||||||||||
| !el.matches('details > summary:first-of-type') | ||||||||||
| ) { | ||||||||||
| return true; | ||||||||||
| } | ||||||||||
| return !( | ||||||||||
| el.offsetWidth || | ||||||||||
| el.offsetHeight || | ||||||||||
| el.getClientRects().length | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function isTabbable(el: HTMLElement, win: Window): boolean { | ||||||||||
| if (el.shadowRoot?.delegatesFocus) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| if (!el.matches(TABBABLE_SELECTOR)) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| if (isNonVisibleForInteraction(el)) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| if (el.closest('[aria-hidden="true"]') || el.closest('[inert]')) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| if ('disabled' in el && (el as HTMLButtonElement).disabled) { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| if (el instanceof HTMLInputElement && el.type === 'hidden') { | ||||||||||
| return false; | ||||||||||
| } | ||||||||||
| const style = win.getComputedStyle(el); | ||||||||||
| if (style.display === 'none' || style.visibility === 'hidden') { | ||||||||||
|
Contributor
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. Checking
Suggested change
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. The suggestion from Gemini makes sense. You want to check for the element’s offset dimensions and bounding rects instead of every possible way of manually hiding an element with CSS. function isVisible(element) {
return Boolean(
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
)
} |
||||||||||
| return false; | ||||||||||
| } | ||||||||||
| return true; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function getNextChildEl(parent: ParentNode, forward: boolean): Element | null { | ||||||||||
| return forward ? parent.firstElementChild : parent.lastElementChild; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function getNextSiblingEl(el: Element, forward: boolean): Element | null { | ||||||||||
| return forward ? el.nextElementSibling : el.previousElementSibling; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * First or last tabbable descendant in tree order (light DOM, shadow roots, slots). | ||||||||||
| * @see https://github.com/KittyGiraudel/a11y-dialog/blob/4674ff3e4d626430a028a64969328e339c533ce8/src/dom-utils.ts | ||||||||||
| */ | ||||||||||
| function findTabbableEl( | ||||||||||
| el: HTMLElement, | ||||||||||
| forward: boolean, | ||||||||||
| win: Window, | ||||||||||
| ): HTMLElement | null { | ||||||||||
| if (forward && isTabbable(el, win)) { | ||||||||||
| return el; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (canHaveTabbableChildren(el)) { | ||||||||||
| if (el.shadowRoot) { | ||||||||||
| let next = getNextChildEl(el.shadowRoot, forward); | ||||||||||
| while (next) { | ||||||||||
| const hit = findTabbableEl(next as HTMLElement, forward, win); | ||||||||||
| if (hit) { | ||||||||||
| return hit; | ||||||||||
| } | ||||||||||
| next = getNextSiblingEl(next, forward); | ||||||||||
| } | ||||||||||
| } else if (el.localName === 'slot') { | ||||||||||
| const assigned = (el as HTMLSlotElement).assignedElements({ | ||||||||||
| flatten: true, | ||||||||||
| }) as HTMLElement[]; | ||||||||||
| const ordered = forward ? assigned : [...assigned].reverse(); | ||||||||||
| for (let i = 0; i < ordered.length; i += 1) { | ||||||||||
| const hit = findTabbableEl(ordered[i], forward, win); | ||||||||||
| if (hit) { | ||||||||||
| return hit; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } else { | ||||||||||
| let next = getNextChildEl(el, forward); | ||||||||||
| while (next) { | ||||||||||
| const hit = findTabbableEl(next as HTMLElement, forward, win); | ||||||||||
| if (hit) { | ||||||||||
| return hit; | ||||||||||
| } | ||||||||||
| next = getNextSiblingEl(next, forward); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!forward && isTabbable(el, win)) { | ||||||||||
| return el; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return null; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** First and last tabbable nodes inside `container` (inclusive). `last === first` if only one. */ | ||||||||||
| export function getTabbableEdges( | ||||||||||
| container: HTMLElement, | ||||||||||
| ): readonly [HTMLElement | null, HTMLElement | null] { | ||||||||||
| const win = container.ownerDocument.defaultView!; | ||||||||||
| const first = findTabbableEl(container, true, win); | ||||||||||
| const last = first | ||||||||||
| ? findTabbableEl(container, false, win) || first | ||||||||||
| : null; | ||||||||||
| return [first, last] as const; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export function focusPopupRootOrFirst( | ||||||||||
| container: HTMLElement, | ||||||||||
| ): HTMLElement | null { | ||||||||||
| const [first] = getTabbableEdges(container); | ||||||||||
| if (first) { | ||||||||||
| first.focus(); | ||||||||||
| return first; | ||||||||||
| } | ||||||||||
| if (!container.hasAttribute('tabindex')) { | ||||||||||
| container.setAttribute('tabindex', '-1'); | ||||||||||
| } | ||||||||||
| container.focus(); | ||||||||||
| return container; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export function handlePopupTabTrap( | ||||||||||
| e: React.KeyboardEvent, | ||||||||||
| container: HTMLElement, | ||||||||||
| ): void { | ||||||||||
| if (e.key !== 'Tab' || e.defaultPrevented) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const [first, last] = getTabbableEdges(container); | ||||||||||
| const active = document.activeElement as HTMLElement | null; | ||||||||||
|
|
||||||||||
| if (!active || !container.contains(active)) { | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!first || !last) { | ||||||||||
| if (active === container) { | ||||||||||
| e.preventDefault(); | ||||||||||
| } | ||||||||||
| return; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!e.shiftKey) { | ||||||||||
| if (active === last || active === container) { | ||||||||||
| e.preventDefault(); | ||||||||||
| first.focus(); | ||||||||||
| } | ||||||||||
| } else if (active === first || active === container) { | ||||||||||
| e.preventDefault(); | ||||||||||
| last.focus(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
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.
Why do we check whether we have an active element before moving the focus to the target (which I assume is the previously focused element prior opening the popover)? I think the code can be simplified into:
target?.focus(). Unless I’m missing something.