Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f23d14c
feat(deep-active-element): create deep-active-element package
cpsoinos Sep 4, 2025
024a2f7
feat(focus-scope): use get-deep-active-element to cross the shadow DO…
cpsoinos Sep 4, 2025
c0aaf24
feat(react-menu): use get-deep-active-element to cross the shadow DOM…
cpsoinos Sep 4, 2025
4eb18ea
feat(react-navigation-menu): use get-deep-active-element to cross the…
cpsoinos Sep 4, 2025
cc280c0
feat(react-one-time-password-field): use get-deep-active-element to c…
cpsoinos Sep 4, 2025
35b1d00
feat(react-password-toggle-field): use get-deep-active-element to cro…
cpsoinos Sep 4, 2025
18deecb
feat(react-roving-focus-group): use get-deep-active-element to cross …
cpsoinos Sep 4, 2025
1d6333d
feat(react-select): use get-deep-active-element to cross the shadow D…
cpsoinos Sep 4, 2025
0f84cd0
feat(react-toast): use get-deep-active-element to cross the shadow DO…
cpsoinos Sep 4, 2025
5fd8517
chore(cross-package): format
cpsoinos Sep 4, 2025
7ffeff5
fix(deep-active-element): remove unused dependency
cpsoinos Sep 4, 2025
28908a8
chore(deep-active-element): remove unused dev dependencies
cpsoinos Sep 5, 2025
acbfc87
refactor(cross-package): move getDeepActiveElement to @radix-ui/primi…
cpsoinos Sep 5, 2025
897233e
fix(primitive): handle null cases in getDeepActiveElement function
cpsoinos Sep 5, 2025
c123d11
fix(menu): resolve shadow DOM submenu closing issues
cpsoinos Sep 23, 2025
b9126cc
refactor(menu): clean up shadow DOM implementation
cpsoinos Sep 23, 2025
18f8faf
refactor(primitive,menu): move isInShadowDOM utility to primitive
cpsoinos Sep 25, 2025
06a5f91
refactor(menu): use constant for custom event name
cpsoinos Sep 25, 2025
de4315b
fix(menu): revert problematic pointer grace intent handling in shadow…
cpsoinos Oct 3, 2025
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
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 @@ -34,6 +34,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
15 changes: 8 additions & 7 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 Down Expand Up @@ -290,7 +291,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
70 changes: 65 additions & 5 deletions packages/react/menu/src/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeEventHandlers, getDeepActiveElement, isInShadowDOM } from '@radix-ui/primitive';
import { createCollection } from '@radix-ui/react-collection';
import { useComposedRefs, composeRefs } from '@radix-ui/react-compose-refs';
import { createContextScope } from '@radix-ui/react-context';
Expand Down Expand Up @@ -36,6 +36,7 @@ const SUB_CLOSE_KEYS: Record<Direction, string[]> = {
ltr: ['ArrowLeft'],
rtl: ['ArrowRight'],
};
const FORCE_CLOSE_CUSTOM_EVENT_NAME = 'radix-force-close-submenu';

/* -------------------------------------------------------------------------------------------------
* Menu
Expand Down Expand Up @@ -397,7 +398,7 @@ const MenuContentImpl = React.forwardRef<MenuContentImplElement, MenuContentImpl
const handleTypeaheadSearch = (key: string) => {
const search = searchRef.current + key;
const items = getItems().filter((item) => !item.disabled);
const currentItem = document.activeElement;
const currentItem = getDeepActiveElement();
const currentMatch = items.find((item) => item.ref.current === currentItem)?.textValue;
const values = items.map((item) => item.textValue);
const nextMatch = getNextMatch(values, search, currentMatch);
Expand Down Expand Up @@ -438,7 +439,29 @@ const MenuContentImpl = React.forwardRef<MenuContentImplElement, MenuContentImpl
searchRef={searchRef}
onItemEnter={React.useCallback(
(event) => {
if (isPointerMovingToSubmenu(event)) event.preventDefault();
if (isPointerMovingToSubmenu(event)) {
event.preventDefault();
} else {
// In shadow DOM, force close other submenus when entering any menu item
const target = event.target as Element;
if (isInShadowDOM(target)) {
const menuItem = event.currentTarget as HTMLElement;

// Clear grace intent
pointerGraceIntentRef.current = null;

// Always close other submenus, regardless of whether this is a subtrigger or not
setTimeout(() => {
// Dispatch a custom event that submenu triggers can listen for
const closeEvent = new CustomEvent(FORCE_CLOSE_CUSTOM_EVENT_NAME, {
bubbles: true,
cancelable: false,
detail: { currentTrigger: menuItem } // Pass the current trigger to exclude it
});
menuItem.dispatchEvent(closeEvent);
}, 0);
}
}
},
[isPointerMovingToSubmenu]
)}
Expand Down Expand Up @@ -1043,6 +1066,41 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
};
}, [pointerGraceTimerRef, onPointerGraceIntentChange]);

// Listen for forced close events in shadow DOM
React.useEffect(() => {
const handleForceClose = (event: CustomEvent) => {
// Don't close this submenu if it's the current trigger being hovered
const currentTrigger = event.detail?.currentTrigger;
const thisTrigger = subContext.trigger;

if (currentTrigger === thisTrigger) {
return; // Don't close the submenu that's currently being hovered
}

if (context.open) {
context.onOpenChange(false);
}
};

const currentElement = subContext.trigger;
if (currentElement) {
currentElement.addEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener);
// Also listen on parent elements since the event bubbles
const menuContent = currentElement.closest('[data-radix-menu-content]');
if (menuContent) {
menuContent.addEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener);
}

return () => {
currentElement.removeEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener);
if (menuContent) {
menuContent.removeEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener);
}
};
}
}, [context, subContext.trigger]);


return (
<MenuAnchor asChild {...scope}>
<MenuItemImpl
Expand All @@ -1051,6 +1109,7 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
aria-expanded={context.open}
aria-controls={subContext.contentId}
data-state={getOpenState(context.open)}
data-radix-menu-sub-trigger=""
{...props}
ref={composeRefs(forwardedRef, subContext.onTriggerChange)}
// This is redundant for mouse users but we cannot determine pointer type from
Expand Down Expand Up @@ -1108,6 +1167,7 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
});

window.clearTimeout(pointerGraceTimerRef.current);

pointerGraceTimerRef.current = window.setTimeout(
() => contentContext.onPointerGraceIntentChange(null),
300
Expand Down Expand Up @@ -1238,12 +1298,12 @@ function getCheckedState(checked: CheckedState) {
}

function focusFirst(candidates: HTMLElement[]) {
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
const PREVIOUSLY_FOCUSED_ELEMENT = getDeepActiveElement();
for (const candidate of candidates) {
// if focus is already where we want to go, we don't want to keep going through the candidates
if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
candidate.focus();
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
if (getDeepActiveElement() !== PREVIOUSLY_FOCUSED_ELEMENT) return;
}
}

Expand Down
10 changes: 5 additions & 5 deletions packages/react/navigation-menu/src/navigation-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import ReactDOM from 'react-dom';
import { createContextScope } from '@radix-ui/react-context';
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeEventHandlers, getDeepActiveElement } from '@radix-ui/primitive';
import { Primitive, dispatchDiscreteCustomEvent } from '@radix-ui/react-primitive';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { composeRefs, useComposedRefs } from '@radix-ui/react-compose-refs';
Expand Down Expand Up @@ -875,7 +875,7 @@ const NavigationMenuContentImpl = React.forwardRef<
const handleClose = () => {
onItemDismiss();
onRootContentClose();
if (content.contains(document.activeElement)) triggerRef.current?.focus();
if (content.contains(getDeepActiveElement())) triggerRef.current?.focus();
};
content.addEventListener(ROOT_CONTENT_DISMISS, handleClose);
return () => content.removeEventListener(ROOT_CONTENT_DISMISS, handleClose);
Expand Down Expand Up @@ -946,7 +946,7 @@ const NavigationMenuContentImpl = React.forwardRef<
const isTabKey = event.key === 'Tab' && !isMetaKey;
if (isTabKey) {
const candidates = getTabbableCandidates(event.currentTarget);
const focusedElement = document.activeElement;
const focusedElement = getDeepActiveElement();
const index = candidates.findIndex((candidate) => candidate === focusedElement);
const isMovingBackwards = event.shiftKey;
const nextCandidates = isMovingBackwards
Expand Down Expand Up @@ -1175,12 +1175,12 @@ function getTabbableCandidates(container: HTMLElement) {
}

function focusFirst(candidates: HTMLElement[]) {
const previouslyFocusedElement = document.activeElement;
const previouslyFocusedElement = getDeepActiveElement();
return candidates.some((candidate) => {
// if focus is already where we want to go, we don't want to keep going through the candidates
if (candidate === previouslyFocusedElement) return true;
candidate.focus();
return document.activeElement !== previouslyFocusedElement;
return getDeepActiveElement() !== previouslyFocusedElement;
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('given a default OneTimePasswordField', () => {
);

const inputs = rendered.container.querySelectorAll('input:not([type="hidden"])');
inputs.forEach(input => {
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Primitive from '@radix-ui/react-primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeEventHandlers, getDeepActiveElement } from '@radix-ui/primitive';
import { unstable_createCollection as createCollection } from '@radix-ui/react-collection';
import * as RovingFocusGroup from '@radix-ui/react-roving-focus';
import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus';
Expand Down Expand Up @@ -752,7 +752,7 @@ const OneTimePasswordFieldInput = React.forwardRef<
const element = event.target;
onInvalidChange?.(element.value);
requestAnimationFrame(() => {
if (element.ownerDocument.activeElement === element) {
if (getDeepActiveElement() === element) {
element.select();
}
});
Expand Down Expand Up @@ -915,7 +915,7 @@ function removeWhitespace(value: string) {

function focusInput(element: HTMLInputElement | null | undefined) {
if (!element) return;
if (element.ownerDocument.activeElement === element) {
if (getDeepActiveElement() === element) {
// if the element is already focused, select the value in the next
// animation frame
window.requestAnimationFrame(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { flushSync } from 'react-dom';
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeEventHandlers, getDeepActiveElement } from '@radix-ui/primitive';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { Primitive } from '@radix-ui/react-primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
Expand Down Expand Up @@ -334,7 +334,7 @@ const PasswordToggleFieldToggle = React.forwardRef<
requestAnimationFrame(() => {
// make sure the input still has focus (developer may have
// programatically moved focus elsewhere)
if (input.ownerDocument.activeElement === input) {
if (getDeepActiveElement() === input) {
input.selectionStart = selectionStart;
input.selectionEnd = selectionEnd;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/react/roving-focus/src/roving-focus-group.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { composeEventHandlers } from '@radix-ui/primitive';
import { composeEventHandlers, getDeepActiveElement } from '@radix-ui/primitive';
import { createCollection } from '@radix-ui/react-collection';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { createContextScope } from '@radix-ui/react-context';
Expand Down Expand Up @@ -325,12 +325,12 @@ function getFocusIntent(event: React.KeyboardEvent, orientation?: Orientation, d
}

function focusFirst(candidates: HTMLElement[], preventScroll = false) {
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
const PREVIOUSLY_FOCUSED_ELEMENT = getDeepActiveElement();
for (const candidate of candidates) {
// if focus is already where we want to go, we don't want to keep going through the candidates
if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
candidate.focus({ preventScroll });
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
if (getDeepActiveElement() !== PREVIOUSLY_FOCUSED_ELEMENT) return;
}
}

Expand Down
Loading