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 56 commits into
base: main
Choose a base branch
from

Conversation

MahmoudElsayad
Copy link

@MahmoudElsayad MahmoudElsayad commented Mar 11, 2024

Closes #1472

This PR enhances focus management capabilities in React Spectrum applications when used within Shadow DOM environments.

Changes

  • Introduced a new utility function 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.
  • Added Shadow DOM support to the following:
    • FocusScope
    • useFocus
    • useFocusVisible
    • useFocusWithin
    • useInteractionOutside
    • usePress
  • Implemented a new utility function, getRootBody that determines the effective "body" element for an event's propagation path, supporting both Shadow DOM and traditional document structures.
  • implemented a new utility, ' getDeepActiveElement,` which retrieves the currently focused element across Shadow DOM boundaries, ensuring accurate focus management in complex DOM structures.
  • Added tests for all new utility functions and affected hooks/components.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

  • Open any storybook example that uses any of the affected hooks/components
  • Test the functionality and make sure it works when the story is encapsulated inside a shadow root.
// custom wrapper to run any story inside a shadow root
function ShadowDomWrapper({ children }) {
  const container = useRef(null);

  useEffect(() => {
    const _container = container.current;
    if (!_container) return;

    const div = document.createElement('div');
    _container.appendChild(div);

    const shadowRoot = div.attachShadow({ mode: 'open' });
    const main = document.createElement('main');
    shadowRoot.appendChild(main);

    // Wrap children with the ThemeProvider when rendering
    ReactDOM.render(
      children,
      main
    );

    return () => {
      ReactDOM.unmountComponentAtNode(main);
      if (_container) {
        _container.innerHTML = '';
      }
    };
  }, [children]);

  return <div ref={container} />;
}
// .storybook/preview.js
export const decorators = [
+    (Story) => (
+     <ShadowDomWrapper>
+        <Story />
+      </ShadowDomWrapper>
+    ),
  withScrollingSwitcher,
  ...(process.env.NODE_ENV !== 'production' ? [withStrictModeSwitcher] : []),
  withProviderSwitcher
];

🧢 Your Project:

PSPDFKit -

@MahmoudElsayad MahmoudElsayad marked this pull request as ready for review March 18, 2024 02:53
@ritz078
Copy link
Contributor

ritz078 commented Mar 18, 2024

@snowystinger We are working on fixing the linting and type errors but if you want you can give an early eye to this PR.

@davidferguson
Copy link

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.

overlay-toast-fixes.patch.txt

@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?

@MahmoudElsayad
Copy link
Author

@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.

@MahmoudElsayad
Copy link
Author

@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 ariaHideOutside function sets aria-hidden="true" on the document body, which is incorrectly inherited by elements within the shadow root making all overlays inherit aria-hidden="true".

It's been handled in #6133; I am working on it getting the PR ready for review as well.

@ritz078
Copy link
Contributor

ritz078 commented May 21, 2024

@snowystinger @yihuiliao Can you have a look at this PR and suggest if there is something more that needs to be done ?

@snowystinger
Copy link
Member

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.

@snowystinger
Copy link
Member

It looks like there are some tests failing, do you need assistance figuring them out?

@MahmoudElsayad
Copy link
Author

@snowystinger I fixed the failing tests in the last commit.

@MahmoudElsayad
Copy link
Author

@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.

@MahmoudElsayad
Copy link
Author

@snowystinger They are fixed now.

@snowystinger
Copy link
Member

GET_BUILD

@rspbot
Copy link

rspbot commented May 23, 2024

@rspbot
Copy link

rspbot commented May 23, 2024

## API Changes

unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }

@react-aria/focus

getFocusableTreeWalker

 getFocusableTreeWalker {
-  root: Element
+  root: Element | ShadowRoot
   opts?: FocusManagerOptions
   scope?: Array<Element>
   returnVal: undefined
 }

@react-aria/utils

useFormReset

-
+getRootNode {
+  el: Element | null | undefined
+  returnVal: undefined
+}

getRootNode

-
+getRootBody {
+  root: Document | ShadowRoot
+  returnVal: undefined
+}

getRootBody

-
+getDeepActiveElement {
+  returnVal: undefined
+}

Copy link
Member

@snowystinger snowystinger left a 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.

@davidferguson

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.

packages/@react-aria/interactions/test/usePress.test.js Outdated Show resolved Hide resolved
import {setInteractionModality} from '@react-aria/interactions';

function createShadowRoot() {
Copy link
Member

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 => {
Copy link
Member

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?

packages/@react-aria/utils/src/domHelpers.ts Outdated Show resolved Hide resolved
Comment on lines +3314 to +3315
cleanupShadowRoot = shadowRoot;
cleanupShadowHost = shadowHost;
Copy link
Member

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);
Copy link
Member

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 () {
Copy link
Member

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(() => {
Copy link
Member

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

Comment on lines +160 to +172
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);
});
Copy link
Member

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?

MahmoudElsayad and others added 2 commits May 28, 2024 12:42
Co-authored-by: Robert Snow <snowystinger@gmail.com>
Co-authored-by: Robert Snow <snowystinger@gmail.com>
Copy link
Member

@snowystinger snowystinger left a 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);
and that the tree walker won't go into a shadowroot, so you'll have to recurse into them yourself. It may be easier to build it into 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
Copy link
Member

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);
Copy link
Member

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
Copy link
Member

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

Suggested change
* 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

FocusScope not working when used inside shadowRoot
6 participants