From 166669af73956b2bca8aa4785fa1d33d86edb73c Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Wed, 21 Oct 2026 09:37:49 +0800 Subject: [PATCH 1/2] fix: wrap Tab focus at document boundaries --- docs/demo/focus.md | 6 ++ docs/examples/focus-boundary.tsx | 26 ++++++ src/Dom/focus.ts | 14 +++- tests/focus.test.tsx | 136 ++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 docs/examples/focus-boundary.tsx diff --git a/docs/demo/focus.md b/docs/demo/focus.md index 914997fc..53656adf 100644 --- a/docs/demo/focus.md +++ b/docs/demo/focus.md @@ -7,3 +7,9 @@ title: Focus Utils Demonstrates the usage of focus-related utility functions. + +## 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. + + diff --git a/docs/examples/focus-boundary.tsx b/docs/examples/focus-boundary.tsx new file mode 100644 index 00000000..2f93f307 --- /dev/null +++ b/docs/examples/focus-boundary.tsx @@ -0,0 +1,26 @@ +import React, { useRef, useState } from 'react'; +import { useLockFocus } from '../../src/Dom/focus'; + +export default function FocusBoundaryDemo() { + const ref = useRef(null); + const [locked, setLocked] = useState(true); + useLockFocus(locked, () => ref.current); + + return ( + <> +

+ Tab and Shift+Tab should cycle between the two buttons. Escape releases + the focus lock. +

+
{ + if (event.key === 'Escape') setLocked(false); + }} + > + + +
+ + ); +} diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 556d79ef..c897f4a2 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -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 }); } } } diff --git a/tests/focus.test.tsx b/tests/focus.test.tsx index 80142537..e64c52df 100644 --- a/tests/focus.test.tsx +++ b/tests/focus.test.tsx @@ -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(() => { @@ -59,6 +64,133 @@ describe('focus', () => { setSelectionRangeSpy.mockRestore(); }); + describe('lockFocus Tab boundaries', () => { + const renderLock = (id: string) => { + const view = render( +
+ + + +
, + ); + 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])( + '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(); + 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(null); From d3c7cc459d9d585de30cf60aa3f81f887b493e68 Mon Sep 17 00:00:00 2001 From: Muspi Merol Date: Wed, 21 Oct 2026 11:09:40 +0800 Subject: [PATCH 2/2] fix: respect child Tab handlers before wrapping focus --- src/Dom/focus.ts | 5 +++-- tests/focus.test.tsx | 48 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index c897f4a2..bc8b9822 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -203,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(); } @@ -215,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); } }; } diff --git a/tests/focus.test.tsx b/tests/focus.test.tsx index e64c52df..3132c01c 100644 --- a/tests/focus.test.tsx +++ b/tests/focus.test.tsx @@ -65,12 +65,12 @@ describe('focus', () => { }); describe('lockFocus Tab boundaries', () => { - const renderLock = (id: string) => { + const renderLock = (id: string, onKeyDown?: React.KeyboardEventHandler) => { const view = render(
- - - + + +
, ); const [first, middle, last] = view.container.querySelectorAll('button'); @@ -109,6 +109,46 @@ describe('focus', () => { }, ); + 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 => {