diff --git a/packages/core/primitive/src/primitive.tsx b/packages/core/primitive/src/primitive.tsx index 6ee69659c..47054d6c2 100644 --- a/packages/core/primitive/src/primitive.tsx +++ b/packages/core/primitive/src/primitive.tsx @@ -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; +} diff --git a/packages/react/focus-scope/package.json b/packages/react/focus-scope/package.json index 0cf0d5043..edf89f5b1 100644 --- a/packages/react/focus-scope/package.json +++ b/packages/react/focus-scope/package.json @@ -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:*" diff --git a/packages/react/focus-scope/src/focus-scope.tsx b/packages/react/focus-scope/src/focus-scope.tsx index d28f69383..ff3f575d2 100644 --- a/packages/react/focus-scope/src/focus-scope.tsx +++ b/packages/react/focus-scope/src/focus-scope.tsx @@ -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'; @@ -109,7 +110,7 @@ const FocusScope = React.forwardRef((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); @@ -132,7 +133,7 @@ const FocusScope = React.forwardRef((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) { @@ -141,7 +142,7 @@ const FocusScope = React.forwardRef((props, container.dispatchEvent(mountEvent); if (!mountEvent.defaultPrevented) { focusFirst(removeLinks(getTabbableCandidates(container)), { select: true }); - if (document.activeElement === previouslyFocusedElement) { + if (getDeepActiveElement() === previouslyFocusedElement) { focus(container); } } @@ -176,7 +177,7 @@ const FocusScope = React.forwardRef((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; @@ -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; } } @@ -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 diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index bbc040aa7..15a2effc3 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -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'; @@ -36,6 +36,7 @@ const SUB_CLOSE_KEYS: Record = { ltr: ['ArrowLeft'], rtl: ['ArrowRight'], }; +const FORCE_CLOSE_CUSTOM_EVENT_NAME = 'radix-force-close-submenu'; /* ------------------------------------------------------------------------------------------------- * Menu @@ -397,7 +398,7 @@ const MenuContentImpl = React.forwardRef { 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); @@ -438,7 +439,29 @@ const MenuContentImpl = React.forwardRef { - 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] )} @@ -1043,6 +1066,41 @@ const MenuSubTrigger = React.forwardRef { + 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 ( contentContext.onPointerGraceIntentChange(null), 300 @@ -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; } } diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx index d911b06e7..28c492f23 100644 --- a/packages/react/navigation-menu/src/navigation-menu.tsx +++ b/packages/react/navigation-menu/src/navigation-menu.tsx @@ -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'; @@ -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); @@ -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 @@ -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; }); } diff --git a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx index 94fa3ef38..78769f5a3 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.test.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.test.tsx @@ -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(); }); }); diff --git a/packages/react/one-time-password-field/src/one-time-password-field.tsx b/packages/react/one-time-password-field/src/one-time-password-field.tsx index ad7c4b596..f88ba3092 100644 --- a/packages/react/one-time-password-field/src/one-time-password-field.tsx +++ b/packages/react/one-time-password-field/src/one-time-password-field.tsx @@ -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'; @@ -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(); } }); @@ -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(() => { diff --git a/packages/react/password-toggle-field/src/password-toggle-field.tsx b/packages/react/password-toggle-field/src/password-toggle-field.tsx index a132469a9..64358e1e1 100644 --- a/packages/react/password-toggle-field/src/password-toggle-field.tsx +++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx @@ -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'; @@ -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; } diff --git a/packages/react/roving-focus/src/roving-focus-group.tsx b/packages/react/roving-focus/src/roving-focus-group.tsx index 4ab89d893..8733f9d2a 100644 --- a/packages/react/roving-focus/src/roving-focus-group.tsx +++ b/packages/react/roving-focus/src/roving-focus-group.tsx @@ -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'; @@ -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; } } diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 042a0f333..fbabd4bca 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { clamp } from '@radix-ui/number'; -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'; @@ -583,7 +583,7 @@ const SelectContentImpl = React.forwardRef item.ref.current); const [lastItem] = restItems.slice(-1); - 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; @@ -592,7 +592,7 @@ const SelectContentImpl = React.forwardRef { const enabledItems = getItems().filter((item) => !item.disabled); - const currentItem = enabledItems.find((item) => item.ref.current === document.activeElement); + const currentItem = enabledItems.find((item) => item.ref.current === getDeepActiveElement()); const nextItem = findNextItem(enabledItems, search, currentItem); if (nextItem) { /** @@ -1335,7 +1335,7 @@ const SelectItem = React.forwardRef( } })} onPointerLeave={composeEventHandlers(itemProps.onPointerLeave, (event) => { - if (event.currentTarget === document.activeElement) { + if (event.currentTarget === getDeepActiveElement()) { contentContext.onItemLeave?.(); } })} @@ -1559,7 +1559,7 @@ const SelectScrollButtonImpl = React.forwardRef< // the viewport, potentially causing the active item to now be partially out of view. // We re-run the `scrollIntoView` logic to make sure it stays within the viewport. useLayoutEffect(() => { - const activeItem = getItems().find((item) => item.ref.current === document.activeElement); + const activeItem = getItems().find((item) => item.ref.current === getDeepActiveElement()); activeItem?.ref.current?.scrollIntoView({ block: 'nearest' }); }, [getItems]); diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 25f27f402..2bfc5ea0b 100644 --- a/packages/react/toast/src/toast.tsx +++ b/packages/react/toast/src/toast.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { composeEventHandlers } from '@radix-ui/primitive'; +import { composeEventHandlers, getDeepActiveElement } from '@radix-ui/primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { createCollection } from '@radix-ui/react-collection'; import { createContextScope } from '@radix-ui/react-context'; @@ -193,7 +193,7 @@ const ToastViewport = React.forwardRef }; const handlePointerLeaveResume = () => { - const isFocusInside = wrapper.contains(document.activeElement); + const isFocusInside = wrapper.contains(getDeepActiveElement()); if (!isFocusInside) handleResume(); }; @@ -243,7 +243,7 @@ const ToastViewport = React.forwardRef const isTabKey = event.key === 'Tab' && !isMetaKey; if (isTabKey) { - const focusedElement = document.activeElement; + const focusedElement = getDeepActiveElement(); const isTabbingBackwards = event.shiftKey; const targetIsViewport = event.target === viewport; @@ -491,7 +491,7 @@ const ToastImpl = React.forwardRef( const handleClose = useCallbackRef(() => { // focus viewport if focus is within toast to read the remaining toast // count to SR users and ensure focus isn't lost - const isFocusInToast = node?.contains(document.activeElement); + const isFocusInToast = node?.contains(getDeepActiveElement()); if (isFocusInToast) context.viewport?.focus(); onClose(); }); @@ -935,12 +935,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; }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8a429e9..a607303a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1156,6 +1156,9 @@ importers: packages/react/focus-scope: dependencies: + '@radix-ui/primitive': + specifier: workspace:* + version: link:../../core/primitive '@radix-ui/react-compose-refs': specifier: workspace:* version: link:../compose-refs