From f23d14cd9801199d0fee950e28d9dee00e5ccd00 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:46:18 -0400 Subject: [PATCH 01/19] feat(deep-active-element): create deep-active-element package --- packages/core/deep-active-element/README.md | 3 + .../deep-active-element/eslint.config.mjs | 4 ++ .../core/deep-active-element/package.json | 72 +++++++++++++++++++ .../src/deep-active-element.ts | 13 ++++ .../core/deep-active-element/src/index.ts | 1 + .../core/deep-active-element/tsconfig.json | 9 +++ 6 files changed, 102 insertions(+) create mode 100644 packages/core/deep-active-element/README.md create mode 100644 packages/core/deep-active-element/eslint.config.mjs create mode 100644 packages/core/deep-active-element/package.json create mode 100644 packages/core/deep-active-element/src/deep-active-element.ts create mode 100644 packages/core/deep-active-element/src/index.ts create mode 100644 packages/core/deep-active-element/tsconfig.json diff --git a/packages/core/deep-active-element/README.md b/packages/core/deep-active-element/README.md new file mode 100644 index 000000000..2aac5edf4 --- /dev/null +++ b/packages/core/deep-active-element/README.md @@ -0,0 +1,3 @@ +# `deep-active-element` + +This package contains a utility to get the currently focused element even across Shadow DOM boundaries. diff --git a/packages/core/deep-active-element/eslint.config.mjs b/packages/core/deep-active-element/eslint.config.mjs new file mode 100644 index 000000000..26d682564 --- /dev/null +++ b/packages/core/deep-active-element/eslint.config.mjs @@ -0,0 +1,4 @@ +// @ts-check +import { configs } from '@repo/eslint-config/react-package'; + +export default configs; diff --git a/packages/core/deep-active-element/package.json b/packages/core/deep-active-element/package.json new file mode 100644 index 000000000..1a31c8522 --- /dev/null +++ b/packages/core/deep-active-element/package.json @@ -0,0 +1,72 @@ +{ + "name": "@radix-ui/deep-active-element", + "version": "1.0.0", + "license": "MIT", + "source": "./src/index.ts", + "main": "./src/index.ts", + "module": "./src/index.ts", + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + } + }, + "files": [ + "dist", + "README.md" + ], + "sideEffects": false, + "scripts": { + "lint": "eslint --max-warnings 0 src", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "build": "radix-build" + }, + "dependencies": { + "@radix-ui/react-primitive": "workspace:*" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + }, + "devDependencies": { + "@repo/builder": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "eslint": "^9.18.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.7.3" + }, + "homepage": "https://radix-ui.com/primitives", + "repository": { + "type": "git", + "url": "git+https://github.com/radix-ui/primitives.git" + }, + "bugs": { + "url": "https://github.com/radix-ui/primitives/issues" + } +} diff --git a/packages/core/deep-active-element/src/deep-active-element.ts b/packages/core/deep-active-element/src/deep-active-element.ts new file mode 100644 index 000000000..552456315 --- /dev/null +++ b/packages/core/deep-active-element/src/deep-active-element.ts @@ -0,0 +1,13 @@ +/** + * Utility to get the currently focused element even across Shadow DOM boundaries + */ +export function getDeepActiveElement(): Element | null { + let activeElement = document.activeElement; + + // Traverse through shadow DOMs to find the deepest active element + while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement; +} diff --git a/packages/core/deep-active-element/src/index.ts b/packages/core/deep-active-element/src/index.ts new file mode 100644 index 000000000..e30101fb0 --- /dev/null +++ b/packages/core/deep-active-element/src/index.ts @@ -0,0 +1 @@ +export { getDeepActiveElement } from './deep-active-element'; diff --git a/packages/core/deep-active-element/tsconfig.json b/packages/core/deep-active-element/tsconfig.json new file mode 100644 index 000000000..ecf7f7008 --- /dev/null +++ b/packages/core/deep-active-element/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist", + "types": ["@repo/typescript-config/react-library"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 024a2f7d51dc7b2fad0addaa4f98f5ff4f28b71c Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:48:11 -0400 Subject: [PATCH 02/19] feat(focus-scope): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/focus-scope/package.json | 1 + .../react/focus-scope/src/focus-scope.tsx | 15 ++++---- pnpm-lock.yaml | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/react/focus-scope/package.json b/packages/react/focus-scope/package.json index 0cf0d5043..ea8e8cdf4 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/deep-active-element": "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..39df43e71 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/deep-active-element' 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/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8a429e9..52947a230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,40 @@ importers: internal/typescript-config: {} + packages/core/deep-active-element: + dependencies: + '@radix-ui/react-primitive': + specifier: workspace:* + version: link:../../react/primitive + devDependencies: + '@repo/builder': + specifier: workspace:* + version: link:../../../internal/builder + '@repo/eslint-config': + specifier: workspace:* + version: link:../../../internal/eslint-config + '@repo/typescript-config': + specifier: workspace:* + version: link:../../../internal/typescript-config + '@types/react': + specifier: ^19.0.7 + version: 19.1.0 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.1.1(@types/react@19.1.0) + eslint: + specifier: ^9.18.0 + version: 9.24.0 + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) + typescript: + specifier: ^5.7.3 + version: 5.8.3 + packages/core/number: devDependencies: '@repo/builder': @@ -1156,6 +1190,9 @@ importers: packages/react/focus-scope: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/react-compose-refs': specifier: workspace:* version: link:../compose-refs From c0aaf247adbf0f14a3a225080b947bda50ae9a5c Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:50:21 -0400 Subject: [PATCH 03/19] feat(react-menu): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/menu/package.json | 1 + packages/react/menu/src/menu.tsx | 7 ++++--- pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/react/menu/package.json b/packages/react/menu/package.json index f9eccc3f8..d79ba606e 100644 --- a/packages/react/menu/package.json +++ b/packages/react/menu/package.json @@ -34,6 +34,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index bbc040aa7..4b97f781f 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -21,6 +21,7 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' type Direction = 'ltr' | 'rtl'; @@ -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); @@ -1238,12 +1239,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/pnpm-lock.yaml b/pnpm-lock.yaml index 52947a230..b932e62b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1408,6 +1408,9 @@ importers: packages/react/menu: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From 4eb18eaf2b2e6e77d896bbe9811221fe62a1818d Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:51:45 -0400 Subject: [PATCH 04/19] feat(react-navigation-menu): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/navigation-menu/package.json | 1 + packages/react/navigation-menu/src/navigation-menu.tsx | 9 +++++---- pnpm-lock.yaml | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/react/navigation-menu/package.json b/packages/react/navigation-menu/package.json index 9c6bcf72c..82496af75 100644 --- a/packages/react/navigation-menu/package.json +++ b/packages/react/navigation-menu/package.json @@ -34,6 +34,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx index d911b06e7..785526129 100644 --- a/packages/react/navigation-menu/src/navigation-menu.tsx +++ b/packages/react/navigation-menu/src/navigation-menu.tsx @@ -16,6 +16,7 @@ import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import * as VisuallyHiddenPrimitive from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' type Orientation = 'vertical' | 'horizontal'; type Direction = 'ltr' | 'rtl'; @@ -875,7 +876,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 +947,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 +1176,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 b932e62b3..1e33bd919 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1563,6 +1563,9 @@ importers: packages/react/navigation-menu: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From cc280c093e19e6388b75214e5b39b4924ab61c01 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:57:32 -0400 Subject: [PATCH 05/19] feat(react-one-time-password-field): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/one-time-password-field/package.json | 1 + .../one-time-password-field/src/one-time-password-field.tsx | 5 +++-- pnpm-lock.yaml | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react/one-time-password-field/package.json b/packages/react/one-time-password-field/package.json index ec098bc80..d2abcde7a 100644 --- a/packages/react/one-time-password-field/package.json +++ b/packages/react/one-time-password-field/package.json @@ -35,6 +35,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", 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..2514e8ef5 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 @@ -13,6 +13,7 @@ import { createContextScope } from '@radix-ui/react-context'; import { useDirection } from '@radix-ui/react-direction'; import { clamp } from '@radix-ui/number'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none'; @@ -752,7 +753,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 +916,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/pnpm-lock.yaml b/pnpm-lock.yaml index 1e33bd919..8d3004736 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1639,6 +1639,9 @@ importers: packages/react/one-time-password-field: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/number': specifier: workspace:* version: link:../../core/number From 35b1d00633eb9c4f4e5fb02779544944e5627620 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 16:59:03 -0400 Subject: [PATCH 06/19] feat(react-password-toggle-field): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/password-toggle-field/package.json | 1 + .../react/password-toggle-field/src/password-toggle-field.tsx | 3 ++- pnpm-lock.yaml | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react/password-toggle-field/package.json b/packages/react/password-toggle-field/package.json index fd23f859d..74bd87561 100644 --- a/packages/react/password-toggle-field/package.json +++ b/packages/react/password-toggle-field/package.json @@ -35,6 +35,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", "@radix-ui/react-context": "workspace:*", 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..97d69c79d 100644 --- a/packages/react/password-toggle-field/src/password-toggle-field.tsx +++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx @@ -9,6 +9,7 @@ import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' const PASSWORD_TOGGLE_FIELD_NAME = 'PasswordToggleField'; @@ -334,7 +335,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/pnpm-lock.yaml b/pnpm-lock.yaml index 8d3004736..6542e4617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1709,6 +1709,9 @@ importers: packages/react/password-toggle-field: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From 18deecb1bce8c45fa798a7c854a9f532996d351d Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 17:00:08 -0400 Subject: [PATCH 07/19] feat(react-roving-focus-group): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/roving-focus/package.json | 1 + packages/react/roving-focus/src/roving-focus-group.tsx | 5 +++-- pnpm-lock.yaml | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react/roving-focus/package.json b/packages/react/roving-focus/package.json index 2690a441f..d6bc3a4c4 100644 --- a/packages/react/roving-focus/package.json +++ b/packages/react/roving-focus/package.json @@ -34,6 +34,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/roving-focus/src/roving-focus-group.tsx b/packages/react/roving-focus/src/roving-focus-group.tsx index 4ab89d893..9e952c411 100644 --- a/packages/react/roving-focus/src/roving-focus-group.tsx +++ b/packages/react/roving-focus/src/roving-focus-group.tsx @@ -10,6 +10,7 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { useDirection } from '@radix-ui/react-direction'; import type { Scope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus'; const EVENT_OPTIONS = { bubbles: false, cancelable: true }; @@ -325,12 +326,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/pnpm-lock.yaml b/pnpm-lock.yaml index 6542e4617..f750d3c36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2306,6 +2306,9 @@ importers: packages/react/roving-focus: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From 1d6333d0306c0a5f61f70a3eae1f4c28315eb982 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 17:01:00 -0400 Subject: [PATCH 08/19] feat(react-select): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/select/package.json | 1 + packages/react/select/src/select.tsx | 11 ++++++----- pnpm-lock.yaml | 3 +++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/react/select/package.json b/packages/react/select/package.json index 641decb89..1bf63f46c 100644 --- a/packages/react/select/package.json +++ b/packages/react/select/package.json @@ -34,6 +34,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/number": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 042a0f333..5dabddb53 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -24,6 +24,7 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' type Direction = 'ltr' | 'rtl'; @@ -583,7 +584,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 +593,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 +1336,7 @@ const SelectItem = React.forwardRef( } })} onPointerLeave={composeEventHandlers(itemProps.onPointerLeave, (event) => { - if (event.currentTarget === document.activeElement) { + if (event.currentTarget === getDeepActiveElement()) { contentContext.onItemLeave?.(); } })} @@ -1559,7 +1560,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/pnpm-lock.yaml b/pnpm-lock.yaml index f750d3c36..75a0db1a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2425,6 +2425,9 @@ importers: packages/react/select: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/number': specifier: workspace:* version: link:../../core/number From 0f84cd08afe1944f118ce6773f4b0d5ede63b37e Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 17:01:58 -0400 Subject: [PATCH 09/19] feat(react-toast): use get-deep-active-element to cross the shadow DOM boundary --- packages/react/toast/package.json | 1 + packages/react/toast/src/toast.tsx | 11 ++++++----- pnpm-lock.yaml | 3 +++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/react/toast/package.json b/packages/react/toast/package.json index 689d62324..50dcc426e 100644 --- a/packages/react/toast/package.json +++ b/packages/react/toast/package.json @@ -34,6 +34,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 25f27f402..b07ae7f96 100644 --- a/packages/react/toast/src/toast.tsx +++ b/packages/react/toast/src/toast.tsx @@ -14,6 +14,7 @@ import { useLayoutEffect } from '@radix-ui/react-use-layout-effect'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; +import { getDeepActiveElement } from '@radix-ui/deep-active-element' /* ------------------------------------------------------------------------------------------------- * ToastProvider @@ -193,7 +194,7 @@ const ToastViewport = React.forwardRef }; const handlePointerLeaveResume = () => { - const isFocusInside = wrapper.contains(document.activeElement); + const isFocusInside = wrapper.contains(getDeepActiveElement()); if (!isFocusInside) handleResume(); }; @@ -243,7 +244,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 +492,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 +936,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 75a0db1a4..4f8d81679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2764,6 +2764,9 @@ importers: packages/react/toast: dependencies: + '@radix-ui/deep-active-element': + specifier: workspace:* + version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From 5fd8517d6ac8e43b5526c2d56ae33a55e4bd0525 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 17:03:51 -0400 Subject: [PATCH 10/19] chore(cross-package): format --- packages/react/focus-scope/src/focus-scope.tsx | 2 +- packages/react/menu/src/menu.tsx | 2 +- packages/react/navigation-menu/src/navigation-menu.tsx | 2 +- .../src/one-time-password-field.test.tsx | 2 +- .../one-time-password-field/src/one-time-password-field.tsx | 2 +- .../react/password-toggle-field/src/password-toggle-field.tsx | 2 +- packages/react/roving-focus/src/roving-focus-group.tsx | 2 +- packages/react/select/src/select.tsx | 2 +- packages/react/toast/src/toast.tsx | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react/focus-scope/src/focus-scope.tsx b/packages/react/focus-scope/src/focus-scope.tsx index 39df43e71..df3fb35c6 100644 --- a/packages/react/focus-scope/src/focus-scope.tsx +++ b/packages/react/focus-scope/src/focus-scope.tsx @@ -2,7 +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/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount'; const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount'; diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 4b97f781f..776a784e8 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -21,7 +21,7 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Direction = 'ltr' | 'rtl'; diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx index 785526129..481d53686 100644 --- a/packages/react/navigation-menu/src/navigation-menu.tsx +++ b/packages/react/navigation-menu/src/navigation-menu.tsx @@ -16,7 +16,7 @@ import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import * as VisuallyHiddenPrimitive from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Orientation = 'vertical' | 'horizontal'; type Direction = 'ltr' | 'rtl'; 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 2514e8ef5..b97999d90 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 @@ -13,7 +13,7 @@ import { createContextScope } from '@radix-ui/react-context'; import { useDirection } from '@radix-ui/react-direction'; import { clamp } from '@radix-ui/number'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none'; 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 97d69c79d..b8a404889 100644 --- a/packages/react/password-toggle-field/src/password-toggle-field.tsx +++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx @@ -9,7 +9,7 @@ import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; const PASSWORD_TOGGLE_FIELD_NAME = 'PasswordToggleField'; diff --git a/packages/react/roving-focus/src/roving-focus-group.tsx b/packages/react/roving-focus/src/roving-focus-group.tsx index 9e952c411..8a76081f9 100644 --- a/packages/react/roving-focus/src/roving-focus-group.tsx +++ b/packages/react/roving-focus/src/roving-focus-group.tsx @@ -10,7 +10,7 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { useDirection } from '@radix-ui/react-direction'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus'; const EVENT_OPTIONS = { bubbles: false, cancelable: true }; diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 5dabddb53..f424dcca5 100644 --- a/packages/react/select/src/select.tsx +++ b/packages/react/select/src/select.tsx @@ -24,7 +24,7 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Direction = 'ltr' | 'rtl'; diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index b07ae7f96..5e0ce20d3 100644 --- a/packages/react/toast/src/toast.tsx +++ b/packages/react/toast/src/toast.tsx @@ -14,7 +14,7 @@ import { useLayoutEffect } from '@radix-ui/react-use-layout-effect'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element' +import { getDeepActiveElement } from '@radix-ui/deep-active-element'; /* ------------------------------------------------------------------------------------------------- * ToastProvider From 7ffeff5e8813ff709805b64e2b03ce2838994bc7 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 4 Sep 2025 17:27:17 -0400 Subject: [PATCH 11/19] fix(deep-active-element): remove unused dependency --- packages/core/deep-active-element/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/deep-active-element/package.json b/packages/core/deep-active-element/package.json index 1a31c8522..766d589c7 100644 --- a/packages/core/deep-active-element/package.json +++ b/packages/core/deep-active-element/package.json @@ -33,9 +33,7 @@ "typecheck": "tsc --noEmit", "build": "radix-build" }, - "dependencies": { - "@radix-ui/react-primitive": "workspace:*" - }, + "dependencies": {}, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", From 28908a8821f133b59c99f4dc85af059f8a3b29fc Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Fri, 5 Sep 2025 16:08:52 -0400 Subject: [PATCH 12/19] chore(deep-active-element): remove unused dev dependencies --- .../core/deep-active-element/package.json | 19 ------------------- pnpm-lock.yaml | 16 ---------------- 2 files changed, 35 deletions(-) diff --git a/packages/core/deep-active-element/package.json b/packages/core/deep-active-element/package.json index 766d589c7..2ca130195 100644 --- a/packages/core/deep-active-element/package.json +++ b/packages/core/deep-active-element/package.json @@ -33,30 +33,11 @@ "typecheck": "tsc --noEmit", "build": "radix-build" }, - "dependencies": {}, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - }, "devDependencies": { "@repo/builder": "workspace:*", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", - "@types/react": "^19.0.7", - "@types/react-dom": "^19.0.3", "eslint": "^9.18.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", "typescript": "^5.7.3" }, "homepage": "https://radix-ui.com/primitives", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f8d81679..f7493a487 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,10 +304,6 @@ importers: internal/typescript-config: {} packages/core/deep-active-element: - dependencies: - '@radix-ui/react-primitive': - specifier: workspace:* - version: link:../../react/primitive devDependencies: '@repo/builder': specifier: workspace:* @@ -318,21 +314,9 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../../../internal/typescript-config - '@types/react': - specifier: ^19.0.7 - version: 19.1.0 - '@types/react-dom': - specifier: ^19.0.3 - version: 19.1.1(@types/react@19.1.0) eslint: specifier: ^9.18.0 version: 9.24.0 - react: - specifier: ^19.1.0 - version: 19.1.0 - react-dom: - specifier: ^19.1.0 - version: 19.1.0(react@19.1.0) typescript: specifier: ^5.7.3 version: 5.8.3 From acbfc879256f5da9377dfa0bf9b898f11310a9d6 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Fri, 5 Sep 2025 16:27:50 -0400 Subject: [PATCH 13/19] refactor(cross-package): move getDeepActiveElement to @radix-ui/primitives --- packages/core/deep-active-element/README.md | 3 -- .../deep-active-element/eslint.config.mjs | 4 -- .../core/deep-active-element/package.json | 51 ------------------- .../src/deep-active-element.ts | 13 ----- .../core/deep-active-element/src/index.ts | 1 - .../core/deep-active-element/tsconfig.json | 9 ---- packages/core/primitive/src/primitive.tsx | 14 +++++ packages/react/focus-scope/package.json | 2 +- .../react/focus-scope/src/focus-scope.tsx | 2 +- packages/react/menu/package.json | 1 - packages/react/menu/src/menu.tsx | 3 +- packages/react/navigation-menu/package.json | 1 - .../navigation-menu/src/navigation-menu.tsx | 3 +- .../one-time-password-field/package.json | 1 - .../src/one-time-password-field.tsx | 3 +- .../react/password-toggle-field/package.json | 1 - .../src/password-toggle-field.tsx | 3 +- packages/react/roving-focus/package.json | 1 - .../roving-focus/src/roving-focus-group.tsx | 3 +- packages/react/select/package.json | 1 - packages/react/select/src/select.tsx | 3 +- packages/react/toast/package.json | 1 - packages/react/toast/src/toast.tsx | 3 +- pnpm-lock.yaml | 43 +--------------- 24 files changed, 25 insertions(+), 145 deletions(-) delete mode 100644 packages/core/deep-active-element/README.md delete mode 100644 packages/core/deep-active-element/eslint.config.mjs delete mode 100644 packages/core/deep-active-element/package.json delete mode 100644 packages/core/deep-active-element/src/deep-active-element.ts delete mode 100644 packages/core/deep-active-element/src/index.ts delete mode 100644 packages/core/deep-active-element/tsconfig.json diff --git a/packages/core/deep-active-element/README.md b/packages/core/deep-active-element/README.md deleted file mode 100644 index 2aac5edf4..000000000 --- a/packages/core/deep-active-element/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `deep-active-element` - -This package contains a utility to get the currently focused element even across Shadow DOM boundaries. diff --git a/packages/core/deep-active-element/eslint.config.mjs b/packages/core/deep-active-element/eslint.config.mjs deleted file mode 100644 index 26d682564..000000000 --- a/packages/core/deep-active-element/eslint.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -// @ts-check -import { configs } from '@repo/eslint-config/react-package'; - -export default configs; diff --git a/packages/core/deep-active-element/package.json b/packages/core/deep-active-element/package.json deleted file mode 100644 index 2ca130195..000000000 --- a/packages/core/deep-active-element/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@radix-ui/deep-active-element", - "version": "1.0.0", - "license": "MIT", - "source": "./src/index.ts", - "main": "./src/index.ts", - "module": "./src/index.ts", - "publishConfig": { - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - } - } - }, - "files": [ - "dist", - "README.md" - ], - "sideEffects": false, - "scripts": { - "lint": "eslint --max-warnings 0 src", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit", - "build": "radix-build" - }, - "devDependencies": { - "@repo/builder": "workspace:*", - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "eslint": "^9.18.0", - "typescript": "^5.7.3" - }, - "homepage": "https://radix-ui.com/primitives", - "repository": { - "type": "git", - "url": "git+https://github.com/radix-ui/primitives.git" - }, - "bugs": { - "url": "https://github.com/radix-ui/primitives/issues" - } -} diff --git a/packages/core/deep-active-element/src/deep-active-element.ts b/packages/core/deep-active-element/src/deep-active-element.ts deleted file mode 100644 index 552456315..000000000 --- a/packages/core/deep-active-element/src/deep-active-element.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Utility to get the currently focused element even across Shadow DOM boundaries - */ -export function getDeepActiveElement(): Element | null { - let activeElement = document.activeElement; - - // Traverse through shadow DOMs to find the deepest active element - while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } - - return activeElement; -} diff --git a/packages/core/deep-active-element/src/index.ts b/packages/core/deep-active-element/src/index.ts deleted file mode 100644 index e30101fb0..000000000 --- a/packages/core/deep-active-element/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getDeepActiveElement } from './deep-active-element'; diff --git a/packages/core/deep-active-element/tsconfig.json b/packages/core/deep-active-element/tsconfig.json deleted file mode 100644 index ecf7f7008..000000000 --- a/packages/core/deep-active-element/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "@repo/typescript-config/react-library.json", - "compilerOptions": { - "outDir": "dist", - "types": ["@repo/typescript-config/react-library"] - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/core/primitive/src/primitive.tsx b/packages/core/primitive/src/primitive.tsx index 6ee69659c..b048ab5c0 100644 --- a/packages/core/primitive/src/primitive.tsx +++ b/packages/core/primitive/src/primitive.tsx @@ -73,3 +73,17 @@ export function getActiveElement( export function isFrame(element: Element): element is HTMLIFrameElement { return element.tagName === 'IFRAME'; } + +/** + * Utility to get the currently focused element even across Shadow DOM boundaries + */ +export function getDeepActiveElement(): Element | null { + let activeElement = document.activeElement; + + // Traverse through shadow DOMs to find the deepest active element + while (activeElement && activeElement.shadowRoot && 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 ea8e8cdf4..edf89f5b1 100644 --- a/packages/react/focus-scope/package.json +++ b/packages/react/focus-scope/package.json @@ -34,7 +34,7 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", + "@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 df3fb35c6..ff3f575d2 100644 --- a/packages/react/focus-scope/src/focus-scope.tsx +++ b/packages/react/focus-scope/src/focus-scope.tsx @@ -2,7 +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/deep-active-element'; +import { getDeepActiveElement } from '@radix-ui/primitive' const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount'; const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount'; diff --git a/packages/react/menu/package.json b/packages/react/menu/package.json index d79ba606e..f9eccc3f8 100644 --- a/packages/react/menu/package.json +++ b/packages/react/menu/package.json @@ -34,7 +34,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 776a784e8..e623320d8 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 } 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'; @@ -21,7 +21,6 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Direction = 'ltr' | 'rtl'; diff --git a/packages/react/navigation-menu/package.json b/packages/react/navigation-menu/package.json index 82496af75..9c6bcf72c 100644 --- a/packages/react/navigation-menu/package.json +++ b/packages/react/navigation-menu/package.json @@ -34,7 +34,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/navigation-menu/src/navigation-menu.tsx b/packages/react/navigation-menu/src/navigation-menu.tsx index 481d53686..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'; @@ -16,7 +16,6 @@ import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import * as VisuallyHiddenPrimitive from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Orientation = 'vertical' | 'horizontal'; type Direction = 'ltr' | 'rtl'; diff --git a/packages/react/one-time-password-field/package.json b/packages/react/one-time-password-field/package.json index d2abcde7a..ec098bc80 100644 --- a/packages/react/one-time-password-field/package.json +++ b/packages/react/one-time-password-field/package.json @@ -35,7 +35,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", 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 b97999d90..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'; @@ -13,7 +13,6 @@ import { createContextScope } from '@radix-ui/react-context'; import { useDirection } from '@radix-ui/react-direction'; import { clamp } from '@radix-ui/number'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type InputValidationType = 'alpha' | 'numeric' | 'alphanumeric' | 'none'; diff --git a/packages/react/password-toggle-field/package.json b/packages/react/password-toggle-field/package.json index 74bd87561..fd23f859d 100644 --- a/packages/react/password-toggle-field/package.json +++ b/packages/react/password-toggle-field/package.json @@ -35,7 +35,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", "@radix-ui/react-context": "workspace:*", 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 b8a404889..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'; @@ -9,7 +9,6 @@ import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; const PASSWORD_TOGGLE_FIELD_NAME = 'PasswordToggleField'; diff --git a/packages/react/roving-focus/package.json b/packages/react/roving-focus/package.json index d6bc3a4c4..2690a441f 100644 --- a/packages/react/roving-focus/package.json +++ b/packages/react/roving-focus/package.json @@ -34,7 +34,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/roving-focus/src/roving-focus-group.tsx b/packages/react/roving-focus/src/roving-focus-group.tsx index 8a76081f9..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'; @@ -10,7 +10,6 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { useDirection } from '@radix-ui/react-direction'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus'; const EVENT_OPTIONS = { bubbles: false, cancelable: true }; diff --git a/packages/react/select/package.json b/packages/react/select/package.json index 1bf63f46c..641decb89 100644 --- a/packages/react/select/package.json +++ b/packages/react/select/package.json @@ -34,7 +34,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/number": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index f424dcca5..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'; @@ -24,7 +24,6 @@ import { hideOthers } from 'aria-hidden'; import { RemoveScroll } from 'react-remove-scroll'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; type Direction = 'ltr' | 'rtl'; diff --git a/packages/react/toast/package.json b/packages/react/toast/package.json index 50dcc426e..689d62324 100644 --- a/packages/react/toast/package.json +++ b/packages/react/toast/package.json @@ -34,7 +34,6 @@ "build": "radix-build" }, "dependencies": { - "@radix-ui/deep-active-element": "workspace:*", "@radix-ui/primitive": "workspace:*", "@radix-ui/react-collection": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 5e0ce20d3..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'; @@ -14,7 +14,6 @@ import { useLayoutEffect } from '@radix-ui/react-use-layout-effect'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; -import { getDeepActiveElement } from '@radix-ui/deep-active-element'; /* ------------------------------------------------------------------------------------------------- * ToastProvider diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7493a487..a607303a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,24 +303,6 @@ importers: internal/typescript-config: {} - packages/core/deep-active-element: - devDependencies: - '@repo/builder': - specifier: workspace:* - version: link:../../../internal/builder - '@repo/eslint-config': - specifier: workspace:* - version: link:../../../internal/eslint-config - '@repo/typescript-config': - specifier: workspace:* - version: link:../../../internal/typescript-config - eslint: - specifier: ^9.18.0 - version: 9.24.0 - typescript: - specifier: ^5.7.3 - version: 5.8.3 - packages/core/number: devDependencies: '@repo/builder': @@ -1174,9 +1156,9 @@ importers: packages/react/focus-scope: dependencies: - '@radix-ui/deep-active-element': + '@radix-ui/primitive': specifier: workspace:* - version: link:../../core/deep-active-element + version: link:../../core/primitive '@radix-ui/react-compose-refs': specifier: workspace:* version: link:../compose-refs @@ -1392,9 +1374,6 @@ importers: packages/react/menu: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive @@ -1547,9 +1526,6 @@ importers: packages/react/navigation-menu: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive @@ -1623,9 +1599,6 @@ importers: packages/react/one-time-password-field: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/number': specifier: workspace:* version: link:../../core/number @@ -1693,9 +1666,6 @@ importers: packages/react/password-toggle-field: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive @@ -2290,9 +2260,6 @@ importers: packages/react/roving-focus: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive @@ -2409,9 +2376,6 @@ importers: packages/react/select: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/number': specifier: workspace:* version: link:../../core/number @@ -2748,9 +2712,6 @@ importers: packages/react/toast: dependencies: - '@radix-ui/deep-active-element': - specifier: workspace:* - version: link:../../core/deep-active-element '@radix-ui/primitive': specifier: workspace:* version: link:../../core/primitive From 897233e62972c0520b033f696c298dbc766a852f Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Fri, 5 Sep 2025 16:36:10 -0400 Subject: [PATCH 14/19] fix(primitive): handle null cases in getDeepActiveElement function --- packages/core/primitive/src/primitive.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/core/primitive/src/primitive.tsx b/packages/core/primitive/src/primitive.tsx index b048ab5c0..0f9a3d2e7 100644 --- a/packages/core/primitive/src/primitive.tsx +++ b/packages/core/primitive/src/primitive.tsx @@ -78,10 +78,17 @@ export function isFrame(element: Element): element is HTMLIFrameElement { * 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 && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { + while (activeElement.shadowRoot?.activeElement) { activeElement = activeElement.shadowRoot.activeElement; } From c123d110203f94e2b50c56788a3c511d438ea403 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Tue, 23 Sep 2025 15:43:12 -0400 Subject: [PATCH 15/19] fix(menu): resolve shadow DOM submenu closing issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add data-radix-menu-sub-trigger attribute to MenuSubTrigger for proper identification - Implement custom event system for explicit submenu closure in shadow DOM contexts - Disable automatic grace timer closure in shadow DOM to prevent premature submenu closing - Use native event coordinates for improved accuracy in shadow DOM environments - Extend grace period to 800ms in shadow DOM to account for coordinate detection limitations - Ensure only one submenu remains open when navigating between multiple subtriggers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/react/menu/src/menu.tsx | 116 ++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index e623320d8..630e66d85 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -438,7 +438,30 @@ 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; + const isInShadowDOM = target && target.getRootNode() !== document && 'host' in target.getRootNode(); + if (isInShadowDOM) { + 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('radix-force-close-submenu', { + bubbles: true, + cancelable: false, + detail: { currentTrigger: menuItem } // Pass the current trigger to exclude it + }); + menuItem.dispatchEvent(closeEvent); + }, 0); + } + } }, [isPointerMovingToSubmenu] )} @@ -732,6 +755,7 @@ const MenuItemImpl = React.forwardRef( if (!event.defaultPrevented) { const item = event.currentTarget; item.focus({ preventScroll: true }); + } } }) @@ -1043,6 +1067,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('radix-force-close-submenu', handleForceClose as EventListener); + // Also listen on parent elements since the event bubbles + const menuContent = currentElement.closest('[data-radix-menu-content]'); + if (menuContent) { + menuContent.addEventListener('radix-force-close-submenu', handleForceClose as EventListener); + } + + return () => { + currentElement.removeEventListener('radix-force-close-submenu', handleForceClose as EventListener); + if (menuContent) { + menuContent.removeEventListener('radix-force-close-submenu', handleForceClose as EventListener); + } + }; + } + }, [context, subContext.trigger]); + + return ( contentContext.onPointerGraceIntentChange(null), - 300 + () => { + contentContext.onPointerGraceIntentChange(null); + + // In shadow DOM, don't automatically close submenu on grace timer expiry + // Only close via explicit menu item selection logic + if (!isInShadowDOM && context.open) { + // Normal behavior for non-shadow DOM + context.onOpenChange(false); + } + }, + gracePeriod ); } else { contentContext.onTriggerLeave(event); @@ -1313,7 +1404,14 @@ function isPointInPolygon(point: Point, polygon: Polygon) { function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) { if (!area) return false; - const cursorPos = { x: event.clientX, y: event.clientY }; + + // Use the most reliable coordinates available + const nativeEvent = event.nativeEvent; + const cursorPos = { + x: nativeEvent ? nativeEvent.clientX : event.clientX, + y: nativeEvent ? nativeEvent.clientY : event.clientY + }; + return isPointInPolygon(cursorPos, area); } From b9126cccabba36824577a5e72beb4728080c4853 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Tue, 23 Sep 2025 15:45:26 -0400 Subject: [PATCH 16/19] refactor(menu): clean up shadow DOM implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unnecessary empty lines and unused variables - Extract shadow DOM detection into reusable utility function - Simplify coordinate handling by removing unused adjustments - Revert isPointerInGraceArea changes that weren't needed for the fix - Reduce code duplication in shadow DOM detection logic 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- packages/react/menu/src/menu.tsx | 49 ++++++++++---------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 630e66d85..138c865d2 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -443,8 +443,7 @@ const MenuContentImpl = React.forwardRef( if (!event.defaultPrevented) { const item = event.currentTarget; item.focus({ preventScroll: true }); - } } }) @@ -1154,33 +1152,19 @@ const MenuSubTrigger = React.forwardRef { @@ -1196,7 +1180,7 @@ const MenuSubTrigger = React.forwardRef(handler: React.PointerEventHandler): React.PointerEventHandler { return (event) => (event.pointerType === 'mouse' ? handler(event) : undefined); } From 18f8faf08bb314af40bb08980c3974399b1f40f2 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 25 Sep 2025 08:38:24 -0400 Subject: [PATCH 17/19] refactor(primitive,menu): move isInShadowDOM utility to primitive --- packages/core/primitive/src/primitive.tsx | 10 +++++++++- packages/react/menu/src/menu.tsx | 6 +----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/primitive/src/primitive.tsx b/packages/core/primitive/src/primitive.tsx index 0f9a3d2e7..47054d6c2 100644 --- a/packages/core/primitive/src/primitive.tsx +++ b/packages/core/primitive/src/primitive.tsx @@ -74,8 +74,16 @@ 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 + * Utility to get the currently focused element even across shadow DOM boundaries */ export function getDeepActiveElement(): Element | null { if (!canUseDOM) { diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 138c865d2..239c83210 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, getDeepActiveElement } 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'; @@ -1392,10 +1392,6 @@ function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) { return isPointInPolygon(cursorPos, area); } -function isInShadowDOM(element: Element): boolean { - return element && element.getRootNode() !== document && 'host' in element.getRootNode(); -} - function whenMouse(handler: React.PointerEventHandler): React.PointerEventHandler { return (event) => (event.pointerType === 'mouse' ? handler(event) : undefined); } From 06a5f916d5f9edf4d4920e5af3d828aaeec10ff4 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Thu, 25 Sep 2025 08:38:37 -0400 Subject: [PATCH 18/19] refactor(menu): use constant for custom event name --- packages/react/menu/src/menu.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 239c83210..188e93071 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -36,6 +36,7 @@ const SUB_CLOSE_KEYS: Record = { ltr: ['ArrowLeft'], rtl: ['ArrowRight'], }; +const FORCE_CLOSE_CUSTOM_EVENT_NAME = 'radix-force-close-submenu'; /* ------------------------------------------------------------------------------------------------- * Menu @@ -452,7 +453,7 @@ const MenuContentImpl = React.forwardRef { // Dispatch a custom event that submenu triggers can listen for - const closeEvent = new CustomEvent('radix-force-close-submenu', { + const closeEvent = new CustomEvent(FORCE_CLOSE_CUSTOM_EVENT_NAME, { bubbles: true, cancelable: false, detail: { currentTrigger: menuItem } // Pass the current trigger to exclude it @@ -1083,17 +1084,17 @@ const MenuSubTrigger = React.forwardRef { - currentElement.removeEventListener('radix-force-close-submenu', handleForceClose as EventListener); + currentElement.removeEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener); if (menuContent) { - menuContent.removeEventListener('radix-force-close-submenu', handleForceClose as EventListener); + menuContent.removeEventListener(FORCE_CLOSE_CUSTOM_EVENT_NAME, handleForceClose as EventListener); } }; } From de4315b3f88d71f6eaee88f2e119dce5fc3df869 Mon Sep 17 00:00:00 2001 From: Corey Psoinos Date: Fri, 3 Oct 2025 12:04:47 -0400 Subject: [PATCH 19/19] fix(menu): revert problematic pointer grace intent handling in shadow DOM The logic was unnecessary, and infact, was incorrect, as it was running when _not_ in a shadow DOM --- packages/react/menu/src/menu.tsx | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 188e93071..15a2effc3 100644 --- a/packages/react/menu/src/menu.tsx +++ b/packages/react/menu/src/menu.tsx @@ -1153,10 +1153,6 @@ const MenuSubTrigger = React.forwardRef { - contentContext.onPointerGraceIntentChange(null); - - // In shadow DOM, don't automatically close submenu on grace timer expiry - // Only close via explicit menu item selection logic - if (!shadowDOM && context.open) { - // Normal behavior for non-shadow DOM - context.onOpenChange(false); - } - }, - gracePeriod + () => contentContext.onPointerGraceIntentChange(null), + 300 ); } else { contentContext.onTriggerLeave(event);