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
47 changes: 12 additions & 35 deletions src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from 'react';
import * as React from 'react';
import ReactDOM from 'react-dom';
import useMounted from '@restart/hooks/useMounted';
import useWillUnmount from '@restart/hooks/useWillUnmount';

import usePrevious from '@restart/hooks/usePrevious';
Expand All @@ -21,11 +20,13 @@ import ModalManager from './ModalManager.js';
import useWaitForDOMRef, { type DOMContainer } from './useWaitForDOMRef.js';
import type { TransitionCallbacks, TransitionComponent } from './types.js';
import useWindow from './useWindow.js';
import { useFocusTrap } from './useFocusTrap.js';
import {
renderTransition,
type TransitionHandler,
} from './ImperativeTransition.js';
import { isEscKey } from './utils.js';
import { getTabbableElementsOrSelf } from './tabbable.js';

let manager: ModalManager;

Expand Down Expand Up @@ -298,11 +299,16 @@ const Modal: React.ForwardRefExoticComponent<
const container = useWaitForDOMRef(containerRef);
const modal = useModalManager(providedManager);

const isMounted = useMounted();
const prevShow = usePrevious(show);
const [exited, setExited] = useState(!show);
const removeKeydownListenerRef = useRef<(() => void) | null>(null);
const lastFocusRef = useRef<HTMLElement | null>(null);

const focusTrap = useFocusTrap({
getContainer: () => modal.dialog,
disabled: () => !enforceFocus || !modal.isTopModal(),
});

useImperativeHandle(ref, () => modal, [modal]);

if (canUseDOM && !prevShow && show) {
Expand All @@ -325,14 +331,7 @@ const Modal: React.ForwardRefExoticComponent<
handleDocumentKeyDown,
);

removeFocusListenerRef.current = listen(
document as any,
'focus',
// the timeout is necessary b/c this will run before the new modal is mounted
// and so steals focus from it
() => setTimeout(handleEnforceFocus),
true,
);
focusTrap.start();

if (onShow) {
onShow();
Expand All @@ -351,7 +350,8 @@ const Modal: React.ForwardRefExoticComponent<
!contains(modal.dialog, currentActiveElement)
) {
lastFocusRef.current = currentActiveElement;
modal.dialog.focus();
const tabbables = getTabbableElementsOrSelf(modal.dialog);
tabbables[0]?.focus();
}
}
});
Expand All @@ -360,7 +360,7 @@ const Modal: React.ForwardRefExoticComponent<
modal.remove();

removeKeydownListenerRef.current?.();
removeFocusListenerRef.current?.();
focusTrap.stop();

if (restoreFocus) {
// Support: <=IE11 doesn't support `focus()` on svg elements (RB: #917)
Expand Down Expand Up @@ -394,22 +394,6 @@ const Modal: React.ForwardRefExoticComponent<

// --------------------------------

const handleEnforceFocus = useEventCallback(() => {
if (!enforceFocus || !isMounted() || !modal.isTopModal()) {
return;
}

const currentActiveElement = activeElement(ownerWindow?.document);

if (
modal.dialog &&
currentActiveElement &&
!contains(modal.dialog, currentActiveElement)
) {
modal.dialog.focus();
}
});

const handleBackdropClick = useEventCallback((e: React.SyntheticEvent) => {
if (e.target !== e.currentTarget) {
return;
Expand All @@ -432,13 +416,6 @@ const Modal: React.ForwardRefExoticComponent<
}
});

const removeFocusListenerRef = useRef<ReturnType<typeof listen> | null>(
null,
);
const removeKeydownListenerRef = useRef<ReturnType<typeof listen> | null>(
null,
);

const handleHidden: TransitionCallbacks['onExited'] = (...args) => {
setExited(true);
onExited?.(...args);
Expand Down
97 changes: 97 additions & 0 deletions src/tabbable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
function isInput(node: Element | null): node is HTMLInputElement {
return node?.tagName === 'INPUT';
}

function isTabbableRadio(node: HTMLInputElement) {
if (!node.name) {
return true;
}

const radioScope = node.form || node.ownerDocument;

const radioSet = Array.from(
radioScope.querySelectorAll(
`input[type="radio"][name="${escape(node.name)}"]`,
),
) as HTMLInputElement[];

const { form } = node;
const checked = radioSet.find(
(input) => input.checked && input.form === form,
);
return !checked || checked === node;
}

function isInDisabledFieldset(node: Element) {
return !!node && node.matches('fieldset[disabled] *');
}

function isFocusableElementMatchingSelector(element: HTMLElement | SVGElement) {
return (
!(element as any).disabled &&
!isInDisabledFieldset(element) &&
!(isInput(element) && element.type === 'hidden')
);
}

function isTabbableElementMatchingSelector(element: HTMLElement | SVGElement) {
if (
isInput(element) &&
element.type === 'radio' &&
!isTabbableRadio(element)
) {
return false;
}

if (element.tabIndex < 0) {
return false;
}

return isFocusableElementMatchingSelector(element);
}

// An incomplete set of selectors for HTML elements that are focusable.
// Goal here is to cover 95% of the cases.
const FOCUSABLE_SELECTOR = [
'input',
'textarea',
'select',
'button',
'a[href]',
'[tabindex]',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
].join(',');

const isFocusable = (element: HTMLElement | SVGElement) =>
element.matches(FOCUSABLE_SELECTOR) &&
isFocusableElementMatchingSelector(element);

export function getTabbableElements(
container: Element | Document,
startAt?: HTMLElement,
) {
let items = Array.from(
container.querySelectorAll<HTMLElement | SVGElement>(FOCUSABLE_SELECTOR),
);

if (startAt) {
const startIndex = items.indexOf(startAt);

if (startIndex !== -1) {
items = items.slice(startIndex);
}
}

return items.filter(isTabbableElementMatchingSelector);
}

export function getTabbableElementsOrSelf(container: HTMLElement | SVGElement) {
const tabbables = getTabbableElements(container);
return tabbables.length
? tabbables
: isFocusable(container)
? [container]
: [];
}
107 changes: 107 additions & 0 deletions src/useFocusTrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useCallback, useMemo } from 'react';
import { useRef } from 'react';
import useWindow from './useWindow.js';
import useMounted from '@restart/hooks/useMounted';
import useEventCallback from '@restart/hooks/useEventCallback';
import { getTabbableElementsOrSelf } from './tabbable.js';
import activeElement from 'dom-helpers/activeElement';

export function useFocusTrap({
getContainer,
disabled,
}: {
getContainer: () => HTMLElement | null;
disabled?: () => boolean;
}) {
const ownerWindow = useWindow();
const isMounted = useMounted();

const listenersRef = useRef(new Set<(...args: any[]) => void>());

const handleKeydown = useEventCallback((event: KeyboardEvent) => {
const container = getContainer();

if (event.key !== 'Tab' || !container) {
return;
}

const tabbables = getTabbableElementsOrSelf(container);

const firstTabbable = tabbables[0];
const lastTabbable = tabbables[tabbables.length - 1];

if (event.shiftKey && event.target === tabbables[0]) {
lastTabbable?.focus();
event.preventDefault();
return;
}

if (
(!event.shiftKey && event.target === lastTabbable) ||
!container.contains(event.target as Element)
) {
firstTabbable?.focus();
event.preventDefault();
}
});

const handleEnforceFocus = useEventCallback((_event: FocusEvent) => {
if (disabled?.()) {
return;
}

const container = getContainer();
const currentActiveElement = activeElement(ownerWindow?.document);

if (
container &&
currentActiveElement &&
!container.contains(currentActiveElement)
) {
const tabbables = getTabbableElementsOrSelf(container);

tabbables[0]?.focus();
}
});

const start = useCallback(() => {
const document = ownerWindow?.document;

if (!ownerWindow || !document || !isMounted()) {
return;
}

ownerWindow.addEventListener('focus', handleFocus, { capture: true });
ownerWindow.addEventListener('blur', handleBlur);
document.addEventListener('keydown', handleKeydown);

listenersRef.current.add(() => {
ownerWindow.removeEventListener('focus', handleFocus, { capture: true });
ownerWindow.removeEventListener('blur', handleBlur);
document.removeEventListener('keydown', handleKeydown);
});

function handleFocus(event: FocusEvent) {
// the timeout is necessary b/c this will run before the new modal is mounted
// and so steals focus from it
setTimeout(() => handleEnforceFocus(event));
}

function handleBlur(event: FocusEvent) {
console.log('handleBlur', event.target);
}
}, [handleEnforceFocus]);

const stop = useCallback(() => {
listenersRef.current.forEach((listener) => listener());
listenersRef.current.clear();
}, []);

return useMemo(
() => ({
start,
stop,
}),
[start, stop],
);
}
6 changes: 4 additions & 2 deletions test/ModalSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,16 +342,18 @@ describe('<Modal>', () => {
render(
<Modal show className="modal">
<div>
<input autoFocus />
<input type="text" autoFocus />
</div>
</Modal>,
{ container: focusableContainer },
);

focusableContainer.focus();

const input = document.getElementsByTagName('input')[0];

await waitFor(() => {
expect(document.activeElement!.classList.contains('modal')).toBe(true);
expect(document.activeElement).toEqual(input);
});
});
});
Expand Down
18 changes: 12 additions & 6 deletions www/docs/Modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,18 @@ function Example() {
<div>
<h4 id="modal-1-label">Alert!</h4>
<p>Some important content!</p>
<Button
onClick={() => setShow(false)}
className="float-right"
>
Close
</Button>

<div className="flex justify-end gap-4">
<Button onClick={() => setShow(false)}>
Close
</Button>
<Button
onClick={() => setShow(false)}
autoFocus
>
OK
</Button>
</div>
</div>
</Modal>
</div>
Expand Down
2 changes: 1 addition & 1 deletion www/plugins/webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = () => ({
name: 'webpack-plugin',
configureWebpack(_, isServer, { getJSLoader }) {
return {
devtool: 'inline-cheap-module-source-map',
devtool: 'eval-source-map',

resolve: {
alias: {
Expand Down
2 changes: 2 additions & 0 deletions www/src/LiveCodeblock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Tooltip from './Tooltip';
import Transition from 'react-transition-group/Transition';
import scrollParent from 'dom-helpers/scrollParent';
import '../src/css/transitions.css';
import styled from '@emotion/styled';

// @ts-ignore
import styles from './LiveCodeBlock.module.css';
Expand All @@ -41,6 +42,7 @@ const LocalImports = {
'../src/Dropdown': Dropdown,
'../src/Tooltip': Tooltip,
'../src/css/transitions.css': '',
'@emotion/styled': styled,
};

export interface Props
Expand Down
Loading