Skip to content

Commit e4e7170

Browse files
authored
feat: add support for iframe and differing owner documents (#38)
* feat: add support for iframe and differing owner documents * address feedback
1 parent a6065cb commit e4e7170

File tree

9 files changed

+168
-79
lines changed

9 files changed

+168
-79
lines changed

src/Dropdown.tsx

Lines changed: 72 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as React from 'react';
55
import { useUncontrolledProp } from 'uncontrollable';
66
import usePrevious from '@restart/hooks/usePrevious';
77
import useForceUpdate from '@restart/hooks/useForceUpdate';
8-
import useGlobalListener from '@restart/hooks/useGlobalListener';
8+
import useEventListener from '@restart/hooks/useEventListener';
99
import useEventCallback from '@restart/hooks/useEventCallback';
1010

1111
import DropdownContext from './DropdownContext';
@@ -24,6 +24,7 @@ import SelectableContext from './SelectableContext';
2424
import { SelectCallback } from './types';
2525
import { dataAttr } from './DataKey';
2626
import { Placement } from './usePopper';
27+
import useWindow from './useWindow';
2728

2829
export type {
2930
DropdownMenuProps,
@@ -147,6 +148,7 @@ function Dropdown({
147148
placement = 'bottom-start',
148149
children,
149150
}: DropdownProps) {
151+
const window = useWindow();
150152
const [show, onToggle] = useUncontrolledProp(
151153
rawShow,
152154
defaultShow!,
@@ -203,7 +205,9 @@ function Dropdown({
203205
);
204206

205207
if (menuElement && lastShow && !show) {
206-
focusInDropdown.current = menuElement.contains(document.activeElement);
208+
focusInDropdown.current = menuElement.contains(
209+
menuElement.ownerDocument.activeElement,
210+
);
207211
}
208212

209213
const focusToggle = useEventCallback(() => {
@@ -256,77 +260,81 @@ function Dropdown({
256260
return items[index];
257261
};
258262

259-
useGlobalListener('keydown', (event: KeyboardEvent) => {
260-
const { key } = event;
261-
const target = event.target as HTMLElement;
263+
useEventListener(
264+
useCallback(() => window!.document, [window]),
265+
'keydown',
266+
(event: KeyboardEvent) => {
267+
const { key } = event;
268+
const target = event.target as HTMLElement;
262269

263-
const fromMenu = menuRef.current?.contains(target);
264-
const fromToggle = toggleRef.current?.contains(target);
270+
const fromMenu = menuRef.current?.contains(target);
271+
const fromToggle = toggleRef.current?.contains(target);
265272

266-
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
267-
// in inscrutability
268-
const isInput = /input|textarea/i.test(target.tagName);
269-
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
270-
return;
271-
}
272-
273-
if (!fromMenu && !fromToggle) {
274-
return;
275-
}
276-
277-
if (key === 'Tab' && (!menuRef.current || !show)) {
278-
return;
279-
}
273+
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
274+
// in inscrutability
275+
const isInput = /input|textarea/i.test(target.tagName);
276+
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
277+
return;
278+
}
280279

281-
lastSourceEvent.current = event.type;
282-
const meta = { originalEvent: event, source: event.type };
283-
switch (key) {
284-
case 'ArrowUp': {
285-
const next = getNextFocusedChild(target, -1);
286-
if (next && next.focus) next.focus();
287-
event.preventDefault();
280+
if (!fromMenu && !fromToggle) {
281+
return;
282+
}
288283

284+
if (key === 'Tab' && (!menuRef.current || !show)) {
289285
return;
290286
}
291-
case 'ArrowDown':
292-
event.preventDefault();
293-
if (!show) {
294-
onToggle(true, meta);
295-
} else {
296-
const next = getNextFocusedChild(target, 1);
287+
288+
lastSourceEvent.current = event.type;
289+
const meta = { originalEvent: event, source: event.type };
290+
switch (key) {
291+
case 'ArrowUp': {
292+
const next = getNextFocusedChild(target, -1);
297293
if (next && next.focus) next.focus();
298-
}
299-
return;
300-
case 'Tab':
301-
// on keydown the target is the element being tabbed FROM, we need that
302-
// to know if this event is relevant to this dropdown (e.g. in this menu).
303-
// On `keyup` the target is the element being tagged TO which we use to check
304-
// if focus has left the menu
305-
addEventListener(
306-
document as any,
307-
'keyup',
308-
(e) => {
309-
if (
310-
(e.key === 'Tab' && !e.target) ||
311-
!menuRef.current?.contains(e.target as HTMLElement)
312-
) {
313-
onToggle(false, meta);
314-
}
315-
},
316-
{ once: true },
317-
);
318-
break;
319-
case 'Escape':
320-
if (key === 'Escape') {
321294
event.preventDefault();
322-
event.stopPropagation();
323-
}
324295

325-
onToggle(false, meta);
326-
break;
327-
default:
328-
}
329-
});
296+
return;
297+
}
298+
case 'ArrowDown':
299+
event.preventDefault();
300+
if (!show) {
301+
onToggle(true, meta);
302+
} else {
303+
const next = getNextFocusedChild(target, 1);
304+
if (next && next.focus) next.focus();
305+
}
306+
return;
307+
case 'Tab':
308+
// on keydown the target is the element being tabbed FROM, we need that
309+
// to know if this event is relevant to this dropdown (e.g. in this menu).
310+
// On `keyup` the target is the element being tagged TO which we use to check
311+
// if focus has left the menu
312+
addEventListener(
313+
target.ownerDocument as any,
314+
'keyup',
315+
(e) => {
316+
if (
317+
(e.key === 'Tab' && !e.target) ||
318+
!menuRef.current?.contains(e.target as HTMLElement)
319+
) {
320+
onToggle(false, meta);
321+
}
322+
},
323+
{ once: true },
324+
);
325+
break;
326+
case 'Escape':
327+
if (key === 'Escape') {
328+
event.preventDefault();
329+
event.stopPropagation();
330+
}
331+
332+
onToggle(false, meta);
333+
break;
334+
default:
335+
}
336+
},
337+
);
330338

331339
return (
332340
<SelectableContext.Provider value={handleSelect}>

src/Modal.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import useEventCallback from '@restart/hooks/useEventCallback';
2222
import ModalManager from './ModalManager';
2323
import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef';
2424
import { TransitionCallbacks } from './types';
25+
import useWindow from './useWindow';
2526

2627
let manager: ModalManager;
2728

@@ -172,13 +173,14 @@ export interface ModalProps extends BaseModalProps {
172173
[other: string]: any;
173174
}
174175

175-
function getManager() {
176-
if (!manager) manager = new ModalManager();
176+
function getManager(window?: Window) {
177+
if (!manager) manager = new ModalManager({ ownerDocument: window?.document });
177178
return manager;
178179
}
179180

180181
function useModalManager(provided?: ModalManager) {
181-
const modalManager = provided || getManager();
182+
const window = useWindow();
183+
const modalManager = provided || getManager(window);
182184

183185
const modal = useRef({
184186
dialog: null as any as HTMLElement,

src/ModalManager.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface ModalInstance {
88
}
99

1010
export interface ModalManagerOptions {
11+
ownerDocument?: Document;
1112
handleContainerOverflow?: boolean;
1213
isRTL?: boolean;
1314
}
@@ -31,23 +32,27 @@ class ModalManager {
3132

3233
readonly modals: ModalInstance[];
3334

34-
private state!: ContainerState;
35+
protected state!: ContainerState;
36+
37+
protected ownerDocument: Document | undefined;
3538

3639
constructor({
40+
ownerDocument,
3741
handleContainerOverflow = true,
3842
isRTL = false,
3943
}: ModalManagerOptions = {}) {
4044
this.handleContainerOverflow = handleContainerOverflow;
4145
this.isRTL = isRTL;
4246
this.modals = [];
47+
this.ownerDocument = ownerDocument;
4348
}
4449

4550
getScrollbarWidth() {
46-
return getBodyScrollbarWidth();
51+
return getBodyScrollbarWidth(this.ownerDocument);
4752
}
4853

4954
getElement() {
50-
return document.body;
55+
return (this.ownerDocument || document).body;
5156
}
5257

5358
setModalAttributes(_modal: ModalInstance) {

src/getScrollbarWidth.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/**
22
* Get the width of the vertical window scrollbar if it's visible
33
*/
4-
export default function getBodyScrollbarWidth() {
5-
return Math.abs(window.innerWidth - document.documentElement.clientWidth);
4+
export default function getBodyScrollbarWidth(ownerDocument = document) {
5+
const window = ownerDocument.defaultView!;
6+
7+
return Math.abs(
8+
window.innerWidth - ownerDocument.documentElement.clientWidth,
9+
);
610
}

src/useRootClose.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ function useRootClose(
8585
useEffect(() => {
8686
if (disabled || ref == null) return undefined;
8787

88+
const doc = ownerDocument(getRefTarget(ref)!);
89+
8890
// Store the current event to avoid triggering handlers immediately
8991
// https://github.com/facebook/react/issues/20074
90-
let currentEvent = window.event;
91-
92-
const doc = ownerDocument(getRefTarget(ref)!);
92+
let currentEvent = (doc.defaultView || window).event;
9393

9494
// Use capture for this listener so it fires before React's listener, to
9595
// avoid false positives in the contains() check below if the target DOM
@@ -139,6 +139,7 @@ function useRootClose(
139139
handleMouseCapture,
140140
handleMouse,
141141
handleKeyUp,
142+
window,
142143
]);
143144
}
144145

src/useWaitForDOMRef.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ownerDocument from 'dom-helpers/ownerDocument';
2+
import canUseDOM from 'dom-helpers/canUseDOM';
23
import { useState, useEffect } from 'react';
4+
import useWindow from './useWindow';
35

46
export type DOMContainer<T extends HTMLElement = HTMLElement> =
57
| T
@@ -9,9 +11,10 @@ export type DOMContainer<T extends HTMLElement = HTMLElement> =
911

1012
export const resolveContainerRef = <T extends HTMLElement>(
1113
ref: DOMContainer<T> | undefined,
14+
document?: Document,
1215
): T | HTMLBodyElement | null => {
13-
if (typeof document === 'undefined') return null;
14-
if (ref == null) return ownerDocument().body as HTMLBodyElement;
16+
if (!canUseDOM) return null;
17+
if (ref == null) return (document || ownerDocument()).body as HTMLBodyElement;
1518
if (typeof ref === 'function') ref = ref();
1619

1720
if (ref && 'current' in ref) ref = ref.current;
@@ -24,7 +27,10 @@ export default function useWaitForDOMRef<T extends HTMLElement = HTMLElement>(
2427
ref: DOMContainer<T> | undefined,
2528
onResolved?: (element: T | HTMLBodyElement) => void,
2629
) {
27-
const [resolvedRef, setRef] = useState(() => resolveContainerRef(ref));
30+
const window = useWindow();
31+
const [resolvedRef, setRef] = useState(() =>
32+
resolveContainerRef(ref, window?.document),
33+
);
2834

2935
if (!resolvedRef) {
3036
const earlyRef = resolveContainerRef(ref);

src/useWindow.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createContext, useContext } from 'react';
2+
import canUseDOM from 'dom-helpers/canUseDOM';
3+
4+
const Context = createContext(canUseDOM ? window : undefined);
5+
6+
export const WindowProvider = Context.Provider;
7+
8+
/**
9+
* The document "window" placed in React context. Helpful for determining
10+
* SSR context, or when rendering into an iframe.
11+
*
12+
* @returns the current window
13+
*/
14+
export default function useWindow() {
15+
return useContext(Context);
16+
}

www/docs/useWindow.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
A hook that returns the current DOM window. Generally this is the same as the global
2+
`window` value, except in an SSR context it will return undefined, saving you a `typeof window === 'undefined'` guard.
3+
4+
```tsx
5+
import useWindow from "@restart/ui/useWindow";
6+
7+
function Widget() {
8+
const window = useWindow();
9+
10+
return (
11+
<>
12+
<Button>Click me</Button>
13+
{window &&
14+
createPortal(<Tooltip />, window.document.body)}
15+
</>
16+
);
17+
}
18+
```
19+
20+
It's also useful for situations where components are rendered into an `iframe` and need a reference
21+
to target window, not the one they originate from.
22+
23+
```tsx
24+
import { WindowProvider } from "useWindow";
25+
26+
function Iframe({
27+
children,
28+
...props
29+
}: React.ComponentPropsWithoutRef<"iframe">) {
30+
const [contentRef, setContentRef] = React.useState(null);
31+
const mountNode = contentRef?.contentWindow.document.body;
32+
33+
return (
34+
<>
35+
<iframe {...props} ref={setContentRef} />
36+
37+
{mountNode &&
38+
createPortal(
39+
<WindowProvider value={contentRef?.contentWindow}>
40+
{children}
41+
</WindowProvider>,
42+
mountNode
43+
)}
44+
</>
45+
);
46+
}
47+
```

www/sidebars.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module.exports = {
2020
type: 'category',
2121
label: 'Utilities',
2222
collapsed: false,
23-
items: ['usePopper', 'useRootClose'],
23+
items: ['usePopper', 'useRootClose', 'useWindow'],
2424
},
2525

2626
'transitions',

0 commit comments

Comments
 (0)