Skip to content
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

Focus Management within Shadow DOM #6046

Open
wants to merge 58 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
c026270
Add `getRootNode` utility.
MahmoudElsayad Mar 11, 2024
9299cee
Update `getRootNode` util.
MahmoudElsayad Mar 11, 2024
71836e3
Update `getOwnerWindow` util.
MahmoudElsayad Mar 11, 2024
10893b9
Add tests for Shadow DOM handling using `getRootNode`.
MahmoudElsayad Mar 11, 2024
5f81d34
Update comment.
MahmoudElsayad Mar 11, 2024
bfb9429
Fix FocusScope.tsx in Shadow DOM.
MahmoudElsayad Mar 12, 2024
3448ed7
Add more test for FocusScope.test.js.
MahmoudElsayad Mar 12, 2024
0c07404
Fix another `useRestoreFocus` issue with restoring focus in `Keyboard…
MahmoudElsayad Mar 13, 2024
90906bf
Add tests for `getDeepActiveElement`
MahmoudElsayad Mar 13, 2024
314fc44
Add `useFocus` shadow DOM tests.
MahmoudElsayad Mar 13, 2024
9cb6c5a
Update `focusSafely`.
MahmoudElsayad Mar 13, 2024
67399bb
Update `useInteractionOutside` for Shadow DOM support.
MahmoudElsayad Mar 13, 2024
59c3705
Update `useFocusVisible` for Shadow DOM support.
MahmoudElsayad Mar 14, 2024
3c85408
Add `useInteractOutside` tests.
MahmoudElsayad Mar 14, 2024
61ba1b0
Add test for use case mentioned in issue #1472.
MahmoudElsayad Mar 14, 2024
75d7fa5
Add tests for `usePress` hook.
MahmoudElsayad Mar 15, 2024
db45ef4
Update the fix for `useInteractOutside` to use simpler one.
MahmoudElsayad Mar 15, 2024
f9b94a3
Update `useOverlay` to use composedPath.
MahmoudElsayad Mar 15, 2024
758575c
Tests refactor.
MahmoudElsayad Mar 15, 2024
25ef5c5
Revert `useOverlay` changes as it works correctly without these changes.
MahmoudElsayad Mar 17, 2024
0d4f70e
Fix types.
MahmoudElsayad Mar 18, 2024
3cf2091
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad Mar 18, 2024
1d8c439
Fix types.
MahmoudElsayad Mar 18, 2024
e5dbda1
lint.
MahmoudElsayad Mar 18, 2024
ae7b76e
lint.
MahmoudElsayad Mar 18, 2024
aea5d8d
Fix failing tests.
MahmoudElsayad Mar 19, 2024
f2ca9b7
Fix failing tests.
MahmoudElsayad Mar 19, 2024
4d6ff95
Fix failing tests.
MahmoudElsayad Mar 19, 2024
4c53797
Test CI
MahmoudElsayad Mar 19, 2024
85b7146
Test CI
MahmoudElsayad Mar 19, 2024
a001f2a
Fix shadow DOM tests
MahmoudElsayad Mar 19, 2024
d00f0b2
Fix shadow DOM tests.
MahmoudElsayad Mar 20, 2024
e875828
Fix CI?
MahmoudElsayad Mar 20, 2024
b483e61
Fix CI?
MahmoudElsayad Mar 20, 2024
b8ef055
Fix CI?
MahmoudElsayad Mar 20, 2024
cc2a7c4
Re-add commented test.
MahmoudElsayad Mar 20, 2024
7447a79
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
ritz078 Mar 28, 2024
492d83f
Update `getRootNode` to handle iframes as well, and everything that …
MahmoudElsayad Mar 28, 2024
139a209
Merge remote-tracking branch 'upstream/main' into shadow-dom-enhancem…
MahmoudElsayad Apr 26, 2024
805561b
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
snowystinger Apr 27, 2024
a319991
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad May 7, 2024
dc2231d
Fix tests.
MahmoudElsayad May 7, 2024
bd536b6
Fix tests?
MahmoudElsayad May 7, 2024
f1aa64a
Fix tests?
MahmoudElsayad May 7, 2024
6426c08
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad May 9, 2024
7cafb6a
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad May 20, 2024
1a3e068
Fix tests.
MahmoudElsayad May 21, 2024
f1fe364
Fix tests.?
MahmoudElsayad May 21, 2024
24550d6
Fix tests.?
MahmoudElsayad May 21, 2024
b9a11ee
Fix tests.?
MahmoudElsayad May 22, 2024
97d73f2
Fix tests.?
MahmoudElsayad May 22, 2024
720ee18
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad May 22, 2024
6f80dbc
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
snowystinger May 23, 2024
6ca6996
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
snowystinger May 28, 2024
1f6ecf3
Apply suggestions from code review
MahmoudElsayad May 28, 2024
13278d9
Update packages/@react-aria/interactions/test/usePress.test.js
MahmoudElsayad May 28, 2024
93a5071
- Update tests to use `createShadowRoot` util.
MahmoudElsayad Jun 3, 2024
4ca6f75
Merge branch 'main' into shadow-dom-enhancement-1-getRootNode
MahmoudElsayad Jun 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
61 changes: 34 additions & 27 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {FocusableElement} from '@react-types/shared';
import {focusSafely} from './focusSafely';
import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils';
import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect} from '@react-aria/utils';
import {isElementVisible} from './isElementVisible';
import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';

Expand Down Expand Up @@ -54,7 +54,7 @@ export interface FocusManager {
focusPrevious(opts?: FocusManagerOptions): FocusableElement | null,
/** Moves focus to the first focusable or tabbable element in the focus scope. */
focusFirst(opts?: FocusManagerOptions): FocusableElement | null,
/** Moves focus to the last focusable or tabbable element in the focus scope. */
/** Moves focus to the last focusable or tabbable element in the focus scope. */
focusLast(opts?: FocusManagerOptions): FocusableElement | null
}

Expand Down Expand Up @@ -133,7 +133,8 @@ export function FocusScope(props: FocusScopeProps) {
// This needs to be an effect so that activeScope is updated after the FocusScope tree is complete.
// It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet.
useEffect(() => {
const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement;
// eslint-disable-next-line no-undef
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement;
let scope: TreeNode | null = null;

if (isElementInScope(activeElement, scopeRef.current)) {
Expand Down Expand Up @@ -197,7 +198,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject<Element[]>): Focus
focusNext(opts: FocusManagerOptions = {}) {
let scope = scopeRef.current!;
let {from, tabbable, wrap, accept} = opts;
let node = from || getOwnerDocument(scope[0]).activeElement!;
let node = from || getRootNode(scope[0]).activeElement!;
let sentinel = scope[0].previousElementSibling!;
let scopeRoot = getScopeRoot(scope);
let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
Expand All @@ -215,7 +216,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject<Element[]>): Focus
focusPrevious(opts: FocusManagerOptions = {}) {
let scope = scopeRef.current!;
let {from, tabbable, wrap, accept} = opts;
let node = from || getOwnerDocument(scope[0]).activeElement!;
let node = from || getRootNode(scope[0]).activeElement!;
let sentinel = scope[scope.length - 1].nextElementSibling!;
let scopeRoot = getScopeRoot(scope);
let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
Expand Down Expand Up @@ -314,7 +315,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain?: boolean)
return;
}

const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
const ownerDocument = getRootNode(scope ? scope[0] : undefined);

// Handle the Tab key to contain focus within the scope
let onKeyDown = (e) => {
Expand Down Expand Up @@ -374,7 +375,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain?: boolean)
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) {
activeScope = scopeRef;
if (ownerDocument.body.contains(e.target)) {
if (getRootBody(ownerDocument).contains(e.target)) {
focusedNode.current = e.target;
focusedNode.current?.focus();
} else if (activeScope.current) {
Expand Down Expand Up @@ -511,7 +512,7 @@ function useActiveScopeTracker(scopeRef: RefObject<Element[]>, restore?: boolean
}

let scope = scopeRef.current;
const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
const ownerDocument = getRootNode(scope ? scope[0] : undefined);

let onFocus = (e) => {
let target = e.target as Element;
Expand Down Expand Up @@ -553,7 +554,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
// restoring-non-containing scopes should only care if they become active so they can perform the restore
useLayoutEffect(() => {
let scope = scopeRef.current;
const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
const ownerDocument = getRootNode(scope ? scope[0] : undefined);
if (!restoreFocus || contain) {
return;
}
Expand All @@ -562,7 +563,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
// If focusing an element in a child scope of the currently active scope, the child becomes active.
// Moving out of the active scope to an ancestor is not allowed.
if ((!activeScope || isAncestorScope(activeScope, scopeRef)) &&
isElementInScope(ownerDocument.activeElement, scopeRef.current)
isElementInScope(ownerDocument.activeElement, scopeRef.current)
) {
activeScope = scopeRef;
}
Expand All @@ -574,11 +575,11 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
ownerDocument.removeEventListener('focusin', onFocus, false);
scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeRef, contain]);

useLayoutEffect(() => {
const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined);

if (!restoreFocus) {
return;
Expand All @@ -603,14 +604,16 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
}
let nodeToRestore = treeNode.nodeToRestore;

const rootBody = getRootBody(ownerDocument);

// Create a DOM tree walker that matches all tabbable elements
let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true});
let walker = getFocusableTreeWalker(rootBody, {tabbable: true});

// Find the next tabbable element after the currently focused element
walker.currentNode = focusedElement;
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;

if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) {
if (!nodeToRestore || !rootBody.contains(nodeToRestore) || nodeToRestore === rootBody) {
nodeToRestore = undefined;
treeNode.nodeToRestore = undefined;
}
Expand All @@ -630,9 +633,9 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
if (nextElement) {
focusElement(nextElement, true);
} else {
// If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
// then move focus to the body.
// Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
// If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
// then move focus to the body.
// Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
if (!isElementInAnyScope(nodeToRestore)) {
focusedElement.blur();
} else {
Expand All @@ -643,19 +646,20 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
};

if (!contain) {
ownerDocument.addEventListener('keydown', onKeyDown, true);
ownerDocument.addEventListener('keydown', onKeyDown as EventListener, true);
}

return () => {
if (!contain) {
ownerDocument.removeEventListener('keydown', onKeyDown, true);
ownerDocument.removeEventListener('keydown', onKeyDown as EventListener, true);
}
};
}, [scopeRef, restoreFocus, contain]);

// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
useLayoutEffect(() => {
const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined);
const rootBody = getRootBody(ownerDocument);

if (!restoreFocus) {
return;
Expand All @@ -680,14 +684,14 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
&& (
// eslint-disable-next-line react-hooks/exhaustive-deps
isElementInScope(ownerDocument.activeElement, scopeRef.current)
|| (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))
|| (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef))
)
) {
// freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it
let clonedTree = focusScopeTree.clone();
requestAnimationFrame(() => {
// Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
if (ownerDocument.activeElement === ownerDocument.body) {
if (ownerDocument.activeElement === rootBody) {
// look up the tree starting with our scope to find a nodeToRestore still in the DOM
let treeNode = clonedTree.getTreeNode(scopeRef);
while (treeNode) {
Expand Down Expand Up @@ -719,10 +723,13 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
* Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker}
* that matches all focusable/tabbable elements.
*/
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) {
let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
let walker = getOwnerDocument(root).createTreeWalker(
root,
// Adjusted to directly handle root being a Document or ShadowRoot
let doc = root instanceof ShadowRoot ? root : getRootNode(root);
let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc;
let walker = effectiveDocument.createTreeWalker(
root || doc,
NodeFilter.SHOW_ELEMENT,
{
acceptNode(node) {
Expand Down Expand Up @@ -762,7 +769,7 @@ export function createFocusManager(ref: RefObject<Element>, defaultOptions: Focu
return null;
}
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
let node = from || getOwnerDocument(root).activeElement;
let node = from || getRootNode(root).activeElement;
let walker = getFocusableTreeWalker(root, {tabbable, accept});
if (root.contains(node)) {
walker.currentNode = node!;
Expand All @@ -783,7 +790,7 @@ export function createFocusManager(ref: RefObject<Element>, defaultOptions: Focu
return null;
}
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
let node = from || getOwnerDocument(root).activeElement;
let node = from || getRootNode(root).activeElement;
let walker = getFocusableTreeWalker(root, {tabbable, accept});
if (root.contains(node)) {
walker.currentNode = node!;
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/focus/src/focusSafely.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {FocusableElement} from '@react-types/shared';
import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils';
import {focusWithoutScrolling, getRootNode, runAfterTransition} from '@react-aria/utils';
import {getInteractionModality} from '@react-aria/interactions';

/**
Expand All @@ -24,7 +24,7 @@ export function focusSafely(element: FocusableElement) {
// the page before shifting focus. This avoids issues with VoiceOver on iOS
// causing the page to scroll when moving focus if the element is transitioning
// from off the screen.
const ownerDocument = getOwnerDocument(element);
const ownerDocument = getRootNode(element);
if (getInteractionModality() === 'virtual') {
let lastFocusedElement = ownerDocument.activeElement;
runAfterTransition(() => {
Expand Down