diff --git a/apps/ssr-testing/app/document-context/page.tsx b/apps/ssr-testing/app/document-context/page.tsx new file mode 100644 index 000000000..f30767932 --- /dev/null +++ b/apps/ssr-testing/app/document-context/page.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +import * as DocumentContext from '@radix-ui/react-document-context'; + +export default function Page() { + return Document Context; +} diff --git a/apps/ssr-testing/app/layout.tsx b/apps/ssr-testing/app/layout.tsx index 8cda635d4..8ee00331a 100644 --- a/apps/ssr-testing/app/layout.tsx +++ b/apps/ssr-testing/app/layout.tsx @@ -42,6 +42,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { Toolbar Tooltip VisuallyHidden + DocumentContext
{children}
diff --git a/packages/react/alert-dialog/src/alert-dialog.tsx b/packages/react/alert-dialog/src/alert-dialog.tsx index be6969978..616f0f8bf 100644 --- a/packages/react/alert-dialog/src/alert-dialog.tsx +++ b/packages/react/alert-dialog/src/alert-dialog.tsx @@ -7,6 +7,7 @@ import { composeEventHandlers } from '@radix-ui/primitive'; import { Slottable } from '@radix-ui/react-slot'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * AlertDialog @@ -242,6 +243,7 @@ type DescriptionWarningProps = { }; const DescriptionWarning: React.FC = ({ contentRef }) => { + const providedDocument = useDocument(); const MESSAGE = `\`${CONTENT_NAME}\` requires a description for the component to be accessible for screen reader users. You can add a description to the \`${CONTENT_NAME}\` by passing a \`${DESCRIPTION_NAME}\` component as a child, which also benefits sighted users by adding visible context to the dialog. @@ -251,11 +253,12 @@ Alternatively, you can use your own component as a description by assigning it a For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`; React.useEffect(() => { - const hasDescription = document.getElementById( + if (!providedDocument) return; + const hasDescription = providedDocument.getElementById( contentRef.current?.getAttribute('aria-describedby')! ); if (!hasDescription) console.warn(MESSAGE); - }, [MESSAGE, contentRef]); + }, [MESSAGE, contentRef, providedDocument]); return null; }; diff --git a/packages/react/avatar/src/avatar.tsx b/packages/react/avatar/src/avatar.tsx index fe3baf6b9..dba98221c 100644 --- a/packages/react/avatar/src/avatar.tsx +++ b/packages/react/avatar/src/avatar.tsx @@ -5,6 +5,7 @@ import { useLayoutEffect } from '@radix-ui/react-use-layout-effect'; import { Primitive } from '@radix-ui/react-primitive'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * Avatar @@ -98,13 +99,16 @@ const AvatarFallback = React.forwardRef { + if (!documentWindow) return; if (delayMs !== undefined) { - const timerId = window.setTimeout(() => setCanRender(true), delayMs); - return () => window.clearTimeout(timerId); + const timerId = documentWindow.setTimeout(() => setCanRender(true), delayMs); + return () => documentWindow.clearTimeout(timerId); } - }, [delayMs]); + }, [delayMs, documentWindow]); return canRender && context.imageLoadingStatus !== 'loaded' ? ( @@ -121,15 +125,17 @@ function useImageLoadingStatus( { referrerPolicy, crossOrigin }: AvatarImageProps ) { const [loadingStatus, setLoadingStatus] = React.useState('idle'); - + const providedDocument = useDocument(); + const documentWindow = providedDocument?.defaultView; useLayoutEffect(() => { + if (!documentWindow) return; if (!src) { setLoadingStatus('error'); return; } let isMounted = true; - const image = new window.Image(); + const image = new documentWindow.Image(); const updateStatus = (status: ImageLoadingStatus) => () => { if (!isMounted) return; @@ -149,7 +155,7 @@ function useImageLoadingStatus( return () => { isMounted = false; }; - }, [src, referrerPolicy, crossOrigin]); + }, [src, referrerPolicy, documentWindow, crossOrigin]); return loadingStatus; } diff --git a/packages/react/context-menu/src/context-menu.tsx b/packages/react/context-menu/src/context-menu.tsx index a1d252114..b9935a76a 100644 --- a/packages/react/context-menu/src/context-menu.tsx +++ b/packages/react/context-menu/src/context-menu.tsx @@ -8,6 +8,7 @@ import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; type Direction = 'ltr' | 'rtl'; type Point = { x: number; y: number }; @@ -97,10 +98,12 @@ const ContextMenuTrigger = React.forwardRef DOMRect.fromRect({ width: 0, height: 0, ...pointRef.current }), }); + const documentWindow = useDocument()?.defaultView; + const longPressTimerRef = React.useRef(0); const clearLongPress = React.useCallback( - () => window.clearTimeout(longPressTimerRef.current), - [] + () => documentWindow?.clearTimeout(longPressTimerRef.current), + [documentWindow] ); const handleOpen = (event: React.MouseEvent | React.PointerEvent) => { pointRef.current = { x: event.clientX, y: event.clientY }; @@ -140,7 +143,12 @@ const ContextMenuTrigger = React.forwardRef { // clear the long press here in case there's multiple touch points clearLongPress(); - longPressTimerRef.current = window.setTimeout(() => handleOpen(event), 700); + if (documentWindow) { + longPressTimerRef.current = documentWindow?.setTimeout( + () => handleOpen(event), + 700 + ); + } }) ) } diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index db1d22ad1..8cf652daa 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -15,6 +15,7 @@ import { hideOthers } from 'aria-hidden'; import { Slot } from '@radix-ui/react-slot'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * Dialog @@ -503,6 +504,7 @@ const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, { type TitleWarningProps = { titleId?: string }; const TitleWarning: React.FC = ({ titleId }) => { + const providedDocument = useDocument(); const titleWarningContext = useWarningContext(TITLE_WARNING_NAME); const MESSAGE = `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users. @@ -512,11 +514,12 @@ If you want to hide the \`${titleWarningContext.titleName}\`, you can wrap it wi For more information, see https://radix-ui.com/primitives/docs/components/${titleWarningContext.docsSlug}`; React.useEffect(() => { + if (!providedDocument) return; if (titleId) { - const hasTitle = document.getElementById(titleId); + const hasTitle = providedDocument.getElementById(titleId); if (!hasTitle) console.error(MESSAGE); } - }, [MESSAGE, titleId]); + }, [MESSAGE, titleId, providedDocument]); return null; }; @@ -529,17 +532,19 @@ type DescriptionWarningProps = { }; const DescriptionWarning: React.FC = ({ contentRef, descriptionId }) => { + const providedDocument = useDocument(); const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME); const MESSAGE = `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`; React.useEffect(() => { + if (!providedDocument) return; const describedById = contentRef.current?.getAttribute('aria-describedby'); // if we have an id and the user hasn't set aria-describedby={undefined} if (descriptionId && describedById) { - const hasDescription = document.getElementById(descriptionId); + const hasDescription = providedDocument.getElementById(descriptionId); if (!hasDescription) console.warn(MESSAGE); } - }, [MESSAGE, contentRef, descriptionId]); + }, [MESSAGE, contentRef, descriptionId, providedDocument]); return null; }; diff --git a/packages/react/dismissable-layer/package.json b/packages/react/dismissable-layer/package.json index 39977d43e..9cf55332a 100644 --- a/packages/react/dismissable-layer/package.json +++ b/packages/react/dismissable-layer/package.json @@ -31,6 +31,7 @@ "dependencies": { "@radix-ui/primitive": "workspace:*", "@radix-ui/react-compose-refs": "workspace:*", + "@radix-ui/react-document-context": "workspace:*", "@radix-ui/react-primitive": "workspace:*", "@radix-ui/react-use-callback-ref": "workspace:*", "@radix-ui/react-use-escape-keydown": "workspace:*" diff --git a/packages/react/dismissable-layer/src/dismissable-layer.tsx b/packages/react/dismissable-layer/src/dismissable-layer.tsx index 35bbd17f2..830e7c674 100644 --- a/packages/react/dismissable-layer/src/dismissable-layer.tsx +++ b/packages/react/dismissable-layer/src/dismissable-layer.tsx @@ -4,6 +4,7 @@ import { Primitive, dispatchDiscreteCustomEvent } from '@radix-ui/react-primitiv import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown'; +import { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * DismissableLayer @@ -71,7 +72,7 @@ const DismissableLayer = React.forwardRef(null); - const ownerDocument = node?.ownerDocument ?? globalThis?.document; + const providedDocument = useDocument(); const [, force] = React.useState({}); const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node)); const layers = Array.from(context.layers); @@ -88,7 +89,7 @@ const DismissableLayer = React.forwardRef { const target = event.target as HTMLElement; @@ -97,7 +98,7 @@ const DismissableLayer = React.forwardRef { const isHighestLayer = index === context.layers.size - 1; @@ -107,28 +108,28 @@ const DismissableLayer = React.forwardRef { - if (!node) return; + if (!node || !providedDocument) return; if (disableOutsidePointerEvents) { if (context.layersWithOutsidePointerEventsDisabled.size === 0) { - originalBodyPointerEvents = ownerDocument.body.style.pointerEvents; - ownerDocument.body.style.pointerEvents = 'none'; + originalBodyPointerEvents = providedDocument.body.style.pointerEvents; + providedDocument.body.style.pointerEvents = 'none'; } context.layersWithOutsidePointerEventsDisabled.add(node); } context.layers.add(node); - dispatchUpdate(); + dispatchUpdate(providedDocument); return () => { if ( disableOutsidePointerEvents && context.layersWithOutsidePointerEventsDisabled.size === 1 ) { - ownerDocument.body.style.pointerEvents = originalBodyPointerEvents; + providedDocument.body.style.pointerEvents = originalBodyPointerEvents; } }; - }, [node, ownerDocument, disableOutsidePointerEvents, context]); + }, [node, providedDocument, disableOutsidePointerEvents, context]); /** * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect @@ -138,18 +139,19 @@ const DismissableLayer = React.forwardRef { return () => { - if (!node) return; + if (!node || !providedDocument) return; context.layers.delete(node); context.layersWithOutsidePointerEventsDisabled.delete(node); - dispatchUpdate(); + dispatchUpdate(providedDocument); }; - }, [node, context]); + }, [node, context, providedDocument]); React.useEffect(() => { + if (!providedDocument) return; const handleUpdate = () => force({}); - document.addEventListener(CONTEXT_UPDATE, handleUpdate); - return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate); - }, []); + providedDocument.addEventListener(CONTEXT_UPDATE, handleUpdate); + return () => providedDocument.removeEventListener(CONTEXT_UPDATE, handleUpdate); + }, [providedDocument]); return ( ; * to mimic layer dismissing behaviour present in OS. * Returns props to pass to the node we want to check for outside events. */ -function usePointerDownOutside( - onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, - ownerDocument: Document = globalThis?.document -) { - const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener; +function usePointerDownOutside(onPointerDownOutside?: (event: PointerDownOutsideEvent) => void) { + const providedDocument = useDocument(); + const handlePointerDownOutside = useCallbackRef(onPointerDownOutside); const isPointerInsideReactTreeRef = React.useRef(false); const handleClickRef = React.useRef(() => {}); React.useEffect(() => { + // Only add listeners if document exists + const documentWindow = providedDocument?.defaultView; + if (!documentWindow) return; + const handlePointerDown = (event: PointerEvent) => { if (event.target && !isPointerInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; @@ -253,41 +257,42 @@ function usePointerDownOutside( * certain that it was raised, and therefore cleaned-up. */ if (event.pointerType === 'touch') { - ownerDocument.removeEventListener('click', handleClickRef.current); + providedDocument.removeEventListener('click', handleClickRef.current); handleClickRef.current = handleAndDispatchPointerDownOutsideEvent; - ownerDocument.addEventListener('click', handleClickRef.current, { once: true }); + providedDocument.addEventListener('click', handleClickRef.current, { once: true }); } else { handleAndDispatchPointerDownOutsideEvent(); } } else { // We need to remove the event listener in case the outside click has been canceled. // See: https://github.com/radix-ui/primitives/issues/2171 - ownerDocument.removeEventListener('click', handleClickRef.current); + providedDocument.removeEventListener('click', handleClickRef.current); } isPointerInsideReactTreeRef.current = false; }; /** * if this hook executes in a component that mounts via a `pointerdown` event, the event - * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid - * this by delaying the event listener registration on the document. + * would bubble up to the providedDocument and trigger a `pointerDownOutside` event. We avoid + * this by delaying the event listener registration on the providedDocument. * This is not React specific, but rather how the DOM works, ie: * ``` * button.addEventListener('pointerdown', () => { * console.log('I will log'); - * document.addEventListener('pointerdown', () => { + * providedDocument.addEventListener('pointerdown', () => { * console.log('I will also log'); * }) * }); */ - const timerId = window.setTimeout(() => { - ownerDocument.addEventListener('pointerdown', handlePointerDown); + const timerId = documentWindow.setTimeout(() => { + providedDocument.addEventListener('pointerdown', handlePointerDown); }, 0); + return () => { - window.clearTimeout(timerId); - ownerDocument.removeEventListener('pointerdown', handlePointerDown); - ownerDocument.removeEventListener('click', handleClickRef.current); + documentWindow.clearTimeout(timerId); + providedDocument?.removeEventListener('pointerdown', handlePointerDown); + providedDocument?.removeEventListener('click', handleClickRef.current); }; - }, [ownerDocument, handlePointerDownOutside]); + }, [providedDocument, handlePointerDownOutside]); return { // ensures we check React component tree (not just DOM tree) @@ -299,14 +304,13 @@ function usePointerDownOutside( * Listens for when focus happens outside a react subtree. * Returns props to pass to the root (node) of the subtree we want to check. */ -function useFocusOutside( - onFocusOutside?: (event: FocusOutsideEvent) => void, - ownerDocument: Document = globalThis?.document -) { +function useFocusOutside(onFocusOutside?: (event: FocusOutsideEvent) => void) { + const providedDocument = useDocument(); const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener; const isFocusInsideReactTreeRef = React.useRef(false); React.useEffect(() => { + if (!providedDocument) return; const handleFocus = (event: FocusEvent) => { if (event.target && !isFocusInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; @@ -315,9 +319,9 @@ function useFocusOutside( }); } }; - ownerDocument.addEventListener('focusin', handleFocus); - return () => ownerDocument.removeEventListener('focusin', handleFocus); - }, [ownerDocument, handleFocusOutside]); + providedDocument.addEventListener('focusin', handleFocus); + return () => providedDocument.removeEventListener('focusin', handleFocus); + }, [providedDocument, handleFocusOutside]); return { onFocusCapture: () => (isFocusInsideReactTreeRef.current = true), @@ -325,9 +329,9 @@ function useFocusOutside( }; } -function dispatchUpdate() { +function dispatchUpdate(providedDocument: Document) { const event = new CustomEvent(CONTEXT_UPDATE); - document.dispatchEvent(event); + providedDocument.dispatchEvent(event); } function handleAndDispatchCustomEvent( diff --git a/packages/react/document-context/package.json b/packages/react/document-context/package.json new file mode 100644 index 000000000..baf8735d5 --- /dev/null +++ b/packages/react/document-context/package.json @@ -0,0 +1,58 @@ +{ + "name": "@radix-ui/react-document-context", + "version": "1.0.0", + "license": "MIT", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "source": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "sideEffects": false, + "scripts": { + "clean": "rm -rf dist", + "version": "yarn version" + }, + "dependencies": { + "@radix-ui/react-primitive": "workspace:*", + "use-sync-external-store": "^1.4.0" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/react": "^19.0.7", + "@types/use-sync-external-store": "^0.0.6", + "react": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "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/react/document-context/src/document-context.stories.module.css b/packages/react/document-context/src/document-context.stories.module.css new file mode 100644 index 000000000..5eaa6b3a4 --- /dev/null +++ b/packages/react/document-context/src/document-context.stories.module.css @@ -0,0 +1,64 @@ +.trigger { + border: 1px solid black; + border-radius: 6px; + background-color: transparent; + padding: 5px 10px; + font-family: apple-system, BlinkMacSystemFont, helvetica, arial, sans-serif; + font-size: 13px; + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgb(0 0 0 / 0.5); + } +} + +.content { + display: inline-block; + box-sizing: border-box; + min-width: 130px; + background-color: var(--color-white); + border: 1px solid var(--color-gray100); + border-radius: 6px; + padding: 5px; + box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1); + font-family: apple-system, BlinkMacSystemFont, helvetica, arial, sans-serif; + font-size: 13px; + &:focus-within { + border-color: var(--color-black); + } +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + line-height: 1; + cursor: default; + user-select: none; + white-space: nowrap; + height: 25px; + padding: 0 10px; + color: var(--color-black); + border-radius: 3px; + + outline: none; + + &[data-highlighted] { + background-color: var(--color-black); + color: var(--color-white); + } + + &[data-disabled] { + color: var(--color-gray100); + } +} + +.dialog { + position: fixed; + background: white; + border: 1px solid black; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 30px; +} diff --git a/packages/react/document-context/src/document-context.stories.tsx b/packages/react/document-context/src/document-context.stories.tsx new file mode 100644 index 000000000..2a198d324 --- /dev/null +++ b/packages/react/document-context/src/document-context.stories.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; + +import * as Tooltip from '@radix-ui/react-tooltip'; +import * as Dialog from '@radix-ui/react-dialog'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { createPortal } from 'react-dom'; +import * as DocumentContext from '@radix-ui/react-document-context'; + +import styles from './document-context.stories.module.css'; +export default { title: 'Utilities/DocumentContext' }; + +export const Default = () => { + const [portalElement, setPortalElement] = React.useState(null); + const [count, setCount] = React.useState(0); + + const openContentInPopup = async () => { + const popup = window.open( + '', + 'Popup Test', + 'height=600,width=600,left=300,top=300,resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,directories=no,status=no' + ); + if (!popup) return; + + // Copy all parent window styles and fonts + // https://developer.chrome.com/docs/web-platform/document-picture-in-picture/#copy-style-sheets-to-the-picture-in-picture-window + [...document.styleSheets].forEach((styleSheet) => { + try { + const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join(''); + const style = document.createElement('style'); + + style.textContent = cssRules; + popup.document.head.appendChild(style); + } catch (e) { + console.error(e); + const link = document.createElement('link'); + if (styleSheet.href === null) { + return; + } + + link.rel = 'stylesheet'; + link.type = styleSheet.type; + link.media = styleSheet.media.toString(); + link.href = styleSheet.href; + popup.document.head.appendChild(link); + } + }); + + setPortalElement(popup.document.body); + + // Detect when window is closed by user + popup.addEventListener('pagehide', () => { + setPortalElement(null); + }); + }; + + const content = ( +
+
+

This section will be portalled to another document/window

+ + + + Dropdown with dialog test + + + + + + event.preventDefault()}> + Open dialog + + + + + + Nested dropdown + + + Open + + + + console.log('undo')} + > + Undo + + console.log('redo')} + > + Redo + + + + + + Close + + + + Test + + + + + + + + + + + Tooltip content + + +
+
+ ); + + return ( +
+ + {count} + + {portalElement + ? createPortal( + + {content} + , + portalElement + ) + : content} +
+ ); +}; diff --git a/packages/react/document-context/src/document-context.test.tsx b/packages/react/document-context/src/document-context.test.tsx new file mode 100644 index 000000000..817d85464 --- /dev/null +++ b/packages/react/document-context/src/document-context.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import { DocumentProvider, useDocument } from './document-context'; + +// Test component that uses the document context +function TestComponent() { + const doc = useDocument(); + return
Has document: {doc ? 'true' : 'false'}
; +} + +describe('DocumentContext', () => { + it('provides custom document when specified', () => { + const mockDocument = {} as Document; + + function TestDocumentConsumer() { + const doc = useDocument(); + return
{doc === mockDocument ? 'custom document' : 'default document'}
; + } + + render( + + + + ); + + expect(screen.getByText('custom document')).toBeInTheDocument(); + }); + + it('can be nested with different documents', () => { + const mockDocument1 = { id: 1 } as unknown as Document; + const mockDocument2 = { id: 2 } as unknown as Document; + + function TestDocumentConsumer() { + const doc = useDocument(); + return
Document ID: {(doc as any).id}
; + } + + render( + +
+ + + + +
+
+ ); + + expect(screen.getByText('Document ID: 1')).toBeInTheDocument(); + expect(screen.getByText('Document ID: 2')).toBeInTheDocument(); + }); + + it('useDocument returns global document when no value is passed to provider', () => { + function TestDocumentConsumer() { + const doc = useDocument(); + return
{doc === globalThis.document ? 'global document' : 'other document'}
; + } + + render(); + + expect(screen.getByText('global document')).toBeInTheDocument(); + }); + + it('useDocument returns default document when custom document is not provided', () => { + render(); + + expect(screen.getByText('Has document: true')).toBeInTheDocument(); + }); +}); diff --git a/packages/react/document-context/src/document-context.tsx b/packages/react/document-context/src/document-context.tsx new file mode 100644 index 000000000..9b440b66c --- /dev/null +++ b/packages/react/document-context/src/document-context.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +// Use null as initial value to handle SSR safely +const DocumentContext = React.createContext(null); + +interface DocumentProviderProps { + document: Document; + children: React.ReactNode; +} + +export function DocumentProvider({ document, children }: DocumentProviderProps) { + return {children}; +} + +const subscribe = () => () => {}; + +export function useDocument() { + const doc = React.useContext(DocumentContext); + const isHydrated = useSyncExternalStore( + subscribe, + () => true, + () => false + ); + return doc ?? (isHydrated ? document : null); +} diff --git a/packages/react/document-context/src/index.ts b/packages/react/document-context/src/index.ts new file mode 100644 index 000000000..e43b4eb68 --- /dev/null +++ b/packages/react/document-context/src/index.ts @@ -0,0 +1,2 @@ +'use client'; +export { DocumentProvider, useDocument } from './document-context'; diff --git a/packages/react/dropdown-menu/src/dropdown-menu.stories.module.css b/packages/react/dropdown-menu/src/dropdown-menu.stories.module.css index 3b342bb2f..499d8692d 100644 --- a/packages/react/dropdown-menu/src/dropdown-menu.stories.module.css +++ b/packages/react/dropdown-menu/src/dropdown-menu.stories.module.css @@ -1,5 +1,5 @@ .trigger { - border: 1px solid $black; + border: 1px solid black; border-radius: 6px; background-color: transparent; padding: 5px 10px; diff --git a/packages/react/focus-guards/src/focus-guards.tsx b/packages/react/focus-guards/src/focus-guards.tsx index b277138b6..cf87a486e 100644 --- a/packages/react/focus-guards/src/focus-guards.tsx +++ b/packages/react/focus-guards/src/focus-guards.tsx @@ -1,3 +1,4 @@ +import { useDocument } from '@radix-ui/react-document-context'; import * as React from 'react'; /** Number of components which have requested interest to have focus guards */ @@ -13,23 +14,33 @@ function FocusGuards(props: any) { * to ensure `focusin` & `focusout` events can be caught consistently. */ function useFocusGuards() { + const providedDocument = useDocument(); React.useEffect(() => { - const edgeGuards = document.querySelectorAll('[data-radix-focus-guard]'); - document.body.insertAdjacentElement('afterbegin', edgeGuards[0] ?? createFocusGuard()); - document.body.insertAdjacentElement('beforeend', edgeGuards[1] ?? createFocusGuard()); + if (!providedDocument) return; + const edgeGuards = providedDocument.querySelectorAll('[data-radix-focus-guard]'); + providedDocument.body.insertAdjacentElement( + 'afterbegin', + edgeGuards[0] ?? createFocusGuard(providedDocument) + ); + providedDocument.body.insertAdjacentElement( + 'beforeend', + edgeGuards[1] ?? createFocusGuard(providedDocument) + ); count++; return () => { if (count === 1) { - document.querySelectorAll('[data-radix-focus-guard]').forEach((node) => node.remove()); + providedDocument + .querySelectorAll('[data-radix-focus-guard]') + .forEach((node) => node.remove()); } count--; }; - }, []); + }, [providedDocument]); } -function createFocusGuard() { - const element = document.createElement('span'); +function createFocusGuard(providedDocument: Document) { + const element = providedDocument.createElement('span'); element.setAttribute('data-radix-focus-guard', ''); element.tabIndex = 0; element.style.outline = 'none'; diff --git a/packages/react/focus-scope/src/focus-scope.tsx b/packages/react/focus-scope/src/focus-scope.tsx index bda929941..822ec32a5 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 { useDocument } from '@radix-ui/react-document-context'; const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount'; const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount'; @@ -58,6 +59,7 @@ const FocusScope = React.forwardRef((props, const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp); const lastFocusedElementRef = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node)); + const providedDocument = useDocument(); const focusScope = React.useRef({ paused: false, @@ -71,14 +73,15 @@ const FocusScope = React.forwardRef((props, // Takes care of trapping focus if focus is moved outside programmatically for example React.useEffect(() => { + if (!providedDocument) return; if (trapped) { function handleFocusIn(event: FocusEvent) { if (focusScope.paused || !container) return; const target = event.target as HTMLElement | null; if (container.contains(target)) { lastFocusedElementRef.current = target; - } else { - focus(lastFocusedElementRef.current, { select: true }); + } else if (lastFocusedElementRef.current && providedDocument) { + focus(lastFocusedElementRef.current, providedDocument, { select: true }); } } @@ -100,8 +103,12 @@ const FocusScope = React.forwardRef((props, // If the focus has moved to an actual legitimate element (`relatedTarget !== null`) // that is outside the container, we move focus to the last valid focused element inside. - if (!container.contains(relatedTarget)) { - focus(lastFocusedElementRef.current, { select: true }); + if ( + !container.contains(relatedTarget) && + lastFocusedElementRef.current && + providedDocument + ) { + focus(lastFocusedElementRef.current, providedDocument, { select: true }); } } @@ -109,30 +116,32 @@ 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; - if (focusedElement !== document.body) return; + if (!providedDocument) return; + const focusedElement = providedDocument.activeElement as HTMLElement | null; + if (focusedElement !== providedDocument.body) return; for (const mutation of mutations) { - if (mutation.removedNodes.length > 0) focus(container); + if (container && providedDocument && mutation.removedNodes.length > 0) + focus(container, providedDocument); } } - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); + providedDocument.addEventListener('focusin', handleFocusIn); + providedDocument.addEventListener('focusout', handleFocusOut); const mutationObserver = new MutationObserver(handleMutations); if (container) mutationObserver.observe(container, { childList: true, subtree: true }); return () => { - document.removeEventListener('focusin', handleFocusIn); - document.removeEventListener('focusout', handleFocusOut); + providedDocument.removeEventListener('focusin', handleFocusIn); + providedDocument.removeEventListener('focusout', handleFocusOut); mutationObserver.disconnect(); }; } - }, [trapped, container, focusScope.paused]); + }, [trapped, container, focusScope.paused, providedDocument]); React.useEffect(() => { - if (container) { + if (container && providedDocument) { focusScopesStack.add(focusScope); - const previouslyFocusedElement = document.activeElement as HTMLElement | null; + const previouslyFocusedElement = providedDocument?.activeElement as HTMLElement | null; const hasFocusedCandidate = container.contains(previouslyFocusedElement); if (!hasFocusedCandidate) { @@ -140,9 +149,13 @@ const FocusScope = React.forwardRef((props, container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus); container.dispatchEvent(mountEvent); if (!mountEvent.defaultPrevented) { - focusFirst(removeLinks(getTabbableCandidates(container)), { select: true }); - if (document.activeElement === previouslyFocusedElement) { - focus(container); + focusFirst( + removeLinks(getTabbableCandidates(container, providedDocument)), + { select: true }, + providedDocument + ); + if (providedDocument?.activeElement === previouslyFocusedElement) { + focus(container, providedDocument); } } } @@ -158,7 +171,9 @@ const FocusScope = React.forwardRef((props, container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus); container.dispatchEvent(unmountEvent); if (!unmountEvent.defaultPrevented) { - focus(previouslyFocusedElement ?? document.body, { select: true }); + focus(previouslyFocusedElement ?? providedDocument?.body, providedDocument, { + select: true, + }); } // we need to remove the listener after we `dispatchEvent` container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus); @@ -167,7 +182,7 @@ const FocusScope = React.forwardRef((props, }, 0); }; } - }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]); + }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope, providedDocument]); // Takes care of looping focus (when tabbing whilst at the edges) const handleKeyDown = React.useCallback( @@ -176,11 +191,11 @@ 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 = providedDocument?.activeElement as HTMLElement | null; - if (isTabKey && focusedElement) { + if (isTabKey && focusedElement && providedDocument) { const container = event.currentTarget as HTMLElement; - const [first, last] = getTabbableEdges(container); + const [first, last] = getTabbableEdges(container, providedDocument); const hasTabbableElementsInside = first && last; // we can only wrap focus if we have tabbable edges @@ -189,15 +204,15 @@ const FocusScope = React.forwardRef((props, } else { if (!event.shiftKey && focusedElement === last) { event.preventDefault(); - if (loop) focus(first, { select: true }); + if (loop) focus(first, providedDocument, { select: true }); } else if (event.shiftKey && focusedElement === first) { event.preventDefault(); - if (loop) focus(last, { select: true }); + if (loop) focus(last, providedDocument, { select: true }); } } } }, - [loop, trapped, focusScope.paused] + [loop, trapped, focusScope.paused, providedDocument] ); return ( @@ -215,19 +230,24 @@ FocusScope.displayName = FOCUS_SCOPE_NAME; * Attempts focusing the first element in a list of candidates. * Stops when focus has actually moved. */ -function focusFirst(candidates: HTMLElement[], { select = false } = {}) { - const previouslyFocusedElement = document.activeElement; +function focusFirst( + candidates: HTMLElement[], + { select = false } = {}, + providedDocument: Document | null +) { + if (!providedDocument) return; + const previouslyFocusedElement = providedDocument?.activeElement; for (const candidate of candidates) { - focus(candidate, { select }); - if (document.activeElement !== previouslyFocusedElement) return; + focus(candidate, providedDocument, { select }); + if (providedDocument?.activeElement !== previouslyFocusedElement) return; } } /** * Returns the first and last tabbable elements inside a container. */ -function getTabbableEdges(container: HTMLElement) { - const candidates = getTabbableCandidates(container); +function getTabbableEdges(container: HTMLElement, providedDocument: Document) { + const candidates = getTabbableCandidates(container, providedDocument); const first = findVisible(candidates, container); const last = findVisible(candidates.reverse(), container); return [first, last] as const; @@ -243,9 +263,9 @@ function getTabbableEdges(container: HTMLElement) { * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 */ -function getTabbableCandidates(container: HTMLElement) { +function getTabbableCandidates(container: HTMLElement, providedDocument: Document) { const nodes: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + const walker = providedDocument.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; @@ -287,10 +307,10 @@ function isSelectableInput(element: any): element is FocusableTarget & { select: return element instanceof HTMLInputElement && 'select' in element; } -function focus(element?: FocusableTarget | null, { select = false } = {}) { +function focus(element: FocusableTarget, providedDocument: Document, { select = false } = {}) { // only focus if that element is focusable - if (element && element.focus) { - const previouslyFocusedElement = document.activeElement; + if (element && element.focus && providedDocument) { + const previouslyFocusedElement = providedDocument.activeElement; // NOTE: we prevent scrolling on focus, to minimize jarring transitions for users element.focus({ preventScroll: true }); // only select if its not the same element, it supports selection and we need to select diff --git a/packages/react/hover-card/src/hover-card.tsx b/packages/react/hover-card/src/hover-card.tsx index 83f182ef0..9ac3c13af 100644 --- a/packages/react/hover-card/src/hover-card.tsx +++ b/packages/react/hover-card/src/hover-card.tsx @@ -11,6 +11,7 @@ import { Primitive } from '@radix-ui/react-primitive'; import { DismissableLayer } from '@radix-ui/react-dismissable-layer'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * HoverCard @@ -63,6 +64,7 @@ const HoverCard: React.FC = (props: ScopedProps) const closeTimerRef = React.useRef(0); const hasSelectionRef = React.useRef(false); const isPointerDownOnContentRef = React.useRef(false); + const documentWindow = useDocument()?.defaultView; const [open = false, setOpen] = useControllableState({ prop: openProp, @@ -72,15 +74,17 @@ const HoverCard: React.FC = (props: ScopedProps) const handleOpen = React.useCallback(() => { clearTimeout(closeTimerRef.current); - openTimerRef.current = window.setTimeout(() => setOpen(true), openDelay); - }, [openDelay, setOpen]); + if (!documentWindow) return; + openTimerRef.current = documentWindow.setTimeout(() => setOpen(true), openDelay); + }, [openDelay, setOpen, documentWindow]); const handleClose = React.useCallback(() => { clearTimeout(openTimerRef.current); + if (!documentWindow) return; if (!hasSelectionRef.current && !isPointerDownOnContentRef.current) { - closeTimerRef.current = window.setTimeout(() => setOpen(false), closeDelay); + closeTimerRef.current = documentWindow.setTimeout(() => setOpen(false), closeDelay); } - }, [closeDelay, setOpen]); + }, [closeDelay, setOpen, documentWindow]); const handleDismiss = React.useCallback(() => setOpen(false), [setOpen]); @@ -270,10 +274,12 @@ const HoverCardContentImpl = React.forwardRef< const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); const [containSelection, setContainSelection] = React.useState(false); + const providedDocument = useDocument(); React.useEffect(() => { + if (!providedDocument) return; if (containSelection) { - const body = document.body; + const body = providedDocument.body; // Safari requires prefix originalBodyUserSelect = body.style.userSelect || body.style.webkitUserSelect; @@ -285,9 +291,10 @@ const HoverCardContentImpl = React.forwardRef< body.style.webkitUserSelect = originalBodyUserSelect; }; } - }, [containSelection]); + }, [containSelection, providedDocument]); React.useEffect(() => { + if (!providedDocument) return; if (ref.current) { const handlePointerUp = () => { setContainSelection(false); @@ -295,26 +302,27 @@ const HoverCardContentImpl = React.forwardRef< // Delay a frame to ensure we always access the latest selection setTimeout(() => { - const hasSelection = document.getSelection()?.toString() !== ''; + const hasSelection = providedDocument.getSelection()?.toString() !== ''; if (hasSelection) context.hasSelectionRef.current = true; }); }; - document.addEventListener('pointerup', handlePointerUp); + providedDocument.addEventListener('pointerup', handlePointerUp); return () => { - document.removeEventListener('pointerup', handlePointerUp); + providedDocument.removeEventListener('pointerup', handlePointerUp); context.hasSelectionRef.current = false; context.isPointerDownOnContentRef.current = false; }; } - }, [context.isPointerDownOnContentRef, context.hasSelectionRef]); + }, [context.isPointerDownOnContentRef, context.hasSelectionRef, providedDocument]); React.useEffect(() => { + if (!providedDocument) return; if (ref.current) { - const tabbables = getTabbableNodes(ref.current); + const tabbables = getTabbableNodes(ref.current, providedDocument); tabbables.forEach((tabbable) => tabbable.setAttribute('tabindex', '-1')); } - }); + }, [providedDocument]); return ( (eventHandler: () => void) { * Returns a list of nodes that can be in the tab sequence. * @see: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker */ -function getTabbableNodes(container: HTMLElement) { +function getTabbableNodes(container: HTMLElement, providedDocument: Document) { const nodes: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + const walker = providedDocument.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { acceptNode: (node: any) => { // `.tabIndex` is not the same as the `tabindex` attribute. It works on the // runtime's understanding of tabbability, so this automatically accounts diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx index d8f340689..9ee63f76e 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 { useDocument } from '@radix-ui/react-document-context'; type Direction = 'ltr' | 'rtl'; @@ -91,23 +92,31 @@ const Menu: React.FC = (props: ScopedProps) => { const isUsingKeyboardRef = React.useRef(false); const handleOpenChange = useCallbackRef(onOpenChange); const direction = useDirection(dir); + const providedDocument = useDocument(); React.useEffect(() => { + if (!providedDocument) return; // Capture phase ensures we set the boolean before any side effects execute // in response to the key or pointer event as they might depend on this value. const handleKeyDown = () => { isUsingKeyboardRef.current = true; - document.addEventListener('pointerdown', handlePointer, { capture: true, once: true }); - document.addEventListener('pointermove', handlePointer, { capture: true, once: true }); + providedDocument.addEventListener('pointerdown', handlePointer, { + capture: true, + once: true, + }); + providedDocument.addEventListener('pointermove', handlePointer, { + capture: true, + once: true, + }); }; const handlePointer = () => (isUsingKeyboardRef.current = false); - document.addEventListener('keydown', handleKeyDown, { capture: true }); + providedDocument.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { - document.removeEventListener('keydown', handleKeyDown, { capture: true }); - document.removeEventListener('pointerdown', handlePointer, { capture: true }); - document.removeEventListener('pointermove', handlePointer, { capture: true }); + providedDocument.removeEventListener('keydown', handleKeyDown, { capture: true }); + providedDocument.removeEventListener('pointerdown', handlePointer, { capture: true }); + providedDocument.removeEventListener('pointermove', handlePointer, { capture: true }); }; - }, []); + }, [providedDocument]); return ( @@ -386,16 +395,18 @@ const MenuContentImpl = React.forwardRef(null); const pointerDirRef = React.useRef('right'); const lastPointerXRef = React.useRef(0); - + const providedDocument = useDocument(); const ScrollLockWrapper = disableOutsideScroll ? RemoveScroll : React.Fragment; const scrollLockWrapperProps = disableOutsideScroll ? { as: Slot, allowPinchZoom: true } : undefined; + const documentWindow = providedDocument?.defaultView; + const handleTypeaheadSearch = (key: string) => { const search = searchRef.current + key; const items = getItems().filter((item) => !item.disabled); - const currentItem = document.activeElement; + const currentItem = providedDocument?.activeElement; const currentMatch = items.find((item) => item.ref.current === currentItem)?.textValue; const values = items.map((item) => item.textValue); const nextMatch = getNextMatch(values, search, currentMatch); @@ -404,8 +415,10 @@ const MenuContentImpl = React.forwardRef updateSearch(''), 1000); + if (!documentWindow) return; + documentWindow.clearTimeout(timerRef.current); + if (value !== '') + timerRef.current = documentWindow.setTimeout(() => updateSearch(''), 1000); })(search); if (newItem) { @@ -418,8 +431,8 @@ const MenuContentImpl = React.forwardRef { - return () => window.clearTimeout(timerRef.current); - }, []); + return () => documentWindow?.clearTimeout(timerRef.current); + }, [documentWindow]); // Make sure the whole tree has focus guards as our `MenuContent` may be // the last element in the DOM (because of the `Portal`) @@ -524,12 +537,14 @@ const MenuContentImpl = React.forwardRef !item.disabled); const candidateNodes = items.map((item) => item.ref.current!); if (LAST_KEYS.includes(event.key)) candidateNodes.reverse(); - focusFirst(candidateNodes); + if (providedDocument) { + focusFirst(candidateNodes, providedDocument); + } })} onBlur={composeEventHandlers(props.onBlur, (event) => { // clear search buffer when leaving the menu if (!event.currentTarget.contains(event.target)) { - window.clearTimeout(timerRef.current); + documentWindow?.clearTimeout(timerRef.current); searchRef.current = ''; } })} @@ -1025,21 +1040,21 @@ const MenuSubTrigger = React.forwardRef(null); const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext; const scope = { __scopeMenu: props.__scopeMenu }; - + const documentWindow = useDocument()?.defaultView; const clearOpenTimer = React.useCallback(() => { - if (openTimerRef.current) window.clearTimeout(openTimerRef.current); + if (openTimerRef.current) documentWindow?.clearTimeout(openTimerRef.current); openTimerRef.current = null; - }, []); + }, [documentWindow]); React.useEffect(() => clearOpenTimer, [clearOpenTimer]); React.useEffect(() => { const pointerGraceTimer = pointerGraceTimerRef.current; return () => { - window.clearTimeout(pointerGraceTimer); + documentWindow?.clearTimeout(pointerGraceTimer); onPointerGraceIntentChange(null); }; - }, [pointerGraceTimerRef, onPointerGraceIntentChange]); + }, [pointerGraceTimerRef, onPointerGraceIntentChange, documentWindow]); return ( @@ -1071,7 +1086,8 @@ const MenuSubTrigger = React.forwardRef { + if (!documentWindow) return; + openTimerRef.current = documentWindow.setTimeout(() => { context.onOpenChange(true); clearOpenTimer(); }, 100); @@ -1105,8 +1121,9 @@ const MenuSubTrigger = React.forwardRef contentContext.onPointerGraceIntentChange(null), 300 ); @@ -1235,13 +1252,13 @@ function getCheckedState(checked: CheckedState) { return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked'; } -function focusFirst(candidates: HTMLElement[]) { - const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; +function focusFirst(candidates: HTMLElement[], providedDocument: Document) { + const PREVIOUSLY_FOCUSED_ELEMENT = providedDocument.activeElement; 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 (providedDocument.activeElement !== 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 0fe969dd2..0d98fe739 100644 --- a/packages/react/navigation-menu/src/navigation-menu.tsx +++ b/packages/react/navigation-menu/src/navigation-menu.tsx @@ -18,6 +18,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 { useDocument } from '@radix-ui/react-document-context'; type Orientation = 'vertical' | 'horizontal'; type Direction = 'ltr' | 'rtl'; @@ -117,6 +118,7 @@ const NavigationMenu = React.forwardRef { @@ -124,11 +126,12 @@ const NavigationMenu = React.forwardRef 0; if (isOpen) { - window.clearTimeout(skipDelayTimerRef.current); + documentWindow?.clearTimeout(skipDelayTimerRef.current); if (hasSkipDelayDuration) setIsOpenDelayed(false); } else { - window.clearTimeout(skipDelayTimerRef.current); - skipDelayTimerRef.current = window.setTimeout( + documentWindow?.clearTimeout(skipDelayTimerRef.current); + if (!documentWindow) return; + skipDelayTimerRef.current = documentWindow.setTimeout( () => setIsOpenDelayed(true), skipDelayDuration ); @@ -140,16 +143,17 @@ const NavigationMenu = React.forwardRef { - window.clearTimeout(closeTimerRef.current); - closeTimerRef.current = window.setTimeout(() => setValue(''), 150); - }, [setValue]); + if (!documentWindow) return; + documentWindow.clearTimeout(closeTimerRef.current); + closeTimerRef.current = documentWindow.setTimeout(() => setValue(''), 150); + }, [setValue, documentWindow]); const handleOpen = React.useCallback( (itemValue: string) => { - window.clearTimeout(closeTimerRef.current); + documentWindow?.clearTimeout(closeTimerRef.current); setValue(itemValue); }, - [setValue] + [setValue, documentWindow] ); const handleDelayedOpen = React.useCallback( @@ -158,24 +162,24 @@ const NavigationMenu = React.forwardRef { - window.clearTimeout(closeTimerRef.current); + documentWindow?.clearTimeout(closeTimerRef.current); + } else if (documentWindow) { + openTimerRef.current = documentWindow.setTimeout(() => { + documentWindow.clearTimeout(closeTimerRef.current); setValue(itemValue); }, delayDuration); } }, - [value, setValue, delayDuration] + [value, setValue, delayDuration, documentWindow] ); React.useEffect(() => { return () => { - window.clearTimeout(openTimerRef.current); - window.clearTimeout(closeTimerRef.current); - window.clearTimeout(skipDelayTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(closeTimerRef.current); + documentWindow?.clearTimeout(skipDelayTimerRef.current); }; - }, []); + }, [documentWindow]); return ( { - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); if (isOpenDelayed) handleDelayedOpen(itemValue); else handleOpen(itemValue); }} onTriggerLeave={() => { - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); startCloseTimer(); }} - onContentEnter={() => window.clearTimeout(closeTimerRef.current)} + onContentEnter={() => documentWindow?.clearTimeout(closeTimerRef.current)} onContentLeave={startCloseTimer} onItemSelect={(itemValue) => { setValue((prevValue) => (prevValue === itemValue ? '' : itemValue)); @@ -426,21 +430,26 @@ const NavigationMenuItem = React.forwardRef(null); const restoreContentTabOrderRef = React.useRef(() => {}); const wasEscapeCloseRef = React.useRef(false); - - const handleContentEntry = React.useCallback((side = 'start') => { - if (contentRef.current) { - restoreContentTabOrderRef.current(); - const candidates = getTabbableCandidates(contentRef.current); - if (candidates.length) focusFirst(side === 'start' ? candidates : candidates.reverse()); - } - }, []); + const providedDocument = useDocument(); + + const handleContentEntry = React.useCallback( + (side = 'start') => { + if (contentRef.current && providedDocument) { + restoreContentTabOrderRef.current(); + const candidates = getTabbableCandidates(contentRef.current, providedDocument); + if (candidates.length) + focusFirst(side === 'start' ? candidates : candidates.reverse(), providedDocument); + } + }, + [providedDocument] + ); const handleContentExit = React.useCallback(() => { - if (contentRef.current) { - const candidates = getTabbableCandidates(contentRef.current); + if (contentRef.current && providedDocument) { + const candidates = getTabbableCandidates(contentRef.current, providedDocument); if (candidates.length) restoreContentTabOrderRef.current = removeFromTabOrder(candidates); } - }, []); + }, [providedDocument]); return ( (null); + const providedDocument = useDocument(); const { onItemDismiss } = context; @@ -875,12 +885,21 @@ const NavigationMenuContentImpl = React.forwardRef< const handleClose = () => { onItemDismiss(); onRootContentClose(); - if (content.contains(document.activeElement)) triggerRef.current?.focus(); + + if (providedDocument && content.contains(providedDocument.activeElement)) + triggerRef.current?.focus(); }; content.addEventListener(ROOT_CONTENT_DISMISS, handleClose); return () => content.removeEventListener(ROOT_CONTENT_DISMISS, handleClose); } - }, [context.isRootMenu, props.value, triggerRef, onItemDismiss, onRootContentClose]); + }, [ + context.isRootMenu, + props.value, + triggerRef, + onItemDismiss, + onRootContentClose, + providedDocument, + ]); const motionAttribute = React.useMemo(() => { const items = getItems(); @@ -942,18 +961,19 @@ const NavigationMenuContentImpl = React.forwardRef< if (isTrigger || isRootViewport || !context.isRootMenu) event.preventDefault(); })} onKeyDown={composeEventHandlers(props.onKeyDown, (event) => { + if (!providedDocument) return; const isMetaKey = event.altKey || event.ctrlKey || event.metaKey; const isTabKey = event.key === 'Tab' && !isMetaKey; if (isTabKey) { - const candidates = getTabbableCandidates(event.currentTarget); - const focusedElement = document.activeElement; + const candidates = getTabbableCandidates(event.currentTarget, providedDocument); + const focusedElement = providedDocument?.activeElement; const index = candidates.findIndex((candidate) => candidate === focusedElement); const isMovingBackwards = event.shiftKey; const nextCandidates = isMovingBackwards ? candidates.slice(0, index).reverse() : candidates.slice(index + 1, candidates.length); - if (focusFirst(nextCandidates)) { + if (focusFirst(nextCandidates, providedDocument)) { // prevent browser tab keydown because we've handled focus event.preventDefault(); } else { @@ -1113,6 +1133,7 @@ const FocusGroupItem = React.forwardRef @@ -1134,7 +1155,11 @@ const FocusGroupItem = React.forwardRef focusFirst(candidateNodes)); + setTimeout(() => { + if (providedDocument) { + focusFirst(candidateNodes, providedDocument); + } + }); // Prevent page scroll while navigating event.preventDefault(); @@ -1156,9 +1181,9 @@ const FocusGroupItem = React.forwardRef { const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden'; if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP; @@ -1174,13 +1199,13 @@ function getTabbableCandidates(container: HTMLElement) { return nodes; } -function focusFirst(candidates: HTMLElement[]) { - const previouslyFocusedElement = document.activeElement; +function focusFirst(candidates: HTMLElement[], providedDocument: Document) { + const previouslyFocusedElement = providedDocument.activeElement; 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 providedDocument.activeElement !== previouslyFocusedElement; }); } @@ -1198,10 +1223,11 @@ function removeFromTabOrder(candidates: HTMLElement[]) { } function useResizeObserver(element: HTMLElement | null, onResize: () => void) { + const documentWindow = useDocument()?.defaultView; const handleResize = useCallbackRef(onResize); useLayoutEffect(() => { let rAF = 0; - if (element) { + if (element && documentWindow) { /** * Resize Observer will throw an often benign error that says `ResizeObserver loop * completed with undelivered notifications`. This means that ResizeObserver was not @@ -1211,15 +1237,15 @@ function useResizeObserver(element: HTMLElement | null, onResize: () => void) { */ const resizeObserver = new ResizeObserver(() => { cancelAnimationFrame(rAF); - rAF = window.requestAnimationFrame(handleResize); + rAF = documentWindow.requestAnimationFrame(handleResize); }); resizeObserver.observe(element); return () => { - window.cancelAnimationFrame(rAF); + documentWindow.cancelAnimationFrame(rAF); resizeObserver.unobserve(element); }; } - }, [element, handleResize]); + }, [element, handleResize, documentWindow]); } function getOpenState(open: boolean) { diff --git a/packages/react/popper/src/popper.tsx b/packages/react/popper/src/popper.tsx index 16ab3da7f..7109b63f3 100644 --- a/packages/react/popper/src/popper.tsx +++ b/packages/react/popper/src/popper.tsx @@ -21,6 +21,7 @@ import { useSize } from '@radix-ui/react-use-size'; import type { Placement, Middleware } from '@floating-ui/react-dom'; import type { Scope } from '@radix-ui/react-context'; import type { Measurable } from '@radix-ui/rect'; +import { useDocument } from '@radix-ui/react-document-context'; const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const; const ALIGN_OPTIONS = ['start', 'center', 'end'] as const; @@ -224,10 +225,12 @@ const PopperContent = React.forwardRef const arrowY = middlewareData.arrow?.y; const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0; + const documentWindow = useDocument()?.defaultView; const [contentZIndex, setContentZIndex] = React.useState(); useLayoutEffect(() => { - if (content) setContentZIndex(window.getComputedStyle(content).zIndex); - }, [content]); + if (content && documentWindow) + setContentZIndex(documentWindow.getComputedStyle(content).zIndex); + }, [content, documentWindow]); return (
((props, forwardedRef) => { const { container: containerProp, ...portalProps } = props; const [mounted, setMounted] = React.useState(false); + const providedDocument = useDocument(); + useLayoutEffect(() => setMounted(true), []); - const container = containerProp || (mounted && globalThis?.document?.body); + + const container = + containerProp || (mounted && (providedDocument?.body || globalThis?.document?.body)); return container ? ReactDOM.createPortal(, container) : null; diff --git a/packages/react/presence/src/presence.stories.tsx b/packages/react/presence/src/presence.stories.tsx index ae7885cdc..4a0c3bd06 100644 --- a/packages/react/presence/src/presence.stories.tsx +++ b/packages/react/presence/src/presence.stories.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { Presence } from '@radix-ui/react-presence'; + import styles from './presence.stories.module.css'; +import { useDocument } from '@radix-ui/react-document-context'; export default { title: 'Utilities/Presence' }; @@ -35,15 +37,17 @@ export const WithDeferredMountAnimation = () => { const timerRef = React.useRef(0); const [open, setOpen] = React.useState(false); const [animate, setAnimate] = React.useState(false); + const documentWindow = useDocument()?.defaultView; React.useEffect(() => { + if (!documentWindow) return; if (open) { - timerRef.current = window.setTimeout(() => setAnimate(true), 150); + timerRef.current = documentWindow.setTimeout(() => setAnimate(true), 150); } else { setAnimate(false); - window.clearTimeout(timerRef.current); + documentWindow.clearTimeout(timerRef.current); } - }, [open]); + }, [open, documentWindow]); return ( <> diff --git a/packages/react/radio-group/src/radio-group.tsx b/packages/react/radio-group/src/radio-group.tsx index a362af328..9b3fb2a83 100644 --- a/packages/react/radio-group/src/radio-group.tsx +++ b/packages/react/radio-group/src/radio-group.tsx @@ -10,6 +10,7 @@ import { useDirection } from '@radix-ui/react-direction'; import { Radio, RadioIndicator, createRadioScope } from './radio'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; @@ -131,21 +132,23 @@ const RadioGroupItem = React.forwardRef { + if (!providedDocument) return; const handleKeyDown = (event: KeyboardEvent) => { if (ARROW_KEYS.includes(event.key)) { isArrowKeyPressedRef.current = true; } }; const handleKeyUp = () => (isArrowKeyPressedRef.current = false); - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); + providedDocument.addEventListener('keydown', handleKeyDown); + providedDocument.addEventListener('keyup', handleKeyUp); return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); + providedDocument.removeEventListener('keydown', handleKeyDown); + providedDocument.removeEventListener('keyup', handleKeyUp); }; - }, []); + }, [providedDocument]); return ( { const node = ref.current; @@ -181,7 +183,9 @@ const RovingFocusGroupImpl = React.forwardRef< Boolean ) as typeof items; const candidateNodes = candidateItems.map((item) => item.ref.current!); - focusFirst(candidateNodes, preventScrollOnEntryFocus); + if (providedDocument) { + focusFirst(candidateNodes, providedDocument, preventScrollOnEntryFocus); + } } } @@ -221,7 +225,7 @@ const RovingFocusGroupItem = React.forwardRef { @@ -280,7 +284,11 @@ const RovingFocusGroupItem = React.forwardRef focusFirst(candidateNodes)); + setTimeout(() => { + if (providedDocument) { + focusFirst(candidateNodes, providedDocument); + } + }); } })} /> @@ -315,13 +323,13 @@ function getFocusIntent(event: React.KeyboardEvent, orientation?: Orientation, d return MAP_KEY_TO_FOCUS_INTENT[key]; } -function focusFirst(candidates: HTMLElement[], preventScroll = false) { - const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; +function focusFirst(candidates: HTMLElement[], providedDocument: Document, preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = providedDocument.activeElement; 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 (providedDocument.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; } } diff --git a/packages/react/scroll-area/src/scroll-area.tsx b/packages/react/scroll-area/src/scroll-area.tsx index 42246e673..22a694b98 100644 --- a/packages/react/scroll-area/src/scroll-area.tsx +++ b/packages/react/scroll-area/src/scroll-area.tsx @@ -13,6 +13,7 @@ import { composeEventHandlers } from '@radix-ui/primitive'; import { useStateMachine } from './use-state-machine'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; type Direction = 'ltr' | 'rtl'; type Sizes = { @@ -246,27 +247,28 @@ const ScrollAreaScrollbarHover = React.forwardRef< const { forceMount, ...scrollbarProps } = props; const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea); const [visible, setVisible] = React.useState(false); + const documentWindow = useDocument()?.defaultView; React.useEffect(() => { const scrollArea = context.scrollArea; let hideTimer = 0; - if (scrollArea) { + if (scrollArea && documentWindow) { const handlePointerEnter = () => { - window.clearTimeout(hideTimer); + documentWindow.clearTimeout(hideTimer); setVisible(true); }; const handlePointerLeave = () => { - hideTimer = window.setTimeout(() => setVisible(false), context.scrollHideDelay); + hideTimer = documentWindow.setTimeout(() => setVisible(false), context.scrollHideDelay); }; scrollArea.addEventListener('pointerenter', handlePointerEnter); scrollArea.addEventListener('pointerleave', handlePointerLeave); return () => { - window.clearTimeout(hideTimer); + documentWindow.clearTimeout(hideTimer); scrollArea.removeEventListener('pointerenter', handlePointerEnter); scrollArea.removeEventListener('pointerleave', handlePointerLeave); }; } - }, [context.scrollArea, context.scrollHideDelay]); + }, [context.scrollArea, context.scrollHideDelay, documentWindow]); return ( @@ -310,13 +312,14 @@ const ScrollAreaScrollbarScroll = React.forwardRef< POINTER_ENTER: 'interacting', }, }); + const documentWindow = useDocument()?.defaultView; React.useEffect(() => { - if (state === 'idle') { - const hideTimer = window.setTimeout(() => send('HIDE'), context.scrollHideDelay); - return () => window.clearTimeout(hideTimer); + if (state === 'idle' && documentWindow) { + const hideTimer = documentWindow.setTimeout(() => send('HIDE'), context.scrollHideDelay); + return () => documentWindow.clearTimeout(hideTimer); } - }, [state, context.scrollHideDelay, send]); + }, [state, context.scrollHideDelay, send, documentWindow]); React.useEffect(() => { const viewport = context.viewport; @@ -662,6 +665,7 @@ const ScrollAreaScrollbarImpl = React.forwardRef< const handleWheelScroll = useCallbackRef(onWheelScroll); const handleThumbPositionChange = useCallbackRef(onThumbPositionChange); const handleResize = useDebounceCallback(onResize, 10); + const providedDocument = useDocument(); function handleDragScroll(event: React.PointerEvent) { if (rectRef.current) { @@ -676,14 +680,16 @@ const ScrollAreaScrollbarImpl = React.forwardRef< * mode for document wheel event to allow it to be prevented */ React.useEffect(() => { + if (!providedDocument) return; const handleWheel = (event: WheelEvent) => { const element = event.target as HTMLElement; const isScrollbarWheel = scrollbar?.contains(element); if (isScrollbarWheel) handleWheelScroll(event, maxScrollPos); }; - document.addEventListener('wheel', handleWheel, { passive: false }); - return () => document.removeEventListener('wheel', handleWheel, { passive: false } as any); - }, [viewport, scrollbar, maxScrollPos, handleWheelScroll]); + providedDocument.addEventListener('wheel', handleWheel, { passive: false }); + return () => + providedDocument.removeEventListener('wheel', handleWheel, { passive: false } as any); + }, [viewport, scrollbar, maxScrollPos, handleWheelScroll, providedDocument]); /** * Update thumb position on sizes change @@ -715,8 +721,10 @@ const ScrollAreaScrollbarImpl = React.forwardRef< rectRef.current = scrollbar!.getBoundingClientRect(); // pointer capture doesn't prevent text selection in Safari // so we remove text selection manually when scrolling - prevWebkitUserSelectRef.current = document.body.style.webkitUserSelect; - document.body.style.webkitUserSelect = 'none'; + if (providedDocument) { + prevWebkitUserSelectRef.current = providedDocument.body.style.webkitUserSelect; + providedDocument.body.style.webkitUserSelect = 'none'; + } if (context.viewport) context.viewport.style.scrollBehavior = 'auto'; handleDragScroll(event); } @@ -727,7 +735,9 @@ const ScrollAreaScrollbarImpl = React.forwardRef< if (element.hasPointerCapture(event.pointerId)) { element.releasePointerCapture(event.pointerId); } - document.body.style.webkitUserSelect = prevWebkitUserSelectRef.current; + if (providedDocument) { + providedDocument.body.style.webkitUserSelect = prevWebkitUserSelectRef.current; + } if (context.viewport) context.viewport.style.scrollBehavior = ''; rectRef.current = null; })} @@ -961,6 +971,7 @@ function isScrollingWithinScrollbarBounds(scrollPos: number, maxScrollPos: numbe // Custom scroll handler to avoid scroll-linked effects // https://developer.mozilla.org/en-US/docs/Mozilla/Performance/Scroll-linked_effects const addUnlinkedScrollListener = (node: HTMLElement, handler = () => {}) => { + const documentWindow = node.ownerDocument?.defaultView; let prevPosition = { left: node.scrollLeft, top: node.scrollTop }; let rAF = 0; (function loop() { @@ -969,24 +980,31 @@ const addUnlinkedScrollListener = (node: HTMLElement, handler = () => {}) => { const isVerticalScroll = prevPosition.top !== position.top; if (isHorizontalScroll || isVerticalScroll) handler(); prevPosition = position; - rAF = window.requestAnimationFrame(loop); + rAF = documentWindow?.requestAnimationFrame(loop) ?? 0; })(); - return () => window.cancelAnimationFrame(rAF); + return () => documentWindow?.cancelAnimationFrame(rAF); }; function useDebounceCallback(callback: () => void, delay: number) { + const documentWindow = useDocument()?.defaultView; const handleCallback = useCallbackRef(callback); const debounceTimerRef = React.useRef(0); - React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []); + React.useEffect( + () => () => documentWindow?.clearTimeout(debounceTimerRef.current), + [documentWindow] + ); return React.useCallback(() => { - window.clearTimeout(debounceTimerRef.current); - debounceTimerRef.current = window.setTimeout(handleCallback, delay); - }, [handleCallback, delay]); + if (!documentWindow) return; + documentWindow.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = documentWindow.setTimeout(handleCallback, delay); + }, [handleCallback, delay, documentWindow]); } function useResizeObserver(element: HTMLElement | null, onResize: () => void) { + const documentWindow = useDocument()?.defaultView; const handleResize = useCallbackRef(onResize); useLayoutEffect(() => { + if (!documentWindow) return; let rAF = 0; if (element) { /** @@ -998,15 +1016,15 @@ function useResizeObserver(element: HTMLElement | null, onResize: () => void) { */ const resizeObserver = new ResizeObserver(() => { cancelAnimationFrame(rAF); - rAF = window.requestAnimationFrame(handleResize); + rAF = documentWindow.requestAnimationFrame(handleResize); }); resizeObserver.observe(element); return () => { - window.cancelAnimationFrame(rAF); + documentWindow.cancelAnimationFrame(rAF); resizeObserver.unobserve(element); }; } - }, [element, handleResize]); + }, [element, handleResize, documentWindow]); } /* -----------------------------------------------------------------------------------------------*/ diff --git a/packages/react/select/src/select.tsx b/packages/react/select/src/select.tsx index 5e848d8ca..2507c76eb 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 { useDocument } from '@radix-ui/react-document-context'; type Direction = 'ltr' | 'rtl'; @@ -530,6 +531,7 @@ const SelectContentImpl = React.forwardRef { @@ -542,10 +544,11 @@ const SelectContentImpl = React.forwardRef) => { + if (!providedDocument) return; const [firstItem, ...restItems] = getItems().map((item) => item.ref.current); const [lastItem] = restItems.slice(-1); - const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + const PREVIOUSLY_FOCUSED_ELEMENT = providedDocument.activeElement; 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; @@ -554,10 +557,10 @@ const SelectContentImpl = React.forwardRef { - if (content) { + if (content && providedDocument) { let pointerMoveDelta = { x: 0, y: 0 }; const handlePointerMove = (event: PointerEvent) => { @@ -596,35 +599,42 @@ const SelectContentImpl = React.forwardRef { - document.removeEventListener('pointermove', handlePointerMove); - document.removeEventListener('pointerup', handlePointerUp, { capture: true }); + providedDocument.removeEventListener('pointermove', handlePointerMove); + providedDocument.removeEventListener('pointerup', handlePointerUp, { capture: true }); }; } - }, [content, onOpenChange, triggerPointerDownPosRef]); + }, [content, onOpenChange, triggerPointerDownPosRef, providedDocument]); React.useEffect(() => { + const documentWindow = providedDocument?.defaultView; + if (!documentWindow) return; const close = () => onOpenChange(false); - window.addEventListener('blur', close); - window.addEventListener('resize', close); + documentWindow.addEventListener('blur', close); + documentWindow.addEventListener('resize', close); return () => { - window.removeEventListener('blur', close); - window.removeEventListener('resize', close); + documentWindow.removeEventListener('blur', close); + documentWindow.removeEventListener('resize', close); }; - }, [onOpenChange]); + }, [onOpenChange, providedDocument]); const [searchRef, handleTypeaheadSearch] = useTypeaheadSearch((search) => { 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 === providedDocument?.activeElement + ); const nextItem = findNextItem(enabledItems, search, currentItem); if (nextItem) { /** @@ -799,7 +809,7 @@ const SelectItemAlignedPosition = React.forwardRef< const getItems = useCollection(__scopeSelect); const shouldExpandOnScrollRef = React.useRef(false); const shouldRepositionRef = React.useRef(true); - + const documentWindow = useDocument()?.defaultView; const { viewport, selectedItem, selectedItemText, focusSelectedItem } = contentContext; const position = React.useCallback(() => { if ( @@ -809,7 +819,8 @@ const SelectItemAlignedPosition = React.forwardRef< content && viewport && selectedItem && - selectedItemText + selectedItemText && + documentWindow ) { const triggerRect = context.trigger.getBoundingClientRect(); @@ -826,7 +837,7 @@ const SelectItemAlignedPosition = React.forwardRef< const leftDelta = triggerRect.left - left; const minContentWidth = triggerRect.width + leftDelta; const contentWidth = Math.max(minContentWidth, contentRect.width); - const rightEdge = window.innerWidth - CONTENT_MARGIN; + const rightEdge = documentWindow.innerWidth - CONTENT_MARGIN; const clampedLeft = clamp(left, [ CONTENT_MARGIN, // Prevents the content from going off the starting edge of the @@ -841,11 +852,11 @@ const SelectItemAlignedPosition = React.forwardRef< contentWrapper.style.left = clampedLeft + 'px'; } else { const itemTextOffset = contentRect.right - itemTextRect.right; - const right = window.innerWidth - valueNodeRect.right - itemTextOffset; - const rightDelta = window.innerWidth - triggerRect.right - right; + const right = documentWindow.innerWidth - valueNodeRect.right - itemTextOffset; + const rightDelta = documentWindow.innerWidth - triggerRect.right - right; const minContentWidth = triggerRect.width + rightDelta; const contentWidth = Math.max(minContentWidth, contentRect.width); - const leftEdge = window.innerWidth - CONTENT_MARGIN; + const leftEdge = documentWindow.innerWidth - CONTENT_MARGIN; const clampedRight = clamp(right, [ CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth), @@ -859,10 +870,10 @@ const SelectItemAlignedPosition = React.forwardRef< // Vertical positioning // ----------------------------------------------------------------------------------------- const items = getItems(); - const availableHeight = window.innerHeight - CONTENT_MARGIN * 2; + const availableHeight = documentWindow.innerHeight - CONTENT_MARGIN * 2; const itemsHeight = viewport.scrollHeight; - const contentStyles = window.getComputedStyle(content); + const contentStyles = documentWindow.getComputedStyle(content); const contentBorderTopWidth = parseInt(contentStyles.borderTopWidth, 10); const contentPaddingTop = parseInt(contentStyles.paddingTop, 10); const contentBorderBottomWidth = parseInt(contentStyles.borderBottomWidth, 10); @@ -870,7 +881,7 @@ const SelectItemAlignedPosition = React.forwardRef< const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth; // prettier-ignore const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight); - const viewportStyles = window.getComputedStyle(viewport); + const viewportStyles = documentWindow.getComputedStyle(viewport); const viewportPaddingTop = parseInt(viewportStyles.paddingTop, 10); const viewportPaddingBottom = parseInt(viewportStyles.paddingBottom, 10); @@ -937,6 +948,7 @@ const SelectItemAlignedPosition = React.forwardRef< selectedItemText, context.dir, onPlaced, + documentWindow, ]); useLayoutEffect(() => position(), [position]); @@ -944,8 +956,9 @@ const SelectItemAlignedPosition = React.forwardRef< // copy z-index from content to wrapper const [contentZIndex, setContentZIndex] = React.useState(); useLayoutEffect(() => { - if (content) setContentZIndex(window.getComputedStyle(content).zIndex); - }, [content]); + if (content && documentWindow) + setContentZIndex(documentWindow.getComputedStyle(content).zIndex); + }, [content, documentWindow]); // When the viewport becomes scrollable at the top, the scroll up button will mount. // Because it is part of the normal flow, it will push down the viewport, thus throwing our @@ -1073,6 +1086,7 @@ const SelectViewport = React.forwardRef {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} @@ -1102,12 +1116,13 @@ const SelectViewport = React.forwardRef { + if (!documentWindow) return; const viewport = event.currentTarget; const { contentWrapper, shouldExpandOnScrollRef } = viewportContext; if (shouldExpandOnScrollRef?.current && contentWrapper) { const scrolledBy = Math.abs(prevScrollTopRef.current - viewport.scrollTop); if (scrolledBy > 0) { - const availableHeight = window.innerHeight - CONTENT_MARGIN * 2; + const availableHeight = documentWindow.innerHeight - CONTENT_MARGIN * 2; const cssMinHeight = parseFloat(contentWrapper.style.minHeight); const cssHeight = parseFloat(contentWrapper.style.height); const prevHeight = Math.max(cssMinHeight, cssHeight); @@ -1227,6 +1242,7 @@ const SelectItem = React.forwardRef( ); const textId = useId(); const pointerTypeRef = React.useRef('touch'); + const providedDocument = useDocument(); const handleSelect = () => { if (!disabled) { @@ -1296,7 +1312,7 @@ const SelectItem = React.forwardRef( } })} onPointerLeave={composeEventHandlers(itemProps.onPointerLeave, (event) => { - if (event.currentTarget === document.activeElement) { + if (providedDocument && event.currentTarget === providedDocument.activeElement) { contentContext.onItemLeave?.(); } })} @@ -1503,13 +1519,16 @@ const SelectScrollButtonImpl = React.forwardRef< const contentContext = useSelectContentContext('SelectScrollButton', __scopeSelect); const autoScrollTimerRef = React.useRef(null); const getItems = useCollection(__scopeSelect); + const providedDocument = useDocument(); + const documentWindow = providedDocument?.defaultView; const clearAutoScrollTimer = React.useCallback(() => { if (autoScrollTimerRef.current !== null) { - window.clearInterval(autoScrollTimerRef.current); + if (!documentWindow) return; + documentWindow.clearInterval(autoScrollTimerRef.current); autoScrollTimerRef.current = null; } - }, []); + }, [documentWindow]); React.useEffect(() => { return () => clearAutoScrollTimer(); @@ -1520,9 +1539,11 @@ 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 === providedDocument?.activeElement + ); activeItem?.ref.current?.scrollIntoView({ block: 'nearest' }); - }, [getItems]); + }, [getItems, providedDocument]); return ( { - if (autoScrollTimerRef.current === null) { - autoScrollTimerRef.current = window.setInterval(onAutoScroll, 50); + if (autoScrollTimerRef.current === null && documentWindow) { + autoScrollTimerRef.current = documentWindow.setInterval(onAutoScroll, 50); } })} onPointerMove={composeEventHandlers(scrollIndicatorProps.onPointerMove, () => { contentContext.onItemLeave?.(); - if (autoScrollTimerRef.current === null) { - autoScrollTimerRef.current = window.setInterval(onAutoScroll, 50); + if (autoScrollTimerRef.current === null && documentWindow) { + autoScrollTimerRef.current = documentWindow.setInterval(onAutoScroll, 50); } })} onPointerLeave={composeEventHandlers(scrollIndicatorProps.onPointerLeave, () => { @@ -1645,6 +1666,7 @@ function useTypeaheadSearch(onSearchChange: (search: string) => void) { const handleSearchChange = useCallbackRef(onSearchChange); const searchRef = React.useRef(''); const timerRef = React.useRef(0); + const documentWindow = useDocument()?.defaultView; const handleTypeaheadSearch = React.useCallback( (key: string) => { @@ -1653,22 +1675,24 @@ function useTypeaheadSearch(onSearchChange: (search: string) => void) { (function updateSearch(value: string) { searchRef.current = value; - window.clearTimeout(timerRef.current); + if (!documentWindow) return; + documentWindow.clearTimeout(timerRef.current); // Reset `searchRef` 1 second after it was last updated - if (value !== '') timerRef.current = window.setTimeout(() => updateSearch(''), 1000); + if (value !== '') + timerRef.current = documentWindow.setTimeout(() => updateSearch(''), 1000); })(search); }, - [handleSearchChange] + [handleSearchChange, documentWindow] ); const resetTypeahead = React.useCallback(() => { searchRef.current = ''; - window.clearTimeout(timerRef.current); - }, []); + documentWindow?.clearTimeout(timerRef.current); + }, [documentWindow]); React.useEffect(() => { - return () => window.clearTimeout(timerRef.current); - }, []); + return () => documentWindow?.clearTimeout(timerRef.current); + }, [documentWindow]); return [searchRef, handleTypeaheadSearch, resetTypeahead] as const; } diff --git a/packages/react/toast/src/toast.tsx b/packages/react/toast/src/toast.tsx index 6395284e6..5a1fdd26a 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 { useDocument } from '@radix-ui/react-document-context'; /* ------------------------------------------------------------------------------------------------- * ToastProvider @@ -145,6 +146,7 @@ const ToastViewport = React.forwardRef label = 'Notifications ({hotkey})', ...viewportProps } = props; + const providedDocument = useDocument(); const context = useToastProviderContext(VIEWPORT_NAME, __scopeToast); const getItems = useCollection(__scopeToast); const wrapperRef = React.useRef(null); @@ -156,6 +158,7 @@ const ToastViewport = React.forwardRef const hasToasts = context.toastCount > 0; React.useEffect(() => { + if (!providedDocument) return; const handleKeyDown = (event: KeyboardEvent) => { // we use `event.code` as it is consistent regardless of meta keys that were pressed. // for example, `event.key` for `Control+Alt+t` is `†` and `t !== †` @@ -163,11 +166,14 @@ const ToastViewport = React.forwardRef hotkey.length !== 0 && hotkey.every((key) => (event as any)[key] || event.code === key); if (isHotkeyPressed) ref.current?.focus(); }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [hotkey]); + providedDocument.addEventListener('keydown', handleKeyDown); + return () => providedDocument.removeEventListener('keydown', handleKeyDown); + }, [hotkey, providedDocument]); React.useEffect(() => { + const documentWindow = providedDocument?.defaultView; + if (!documentWindow) return; + const wrapper = wrapperRef.current; const viewport = ref.current; if (hasToasts && wrapper && viewport) { @@ -193,7 +199,7 @@ const ToastViewport = React.forwardRef }; const handlePointerLeaveResume = () => { - const isFocusInside = wrapper.contains(document.activeElement); + const isFocusInside = wrapper.contains(providedDocument.activeElement); if (!isFocusInside) handleResume(); }; @@ -202,25 +208,28 @@ const ToastViewport = React.forwardRef wrapper.addEventListener('focusout', handleFocusOutResume); wrapper.addEventListener('pointermove', handlePause); wrapper.addEventListener('pointerleave', handlePointerLeaveResume); - window.addEventListener('blur', handlePause); - window.addEventListener('focus', handleResume); + documentWindow.addEventListener('blur', handlePause); + documentWindow.addEventListener('focus', handleResume); return () => { wrapper.removeEventListener('focusin', handlePause); wrapper.removeEventListener('focusout', handleFocusOutResume); wrapper.removeEventListener('pointermove', handlePause); wrapper.removeEventListener('pointerleave', handlePointerLeaveResume); - window.removeEventListener('blur', handlePause); - window.removeEventListener('focus', handleResume); + documentWindow.removeEventListener('blur', handlePause); + documentWindow.removeEventListener('focus', handleResume); }; } - }, [hasToasts, context.isClosePausedRef]); + }, [hasToasts, context.isClosePausedRef, providedDocument]); const getSortedTabbableCandidates = React.useCallback( ({ tabbingDirection }: { tabbingDirection: 'forwards' | 'backwards' }) => { const toastItems = getItems(); const tabbableCandidates = toastItems.map((toastItem) => { const toastNode = toastItem.ref.current!; - const toastTabbableCandidates = [toastNode, ...getTabbableCandidates(toastNode)]; + const toastTabbableCandidates = [ + toastNode, + ...getTabbableCandidates(toastNode, providedDocument ?? globalThis.document), + ]; return tabbingDirection === 'forwards' ? toastTabbableCandidates : toastTabbableCandidates.reverse(); @@ -229,7 +238,7 @@ const ToastViewport = React.forwardRef tabbingDirection === 'forwards' ? tabbableCandidates.reverse() : tabbableCandidates ).flat(); }, - [getItems] + [getItems, providedDocument] ); React.useEffect(() => { @@ -243,7 +252,7 @@ const ToastViewport = React.forwardRef const isTabKey = event.key === 'Tab' && !isMetaKey; if (isTabKey) { - const focusedElement = document.activeElement; + const focusedElement = providedDocument?.activeElement; const isTabbingBackwards = event.shiftKey; const targetIsViewport = event.target === viewport; @@ -257,7 +266,9 @@ const ToastViewport = React.forwardRef const tabbingDirection = isTabbingBackwards ? 'backwards' : 'forwards'; const sortedCandidates = getSortedTabbableCandidates({ tabbingDirection }); const index = sortedCandidates.findIndex((candidate) => candidate === focusedElement); - if (focusFirst(sortedCandidates.slice(index + 1))) { + if ( + focusFirst(sortedCandidates.slice(index + 1), providedDocument ?? globalThis.document) + ) { event.preventDefault(); } else { // If we can't focus that means we're at the edges so we @@ -274,7 +285,7 @@ const ToastViewport = React.forwardRef viewport.addEventListener('keydown', handleKeyDown); return () => viewport.removeEventListener('keydown', handleKeyDown); } - }, [getItems, getSortedTabbableCandidates]); + }, [getItems, getSortedTabbableCandidates, providedDocument]); return ( const tabbableCandidates = getSortedTabbableCandidates({ tabbingDirection: 'forwards', }); - focusFirst(tabbableCandidates); + focusFirst(tabbableCandidates, providedDocument ?? globalThis.document); }} /> )} @@ -312,7 +323,7 @@ const ToastViewport = React.forwardRef const tabbableCandidates = getSortedTabbableCandidates({ tabbingDirection: 'backwards', }); - focusFirst(tabbableCandidates); + focusFirst(tabbableCandidates, providedDocument ?? globalThis.document); }} /> )} @@ -488,22 +499,25 @@ const ToastImpl = React.forwardRef( const closeTimerRemainingTimeRef = React.useRef(duration); const closeTimerRef = React.useRef(0); const { onToastAdd, onToastRemove } = context; + const providedDocument = useDocument(); + const documentWindow = providedDocument?.defaultView; 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); + if (!providedDocument) return; + const isFocusInToast = node?.contains(providedDocument.activeElement); if (isFocusInToast) context.viewport?.focus(); onClose(); }); const startTimer = React.useCallback( (duration: number) => { - if (!duration || duration === Infinity) return; - window.clearTimeout(closeTimerRef.current); + if (!duration || duration === Infinity || !documentWindow) return; + documentWindow.clearTimeout(closeTimerRef.current); closeTimerStartTimeRef.current = new Date().getTime(); - closeTimerRef.current = window.setTimeout(handleClose, duration); + closeTimerRef.current = documentWindow.setTimeout(handleClose, duration); }, - [handleClose] + [handleClose, documentWindow] ); React.useEffect(() => { @@ -516,7 +530,7 @@ const ToastImpl = React.forwardRef( const handlePause = () => { const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current; closeTimerRemainingTimeRef.current = closeTimerRemainingTimeRef.current - elapsedTime; - window.clearTimeout(closeTimerRef.current); + documentWindow?.clearTimeout(closeTimerRef.current); onPause?.(); }; viewport.addEventListener(VIEWPORT_PAUSE, handlePause); @@ -526,7 +540,7 @@ const ToastImpl = React.forwardRef( viewport.removeEventListener(VIEWPORT_RESUME, handleResume); }; } - }, [context.viewport, duration, onPause, onResume, startTimer]); + }, [context.viewport, duration, onPause, onResume, startTimer, documentWindow]); // start timer when toast opens or duration changes. // we include `open` in deps because closed !== unmounted when animating @@ -684,11 +698,13 @@ const ToastAnnounce: React.FC = (props: ScopedProps setRenderAnnounceText(true)); + const documentWindow = useDocument()?.defaultView; // cleanup after announcing React.useEffect(() => { - const timer = window.setTimeout(() => setIsAnnounced(true), 1000); - return () => window.clearTimeout(timer); - }, []); + if (!documentWindow) return; + const timer = documentWindow.setTimeout(() => setIsAnnounced(true), 1000); + return () => documentWindow.clearTimeout(timer); + }, [documentWindow]); return isAnnounced ? null : ( @@ -895,16 +911,20 @@ const isDeltaInDirection = ( }; function useNextFrame(callback = () => {}) { + const documentWindow = useDocument()?.defaultView; const fn = useCallbackRef(callback); useLayoutEffect(() => { + if (!documentWindow) return; let raf1 = 0; let raf2 = 0; - raf1 = window.requestAnimationFrame(() => (raf2 = window.requestAnimationFrame(fn))); + raf1 = documentWindow.requestAnimationFrame( + () => (raf2 = documentWindow.requestAnimationFrame(fn)) + ); return () => { - window.cancelAnimationFrame(raf1); - window.cancelAnimationFrame(raf2); + documentWindow.cancelAnimationFrame(raf1); + documentWindow.cancelAnimationFrame(raf2); }; - }, [fn]); + }, [fn, documentWindow]); } function isHTMLElement(node: any): node is HTMLElement { @@ -921,9 +941,9 @@ function isHTMLElement(node: any): node is HTMLElement { * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1 */ -function getTabbableCandidates(container: HTMLElement) { +function getTabbableCandidates(container: HTMLElement, providedDocument: Document) { const nodes: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + const walker = providedDocument.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; @@ -939,13 +959,13 @@ function getTabbableCandidates(container: HTMLElement) { return nodes; } -function focusFirst(candidates: HTMLElement[]) { - const previouslyFocusedElement = document.activeElement; +function focusFirst(candidates: HTMLElement[], providedDocument: Document) { + const previouslyFocusedElement = providedDocument.activeElement; 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 providedDocument.activeElement !== previouslyFocusedElement; }); } diff --git a/packages/react/tooltip/src/tooltip.tsx b/packages/react/tooltip/src/tooltip.tsx index cf947ce4e..976641ece 100644 --- a/packages/react/tooltip/src/tooltip.tsx +++ b/packages/react/tooltip/src/tooltip.tsx @@ -14,6 +14,7 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state'; import * as VisuallyHiddenPrimitive from '@radix-ui/react-visually-hidden'; import type { Scope } from '@radix-ui/react-context'; +import { useDocument } from '@radix-ui/react-document-context'; type ScopedProps

= P & { __scopeTooltip?: Scope }; const [createTooltipContext, createTooltipScope] = createContextScope('Tooltip', [ @@ -74,11 +75,12 @@ const TooltipProvider: React.FC = ( const isOpenDelayedRef = React.useRef(true); const isPointerInTransitRef = React.useRef(false); const skipDelayTimerRef = React.useRef(0); + const documentWindow = useDocument()?.defaultView; React.useEffect(() => { const skipDelayTimer = skipDelayTimerRef.current; - return () => window.clearTimeout(skipDelayTimer); - }, []); + return () => documentWindow?.clearTimeout(skipDelayTimer); + }, [documentWindow]); return ( = ( isOpenDelayedRef={isOpenDelayedRef} delayDuration={delayDuration} onOpen={React.useCallback(() => { - window.clearTimeout(skipDelayTimerRef.current); + documentWindow?.clearTimeout(skipDelayTimerRef.current); isOpenDelayedRef.current = false; - }, [])} + }, [documentWindow])} onClose={React.useCallback(() => { - window.clearTimeout(skipDelayTimerRef.current); - skipDelayTimerRef.current = window.setTimeout( + if (!documentWindow) return; + documentWindow.clearTimeout(skipDelayTimerRef.current); + skipDelayTimerRef.current = documentWindow.setTimeout( () => (isOpenDelayedRef.current = true), skipDelayDuration ); - }, [skipDelayDuration])} + }, [skipDelayDuration, documentWindow])} isPointerInTransitRef={isPointerInTransitRef} onPointerInTransitChange={React.useCallback((inTransit: boolean) => { isPointerInTransitRef.current = inTransit; @@ -168,6 +171,8 @@ const Tooltip: React.FC = (props: ScopedProps) => { disableHoverableContentProp ?? providerContext.disableHoverableContent; const delayDuration = delayDurationProp ?? providerContext.delayDuration; const wasOpenDelayedRef = React.useRef(false); + const providedDocument = useDocument(); + const documentWindow = providedDocument?.defaultView; const [open = false, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen, @@ -177,7 +182,7 @@ const Tooltip: React.FC = (props: ScopedProps) => { // as `onChange` is called within a lifecycle method we // avoid dispatching via `dispatchDiscreteCustomEvent`. - document.dispatchEvent(new CustomEvent(TOOLTIP_OPEN)); + providedDocument?.dispatchEvent(new CustomEvent(TOOLTIP_OPEN)); } else { providerContext.onClose(); } @@ -189,35 +194,36 @@ const Tooltip: React.FC = (props: ScopedProps) => { }, [open]); const handleOpen = React.useCallback(() => { - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); openTimerRef.current = 0; wasOpenDelayedRef.current = false; setOpen(true); - }, [setOpen]); + }, [setOpen, documentWindow]); const handleClose = React.useCallback(() => { - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); openTimerRef.current = 0; setOpen(false); - }, [setOpen]); + }, [setOpen, documentWindow]); const handleDelayedOpen = React.useCallback(() => { - window.clearTimeout(openTimerRef.current); - openTimerRef.current = window.setTimeout(() => { + if (!documentWindow) return; + documentWindow.clearTimeout(openTimerRef.current); + openTimerRef.current = documentWindow?.setTimeout(() => { wasOpenDelayedRef.current = true; setOpen(true); openTimerRef.current = 0; }, delayDuration); - }, [delayDuration, setOpen]); + }, [delayDuration, setOpen, documentWindow]); React.useEffect(() => { return () => { if (openTimerRef.current) { - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); openTimerRef.current = 0; } }; - }, []); + }, [documentWindow]); return ( @@ -237,10 +243,10 @@ const Tooltip: React.FC = (props: ScopedProps) => { handleClose(); } else { // Clear the timer in case the pointer leaves the trigger before the tooltip is opened. - window.clearTimeout(openTimerRef.current); + documentWindow?.clearTimeout(openTimerRef.current); openTimerRef.current = 0; } - }, [handleClose, disableHoverableContent])} + }, [handleClose, disableHoverableContent, documentWindow])} onOpen={handleOpen} onClose={handleClose} disableHoverableContent={disableHoverableContent} @@ -274,10 +280,11 @@ const TooltipTrigger = React.forwardRef (isPointerDownRef.current = false), []); + const providedDocument = useDocument(); React.useEffect(() => { - return () => document.removeEventListener('pointerup', handlePointerUp); - }, [handlePointerUp]); + return () => providedDocument?.removeEventListener('pointerup', handlePointerUp); + }, [handlePointerUp, providedDocument]); return ( @@ -304,7 +311,7 @@ const TooltipTrigger = React.forwardRef { isPointerDownRef.current = true; - document.addEventListener('pointerup', handlePointerUp, { once: true }); + providedDocument?.addEventListener('pointerup', handlePointerUp, { once: true }); })} onFocus={composeEventHandlers(props.onFocus, () => { if (!isPointerDownRef.current) context.onOpen(); @@ -407,6 +414,7 @@ const TooltipContentHoverable = React.forwardRef< const providerContext = useTooltipProviderContext(CONTENT_NAME, props.__scopeTooltip); const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); + const providedDocument = useDocument(); const [pointerGraceArea, setPointerGraceArea] = React.useState(null); const { trigger, onClose } = context; @@ -466,10 +474,10 @@ const TooltipContentHoverable = React.forwardRef< onClose(); } }; - document.addEventListener('pointermove', handleTrackPointerGrace); - return () => document.removeEventListener('pointermove', handleTrackPointerGrace); + providedDocument?.addEventListener('pointermove', handleTrackPointerGrace); + return () => providedDocument?.removeEventListener('pointermove', handleTrackPointerGrace); } - }, [trigger, content, pointerGraceArea, onClose, handleRemoveGraceArea]); + }, [trigger, content, pointerGraceArea, onClose, handleRemoveGraceArea, providedDocument]); return ; }); @@ -511,24 +519,33 @@ const TooltipContentImpl = React.forwardRef { - document.addEventListener(TOOLTIP_OPEN, onClose); - return () => document.removeEventListener(TOOLTIP_OPEN, onClose); - }, [onClose]); + if (providedDocument) { + providedDocument.addEventListener(TOOLTIP_OPEN, onClose); + return () => providedDocument.removeEventListener(TOOLTIP_OPEN, onClose); + } + }, [onClose, providedDocument]); // Close the tooltip if the trigger is scrolled + const documentWindow = providedDocument?.defaultView; React.useEffect(() => { + if (!documentWindow) return; + if (context.trigger) { const handleScroll = (event: Event) => { const target = event.target as HTMLElement; if (target?.contains(context.trigger)) onClose(); }; - window.addEventListener('scroll', handleScroll, { capture: true }); - return () => window.removeEventListener('scroll', handleScroll, { capture: true }); + documentWindow.addEventListener('scroll', handleScroll, { capture: true }); + return () => + documentWindow.removeEventListener('scroll', handleScroll, { + capture: true, + }); } - }, [context.trigger, onClose]); + }, [context.trigger, onClose, documentWindow]); return ( void, - ownerDocument: Document = globalThis?.document -) { +function useEscapeKeydown(onEscapeKeyDownProp?: (event: KeyboardEvent) => void) { + const providedDocument = useDocument(); const onEscapeKeyDown = useCallbackRef(onEscapeKeyDownProp); React.useEffect(() => { + if (!providedDocument) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onEscapeKeyDown(event); } }; - ownerDocument.addEventListener('keydown', handleKeyDown, { capture: true }); - return () => ownerDocument.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, [onEscapeKeyDown, ownerDocument]); + providedDocument.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => providedDocument.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [onEscapeKeyDown, providedDocument]); } export { useEscapeKeydown }; diff --git a/tsconfig.json b/tsconfig.json index 8a848e9fc..439f8732b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,6 +55,7 @@ "@radix-ui/react-direction": ["./packages/react/direction/src"], "@radix-ui/react-dismissable-layer": ["./packages/react/dismissable-layer/src"], "@radix-ui/react-dropdown-menu": ["./packages/react/dropdown-menu/src"], + "@radix-ui/react-document-context": ["./packages/react/document-context/src"], "@radix-ui/react-focus-guards": ["./packages/react/focus-guards/src"], "@radix-ui/react-focus-scope": ["./packages/react/focus-scope/src"], "@radix-ui/react-form": ["./packages/react/form/src"], diff --git a/yarn.lock b/yarn.lock index 252aa86f5..3eb2697e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2040,6 +2040,7 @@ __metadata: dependencies: "@radix-ui/primitive": "workspace:*" "@radix-ui/react-compose-refs": "workspace:*" + "@radix-ui/react-document-context": "workspace:*" "@radix-ui/react-primitive": "workspace:*" "@radix-ui/react-use-callback-ref": "workspace:*" "@radix-ui/react-use-escape-keydown": "workspace:*" @@ -2065,6 +2066,26 @@ __metadata: languageName: unknown linkType: soft +"@radix-ui/react-document-context@workspace:*, @radix-ui/react-document-context@workspace:packages/react/document-context": + version: 0.0.0-use.local + resolution: "@radix-ui/react-document-context@workspace:packages/react/document-context" + dependencies: + "@radix-ui/react-primitive": "workspace:*" + "@repo/typescript-config": "workspace:*" + "@types/react": "npm:^19.0.7" + "@types/use-sync-external-store": "npm:^0.0.6" + react: "npm:^19.0.0" + typescript: "npm:^5.7.3" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@radix-ui/react-dropdown-menu@workspace:*, @radix-ui/react-dropdown-menu@workspace:packages/react/dropdown-menu": version: 0.0.0-use.local resolution: "@radix-ui/react-dropdown-menu@workspace:packages/react/dropdown-menu" @@ -2458,6 +2479,7 @@ __metadata: version: 0.0.0-use.local resolution: "@radix-ui/react-portal@workspace:packages/react/portal" dependencies: + "@radix-ui/react-document-context": "workspace:*" "@radix-ui/react-primitive": "workspace:*" "@radix-ui/react-use-layout-effect": "workspace:*" "@repo/eslint-config": "workspace:*" @@ -4514,6 +4536,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 + languageName: node + linkType: hard + "@types/uuid@npm:^9.0.1": version: 9.0.8 resolution: "@types/uuid@npm:9.0.8" @@ -13417,6 +13446,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.4.0": + version: 1.4.0 + resolution: "use-sync-external-store@npm:1.4.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/08bf581a8a2effaefc355e9d18ed025d436230f4cc973db2f593166df357cf63e47b9097b6e5089b594758bde322e1737754ad64905e030d70f8ff7ee671fd01 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"