diff --git a/apps/storybook/stories/focus-scope.stories.tsx b/apps/storybook/stories/focus-scope.stories.tsx index 90dd193aa..c9704766f 100644 --- a/apps/storybook/stories/focus-scope.stories.tsx +++ b/apps/storybook/stories/focus-scope.stories.tsx @@ -51,6 +51,75 @@ export const Basic = () => { ); }; +function ShadowDOMFields() { + const ref = React.useRef(null); + + React.useEffect(() => { + const el = ref.current; + if (!el || el.shadowRoot) return; + const shadow = el.attachShadow({ mode: 'open' }); + + const style = document.createElement('style'); + style.textContent = ` + div { display: flex; flex-direction: column; gap: 8px; padding: 12px; border: 1px dashed #aaa; } + label { display: flex; flex-direction: column; gap: 4px; font-family: sans-serif; font-size: 14px; } + `; + shadow.appendChild(style); + + const wrapper = document.createElement('div'); + for (const label of ['Shadow field 1', 'Shadow field 2', 'Shadow field 3']) { + const labelEl = document.createElement('label'); + labelEl.textContent = label; + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = label; + labelEl.appendChild(input); + wrapper.appendChild(labelEl); + } + shadow.appendChild(wrapper); + }, []); + + return
; +} + +export const WithShadowDOM = () => { + const [trapped, setTrapped] = React.useState(false); + + return ( + <> +
+ {' '} + +
+ {trapped ? ( + +
+ + + +
+ ) : null} +
+ +
+ + ); +}; + export const Multiple = () => { const [trapped1, setTrapped1] = React.useState(false); const [trapped2, setTrapped2] = React.useState(false); diff --git a/packages/core/primitive/src/primitive.tsx b/packages/core/primitive/src/primitive.tsx index fa9040eec..f20676c37 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 71854c1be..1848be012 100644 --- a/packages/react/focus-scope/package.json +++ b/packages/react/focus-scope/package.json @@ -35,6 +35,7 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/primitive": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", "@radix-ui/react-primitive": "workspace:*", "@radix-ui/react-use-callback-ref": "workspace:*" diff --git a/packages/react/focus-scope/src/focus-scope.test.tsx b/packages/react/focus-scope/src/focus-scope.test.tsx index 553fa7cf7..eb635cddd 100644 --- a/packages/react/focus-scope/src/focus-scope.test.tsx +++ b/packages/react/focus-scope/src/focus-scope.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import userEvent from '@testing-library/user-event'; import { cleanup, render, waitFor } from '@testing-library/react'; -import { FocusScope } from './focus-scope'; +import { FocusScope, getTabbableCandidates } from './focus-scope'; import type { RenderResult } from '@testing-library/react'; import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest'; @@ -92,6 +92,132 @@ describe('FocusScope', () => { }); }); + describe('getTabbableCandidates', () => { + it('should find tabbable elements inside shadow DOM', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + // Regular input + const regularInput = document.createElement('input'); + regularInput.type = 'text'; + container.appendChild(regularInput); + + // Shadow host with an input inside + const shadowHost = document.createElement('div'); + container.appendChild(shadowHost); + const shadow = shadowHost.attachShadow({ mode: 'open' }); + const shadowInput = document.createElement('input'); + shadowInput.type = 'text'; + shadow.appendChild(shadowInput); + + const candidates = getTabbableCandidates(container); + expect(candidates).toContain(regularInput); + expect(candidates).toContain(shadowInput); + expect(candidates).toHaveLength(2); + + document.body.removeChild(container); + }); + + it('should find tabbable elements in nested shadow DOMs', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + // Outer shadow host + const outerHost = document.createElement('div'); + container.appendChild(outerHost); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + + const outerInput = document.createElement('input'); + outerInput.type = 'text'; + outerShadow.appendChild(outerInput); + + // Inner shadow host inside outer shadow + const innerHost = document.createElement('div'); + outerShadow.appendChild(innerHost); + const innerShadow = innerHost.attachShadow({ mode: 'open' }); + const innerInput = document.createElement('input'); + innerInput.type = 'text'; + innerShadow.appendChild(innerInput); + + const candidates = getTabbableCandidates(container); + expect(candidates).toContain(outerInput); + expect(candidates).toContain(innerInput); + expect(candidates).toHaveLength(2); + + document.body.removeChild(container); + }); + + it('should skip disabled and hidden elements inside shadow DOM', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const shadowHost = document.createElement('div'); + container.appendChild(shadowHost); + const shadow = shadowHost.attachShadow({ mode: 'open' }); + + const disabledInput = document.createElement('input'); + disabledInput.type = 'text'; + disabledInput.disabled = true; + shadow.appendChild(disabledInput); + + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + shadow.appendChild(hiddenInput); + + const visibleInput = document.createElement('input'); + visibleInput.type = 'text'; + shadow.appendChild(visibleInput); + + const candidates = getTabbableCandidates(container); + expect(candidates).not.toContain(disabledInput); + expect(candidates).not.toContain(hiddenInput); + expect(candidates).toContain(visibleInput); + expect(candidates).toHaveLength(1); + + document.body.removeChild(container); + }); + }); + + describe('given a FocusScope with shadow DOM elements', () => { + let rendered: RenderResult; + let tabbableFirst: HTMLButtonElement; + let tabbableLast: HTMLInputElement; + + beforeEach(async () => { + rendered = render( +
+ +
+ + + +
+
, + ); + tabbableFirst = rendered.getByText('Close') as HTMLButtonElement; + // Wait for the useEffect inside ShadowHostField to attach the shadow root + await waitFor(() => { + const host = rendered.container.querySelector('[data-shadow-host]'); + expect(host?.shadowRoot?.querySelector('input')).not.toBeNull(); + }); + tabbableLast = rendered.container + .querySelector('[data-shadow-host]')! + .shadowRoot!.querySelector('input') as HTMLInputElement; + }); + + it('should focus the first element in scope on tab from the last shadow DOM element', () => { + tabbableLast.focus(); + userEvent.tab(); + waitFor(() => expect(tabbableFirst).toHaveFocus()); + }); + + it('should focus the last shadow DOM element on shift+tab from the first element in scope', () => { + tabbableFirst.focus(); + userEvent.tab({ shift: true }); + waitFor(() => expect(tabbableLast).toHaveFocus()); + }); + }); + describe('given a FocusScope with internal focus handlers', () => { const handleLastFocusableElementBlur = vi.fn(); let rendered: RenderResult; @@ -128,3 +254,16 @@ function TestField({ label, ...props }: { label: string } & React.ComponentProps ); } + +function ShadowHostField() { + const ref = React.useRef(null); + React.useEffect(() => { + const el = ref.current; + if (!el || el.shadowRoot) return; + const shadow = el.attachShadow({ mode: 'open' }); + const input = document.createElement('input'); + input.type = 'text'; + shadow.appendChild(input); + }, []); + return
; +} diff --git a/packages/react/focus-scope/src/focus-scope.tsx b/packages/react/focus-scope/src/focus-scope.tsx index 605c1e79a..81bd8bca1 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; } } @@ -245,17 +246,28 @@ function getTabbableEdges(container: HTMLElement) { */ function getTabbableCandidates(container: HTMLElement) { const nodes: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { - acceptNode: (node: any) => { - const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'; - if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP; - // `.tabIndex` is not the same as the `tabindex` attribute. It works on the - // runtime's understanding of tabbability, so this automatically accounts - // for any kind of element that could be tabbed to. - return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; - }, - }); - while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement); + function walk(root: HTMLElement | ShadowRoot) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: any) => { + const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'; + if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP; + if (node.shadowRoot) return NodeFilter.FILTER_ACCEPT; + // `.tabIndex` is not the same as the `tabindex` attribute. It works on the + // runtime's understanding of tabbability, so this automatically accounts + // for any kind of element that could be tabbed to. + return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + }, + }); + while (walker.nextNode()) { + const el = walker.currentNode as HTMLElement; + if (el.shadowRoot) { + walk(el.shadowRoot); + } else { + nodes.push(el); + } + } + } + walk(container); // we do not take into account the order of nodes with positive `tabIndex` as it // hinders accessibility to have tab order different from visual order. return nodes; @@ -290,7 +302,7 @@ function isSelectableInput(element: any): element is FocusableTarget & { select: function focus(element?: FocusableTarget | null, { select = false } = {}) { // only focus if that element is focusable if (element && element.focus) { - const previouslyFocusedElement = document.activeElement; + const previouslyFocusedElement = getDeepActiveElement(); // NOTE: we prevent scrolling on focus, to minimize jarring transitions for users element.focus({ preventScroll: true }); // only select if its not the same element, it supports selection and we need to select @@ -348,5 +360,6 @@ export { FocusScope, // Root, + getTabbableCandidates, }; export type { FocusScopeProps }; diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index 6840842ac..1cd15043f 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, + 300 ); } else { contentContext.onTriggerLeave(event); @@ -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 f4e3db802..74da9c330 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.tsx b/packages/react/one-time-password-field/src/one-time-password-field.tsx index 0f1065bf6..8f17c4dfd 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 8d3477795..bcf3aacda 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 9c0bf6810..a5f3eea48 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 01f51923e..132f773d8 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 00cad0a56..1e42ddaf2 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 5ef3d64c0..34cffe8bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1128,6 +1128,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