From 385ff69feaaab25c1d910590c930c60d49d8d615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 9 Dec 2025 15:10:54 +0800 Subject: [PATCH 1/5] chore: init --- docs/demo/focus.md | 9 +++++ docs/examples/focus.tsx | 34 +++++++++++++++++++ src/Dom/focus.ts | 73 +++++++++++++++++------------------------ 3 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 docs/demo/focus.md create mode 100644 docs/examples/focus.tsx diff --git a/docs/demo/focus.md b/docs/demo/focus.md new file mode 100644 index 00000000..0d960866 --- /dev/null +++ b/docs/demo/focus.md @@ -0,0 +1,9 @@ +--- +title: Focus Utils +--- + +# Focus Utils Demo + +Demonstrates the usage of focus-related utility functions, including `limitTabRange`, `getFocusNodeList`, and `triggerFocus`. + + diff --git a/docs/examples/focus.tsx b/docs/examples/focus.tsx new file mode 100644 index 00000000..9ca1447f --- /dev/null +++ b/docs/examples/focus.tsx @@ -0,0 +1,34 @@ +import React, { useRef } from 'react'; +import {} from '../../src'; + +export default function FocusDemo() { + const containerRef = useRef(null); + + return ( +
+

Focus Utils Demo

+ + {/* External buttons */} + + + {/* Middle container - Tab key cycling is limited within this area */} +
+ + + +
+ + {/* External buttons */} + +
+ ); +} diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 6b182463..e67217dc 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -56,48 +56,6 @@ export function getFocusNodeList(node: HTMLElement, includePositive = false) { return res; } -let lastFocusElement = null; - -/** @deprecated Do not use since this may failed when used in async */ -export function saveLastFocusNode() { - lastFocusElement = document.activeElement; -} - -/** @deprecated Do not use since this may failed when used in async */ -export function clearLastFocusNode() { - lastFocusElement = null; -} - -/** @deprecated Do not use since this may failed when used in async */ -export function backLastFocusNode() { - if (lastFocusElement) { - try { - // 元素可能已经被移动了 - lastFocusElement.focus(); - - /* eslint-disable no-empty */ - } catch (e) { - // empty - } - /* eslint-enable no-empty */ - } -} - -export function limitTabRange(node: HTMLElement, e: KeyboardEvent) { - if (e.keyCode === 9) { - const tabNodeList = getFocusNodeList(node); - const lastTabNode = tabNodeList[e.shiftKey ? 0 : tabNodeList.length - 1]; - const leavingTab = - lastTabNode === document.activeElement || node === document.activeElement; - - if (leavingTab) { - const target = tabNodeList[e.shiftKey ? tabNodeList.length - 1 : 0]; - target.focus(); - e.preventDefault(); - } - } -} - export interface InputFocusOptions extends FocusOptions { cursor?: 'start' | 'end' | 'all'; } @@ -137,3 +95,34 @@ export function triggerFocus( } } } + +// ====================================================== +// == Lock Focus == +// ====================================================== +let focusElements: HTMLElement[] = []; + +function onWindowFocus(e: FocusEvent) { + const lastElement = focusElements[focusElements.length - 1]; + + console.log('lock focus', e.target, lastElement); +} + +/** + * Lock focus in the element. + * It will force back to the first focusable element when focus leaves the element. + */ +export function lockFocus(element: HTMLElement): VoidFunction { + // Refresh focus elements + focusElements = focusElements.filter(ele => ele !== element); + focusElements.push(element); + + // Just add event since it will de-duplicate + window.addEventListener('focusin', onWindowFocus, true); + + return () => { + focusElements = focusElements.filter(ele => ele !== element); + if (focusElements.length === 0) { + window.removeEventListener('focusin', onWindowFocus, true); + } + }; +} From 7b642079c65435413f5fa658e60946d0e11103e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 9 Dec 2025 15:45:52 +0800 Subject: [PATCH 2/5] feat: support useFocusLock --- docs/demo/focus.md | 2 +- docs/examples/focus.tsx | 18 ++++++++++-- src/Dom/focus.ts | 61 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/demo/focus.md b/docs/demo/focus.md index 0d960866..914997fc 100644 --- a/docs/demo/focus.md +++ b/docs/demo/focus.md @@ -4,6 +4,6 @@ title: Focus Utils # Focus Utils Demo -Demonstrates the usage of focus-related utility functions, including `limitTabRange`, `getFocusNodeList`, and `triggerFocus`. +Demonstrates the usage of focus-related utility functions. diff --git a/docs/examples/focus.tsx b/docs/examples/focus.tsx index 9ca1447f..346284ad 100644 --- a/docs/examples/focus.tsx +++ b/docs/examples/focus.tsx @@ -1,21 +1,33 @@ import React, { useRef } from 'react'; import {} from '../../src'; +import { lockFocus } from '../../src/Dom/focus'; +import './focus.css'; export default function FocusDemo() { const containerRef = useRef(null); + const [locking, setLocking] = React.useState(false); + + React.useEffect(() => { + if (locking) { + return lockFocus(containerRef.current!); + } + }, [locking]); return ( -
+

Focus Utils Demo

{/* External buttons */} - + {/* Middle container - Tab key cycling is limited within this area */}
{ + lastFocusElement = null; focusElements = focusElements.filter(ele => ele !== element); if (focusElements.length === 0) { - window.removeEventListener('focusin', onWindowFocus, true); + window.removeEventListener('focusin', syncFocus); + window.removeEventListener('keydown', onWindowKeyDown, true); } }; } + +export function useFocusLock(element: HTMLElement | null) { + useEffect(() => { + if (element) { + return lockFocus(element); + } + }, [element]); +} From c441ad112ecb0e314063ab17c96360b1e68c2a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 9 Dec 2025 16:14:06 +0800 Subject: [PATCH 3/5] feat: support useFocusLock --- docs/examples/focus.tsx | 10 ++---- src/Dom/focus.ts | 11 ++++--- tests/{focus.test.ts => focus.test.tsx} | 42 ++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 12 deletions(-) rename tests/{focus.test.ts => focus.test.tsx} (54%) diff --git a/docs/examples/focus.tsx b/docs/examples/focus.tsx index 346284ad..37b63340 100644 --- a/docs/examples/focus.tsx +++ b/docs/examples/focus.tsx @@ -1,17 +1,13 @@ import React, { useRef } from 'react'; import {} from '../../src'; -import { lockFocus } from '../../src/Dom/focus'; +import { useLockFocus } from '../../src/Dom/focus'; import './focus.css'; export default function FocusDemo() { const containerRef = useRef(null); - const [locking, setLocking] = React.useState(false); + const [locking, setLocking] = React.useState(true); - React.useEffect(() => { - if (locking) { - return lockFocus(containerRef.current!); - } - }, [locking]); + useLockFocus(locking, () => containerRef.current); return (
diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index a2cba9d7..45c5f7cb 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -170,10 +170,13 @@ export function lockFocus(element: HTMLElement): VoidFunction { }; } -export function useFocusLock(element: HTMLElement | null) { +export function useLockFocus( + lock: boolean, + getElement: () => HTMLElement | null, +) { useEffect(() => { - if (element) { - return lockFocus(element); + if (lock) { + return lockFocus(getElement()); } - }, [element]); + }, [lock]); } diff --git a/tests/focus.test.ts b/tests/focus.test.tsx similarity index 54% rename from tests/focus.test.ts rename to tests/focus.test.tsx index 1c835bf9..fc299d8d 100644 --- a/tests/focus.test.ts +++ b/tests/focus.test.tsx @@ -1,6 +1,8 @@ /* eslint-disable class-methods-use-this */ +import React, { JSX, useRef } from 'react'; +import { render, cleanup } from '@testing-library/react'; import { spyElementPrototype } from '../src/test/domHook'; -import { getFocusNodeList, triggerFocus } from '../src/Dom/focus'; +import { getFocusNodeList, triggerFocus, useLockFocus } from '../src/Dom/focus'; describe('focus', () => { beforeAll(() => { @@ -56,4 +58,42 @@ describe('focus', () => { focusSpy.mockRestore(); setSelectionRangeSpy.mockRestore(); }); + + describe('useLockFocus', () => { + const TestComponent: React.FC<{ lock: boolean }> = ({ lock }) => { + const elementRef = useRef(null); + useLockFocus(lock, () => elementRef.current); + + return ( + <> + +
+ + +
+ + ); + }; + + it('should restore focus to range when focusing other elements', () => { + const { getByTestId } = render(); + + const focusContainer = getByTestId('focus-container'); + const input1 = getByTestId('input1') as HTMLInputElement; + + // Should focus to first focusable element after lock + expect(document.activeElement).toBe(focusContainer); + + // Focus inside container first + input1.focus(); + expect(document.activeElement).toBe(input1); + + // Focus outer button + const outerButton = getByTestId('outer-button') as HTMLButtonElement; + outerButton.focus(); + expect(document.activeElement).toBe(input1); + }); + }); }); From 59eeebc9ce25ae78d62a60e443fb93ac4410d159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 9 Dec 2025 16:22:49 +0800 Subject: [PATCH 4/5] chore: clean up --- docs/examples/focus.tsx | 1 - src/Dom/focus.ts | 22 ++++++++++++++-------- tests/focus.test.tsx | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/examples/focus.tsx b/docs/examples/focus.tsx index 37b63340..7e7ae662 100644 --- a/docs/examples/focus.tsx +++ b/docs/examples/focus.tsx @@ -1,5 +1,4 @@ import React, { useRef } from 'react'; -import {} from '../../src'; import { useLockFocus } from '../../src/Dom/focus'; import './focus.css'; diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 45c5f7cb..8cf78cf1 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -151,15 +151,18 @@ function onWindowKeyDown(e: KeyboardEvent) { * It will force back to the first focusable element when focus leaves the element. */ export function lockFocus(element: HTMLElement): VoidFunction { - // Refresh focus elements - focusElements = focusElements.filter(ele => ele !== element); - focusElements.push(element); + if (element) { + // Refresh focus elements + focusElements = focusElements.filter(ele => ele !== element); + focusElements.push(element); - // Just add event since it will de-duplicate - window.addEventListener('focusin', syncFocus); - window.addEventListener('keydown', onWindowKeyDown, true); - syncFocus(); + // Just add event since it will de-duplicate + window.addEventListener('focusin', syncFocus); + window.addEventListener('keydown', onWindowKeyDown, true); + syncFocus(); + } + // Always return unregister function return () => { lastFocusElement = null; focusElements = focusElements.filter(ele => ele !== element); @@ -176,7 +179,10 @@ export function useLockFocus( ) { useEffect(() => { if (lock) { - return lockFocus(getElement()); + const element = getElement(); + if (element) { + return lockFocus(element); + } } }, [lock]); } diff --git a/tests/focus.test.tsx b/tests/focus.test.tsx index fc299d8d..90497585 100644 --- a/tests/focus.test.tsx +++ b/tests/focus.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable class-methods-use-this */ -import React, { JSX, useRef } from 'react'; -import { render, cleanup } from '@testing-library/react'; +import React, { useRef } from 'react'; +import { render } from '@testing-library/react'; import { spyElementPrototype } from '../src/test/domHook'; import { getFocusNodeList, triggerFocus, useLockFocus } from '../src/Dom/focus'; From 8b1d93abc3cda7f5f530bf77581071e41d20d324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 9 Dec 2025 16:37:00 +0800 Subject: [PATCH 5/5] feat: support useFocusLock --- src/Dom/focus.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 8cf78cf1..1b8ea5b9 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -173,6 +173,11 @@ export function lockFocus(element: HTMLElement): VoidFunction { }; } +/** + * Lock focus within an element. + * When locked, focus will be restricted to focusable elements within the specified element. + * If multiple elements are locked, only the last locked element will be effective. + */ export function useLockFocus( lock: boolean, getElement: () => HTMLElement | null,