Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/demo/focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ title: Focus Utils
Demonstrates the usage of focus-related utility functions.

<code src="../examples/focus.tsx"></code>

## Document boundary

Open this demo in a new tab so there are no other tabbable elements around it. From **Last**, press Tab; from **First**, press Shift+Tab. Focus should immediately wrap to the other button without leaving the document. Press Escape to release the lock.

<code src="../examples/focus-boundary.tsx"></code>
26 changes: 26 additions & 0 deletions docs/examples/focus-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useRef, useState } from 'react';
import { useLockFocus } from '../../src/Dom/focus';

export default function FocusBoundaryDemo() {
const ref = useRef<HTMLDivElement>(null);
const [locked, setLocked] = useState(true);
useLockFocus(locked, () => ref.current);

return (
<>
<p>
Tab and Shift+Tab should cycle between the two buttons. Escape releases
the focus lock.
</p>
<div
ref={ref}
onKeyDown={event => {
if (event.key === 'Escape') setLocked(false);
}}
>
<button>First</button>
<button>Last</button>
</div>
</>
);
}
19 changes: 14 additions & 5 deletions src/Dom/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,26 @@ function syncFocus() {
}

function onWindowKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab') {
if (e.key === 'Tab' && !e.isComposing && !e.defaultPrevented) {
const { activeElement } = document;
const lastElement = getLastElement();
const focusableList = getFocusNodeList(lastElement);
const last = focusableList[focusableList.length - 1];
let nextElement: HTMLElement | undefined;

if (e.shiftKey && activeElement === focusableList[0]) {
// Tab backward on first focusable element
lastFocusElement = last;
nextElement = last;
} else if (!e.shiftKey && activeElement === last) {
// Tab forward on last focusable element
lastFocusElement = focusableList[0];
nextElement = focusableList[0];
}

// Tabbing into browser chrome does not dispatch focusin in the document.
if (nextElement) {
e.preventDefault();
lastFocusElement = nextElement;
nextElement.focus({ preventScroll: true });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand All @@ -195,7 +203,8 @@ export function lockFocus(element: HTMLElement, id: string): VoidFunction {

// Just add event since it will de-duplicate
window.addEventListener('focusin', syncFocus);
window.addEventListener('keydown', onWindowKeyDown, true);
// Let native and React handlers cancel Tab before enforcing the boundary.
window.addEventListener('keydown', onWindowKeyDown);
syncFocus();
}

Expand All @@ -207,7 +216,7 @@ export function lockFocus(element: HTMLElement, id: string): VoidFunction {
ignoredElementMap.delete(id);
if (focusElements.length === 0) {
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
window.removeEventListener('keydown', onWindowKeyDown);
}
};
}
Expand Down
176 changes: 174 additions & 2 deletions tests/focus.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/* eslint-disable class-methods-use-this */
import React, { useEffect, useRef, useState } from 'react';
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { spyElementPrototype } from '../src/test/domHook';
import { getFocusNodeList, triggerFocus, useLockFocus } from '../src/Dom/focus';
import {
getFocusNodeList,
lockFocus,
triggerFocus,
useLockFocus,
} from '../src/Dom/focus';

describe('focus', () => {
beforeAll(() => {
Expand Down Expand Up @@ -59,6 +64,173 @@ describe('focus', () => {
setSelectionRangeSpy.mockRestore();
});

describe('lockFocus Tab boundaries', () => {
const renderLock = (id: string, onKeyDown?: React.KeyboardEventHandler) => {
const view = render(
<div>
<button onKeyDown={onKeyDown}>First</button>
<button onKeyDown={onKeyDown}>Middle</button>
<button onKeyDown={onKeyDown}>Last</button>
</div>,
);
const [first, middle, last] = view.container.querySelectorAll('button');
return {
...view,
first,
middle,
last,
unlock: lockFocus(view.container, id),
};
};

it.each([false, true])(
'wraps immediately without another focusin event (shift=%s)',
shiftKey => {
const { first, last, unlock } = renderLock('boundary');
try {
const target = shiftKey ? last : first;
(shiftKey ? first : last).focus();
const focusSpy = jest.spyOn(target, 'focus');
const event = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey,
bubbles: true,
cancelable: true,
});

document.activeElement.dispatchEvent(event);

expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(target);
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
} finally {
unlock();
}
},
);

it.each([false, true])(
'lets React handlers cancel boundary Tab before focus wraps (shift=%s)',
shiftKey => {
const onKeyDown = jest.fn((event: React.KeyboardEvent) => {
event.preventDefault();
});
const { first, last, unlock } = renderLock('react-cancel', onKeyDown);
const boundary = shiftKey ? first : last;
try {
boundary.focus();
fireEvent.keyDown(boundary, { key: 'Tab', shiftKey });
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(boundary);
} finally {
unlock();
}
},
);

it.each([false, true])(
'lets native handlers cancel boundary Tab before focus wraps (shift=%s)',
shiftKey => {
const { first, last, unlock } = renderLock('native-cancel');
const boundary = shiftKey ? first : last;
const onKeyDown = jest.fn((event: KeyboardEvent) => {
event.preventDefault();
});
boundary.addEventListener('keydown', onKeyDown);
try {
boundary.focus();
fireEvent.keyDown(boundary, { key: 'Tab', shiftKey });
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(boundary);
} finally {
boundary.removeEventListener('keydown', onKeyDown);
unlock();
}
},
);

it.each([false, true])(
'leaves interior Tab navigation to the browser (shift=%s)',
shiftKey => {
const { middle, unlock } = renderLock('interior');
try {
middle.focus();
expect(fireEvent.keyDown(middle, { key: 'Tab', shiftKey })).toBe(
true,
);
expect(document.activeElement).toBe(middle);
} finally {
unlock();
}
},
);

it.each(['composing', 'prevented', 'other key'])(
'does not handle %s events',
kind => {
const { last, unlock } = renderLock('ignored-key');
try {
last.focus();
const event = new KeyboardEvent('keydown', {
key: kind === 'other key' ? 'ArrowDown' : 'Tab',
isComposing: kind === 'composing',
bubbles: true,
cancelable: true,
});
if (kind === 'prevented') event.preventDefault();

last.dispatchEvent(event);

expect(event.defaultPrevented).toBe(kind === 'prevented');
expect(document.activeElement).toBe(last);
} finally {
unlock();
}
},
);

it.each([false, true])(
'keeps a single focusable element focused (shift=%s)',
shiftKey => {
const { container, getByRole } = render(<button>Only</button>);
const unlock = lockFocus(container, 'single');
try {
const button = getByRole('button', { name: 'Only' });
expect(fireEvent.keyDown(button, { key: 'Tab', shiftKey })).toBe(
false,
);
expect(document.activeElement).toBe(button);
} finally {
unlock();
}
},
);

it('wraps only the latest lock and restores the previous lock after cleanup', () => {
const outer = renderLock('outer');
const inner = renderLock('inner');
try {
inner.last.focus();
expect(fireEvent.keyDown(inner.last, { key: 'Tab' })).toBe(false);
expect(document.activeElement).toBe(inner.first);

inner.unlock();
inner.unmount();
outer.last.focus();
expect(fireEvent.keyDown(outer.last, { key: 'Tab' })).toBe(false);
expect(document.activeElement).toBe(outer.first);

outer.unlock();
expect(
fireEvent.keyDown(outer.first, { key: 'Tab', shiftKey: true }),
).toBe(true);
} finally {
inner.unlock();
outer.unlock();
}
});
});

describe('useLockFocus', () => {
const TestComponent: React.FC<{ lock: boolean }> = ({ lock }) => {
const elementRef = useRef<HTMLDivElement>(null);
Expand Down