Skip to content
Merged
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
58 changes: 54 additions & 4 deletions src/Dom/focus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import isVisible from './isVisible';
import useId from '../hooks/useId';

type DisabledElement =
| HTMLLinkElement
Expand Down Expand Up @@ -102,11 +103,37 @@ export function triggerFocus(
// ======================================================
let lastFocusElement: HTMLElement | null = null;
let focusElements: HTMLElement[] = [];
// Map stable ID to lock element
const idToElementMap = new Map<string, HTMLElement>();
// Map stable ID to ignored element
const ignoredElementMap = new Map<string, HTMLElement | null>();

function getLastElement() {
return focusElements[focusElements.length - 1];
}

function isIgnoredElement(element: Element | null): boolean {
const lastElement = getLastElement();

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;
}
}

const ignoredEle = ignoredElementMap.get(lockId);
return (
!!ignoredEle && (ignoredEle === element || ignoredEle.contains(element))
);
}

return false;
}

function hasFocus(element: HTMLElement) {
const { activeElement } = document;
return element === activeElement || element.contains(activeElement);
Expand All @@ -116,6 +143,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);

Expand Down Expand Up @@ -149,9 +181,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 ID and element
idToElementMap.set(id, element);

// Refresh focus elements
focusElements = focusElements.filter(ele => ele !== element);
focusElements.push(element);
Expand All @@ -166,6 +202,8 @@ export function lockFocus(element: HTMLElement): VoidFunction {
return () => {
lastFocusElement = null;
focusElements = focusElements.filter(ele => ele !== element);
idToElementMap.delete(id);
ignoredElementMap.delete(id);
if (focusElements.length === 0) {
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
Expand All @@ -177,17 +215,29 @@ 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] {
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) => {
if (ele) {
// Set the ignored element using stable ID
ignoredElementMap.set(id, ele);
}
};

return [ignoreElement];
Comment on lines +235 to +242
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

ignoreElement 未使用 useCallback 包裹,每次渲染生成新引用。

ignoreElement 在每次渲染时都会创建新的函数实例。如果消费者将其传递给子组件的 props 或用于 useEffect 的依赖数组,会导致不必要的重渲染或副作用重新执行。

由于 id(来自 useId)是稳定的,用 useCallback 包裹后引用也将保持稳定。

♻️ 建议使用 useCallback
+import { useEffect, useCallback } from 'react';
-import { useEffect } from 'react';
-  const ignoreElement = (ele: HTMLElement) => {
-    if (ele) {
-      // Set the ignored element using stable ID
-      ignoredElementMap.set(id, ele);
-    }
-  };
+  const ignoreElement = useCallback((ele: HTMLElement) => {
+    if (ele) {
+      ignoredElementMap.set(id, ele);
+    }
+  }, [id]);
🤖 Prompt for AI Agents
In `@src/Dom/focus.ts` around lines 235 - 242, The ignoreElement function is
recreated on every render which causes unstable references; wrap it in React's
useCallback to memoize it (e.g. const ignoreElement = useCallback((ele:
HTMLElement) => { if (ele) { ignoredElementMap.set(id, ele); } }, [id])) and
ensure useCallback is imported; this will keep the returned [ignoreElement]
stable for consumers and effects while still using the stable id from useId.

}
35 changes: 35 additions & 0 deletions tests/focus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const [ignoreElement] = useLockFocus(true, () => elementRef.current);

if (ignoreElement && !capturedIgnoreElement) {
capturedIgnoreElement = ignoreElement;
}

return (
<>
<button data-testid="ignored-button">Ignored</button>
<div ref={elementRef} data-testid="focus-container" tabIndex={0}>
<input key="input1" data-testid="input1" />
</div>
</>
);
};

const { getByTestId } = render(<TestComponent />);

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);
});
});
Loading