Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5c9066f
feat(deep-active-element): create deep-active-element package
cpsoinos Sep 4, 2025
2808d17
feat(focus-scope): use get-deep-active-element to cross the shadow DO…
cpsoinos Sep 4, 2025
bf0f6d4
feat(react-menu): use get-deep-active-element to cross the shadow DOM…
cpsoinos Sep 4, 2025
694927e
feat(react-navigation-menu): use get-deep-active-element to cross the…
cpsoinos Sep 4, 2025
81eda8b
feat(react-one-time-password-field): use get-deep-active-element to c…
cpsoinos Sep 4, 2025
a753d91
feat(react-password-toggle-field): use get-deep-active-element to cro…
cpsoinos Sep 4, 2025
791b931
feat(react-roving-focus-group): use get-deep-active-element to cross …
cpsoinos Sep 4, 2025
265bf05
feat(react-select): use get-deep-active-element to cross the shadow D…
cpsoinos Sep 4, 2025
4c314fd
feat(react-toast): use get-deep-active-element to cross the shadow DO…
cpsoinos Sep 4, 2025
e2ae50a
chore(cross-package): format
cpsoinos Sep 4, 2025
08385fd
fix(deep-active-element): remove unused dependency
cpsoinos Sep 4, 2025
3d64382
chore(deep-active-element): remove unused dev dependencies
cpsoinos Sep 5, 2025
c22166e
refactor(cross-package): move getDeepActiveElement to @radix-ui/primi…
cpsoinos Sep 5, 2025
0a64a23
fix(primitive): handle null cases in getDeepActiveElement function
cpsoinos Sep 5, 2025
4f98a4e
fix(menu): resolve shadow DOM submenu closing issues
cpsoinos Sep 23, 2025
ad3a786
refactor(menu): clean up shadow DOM implementation
cpsoinos Sep 23, 2025
e994bb5
refactor(primitive,menu): move isInShadowDOM utility to primitive
cpsoinos Sep 25, 2025
f532798
refactor(menu): use constant for custom event name
cpsoinos Sep 25, 2025
2e024c4
fix(menu): revert problematic pointer grace intent handling in shadow…
cpsoinos Oct 3, 2025
589e4f3
feat(focus-scope): Take shadow DOM elements in consideration for focu…
fbouquet Apr 2, 2026
6c32455
chore(storybook): Add WithShadowDOM story for FocusScope
fbouquet Apr 2, 2026
5b154e5
Add test case for focus loop with shadow DOM
fbouquet Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/storybook/stories/focus-scope.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,75 @@ export const Basic = () => {
);
};

function ShadowDOMFields() {
const ref = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
const el = ref.current;
if (!el || el.shadowRoot) return;
const shadow = el.attachShadow({ mode: 'open' });

const style = document.createElement('style');
style.textContent = `
div { display: flex; flex-direction: column; gap: 8px; padding: 12px; border: 1px dashed #aaa; }
label { display: flex; flex-direction: column; gap: 4px; font-family: sans-serif; font-size: 14px; }
`;
shadow.appendChild(style);

const wrapper = document.createElement('div');
for (const label of ['Shadow field 1', 'Shadow field 2', 'Shadow field 3']) {
const labelEl = document.createElement('label');
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.placeholder = label;
labelEl.appendChild(input);
wrapper.appendChild(labelEl);
}
shadow.appendChild(wrapper);
}, []);

return <div ref={ref} />;
}

export const WithShadowDOM = () => {
const [trapped, setTrapped] = React.useState(false);

return (
<>
<div>
<button type="button" onClick={() => setTrapped(true)}>
Trap
</button>{' '}
<input /> <input />
</div>
{trapped ? (
<FocusScope.Root asChild loop={trapped} trapped={trapped}>
<form
style={{
display: 'inline-flex',
flexDirection: 'column',
gap: 20,
padding: 20,
margin: 50,
maxWidth: 500,
border: '2px solid',
}}
>
<button type="button" onClick={() => setTrapped(false)}>
Close
</button>
<ShadowDOMFields />
</form>
</FocusScope.Root>
) : null}
<div>
<input /> <input />
</div>
</>
);
};

export const Multiple = () => {
const [trapped1, setTrapped1] = React.useState(false);
const [trapped2, setTrapped2] = React.useState(false);
Expand Down
29 changes: 29 additions & 0 deletions packages/core/primitive/src/primitive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,32 @@ export function getActiveElement(
export function isFrame(element: Element): element is HTMLIFrameElement {
return element.tagName === 'IFRAME';
}


/**
* Utility to determine whether an element is within a shadow DOM
*/
export function isInShadowDOM(element: Element): boolean {
return element && element.getRootNode() !== document && 'host' in element.getRootNode();
}

/**
* Utility to get the currently focused element even across shadow DOM boundaries
*/
export function getDeepActiveElement(): Element | null {
if (!canUseDOM) {
return null;
}

let activeElement = document.activeElement;
if (!activeElement) {
return null;
}

// Traverse through shadow DOMs to find the deepest active element
while (activeElement.shadowRoot?.activeElement) {
activeElement = activeElement.shadowRoot.activeElement;
}

return activeElement;
}
1 change: 1 addition & 0 deletions packages/react/focus-scope/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"build": "radix-build"
},
"dependencies": {
"@radix-ui/primitive": "workspace:*",
"@radix-ui/react-compose-refs": "workspace:*",
"@radix-ui/react-primitive": "workspace:*",
"@radix-ui/react-use-callback-ref": "workspace:*"
Expand Down
141 changes: 140 additions & 1 deletion packages/react/focus-scope/src/focus-scope.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import userEvent from '@testing-library/user-event';
import { cleanup, render, waitFor } from '@testing-library/react';
import { FocusScope } from './focus-scope';
import { FocusScope, getTabbableCandidates } from './focus-scope';
import type { RenderResult } from '@testing-library/react';
import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest';

Expand Down Expand Up @@ -92,6 +92,132 @@ describe('FocusScope', () => {
});
});

describe('getTabbableCandidates', () => {
it('should find tabbable elements inside shadow DOM', () => {
const container = document.createElement('div');
document.body.appendChild(container);

// Regular input
const regularInput = document.createElement('input');
regularInput.type = 'text';
container.appendChild(regularInput);

// Shadow host with an input inside
const shadowHost = document.createElement('div');
container.appendChild(shadowHost);
const shadow = shadowHost.attachShadow({ mode: 'open' });
const shadowInput = document.createElement('input');
shadowInput.type = 'text';
shadow.appendChild(shadowInput);

const candidates = getTabbableCandidates(container);
expect(candidates).toContain(regularInput);
expect(candidates).toContain(shadowInput);
expect(candidates).toHaveLength(2);

document.body.removeChild(container);
});

it('should find tabbable elements in nested shadow DOMs', () => {
const container = document.createElement('div');
document.body.appendChild(container);

// Outer shadow host
const outerHost = document.createElement('div');
container.appendChild(outerHost);
const outerShadow = outerHost.attachShadow({ mode: 'open' });

const outerInput = document.createElement('input');
outerInput.type = 'text';
outerShadow.appendChild(outerInput);

// Inner shadow host inside outer shadow
const innerHost = document.createElement('div');
outerShadow.appendChild(innerHost);
const innerShadow = innerHost.attachShadow({ mode: 'open' });
const innerInput = document.createElement('input');
innerInput.type = 'text';
innerShadow.appendChild(innerInput);

const candidates = getTabbableCandidates(container);
expect(candidates).toContain(outerInput);
expect(candidates).toContain(innerInput);
expect(candidates).toHaveLength(2);

document.body.removeChild(container);
});

it('should skip disabled and hidden elements inside shadow DOM', () => {
const container = document.createElement('div');
document.body.appendChild(container);

const shadowHost = document.createElement('div');
container.appendChild(shadowHost);
const shadow = shadowHost.attachShadow({ mode: 'open' });

const disabledInput = document.createElement('input');
disabledInput.type = 'text';
disabledInput.disabled = true;
shadow.appendChild(disabledInput);

const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
shadow.appendChild(hiddenInput);

const visibleInput = document.createElement('input');
visibleInput.type = 'text';
shadow.appendChild(visibleInput);

const candidates = getTabbableCandidates(container);
expect(candidates).not.toContain(disabledInput);
expect(candidates).not.toContain(hiddenInput);
expect(candidates).toContain(visibleInput);
expect(candidates).toHaveLength(1);

document.body.removeChild(container);
});
});

describe('given a FocusScope with shadow DOM elements', () => {
let rendered: RenderResult;
let tabbableFirst: HTMLButtonElement;
let tabbableLast: HTMLInputElement;

beforeEach(async () => {
rendered = render(
<div>
<FocusScope asChild loop trapped>
<form>
<button>Close</button>
<ShadowHostField />
</form>
</FocusScope>
</div>,
);
tabbableFirst = rendered.getByText('Close') as HTMLButtonElement;
// Wait for the useEffect inside ShadowHostField to attach the shadow root
await waitFor(() => {
const host = rendered.container.querySelector('[data-shadow-host]');
expect(host?.shadowRoot?.querySelector('input')).not.toBeNull();
});
tabbableLast = rendered.container
.querySelector('[data-shadow-host]')!
.shadowRoot!.querySelector('input') as HTMLInputElement;
});

it('should focus the first element in scope on tab from the last shadow DOM element', () => {
tabbableLast.focus();
userEvent.tab();
waitFor(() => expect(tabbableFirst).toHaveFocus());
});

it('should focus the last shadow DOM element on shift+tab from the first element in scope', () => {
tabbableFirst.focus();
userEvent.tab({ shift: true });
waitFor(() => expect(tabbableLast).toHaveFocus());
});
});

describe('given a FocusScope with internal focus handlers', () => {
const handleLastFocusableElementBlur = vi.fn();
let rendered: RenderResult;
Expand Down Expand Up @@ -128,3 +254,16 @@ function TestField({ label, ...props }: { label: string } & React.ComponentProps
</label>
);
}

function ShadowHostField() {
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const el = ref.current;
if (!el || el.shadowRoot) return;
const shadow = el.attachShadow({ mode: 'open' });
const input = document.createElement('input');
input.type = 'text';
shadow.appendChild(input);
}, []);
return <div ref={ref} data-shadow-host="" />;
}
49 changes: 31 additions & 18 deletions packages/react/focus-scope/src/focus-scope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { Primitive } from '@radix-ui/react-primitive';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
import { getDeepActiveElement } from '@radix-ui/primitive'

const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
Expand Down Expand Up @@ -109,7 +110,7 @@ const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props,
// back to the document.body. In this case, we move focus to the container
// to keep focus trapped correctly.
function handleMutations(mutations: MutationRecord[]) {
const focusedElement = document.activeElement as HTMLElement | null;
const focusedElement = getDeepActiveElement() as HTMLElement | null;
if (focusedElement !== document.body) return;
for (const mutation of mutations) {
if (mutation.removedNodes.length > 0) focus(container);
Expand All @@ -132,7 +133,7 @@ const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props,
React.useEffect(() => {
if (container) {
focusScopesStack.add(focusScope);
const previouslyFocusedElement = document.activeElement as HTMLElement | null;
const previouslyFocusedElement = getDeepActiveElement() as HTMLElement | null;
const hasFocusedCandidate = container.contains(previouslyFocusedElement);

if (!hasFocusedCandidate) {
Expand All @@ -141,7 +142,7 @@ const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props,
container.dispatchEvent(mountEvent);
if (!mountEvent.defaultPrevented) {
focusFirst(removeLinks(getTabbableCandidates(container)), { select: true });
if (document.activeElement === previouslyFocusedElement) {
if (getDeepActiveElement() === previouslyFocusedElement) {
focus(container);
}
}
Expand Down Expand Up @@ -176,7 +177,7 @@ const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props,
if (focusScope.paused) return;

const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
const focusedElement = document.activeElement as HTMLElement | null;
const focusedElement = getDeepActiveElement() as HTMLElement | null;

if (isTabKey && focusedElement) {
const container = event.currentTarget as HTMLElement;
Expand Down Expand Up @@ -216,10 +217,10 @@ FocusScope.displayName = FOCUS_SCOPE_NAME;
* Stops when focus has actually moved.
*/
function focusFirst(candidates: HTMLElement[], { select = false } = {}) {
const previouslyFocusedElement = document.activeElement;
const previouslyFocusedElement = getDeepActiveElement();
for (const candidate of candidates) {
focus(candidate, { select });
if (document.activeElement !== previouslyFocusedElement) return;
if (getDeepActiveElement() !== previouslyFocusedElement) return;
}
}

Expand All @@ -245,17 +246,28 @@ function getTabbableEdges(container: HTMLElement) {
*/
function getTabbableCandidates(container: HTMLElement) {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: any) => {
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
// runtime's understanding of tabbability, so this automatically accounts
// for any kind of element that could be tabbed to.
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
function walk(root: HTMLElement | ShadowRoot) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: any) => {
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
if (node.shadowRoot) return NodeFilter.FILTER_ACCEPT;
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
// runtime's understanding of tabbability, so this automatically accounts
// for any kind of element that could be tabbed to.
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) {
const el = walker.currentNode as HTMLElement;
if (el.shadowRoot) {
walk(el.shadowRoot);
} else {
nodes.push(el);
}
}
}
walk(container);
// we do not take into account the order of nodes with positive `tabIndex` as it
// hinders accessibility to have tab order different from visual order.
return nodes;
Expand Down Expand Up @@ -290,7 +302,7 @@ function isSelectableInput(element: any): element is FocusableTarget & { select:
function focus(element?: FocusableTarget | null, { select = false } = {}) {
// only focus if that element is focusable
if (element && element.focus) {
const previouslyFocusedElement = document.activeElement;
const previouslyFocusedElement = getDeepActiveElement();
// NOTE: we prevent scrolling on focus, to minimize jarring transitions for users
element.focus({ preventScroll: true });
// only select if its not the same element, it supports selection and we need to select
Expand Down Expand Up @@ -348,5 +360,6 @@ export {
FocusScope,
//
Root,
getTabbableCandidates,
};
export type { FocusScopeProps };
Loading