-
Notifications
You must be signed in to change notification settings - Fork 1k
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
base: main
Are you sure you want to change the base?
Focus Management within Shadow DOM #6046
Conversation
Update domHelpers.test.js.
Add Tests for FocusScope.test.js. New helper util `getRootBody`.
Fix `useRestoreFocus` issue. Add new DOM util `getDeepActiveElement`.
… navigation example`.
update `useFocus` - `useFocusWithin` - `usePress`.
Test for `focusSafely`.
@snowystinger We are working on fixing the linting and type errors but if you want you can give an early eye to this PR. |
Hi @MahmoudElsayad, I did spend some time looking into this, and fixing it for the use cases that we needed. I'm attaching a patch file in the hope it may be useful for you. This is based off 3.34.1, so not the current main, or your PR, but it should be similar to apply. It makes overlays (menus, dialogs) and toasts and landmarks functional for our use case. @yihuiliao and @snowystinger, is there a preferred approach of how you'd like to see webcomponent & React Spectrum support progress? Is working on small individual fixes like @MahmoudElsayad's work, and now my overlay work the way forward, or are you hoping for a more holistic, comprehensive approach that updates the entire library at once? |
@davidferguson Thank you for the patch! I think the way to go would be in small individual fixes; it will be great if you open a PR for the patch changes as well, and you can branch off this PR if you find any useful utility that can be used and that way, we add support incrementally for shadow DOM. @yihuiliao and @snowystinger, Your feedback would be greatly appreciated to move things forward. |
@davidferguson One issue related to overlays, which I don't know if you have encountered yet or not, and that arose from accessibility testing, is that the It's been handled in #6133; I am working on it getting the PR ready for review as well. |
@snowystinger @yihuiliao Can you have a look at this PR and suggest if there is something more that needs to be done ? |
We've had some other priorities. How ready is this PR for a review? I'll try to find some time this week to look at it again. |
It looks like there are some tests failing, do you need assistance figuring them out? |
@snowystinger I fixed the failing tests in the last commit. |
@snowystinger Sorry, the last commit doesn't handle versions 16 and 17, so I am adjusting the fix slightly; I am working on it now. |
@snowystinger They are fixed now. |
GET_BUILD |
## API Changes
unknown top level export { type: 'any' } @react-aria/focusgetFocusableTreeWalker getFocusableTreeWalker {
- root: Element
+ root: Element | ShadowRoot
opts?: FocusManagerOptions
scope?: Array<Element>
returnVal: undefined
} @react-aria/utilsuseFormReset-
+getRootNode {
+ el: Element | null | undefined
+ returnVal: undefined
+} getRootNode-
+getRootBody {
+ root: Document | ShadowRoot
+ returnVal: undefined
+} getRootBody-
+getDeepActiveElement {
+ returnVal: undefined
+} |
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.
Got through tests today, I'll look more at the logic tomorrow hopefully.
is there a preferred approach of how you'd like to see webcomponent & React Spectrum support progress
What would be best would be to understand the use case we're aiming to support. If you could provide information about your architecture and how you actually make use of shadowDOM, that'd be helpful. That way we can keep an eye out for potential pitfalls. Maybe an example app on github or in a codesandbox? (be mindful of what you make public though, you can reach out internally if it's sensitive)
Otherwise, a holistic approach is always good, but the work can be done in smaller chunks for ease of reviewing.
import {setInteractionModality} from '@react-aria/interactions'; | ||
|
||
function createShadowRoot() { |
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.
can use the util you created?
return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc.defaultView || window; | ||
}; | ||
|
||
export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { |
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.
if the element is disconnected, it seems like we should return null? I see your comment about consistency, but where has this mattered that you needed this behavior?
cleanupShadowRoot = shadowRoot; | ||
cleanupShadowHost = shadowHost; |
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.
?
const el = shadowRoot.getElementById('testElement'); | ||
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); | ||
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); | ||
expect(document.activeElement).not.toBe(el); |
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.
need to check getDeepActiveElement? or that the activeElement isn't the shadowRoot at least?
expect(allowDefault).toBe(false); | ||
}); | ||
|
||
it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { |
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.
difference between this test and the one above it?
); | ||
|
||
// Wait for dynamic element to be added | ||
setTimeout(() => { |
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.
useFakeTimers, then run them just after this setTimeout, that way there's no danger of the assertions running after the test has completed
const {shadowRoot, shadowHost} = createShadowRoot(); | ||
const events = []; | ||
const ExampleComponent = () => ( | ||
<Example | ||
onFocus={(e) => events.push({type: 'focus', target: e.target})} | ||
onBlur={(e) => events.push({type: 'blur', target: e.target})} | ||
onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> | ||
); | ||
|
||
let root; | ||
act(() => { | ||
root = reactDomRenderer(<ExampleComponent />, shadowRoot); | ||
}); |
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.
I think you could restructure these and drop the reactDomRenderer if you did something along these lines
const {shadowRoot, shadowHost} = createShadowRoot();
const ExampleComponent = () => ReactDOM.createPortal(
<Example
onFocus={(e) => events.push({type: 'focus', target: e.target})}
onBlur={(e) => events.push({type: 'blur', target: e.target})}
onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} />,
shadowRoot
);
let root = render(<ExampleComponent />);
Might also allow you to get rid of the extra act and it gives you a cleanup function on the react tree. It might also solve the setTimeout(..., 0) you have in here too
Unless the distinction of having the react root being inside the shadow dom matters?
Co-authored-by: Robert Snow <snowystinger@gmail.com>
Co-authored-by: Robert Snow <snowystinger@gmail.com>
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.
I noticed that FocusManager cannot move focus between shadow roots
it('should move focus forward across ShadowDOMs', async function () {
let shadowRoot, unmount;
let shadowRoot2, unmount2;
function Test() {
let ref = useRef(null);
let [mounted, setMounted] = useState(false);
let refCallback = useCallback((val) => {
ref.current = val;
setMounted(true);
if (shadowRoot) {
unmount();
}
if (shadowRoot2) {
unmount2();
}
if (val) {
let rv = createShadowRoot(val);
let rv2 = createShadowRoot(val);
shadowRoot = rv.shadowRoot;
shadowRoot2 = rv2.shadowRoot;
unmount = rv.unmount;
unmount2 = rv2.unmount;
}
}, []);
return (
<div ref={refCallback}>
<FocusScope>
<Item data-testid="item1" />
{mounted && ReactDOM.createPortal(<Item data-testid="item2" />, shadowRoot)}
{mounted && ReactDOM.createPortal(<Item data-testid="item3" />, shadowRoot2)}
</FocusScope>
</div>
);
}
function Item(props) {
let focusManager = useFocusManager();
let onClick = () => {
focusManager.focusNext();
};
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
return <div {...props} tabIndex={-1} role="button" onClick={onClick} />;
}
let {getByTestId} = render(<Test />);
let item1 = getByTestId('item1');
let item2 = shadowRoot.querySelector('[data-testid="item2"]');
let item3 = shadowRoot2.querySelector('[data-testid="item3"]');
act(() => {item1.focus();});
await user.click(item1);
expect(getDeepActiveElement()).toBe(item2);
await user.click(item2);
expect(getDeepActiveElement()).toBe(item3);
await user.click(item3);
expect(getDeepActiveElement()).toBe(item3);
});
I assume it's because of
let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); |
getFocusableTreeWalker
and return an interface that matches the tree walker.
Likewise, restoring across shadowroots is going to be problematic with any checks for contains
.
See comments from #1472 (comment) for more things to keep an eye out for
I don't think we said there'd only be one shadowroot at the root of the entire application, so I'm assuming sibling/disjointed and nested are both valid.
@@ -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 |
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.
?
@@ -9,6 +9,50 @@ export const getOwnerWindow = ( | |||
return el; | |||
} | |||
|
|||
const doc = getOwnerDocument(el as Element | null | undefined); | |||
return doc.defaultView || window; | |||
const doc = getRootNode(el as Element | null | undefined); |
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.
I think this cast is wrong, getRootNode
should be expanded to cover the Window case
|
||
/** | ||
* Test case: https://github.com/adobe/react-spectrum/issues/1472 | ||
* sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 |
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.
sandbox link seems bad, I can't even get this one to load, maybe omit and just keep link to the github issue
* sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 | |
* sandbox example: https://codesandbox.io/s/vigilant-hofstadter-3wf4i?file=/src/index.js |
Closes #1472
This PR enhances focus management capabilities in React Spectrum applications when used within Shadow DOM environments.
Changes
getRootNode
, designed to return a given element's contextually appropriate root (Document or ShadowRoot). This improves the library's ability to query and manipulate focus within shadow DOMs.FocusScope
useFocus
useFocusVisible
useFocusWithin
useInteractionOutside
usePress
getRootBody
that determines the effective "body" element for an event's propagation path, supporting both Shadow DOM and traditional document structures.✅ Pull Request Checklist:
📝 Test Instructions:
🧢 Your Project:
PSPDFKit -