Skip to content

Commit b30a536

Browse files
authored
feat: Implement modal tab behavior according to current APG recommendati… (#128)
* upgrade docusaurus * address feedback * WIP * WIP * feat: Implement modal tab behavior according to current APG recommendation * address feedback
1 parent c08dfa5 commit b30a536

File tree

7 files changed

+235
-44
lines changed

7 files changed

+235
-44
lines changed

src/Modal.tsx

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
} from 'react';
1313
import * as React from 'react';
1414
import ReactDOM from 'react-dom';
15-
import useMounted from '@restart/hooks/useMounted';
1615
import useWillUnmount from '@restart/hooks/useWillUnmount';
1716

1817
import usePrevious from '@restart/hooks/usePrevious';
@@ -21,11 +20,13 @@ import ModalManager from './ModalManager.js';
2120
import useWaitForDOMRef, { type DOMContainer } from './useWaitForDOMRef.js';
2221
import type { TransitionCallbacks, TransitionComponent } from './types.js';
2322
import useWindow from './useWindow.js';
23+
import { useFocusTrap } from './useFocusTrap.js';
2424
import {
2525
renderTransition,
2626
type TransitionHandler,
2727
} from './ImperativeTransition.js';
2828
import { isEscKey } from './utils.js';
29+
import { getTabbableElementsOrSelf } from './tabbable.js';
2930

3031
let manager: ModalManager;
3132

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

301-
const isMounted = useMounted();
302302
const prevShow = usePrevious(show);
303303
const [exited, setExited] = useState(!show);
304+
const removeKeydownListenerRef = useRef<(() => void) | null>(null);
304305
const lastFocusRef = useRef<HTMLElement | null>(null);
305306

307+
const focusTrap = useFocusTrap({
308+
getContainer: () => modal.dialog,
309+
disabled: () => !enforceFocus || !modal.isTopModal(),
310+
});
311+
306312
useImperativeHandle(ref, () => modal, [modal]);
307313

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

328-
removeFocusListenerRef.current = listen(
329-
document as any,
330-
'focus',
331-
// the timeout is necessary b/c this will run before the new modal is mounted
332-
// and so steals focus from it
333-
() => setTimeout(handleEnforceFocus),
334-
true,
335-
);
334+
focusTrap.start();
336335

337336
if (onShow) {
338337
onShow();
@@ -351,7 +350,8 @@ const Modal: React.ForwardRefExoticComponent<
351350
!contains(modal.dialog, currentActiveElement)
352351
) {
353352
lastFocusRef.current = currentActiveElement;
354-
modal.dialog.focus();
353+
const tabbables = getTabbableElementsOrSelf(modal.dialog);
354+
tabbables[0]?.focus();
355355
}
356356
}
357357
});
@@ -360,7 +360,7 @@ const Modal: React.ForwardRefExoticComponent<
360360
modal.remove();
361361

362362
removeKeydownListenerRef.current?.();
363-
removeFocusListenerRef.current?.();
363+
focusTrap.stop();
364364

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

395395
// --------------------------------
396396

397-
const handleEnforceFocus = useEventCallback(() => {
398-
if (!enforceFocus || !isMounted() || !modal.isTopModal()) {
399-
return;
400-
}
401-
402-
const currentActiveElement = activeElement(ownerWindow?.document);
403-
404-
if (
405-
modal.dialog &&
406-
currentActiveElement &&
407-
!contains(modal.dialog, currentActiveElement)
408-
) {
409-
modal.dialog.focus();
410-
}
411-
});
412-
413397
const handleBackdropClick = useEventCallback((e: React.SyntheticEvent) => {
414398
if (e.target !== e.currentTarget) {
415399
return;
@@ -432,13 +416,6 @@ const Modal: React.ForwardRefExoticComponent<
432416
}
433417
});
434418

435-
const removeFocusListenerRef = useRef<ReturnType<typeof listen> | null>(
436-
null,
437-
);
438-
const removeKeydownListenerRef = useRef<ReturnType<typeof listen> | null>(
439-
null,
440-
);
441-
442419
const handleHidden: TransitionCallbacks['onExited'] = (...args) => {
443420
setExited(true);
444421
onExited?.(...args);

src/tabbable.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
function isInput(node: Element | null): node is HTMLInputElement {
2+
return node?.tagName === 'INPUT';
3+
}
4+
5+
function isTabbableRadio(node: HTMLInputElement) {
6+
if (!node.name) {
7+
return true;
8+
}
9+
10+
const radioScope = node.form || node.ownerDocument;
11+
12+
const radioSet = Array.from(
13+
radioScope.querySelectorAll(
14+
`input[type="radio"][name="${escape(node.name)}"]`,
15+
),
16+
) as HTMLInputElement[];
17+
18+
const { form } = node;
19+
const checked = radioSet.find(
20+
(input) => input.checked && input.form === form,
21+
);
22+
return !checked || checked === node;
23+
}
24+
25+
function isInDisabledFieldset(node: Element) {
26+
return !!node && node.matches('fieldset[disabled] *');
27+
}
28+
29+
function isFocusableElementMatchingSelector(element: HTMLElement | SVGElement) {
30+
return (
31+
!(element as any).disabled &&
32+
!isInDisabledFieldset(element) &&
33+
!(isInput(element) && element.type === 'hidden')
34+
);
35+
}
36+
37+
function isTabbableElementMatchingSelector(element: HTMLElement | SVGElement) {
38+
if (
39+
isInput(element) &&
40+
element.type === 'radio' &&
41+
!isTabbableRadio(element)
42+
) {
43+
return false;
44+
}
45+
46+
if (element.tabIndex < 0) {
47+
return false;
48+
}
49+
50+
return isFocusableElementMatchingSelector(element);
51+
}
52+
53+
// An incomplete set of selectors for HTML elements that are focusable.
54+
// Goal here is to cover 95% of the cases.
55+
const FOCUSABLE_SELECTOR = [
56+
'input',
57+
'textarea',
58+
'select',
59+
'button',
60+
'a[href]',
61+
'[tabindex]',
62+
'audio[controls]',
63+
'video[controls]',
64+
'[contenteditable]:not([contenteditable="false"])',
65+
].join(',');
66+
67+
const isFocusable = (element: HTMLElement | SVGElement) =>
68+
element.matches(FOCUSABLE_SELECTOR) &&
69+
isFocusableElementMatchingSelector(element);
70+
71+
export function getTabbableElements(
72+
container: Element | Document,
73+
startAt?: HTMLElement,
74+
) {
75+
let items = Array.from(
76+
container.querySelectorAll<HTMLElement | SVGElement>(FOCUSABLE_SELECTOR),
77+
);
78+
79+
if (startAt) {
80+
const startIndex = items.indexOf(startAt);
81+
82+
if (startIndex !== -1) {
83+
items = items.slice(startIndex);
84+
}
85+
}
86+
87+
return items.filter(isTabbableElementMatchingSelector);
88+
}
89+
90+
export function getTabbableElementsOrSelf(container: HTMLElement | SVGElement) {
91+
const tabbables = getTabbableElements(container);
92+
return tabbables.length
93+
? tabbables
94+
: isFocusable(container)
95+
? [container]
96+
: [];
97+
}

src/useFocusTrap.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useCallback, useMemo } from 'react';
2+
import { useRef } from 'react';
3+
import useWindow from './useWindow.js';
4+
import useMounted from '@restart/hooks/useMounted';
5+
import useEventCallback from '@restart/hooks/useEventCallback';
6+
import { getTabbableElementsOrSelf } from './tabbable.js';
7+
import activeElement from 'dom-helpers/activeElement';
8+
9+
export function useFocusTrap({
10+
getContainer,
11+
disabled,
12+
}: {
13+
getContainer: () => HTMLElement | null;
14+
disabled?: () => boolean;
15+
}) {
16+
const ownerWindow = useWindow();
17+
const isMounted = useMounted();
18+
19+
const listenersRef = useRef(new Set<(...args: any[]) => void>());
20+
21+
const handleKeydown = useEventCallback((event: KeyboardEvent) => {
22+
const container = getContainer();
23+
24+
if (event.key !== 'Tab' || !container) {
25+
return;
26+
}
27+
28+
const tabbables = getTabbableElementsOrSelf(container);
29+
30+
const firstTabbable = tabbables[0];
31+
const lastTabbable = tabbables[tabbables.length - 1];
32+
33+
if (event.shiftKey && event.target === tabbables[0]) {
34+
lastTabbable?.focus();
35+
event.preventDefault();
36+
return;
37+
}
38+
39+
if (
40+
(!event.shiftKey && event.target === lastTabbable) ||
41+
!container.contains(event.target as Element)
42+
) {
43+
firstTabbable?.focus();
44+
event.preventDefault();
45+
}
46+
});
47+
48+
const handleEnforceFocus = useEventCallback((_event: FocusEvent) => {
49+
if (disabled?.()) {
50+
return;
51+
}
52+
53+
const container = getContainer();
54+
const currentActiveElement = activeElement(ownerWindow?.document);
55+
56+
if (
57+
container &&
58+
currentActiveElement &&
59+
!container.contains(currentActiveElement)
60+
) {
61+
const tabbables = getTabbableElementsOrSelf(container);
62+
63+
tabbables[0]?.focus();
64+
}
65+
});
66+
67+
const start = useCallback(() => {
68+
const document = ownerWindow?.document;
69+
70+
if (!ownerWindow || !document || !isMounted()) {
71+
return;
72+
}
73+
74+
ownerWindow.addEventListener('focus', handleFocus, { capture: true });
75+
ownerWindow.addEventListener('blur', handleBlur);
76+
document.addEventListener('keydown', handleKeydown);
77+
78+
listenersRef.current.add(() => {
79+
ownerWindow.removeEventListener('focus', handleFocus, { capture: true });
80+
ownerWindow.removeEventListener('blur', handleBlur);
81+
document.removeEventListener('keydown', handleKeydown);
82+
});
83+
84+
function handleFocus(event: FocusEvent) {
85+
// the timeout is necessary b/c this will run before the new modal is mounted
86+
// and so steals focus from it
87+
setTimeout(() => handleEnforceFocus(event));
88+
}
89+
90+
function handleBlur(event: FocusEvent) {
91+
console.log('handleBlur', event.target);
92+
}
93+
}, [handleEnforceFocus]);
94+
95+
const stop = useCallback(() => {
96+
listenersRef.current.forEach((listener) => listener());
97+
listenersRef.current.clear();
98+
}, []);
99+
100+
return useMemo(
101+
() => ({
102+
start,
103+
stop,
104+
}),
105+
[start, stop],
106+
);
107+
}

test/ModalSpec.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,16 +342,18 @@ describe('<Modal>', () => {
342342
render(
343343
<Modal show className="modal">
344344
<div>
345-
<input autoFocus />
345+
<input type="text" autoFocus />
346346
</div>
347347
</Modal>,
348348
{ container: focusableContainer },
349349
);
350350

351351
focusableContainer.focus();
352352

353+
const input = document.getElementsByTagName('input')[0];
354+
353355
await waitFor(() => {
354-
expect(document.activeElement!.classList.contains('modal')).toBe(true);
356+
expect(document.activeElement).toEqual(input);
355357
});
356358
});
357359
});

www/docs/Modal.mdx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,18 @@ function Example() {
4343
<div>
4444
<h4 id="modal-1-label">Alert!</h4>
4545
<p>Some important content!</p>
46-
<Button
47-
onClick={() => setShow(false)}
48-
className="float-right"
49-
>
50-
Close
51-
</Button>
46+
47+
<div className="flex justify-end gap-4">
48+
<Button onClick={() => setShow(false)}>
49+
Close
50+
</Button>
51+
<Button
52+
onClick={() => setShow(false)}
53+
autoFocus
54+
>
55+
OK
56+
</Button>
57+
</div>
5258
</div>
5359
</Modal>
5460
</div>

www/plugins/webpack.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module.exports = () => ({
44
name: 'webpack-plugin',
55
configureWebpack(_, isServer, { getJSLoader }) {
66
return {
7-
devtool: 'inline-cheap-module-source-map',
7+
devtool: 'eval-source-map',
88

99
resolve: {
1010
alias: {

www/src/LiveCodeblock.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Tooltip from './Tooltip';
1919
import Transition from 'react-transition-group/Transition';
2020
import scrollParent from 'dom-helpers/scrollParent';
2121
import '../src/css/transitions.css';
22+
import styled from '@emotion/styled';
2223

2324
// @ts-ignore
2425
import styles from './LiveCodeBlock.module.css';
@@ -41,6 +42,7 @@ const LocalImports = {
4142
'../src/Dropdown': Dropdown,
4243
'../src/Tooltip': Tooltip,
4344
'../src/css/transitions.css': '',
45+
'@emotion/styled': styled,
4446
};
4547

4648
export interface Props

0 commit comments

Comments
 (0)