@@ -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"