Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 72 additions & 64 deletions src/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as React from 'react';
import { useUncontrolledProp } from 'uncontrollable';
import usePrevious from '@restart/hooks/usePrevious';
import useForceUpdate from '@restart/hooks/useForceUpdate';
import useGlobalListener from '@restart/hooks/useGlobalListener';
import useEventListener from '@restart/hooks/useEventListener';
import useEventCallback from '@restart/hooks/useEventCallback';

import DropdownContext from './DropdownContext';
Expand All @@ -24,6 +24,7 @@ import SelectableContext from './SelectableContext';
import { SelectCallback } from './types';
import { dataAttr } from './DataKey';
import { Placement } from './usePopper';
import useWindow from './useWindow';

export type {
DropdownMenuProps,
Expand Down Expand Up @@ -147,6 +148,7 @@ function Dropdown({
placement = 'bottom-start',
children,
}: DropdownProps) {
const window = useWindow();
const [show, onToggle] = useUncontrolledProp(
rawShow,
defaultShow!,
Expand Down Expand Up @@ -203,7 +205,9 @@ function Dropdown({
);

if (menuElement && lastShow && !show) {
focusInDropdown.current = menuElement.contains(document.activeElement);
focusInDropdown.current = menuElement.contains(
menuElement.ownerDocument.activeElement,
);
}

const focusToggle = useEventCallback(() => {
Expand Down Expand Up @@ -256,77 +260,81 @@ function Dropdown({
return items[index];
};

useGlobalListener('keydown', (event: KeyboardEvent) => {
const { key } = event;
const target = event.target as HTMLElement;
useEventListener(
useCallback(() => window!.document, [window]),
'keydown',
(event: KeyboardEvent) => {
const { key } = event;
const target = event.target as HTMLElement;

const fromMenu = menuRef.current?.contains(target);
const fromToggle = toggleRef.current?.contains(target);
const fromMenu = menuRef.current?.contains(target);
const fromToggle = toggleRef.current?.contains(target);

// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
// in inscrutability
const isInput = /input|textarea/i.test(target.tagName);
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
return;
}

if (!fromMenu && !fromToggle) {
return;
}

if (key === 'Tab' && (!menuRef.current || !show)) {
return;
}
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
// in inscrutability
const isInput = /input|textarea/i.test(target.tagName);
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
return;
}

lastSourceEvent.current = event.type;
const meta = { originalEvent: event, source: event.type };
switch (key) {
case 'ArrowUp': {
const next = getNextFocusedChild(target, -1);
if (next && next.focus) next.focus();
event.preventDefault();
if (!fromMenu && !fromToggle) {
return;
}

if (key === 'Tab' && (!menuRef.current || !show)) {
return;
}
case 'ArrowDown':
event.preventDefault();
if (!show) {
onToggle(true, meta);
} else {
const next = getNextFocusedChild(target, 1);

lastSourceEvent.current = event.type;
const meta = { originalEvent: event, source: event.type };
switch (key) {
case 'ArrowUp': {
const next = getNextFocusedChild(target, -1);
if (next && next.focus) next.focus();
}
return;
case 'Tab':
// on keydown the target is the element being tabbed FROM, we need that
// to know if this event is relevant to this dropdown (e.g. in this menu).
// On `keyup` the target is the element being tagged TO which we use to check
// if focus has left the menu
addEventListener(
document as any,
'keyup',
(e) => {
if (
(e.key === 'Tab' && !e.target) ||
!menuRef.current?.contains(e.target as HTMLElement)
) {
onToggle(false, meta);
}
},
{ once: true },
);
break;
case 'Escape':
if (key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}

onToggle(false, meta);
break;
default:
}
});
return;
}
case 'ArrowDown':
event.preventDefault();
if (!show) {
onToggle(true, meta);
} else {
const next = getNextFocusedChild(target, 1);
if (next && next.focus) next.focus();
}
return;
case 'Tab':
// on keydown the target is the element being tabbed FROM, we need that
// to know if this event is relevant to this dropdown (e.g. in this menu).
// On `keyup` the target is the element being tagged TO which we use to check
// if focus has left the menu
addEventListener(
target.ownerDocument as any,
'keyup',
(e) => {
if (
(e.key === 'Tab' && !e.target) ||
!menuRef.current?.contains(e.target as HTMLElement)
) {
onToggle(false, meta);
}
},
{ once: true },
);
break;
case 'Escape':
if (key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}

onToggle(false, meta);
break;
default:
}
},
);

return (
<SelectableContext.Provider value={handleSelect}>
Expand Down
8 changes: 5 additions & 3 deletions src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import useEventCallback from '@restart/hooks/useEventCallback';
import ModalManager from './ModalManager';
import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef';
import { TransitionCallbacks } from './types';
import useWindow from './useWindow';

let manager: ModalManager;

Expand Down Expand Up @@ -172,13 +173,14 @@ export interface ModalProps extends BaseModalProps {
[other: string]: any;
}

function getManager() {
if (!manager) manager = new ModalManager();
function getManager(window?: Window) {
if (!manager) manager = new ModalManager({ ownerDocument: window?.document });
return manager;
}

function useModalManager(provided?: ModalManager) {
const modalManager = provided || getManager();
const window = useWindow();
const modalManager = provided || getManager(window);

const modal = useRef({
dialog: null as any as HTMLElement,
Expand Down
11 changes: 8 additions & 3 deletions src/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ModalInstance {
}

export interface ModalManagerOptions {
ownerDocument?: Document;
handleContainerOverflow?: boolean;
isRTL?: boolean;
}
Expand All @@ -31,23 +32,27 @@ class ModalManager {

readonly modals: ModalInstance[];

private state!: ContainerState;
protected state!: ContainerState;

protected ownerDocument: Document | undefined;

constructor({
ownerDocument,
handleContainerOverflow = true,
isRTL = false,
}: ModalManagerOptions = {}) {
this.handleContainerOverflow = handleContainerOverflow;
this.isRTL = isRTL;
this.modals = [];
this.ownerDocument = ownerDocument;
}

getScrollbarWidth() {
return getBodyScrollbarWidth();
return getBodyScrollbarWidth(this.ownerDocument);
}

getElement() {
return document.body;
return (this.ownerDocument || document).body;
}

setModalAttributes(_modal: ModalInstance) {
Expand Down
8 changes: 6 additions & 2 deletions src/getScrollbarWidth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/**
* Get the width of the vertical window scrollbar if it's visible
*/
export default function getBodyScrollbarWidth() {
return Math.abs(window.innerWidth - document.documentElement.clientWidth);
export default function getBodyScrollbarWidth(ownerDocument = document) {
const window = ownerDocument.defaultView!;

return Math.abs(
window.innerWidth - ownerDocument.documentElement.clientWidth,
);
}
7 changes: 4 additions & 3 deletions src/useRootClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ function useRootClose(
useEffect(() => {
if (disabled || ref == null) return undefined;

const doc = ownerDocument(getRefTarget(ref)!);

// Store the current event to avoid triggering handlers immediately
// https://github.com/facebook/react/issues/20074
let currentEvent = window.event;

const doc = ownerDocument(getRefTarget(ref)!);
let currentEvent = (doc.defaultView || window).event;

// Use capture for this listener so it fires before React's listener, to
// avoid false positives in the contains() check below if the target DOM
Expand Down Expand Up @@ -139,6 +139,7 @@ function useRootClose(
handleMouseCapture,
handleMouse,
handleKeyUp,
window,
]);
}

Expand Down
12 changes: 9 additions & 3 deletions src/useWaitForDOMRef.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ownerDocument from 'dom-helpers/ownerDocument';
import canUseDOM from 'dom-helpers/canUseDOM';
import { useState, useEffect } from 'react';
import useWindow from './useWindow';

export type DOMContainer<T extends HTMLElement = HTMLElement> =
| T
Expand All @@ -9,9 +11,10 @@ export type DOMContainer<T extends HTMLElement = HTMLElement> =

export const resolveContainerRef = <T extends HTMLElement>(
ref: DOMContainer<T> | undefined,
document?: Document,
): T | HTMLBodyElement | null => {
if (typeof document === 'undefined') return null;
if (ref == null) return ownerDocument().body as HTMLBodyElement;
if (!canUseDOM) return null;
if (ref == null) return (document || ownerDocument()).body as HTMLBodyElement;
if (typeof ref === 'function') ref = ref();

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

if (!resolvedRef) {
const earlyRef = resolveContainerRef(ref);
Expand Down
16 changes: 16 additions & 0 deletions src/useWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createContext, useContext } from 'react';
import canUseDOM from 'dom-helpers/canUseDOM';

const Context = createContext(canUseDOM ? window : undefined);

export const WindowProvider = Context.Provider;

/**
* The document "window" placed in React context. Helpful for determining
* SSR context, or when rendering into an iframe.
*
* @returns the current window
*/
export default function useWindow() {
return useContext(Context);
}
47 changes: 47 additions & 0 deletions www/docs/useWindow.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
A hook that returns the current DOM window. Generally this is the same as the global
`window` value, except in an SSR context it will return undefined, saving you a `typeof window === 'undefined'` guard.

```tsx
import useWindow from "@restart/ui/useWindow";

function Widget() {
const window = useWindow();

return (
<>
<Button>Click me</Button>
{window &&
createPortal(<Tooltip />, window.document.body)}
</>
);
}
```

It's also useful for situations where components are rendered into an `iframe` and need a reference
to target window, not the one they originate from.

```tsx
import { WindowProvider } from "useWindow";

function Iframe({
children,
...props
}: React.ComponentPropsWithoutRef<"iframe">) {
const [contentRef, setContentRef] = React.useState(null);
const mountNode = contentRef?.contentWindow.document.body;

return (
<>
<iframe {...props} ref={setContentRef} />

{mountNode &&
createPortal(
<WindowProvider value={contentRef?.contentWindow}>
{children}
</WindowProvider>,
mountNode
)}
</>
);
}
```
2 changes: 1 addition & 1 deletion www/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module.exports = {
type: 'category',
label: 'Utilities',
collapsed: false,
items: ['usePopper', 'useRootClose'],
items: ['usePopper', 'useRootClose', 'useWindow'],
},

'transitions',
Expand Down