From ee2acde1ce434a57d92c146e5e1a24f64a094813 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: Fri, 6 Feb 2026 15:23:22 +0800 Subject: [PATCH 1/6] feat: add ignored element support for focus lock Add ability to mark elements as ignored during focus lock, allowing temporary focus on elements outside the locked area. Co-Authored-By: Claude Opus 4.6 --- src/Dom/focus.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 7d7759bd..7fc68098 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -102,11 +102,20 @@ export function triggerFocus( // ====================================================== let lastFocusElement: HTMLElement | null = null; let focusElements: HTMLElement[] = []; +const ignoredElementMap = new Map(); function getLastElement() { return focusElements[focusElements.length - 1]; } +function isIgnoredElement(element: HTMLElement | null): boolean { + if (!element) return false; + const ignoredEle = ignoredElementMap.get(getLastElement()); + return ( + !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element)) + ); +} + function hasFocus(element: HTMLElement) { const { activeElement } = document; return element === activeElement || element.contains(activeElement); @@ -116,6 +125,11 @@ function syncFocus() { const lastElement = getLastElement(); const { activeElement } = document; + // If current focus is on an ignored element, don't force it back + if (isIgnoredElement(activeElement as HTMLElement)) { + return; + } + if (lastElement && !hasFocus(lastElement)) { const focusableList = getFocusNodeList(lastElement); @@ -166,6 +180,7 @@ export function lockFocus(element: HTMLElement): VoidFunction { return () => { lastFocusElement = null; focusElements = focusElements.filter(ele => ele !== element); + ignoredElementMap.delete(element); if (focusElements.length === 0) { window.removeEventListener('focusin', syncFocus); window.removeEventListener('keydown', onWindowKeyDown, true); @@ -177,11 +192,12 @@ 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. + * @returns A function to mark an element as ignored, which will temporarily allow focus on that element even if it's outside the locked area. */ export function useLockFocus( lock: boolean, getElement: () => HTMLElement | null, -) { +): [ignoreElement: (ele: HTMLElement) => void] { useEffect(() => { if (lock) { const element = getElement(); @@ -190,4 +206,15 @@ export function useLockFocus( } } }, [lock]); + + const ignoreElement = (ele: HTMLElement) => { + const element = getElement(); + if (element && ele) { + // Set the ignored element for current lock element + // Only one element can be ignored at a time for this lock + ignoredElementMap.set(element, ele); + } + }; + + return [ignoreElement]; } From 21558045533971a37df8c789e475852a37adc99b 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: Fri, 6 Feb 2026 15:27:44 +0800 Subject: [PATCH 2/6] Update src/Dom/focus.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/Dom/focus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 7fc68098..e3141ea0 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -108,7 +108,7 @@ function getLastElement() { return focusElements[focusElements.length - 1]; } -function isIgnoredElement(element: HTMLElement | null): boolean { +function isIgnoredElement(element: Element | null): boolean { if (!element) return false; const ignoredEle = ignoredElementMap.get(getLastElement()); return ( From d73787bfed2a25898a3d90057f91c399f67e7a28 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: Fri, 6 Feb 2026 15:35:01 +0800 Subject: [PATCH 3/6] test: add test for ignoreElement functionality Add a simple test to verify that ignoreElement allows focus on ignored elements outside the locked area. Co-Authored-By: Claude Opus 4.6 --- tests/focus.test.tsx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/focus.test.tsx b/tests/focus.test.tsx index 90497585..78378425 100644 --- a/tests/focus.test.tsx +++ b/tests/focus.test.tsx @@ -96,4 +96,39 @@ describe('focus', () => { expect(document.activeElement).toBe(input1); }); }); + + it('ignoreElement should allow focus on ignored elements', () => { + let capturedIgnoreElement: ((ele: HTMLElement) => void) | null = null; + + const TestComponent: React.FC = () => { + const elementRef = useRef(null); + const [ignoreElement] = useLockFocus(true, () => elementRef.current); + + if (ignoreElement && !capturedIgnoreElement) { + capturedIgnoreElement = ignoreElement; + } + + return ( + <> + +
+ +
+ + ); + }; + + const { getByTestId } = render(); + + const ignoredButton = getByTestId('ignored-button'); + + // Mark the button as ignored + if (capturedIgnoreElement) { + capturedIgnoreElement(ignoredButton); + } + + // Focus should be allowed on the ignored button + ignoredButton.focus(); + expect(document.activeElement).toBe(ignoredButton); + }); }); From ba194a5647574b2f1e865f3e94b5e772da5947bf 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: Fri, 6 Feb 2026 15:39:43 +0800 Subject: [PATCH 4/6] refactor: use stable ID as key for ignoredElementMap Use useId from React hooks as a stable key instead of element reference for the ignoredElementMap. This prevents issues where element reference changes (e.g., during component re-renders) cause the ignore functionality to break. Co-Authored-By: Claude Opus 4.6 --- src/Dom/focus.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index e3141ea0..bdb77874 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import isVisible from './isVisible'; +import useId from '../hooks/useId'; type DisabledElement = | HTMLLinkElement @@ -102,7 +103,10 @@ export function triggerFocus( // ====================================================== let lastFocusElement: HTMLElement | null = null; let focusElements: HTMLElement[] = []; -const ignoredElementMap = new Map(); +// Map lock element to its stable ID +const elementToIdMap = new Map(); +// Map stable ID to ignored element +const ignoredElementMap = new Map(); function getLastElement() { return focusElements[focusElements.length - 1]; @@ -110,7 +114,11 @@ function getLastElement() { function isIgnoredElement(element: Element | null): boolean { if (!element) return false; - const ignoredEle = ignoredElementMap.get(getLastElement()); + const lastElement = getLastElement(); + if (!lastElement) return false; + const lockId = elementToIdMap.get(lastElement); + if (!lockId) return false; + const ignoredEle = ignoredElementMap.get(lockId); return ( !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element)) ); @@ -163,9 +171,13 @@ function onWindowKeyDown(e: KeyboardEvent) { /** * Lock focus in the element. * It will force back to the first focusable element when focus leaves the element. + * @param id - A stable ID for this lock instance */ -export function lockFocus(element: HTMLElement): VoidFunction { +export function lockFocus(element: HTMLElement, id: string): VoidFunction { if (element) { + // Store the mapping between element and its stable ID + elementToIdMap.set(element, id); + // Refresh focus elements focusElements = focusElements.filter(ele => ele !== element); focusElements.push(element); @@ -180,7 +192,8 @@ export function lockFocus(element: HTMLElement): VoidFunction { return () => { lastFocusElement = null; focusElements = focusElements.filter(ele => ele !== element); - ignoredElementMap.delete(element); + elementToIdMap.delete(element); + ignoredElementMap.delete(id); if (focusElements.length === 0) { window.removeEventListener('focusin', syncFocus); window.removeEventListener('keydown', onWindowKeyDown, true); @@ -198,21 +211,24 @@ export function useLockFocus( lock: boolean, getElement: () => HTMLElement | null, ): [ignoreElement: (ele: HTMLElement) => void] { + const id = useId(); + useEffect(() => { if (lock) { const element = getElement(); if (element) { - return lockFocus(element); + return lockFocus(element, id); } } - }, [lock]); + }, [lock, id]); const ignoreElement = (ele: HTMLElement) => { const element = getElement(); if (element && ele) { - // Set the ignored element for current lock element + // Set the ignored element for current lock using stable ID // Only one element can be ignored at a time for this lock - ignoredElementMap.set(element, ele); + const lockId = elementToIdMap.get(element) || id; + ignoredElementMap.set(lockId, ele); } }; From a8fd11c24ed281e1371cce5507e7f8ff4d3b54dd 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: Fri, 6 Feb 2026 15:42:47 +0800 Subject: [PATCH 5/6] refactor: simplify map structure - use id mapping to element Change from elementToIdMap to idToElementMap for clearer logic: stable ID as key maps to element. Co-Authored-By: Claude Opus 4.6 --- src/Dom/focus.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index bdb77874..03555f94 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -103,8 +103,8 @@ export function triggerFocus( // ====================================================== let lastFocusElement: HTMLElement | null = null; let focusElements: HTMLElement[] = []; -// Map lock element to its stable ID -const elementToIdMap = new Map(); +// Map stable ID to lock element +const idToElementMap = new Map(); // Map stable ID to ignored element const ignoredElementMap = new Map(); @@ -116,8 +116,18 @@ function isIgnoredElement(element: Element | null): boolean { if (!element) return false; const lastElement = getLastElement(); if (!lastElement) return false; - const lockId = elementToIdMap.get(lastElement); + + // Find the ID that maps to the last element + let lockId: string | undefined; + for (const [id, ele] of idToElementMap.entries()) { + if (ele === lastElement) { + lockId = id; + break; + } + } + if (!lockId) return false; + const ignoredEle = ignoredElementMap.get(lockId); return ( !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element)) @@ -175,8 +185,8 @@ function onWindowKeyDown(e: KeyboardEvent) { */ export function lockFocus(element: HTMLElement, id: string): VoidFunction { if (element) { - // Store the mapping between element and its stable ID - elementToIdMap.set(element, id); + // Store the mapping between ID and element + idToElementMap.set(id, element); // Refresh focus elements focusElements = focusElements.filter(ele => ele !== element); @@ -192,7 +202,7 @@ export function lockFocus(element: HTMLElement, id: string): VoidFunction { return () => { lastFocusElement = null; focusElements = focusElements.filter(ele => ele !== element); - elementToIdMap.delete(element); + idToElementMap.delete(id); ignoredElementMap.delete(id); if (focusElements.length === 0) { window.removeEventListener('focusin', syncFocus); @@ -223,12 +233,9 @@ export function useLockFocus( }, [lock, id]); const ignoreElement = (ele: HTMLElement) => { - const element = getElement(); - if (element && ele) { - // Set the ignored element for current lock using stable ID - // Only one element can be ignored at a time for this lock - const lockId = elementToIdMap.get(element) || id; - ignoredElementMap.set(lockId, ele); + if (ele) { + // Set the ignored element using stable ID + ignoredElementMap.set(id, ele); } }; From 6cd187e396639463765ba13416058c023211ab77 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: Fri, 6 Feb 2026 15:45:42 +0800 Subject: [PATCH 6/6] refactor: simplify isIgnoredElement logic Simplify the isIgnoredElement function for better readability. Co-Authored-By: Claude Opus 4.6 --- src/Dom/focus.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Dom/focus.ts b/src/Dom/focus.ts index 03555f94..f6a6af98 100644 --- a/src/Dom/focus.ts +++ b/src/Dom/focus.ts @@ -113,25 +113,25 @@ function getLastElement() { } function isIgnoredElement(element: Element | null): boolean { - if (!element) return false; const lastElement = getLastElement(); - if (!lastElement) return false; - - // Find the ID that maps to the last element - let lockId: string | undefined; - for (const [id, ele] of idToElementMap.entries()) { - if (ele === lastElement) { - lockId = id; - break; + + if (element && lastElement) { + // Find the ID that maps to the last element + let lockId: string | undefined; + for (const [id, ele] of idToElementMap.entries()) { + if (ele === lastElement) { + lockId = id; + break; + } } - } - if (!lockId) return false; + const ignoredEle = ignoredElementMap.get(lockId); + return ( + !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element)) + ); + } - const ignoredEle = ignoredElementMap.get(lockId); - return ( - !!ignoredEle && (ignoredEle === element || ignoredEle.contains(element)) - ); + return false; } function hasFocus(element: HTMLElement) {