From b1345647bbb5e6788120d2f85797f7084838733a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Tue, 9 Dec 2025 16:42:12 +0800 Subject: [PATCH 1/2] feat: Support useLockFocus (#712) * chore: init * feat: support useFocusLock * feat: support useFocusLock * chore: clean up * feat: support useFocusLock --- docs/demo/focus.md | 9 ++ docs/examples/focus.tsx | 41 +++++++ src/Dom/focus.ts | 138 ++++++++++++++++-------- tests/{focus.test.ts => focus.test.tsx} | 42 +++++++- 4 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 docs/demo/focus.md create mode 100644 docs/examples/focus.tsx rename tests/{focus.test.ts => focus.test.tsx} (54%) diff --git a/docs/demo/focus.md b/docs/demo/focus.md new file mode 100644 index 00000000..914997fc --- /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. + + diff --git a/docs/examples/focus.tsx b/docs/examples/focus.tsx new file mode 100644 index 00000000..7e7ae662 --- /dev/null +++ b/docs/examples/focus.tsx @@ -0,0 +1,41 @@ +import React, { useRef } from 'react'; +import { useLockFocus } from '../../src/Dom/focus'; +import './focus.css'; + +export default function FocusDemo() { + const containerRef = useRef(null); + const [locking, setLocking] = React.useState(true); + + useLockFocus(locking, () => containerRef.current); + + 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..1b8ea5b9 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import isVisible from './isVisible'; type DisabledElement = @@ -56,48 +57,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 +96,98 @@ export function triggerFocus( } } } + +// ====================================================== +// == Lock Focus == +// ====================================================== +let lastFocusElement: HTMLElement | null = null; +let focusElements: HTMLElement[] = []; + +function getLastElement() { + return focusElements[focusElements.length - 1]; +} + +function hasFocus(element: HTMLElement) { + const { activeElement } = document; + return element === activeElement || element.contains(activeElement); +} + +function syncFocus() { + const lastElement = getLastElement(); + const { activeElement } = document; + + if (lastElement && !hasFocus(lastElement)) { + const focusableList = getFocusNodeList(lastElement); + + const matchElement = focusableList.includes(lastFocusElement as HTMLElement) + ? lastFocusElement + : focusableList[0]; + + matchElement?.focus(); + } else { + lastFocusElement = activeElement as HTMLElement; + } +} + +function onWindowKeyDown(e: KeyboardEvent) { + if (e.key === 'Tab') { + const { activeElement } = document; + const lastElement = getLastElement(); + const focusableList = getFocusNodeList(lastElement); + const last = focusableList[focusableList.length - 1]; + + if (e.shiftKey && activeElement === focusableList[0]) { + // Tab backward on first focusable element + lastFocusElement = last; + } else if (!e.shiftKey && activeElement === last) { + // Tab forward on last focusable element + lastFocusElement = focusableList[0]; + } + } +} + +/** + * 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 { + 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(); + } + + // Always return unregister function + return () => { + lastFocusElement = null; + focusElements = focusElements.filter(ele => ele !== element); + if (focusElements.length === 0) { + window.removeEventListener('focusin', syncFocus); + window.removeEventListener('keydown', onWindowKeyDown, true); + } + }; +} + +/** + * 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, +) { + useEffect(() => { + if (lock) { + const element = getElement(); + if (element) { + return lockFocus(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..90497585 100644 --- a/tests/focus.test.ts +++ b/tests/focus.test.tsx @@ -1,6 +1,8 @@ /* eslint-disable class-methods-use-this */ +import React, { useRef } from 'react'; +import { render } 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 7e344f2722e34a92e59999a8029b54ac823bcfbf 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:44:02 +0800 Subject: [PATCH 2/2] chore: bump version to 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d492a68d..c5b9b155 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rc-component/util", - "version": "1.5.0", + "version": "1.6.0", "description": "Common Utils For React Component", "keywords": [ "react",