Skip to content

Commit 820dee7

Browse files
committed
feat: Implement modal tab behavior according to current APG recommendation
1 parent ef83ba6 commit 820dee7

File tree

6 files changed

+66
-57
lines changed

6 files changed

+66
-57
lines changed

src/Modal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
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

@@ -304,7 +305,7 @@ const Modal: React.ForwardRefExoticComponent<
304305
const lastFocusRef = useRef<HTMLElement | null>(null);
305306

306307
const focusTrap = useFocusTrap({
307-
container: modal.dialog,
308+
getContainer: () => modal.dialog,
308309
disabled: () => !enforceFocus || !modal.isTopModal(),
309310
});
310311

@@ -349,7 +350,8 @@ const Modal: React.ForwardRefExoticComponent<
349350
!contains(modal.dialog, currentActiveElement)
350351
) {
351352
lastFocusRef.current = currentActiveElement;
352-
modal.dialog.focus();
353+
const tabbables = getTabbableElementsOrSelf(modal.dialog);
354+
tabbables[0]?.focus();
353355
}
354356
}
355357
});

src/tabbable.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const FOCUSABLE_SELECTOR = [
6464
'[contenteditable]:not([contenteditable="false"])',
6565
].join(',');
6666

67+
const isFocusable = (element: HTMLElement | SVGElement) =>
68+
element.matches(FOCUSABLE_SELECTOR) &&
69+
isFocusableElementMatchingSelector(element);
70+
6771
export function getTabbableElements(
6872
container: Element | Document,
6973
startAt?: HTMLElement,
@@ -82,3 +86,12 @@ export function getTabbableElements(
8286

8387
return items.filter(isTabbableElementMatchingSelector);
8488
}
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: 31 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import { useRef } from 'react';
33
import useWindow from './useWindow.js';
44
import useMounted from '@restart/hooks/useMounted';
55
import useEventCallback from '@restart/hooks/useEventCallback';
6-
import { getTabbableElements } from './tabbable.js';
6+
import { getTabbableElements, getTabbableElementsOrSelf } from './tabbable.js';
77
import activeElement from 'dom-helpers/activeElement';
88

99
export function useFocusTrap({
10-
container,
10+
getContainer,
1111
disabled,
1212
}: {
13-
container: HTMLElement;
13+
getContainer: () => HTMLElement | null;
1414
disabled?: () => boolean;
1515
}) {
1616
const ownerWindow = useWindow();
@@ -19,50 +19,29 @@ export function useFocusTrap({
1919
const listenersRef = useRef(new Set<(...args: any[]) => void>());
2020

2121
const handleKeydown = useEventCallback((event: KeyboardEvent) => {
22+
const container = getContainer();
23+
2224
if (event.key !== 'Tab' || !container) {
2325
return;
2426
}
2527

26-
const tabbables = getTabbableElements(container);
27-
const currentActiveElement = activeElement(ownerWindow?.document);
28-
29-
const isTabbingBackwards =
30-
currentActiveElement === tabbables[0] && event.shiftKey;
28+
const tabbables = getTabbableElementsOrSelf(container);
3129

32-
const isTabbingForward =
33-
currentActiveElement === tabbables[tabbables.length - 1];
30+
const firstTabbable = tabbables[0];
31+
const lastTabbable = tabbables[tabbables.length - 1];
3432

35-
if (!isTabbingBackwards && !isTabbingForward) {
33+
if (event.shiftKey && event.target === tabbables[0]) {
34+
lastTabbable?.focus();
35+
event.preventDefault();
3636
return;
3737
}
3838

39-
/**
40-
* We want focus to move from the focus trapped container out of the document like
41-
* it would if you tabbed from the end or start of the page.
42-
*
43-
* Generally this Just Works for tabbing forward out of the modal, as modals are often
44-
* the last element in the document. In cases where it isn't or if you tabbed backwards
45-
* we need to allow focus to move out of the document.
46-
*
47-
* This is done by way of a little "trick". `tab` events happen before focus moves so
48-
* you can shift the focus to a new element and the tab will then move focus forward or backwards
49-
* depending on the direction. We take advantage of this by moving focus to the first or last tabbable element
50-
* in the document.
51-
*/
52-
const bodyTabbables = getTabbableElements(
53-
ownerWindow?.document ?? document!,
54-
);
55-
56-
if (isTabbingBackwards) {
57-
if (bodyTabbables[0] !== currentActiveElement) {
58-
bodyTabbables[0]?.focus();
59-
}
60-
} else if (isTabbingForward) {
61-
const lastTabbable = bodyTabbables[bodyTabbables.length - 1];
62-
63-
if (lastTabbable !== currentActiveElement) {
64-
lastTabbable?.focus();
65-
}
39+
if (
40+
(!event.shiftKey && event.target === lastTabbable) ||
41+
!container.contains(event.target as Element)
42+
) {
43+
firstTabbable?.focus();
44+
event.preventDefault();
6645
}
6746
});
6847

@@ -71,16 +50,17 @@ export function useFocusTrap({
7150
return;
7251
}
7352

53+
const container = getContainer();
7454
const currentActiveElement = activeElement(ownerWindow?.document);
7555

7656
if (
7757
container &&
7858
currentActiveElement &&
7959
!container.contains(currentActiveElement)
8060
) {
81-
const tabbables = getTabbableElements(container);
61+
const tabbables = getTabbableElementsOrSelf(container);
8262

83-
(tabbables[0] ?? container).focus();
63+
tabbables[0]?.focus();
8464
}
8565
});
8666

@@ -92,22 +72,26 @@ export function useFocusTrap({
9272
}
9373

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

97-
listenersRef.current.add(() =>
98-
ownerWindow.removeEventListener('focus', handleFocus, { capture: true }),
99-
);
100-
listenersRef.current.add(() =>
101-
document.removeEventListener('keydown', handleKeydown),
102-
);
78+
listenersRef.current.add(() => {
79+
ownerWindow.removeEventListener('focus', handleFocus, { capture: true });
80+
ownerWindow.removeEventListener('blur', handleBlur);
81+
document.removeEventListener('keydown', handleKeydown);
82+
});
10383

10484
function handleFocus(event: FocusEvent) {
10585
console.log('handleFocus', event.target);
10686
// the timeout is necessary b/c this will run before the new modal is mounted
10787
// and so steals focus from it
10888
setTimeout(() => handleEnforceFocus(event));
10989
}
110-
}, [container, handleEnforceFocus]);
90+
91+
function handleBlur(event: FocusEvent) {
92+
console.log('handleBlur', event.target);
93+
}
94+
}, [handleEnforceFocus]);
11195

11296
const stop = useCallback(() => {
11397
listenersRef.current.forEach((listener) => listener());

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/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)