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
9 changes: 9 additions & 0 deletions docs/demo/focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Focus Utils
---

# Focus Utils Demo

Demonstrates the usage of focus-related utility functions.

<code src="../examples/focus.tsx"></code>
41 changes: 41 additions & 0 deletions docs/examples/focus.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const [locking, setLocking] = React.useState(true);

useLockFocus(locking, () => containerRef.current);

return (
<div style={{ padding: 32 }} className="focus-demo">
<h2>Focus Utils Demo</h2>

{/* External buttons */}
<button onClick={() => setLocking(!locking)}>
Lock ({String(locking)})
</button>

{/* Middle container - Tab key cycling is limited within this area */}
<div
ref={containerRef}
tabIndex={0}
style={{
border: '2px solid green',
padding: 24,
margin: 16,
borderRadius: 8,
backgroundColor: '#f0f8ff',
}}
>
<button>Container Button 1</button>
<button>Container Button 2</button>
<button>Container Button 3</button>
</div>

{/* External buttons */}
<button>External Button 2</button>
</div>
);
}
138 changes: 96 additions & 42 deletions src/Dom/focus.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import isVisible from './isVisible';

type DisabledElement =
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -137,3 +96,98 @@ export function triggerFocus(
}
}
}

// ======================================================
// == Lock Focus ==
// ======================================================
let lastFocusElement: HTMLElement | null = null;
let focusElements: HTMLElement[] = [];
Comment on lines +103 to +104

Choose a reason for hiding this comment

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

high

The lastFocusElement and focusElements variables are global mutable states. The current implementation of getLastElement() always returns the most recently added element to focusElements, and syncFocus and onWindowKeyDown exclusively operate on this single "last" element. This design implies that only one focus lock can be truly active at any given time, with newer locks overriding older ones. If the intention is to support multiple independent focus locks simultaneously (e.g., for nested modals or multiple active components), this global state management will lead to incorrect and unpredictable behavior. Consider encapsulating the state for each lockFocus instance or redesigning the global state to properly manage multiple active locks, perhaps by iterating through focusElements or using a more robust stack/queue approach that respects the order and context of active locks.

Comment on lines +103 to +104
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

全局状态设计仍然存在多锁冲突问题。

之前的审查已经指出,lastFocusElementfocusElements 作为全局可变状态,getLastElement() 总是返回最新添加的元素,syncFocusonWindowKeyDown 只对这个"最后"的元素进行操作。这意味着同一时间只有一个焦点锁真正生效,新的锁会覆盖旧的锁。

如果需要支持多个独立的焦点锁同时存在(例如嵌套的模态框),当前的全局状态管理会导致不正确和不可预测的行为。建议为每个 lockFocus 实例封装状态,或重新设计全局状态以正确管理多个活动锁(例如使用栈/队列方式来尊重活动锁的顺序和上下文)。

基于之前的审查意见。

🤖 Prompt for AI Agents
In src/Dom/focus.ts around lines 103-104, the globals lastFocusElement and
focusElements create multi-lock conflicts because
getLastElement()/syncFocus/onWindowKeyDown only operate on the most recently
added element, allowing newer locks to override older ones; refactor to
encapsulate focus lock state per lock (e.g., a FocusLock class or factory) and
maintain a central stack/registry of active locks so only the top (active) lock
handles keyboard/window events, and ensure locks register/unregister themselves
(push on lock creation, remove on release) and perform cleanup; update syncFocus
and onWindowKeyDown to consult the central stack for the current active lock
rather than global arrays so nested modals/focus regions behave predictably.


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);
}
Comment on lines +167 to +172
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

清理函数过早重置 lastFocusElement 导致焦点追踪失效。

在第 167 行,清理函数无条件地将 lastFocusElement 设置为 null,即使还存在其他被锁定的元素(focusElements.length > 0)。这会破坏焦点追踪机制。

当多个元素依次被锁定和解锁时,如果某个中间元素解锁,lastFocusElement 会被重置,导致剩余锁定元素的焦点恢复逻辑失效。

应用以下差异修复:

   // Always return unregister function
   return () => {
-    lastFocusElement = null;
     focusElements = focusElements.filter(ele => ele !== element);
     if (focusElements.length === 0) {
+      lastFocusElement = null;
       window.removeEventListener('focusin', syncFocus);
       window.removeEventListener('keydown', onWindowKeyDown, true);
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lastFocusElement = null;
focusElements = focusElements.filter(ele => ele !== element);
if (focusElements.length === 0) {
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
lastFocusElement = null;
focusElements = focusElements.filter(ele => ele !== element);
if (focusElements.length === 0) {
lastFocusElement = null;
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
Suggested change
lastFocusElement = null;
focusElements = focusElements.filter(ele => ele !== element);
if (focusElements.length === 0) {
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
focusElements = focusElements.filter(ele => ele !== element);
if (focusElements.length === 0) {
lastFocusElement = null;
window.removeEventListener('focusin', syncFocus);
window.removeEventListener('keydown', onWindowKeyDown, true);
}
🤖 Prompt for AI Agents
In src/Dom/focus.ts around lines 167 to 172, the cleanup unconditionally sets
lastFocusElement = null which breaks focus tracking when other locked elements
remain; only clear lastFocusElement and remove the window listeners when
focusElements.length === 0. Move the lastFocusElement = null assignment inside
the existing if block (so it only runs when focusElements becomes empty) and
keep the event listener removals there as well.

};
}

/**
* 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(() => {

Choose a reason for hiding this comment

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

high

The lockFocus function expects an HTMLElement as its argument, but getElement() can return null. If getElement() returns null, this will lead to a runtime error when lockFocus is called. A null check should be performed before attempting to call lockFocus.

      if (lock) {
        const element = getElement();
        if (element) {
          return lockFocus(element);
        }
      }

if (lock) {
const element = getElement();
if (element) {
return lockFocus(element);
}
}
}, [lock]);
}
42 changes: 41 additions & 1 deletion tests/focus.test.ts → tests/focus.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -56,4 +58,42 @@ describe('focus', () => {
focusSpy.mockRestore();
setSelectionRangeSpy.mockRestore();
});

describe('useLockFocus', () => {
const TestComponent: React.FC<{ lock: boolean }> = ({ lock }) => {
const elementRef = useRef<HTMLDivElement>(null);
useLockFocus(lock, () => elementRef.current);

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

it('should restore focus to range when focusing other elements', () => {
const { getByTestId } = render(<TestComponent lock={true} />);

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