Skip to content

Commit b134564

Browse files
authored
feat: Support useLockFocus (#712)
* chore: init * feat: support useFocusLock * feat: support useFocusLock * chore: clean up * feat: support useFocusLock
1 parent 8ea0ed1 commit b134564

File tree

4 files changed

+187
-43
lines changed

4 files changed

+187
-43
lines changed

docs/demo/focus.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Focus Utils
3+
---
4+
5+
# Focus Utils Demo
6+
7+
Demonstrates the usage of focus-related utility functions.
8+
9+
<code src="../examples/focus.tsx"></code>

docs/examples/focus.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React, { useRef } from 'react';
2+
import { useLockFocus } from '../../src/Dom/focus';
3+
import './focus.css';
4+
5+
export default function FocusDemo() {
6+
const containerRef = useRef<HTMLDivElement>(null);
7+
const [locking, setLocking] = React.useState(true);
8+
9+
useLockFocus(locking, () => containerRef.current);
10+
11+
return (
12+
<div style={{ padding: 32 }} className="focus-demo">
13+
<h2>Focus Utils Demo</h2>
14+
15+
{/* External buttons */}
16+
<button onClick={() => setLocking(!locking)}>
17+
Lock ({String(locking)})
18+
</button>
19+
20+
{/* Middle container - Tab key cycling is limited within this area */}
21+
<div
22+
ref={containerRef}
23+
tabIndex={0}
24+
style={{
25+
border: '2px solid green',
26+
padding: 24,
27+
margin: 16,
28+
borderRadius: 8,
29+
backgroundColor: '#f0f8ff',
30+
}}
31+
>
32+
<button>Container Button 1</button>
33+
<button>Container Button 2</button>
34+
<button>Container Button 3</button>
35+
</div>
36+
37+
{/* External buttons */}
38+
<button>External Button 2</button>
39+
</div>
40+
);
41+
}

src/Dom/focus.ts

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react';
12
import isVisible from './isVisible';
23

34
type DisabledElement =
@@ -56,48 +57,6 @@ export function getFocusNodeList(node: HTMLElement, includePositive = false) {
5657
return res;
5758
}
5859

59-
let lastFocusElement = null;
60-
61-
/** @deprecated Do not use since this may failed when used in async */
62-
export function saveLastFocusNode() {
63-
lastFocusElement = document.activeElement;
64-
}
65-
66-
/** @deprecated Do not use since this may failed when used in async */
67-
export function clearLastFocusNode() {
68-
lastFocusElement = null;
69-
}
70-
71-
/** @deprecated Do not use since this may failed when used in async */
72-
export function backLastFocusNode() {
73-
if (lastFocusElement) {
74-
try {
75-
// 元素可能已经被移动了
76-
lastFocusElement.focus();
77-
78-
/* eslint-disable no-empty */
79-
} catch (e) {
80-
// empty
81-
}
82-
/* eslint-enable no-empty */
83-
}
84-
}
85-
86-
export function limitTabRange(node: HTMLElement, e: KeyboardEvent) {
87-
if (e.keyCode === 9) {
88-
const tabNodeList = getFocusNodeList(node);
89-
const lastTabNode = tabNodeList[e.shiftKey ? 0 : tabNodeList.length - 1];
90-
const leavingTab =
91-
lastTabNode === document.activeElement || node === document.activeElement;
92-
93-
if (leavingTab) {
94-
const target = tabNodeList[e.shiftKey ? tabNodeList.length - 1 : 0];
95-
target.focus();
96-
e.preventDefault();
97-
}
98-
}
99-
}
100-
10160
export interface InputFocusOptions extends FocusOptions {
10261
cursor?: 'start' | 'end' | 'all';
10362
}
@@ -137,3 +96,98 @@ export function triggerFocus(
13796
}
13897
}
13998
}
99+
100+
// ======================================================
101+
// == Lock Focus ==
102+
// ======================================================
103+
let lastFocusElement: HTMLElement | null = null;
104+
let focusElements: HTMLElement[] = [];
105+
106+
function getLastElement() {
107+
return focusElements[focusElements.length - 1];
108+
}
109+
110+
function hasFocus(element: HTMLElement) {
111+
const { activeElement } = document;
112+
return element === activeElement || element.contains(activeElement);
113+
}
114+
115+
function syncFocus() {
116+
const lastElement = getLastElement();
117+
const { activeElement } = document;
118+
119+
if (lastElement && !hasFocus(lastElement)) {
120+
const focusableList = getFocusNodeList(lastElement);
121+
122+
const matchElement = focusableList.includes(lastFocusElement as HTMLElement)
123+
? lastFocusElement
124+
: focusableList[0];
125+
126+
matchElement?.focus();
127+
} else {
128+
lastFocusElement = activeElement as HTMLElement;
129+
}
130+
}
131+
132+
function onWindowKeyDown(e: KeyboardEvent) {
133+
if (e.key === 'Tab') {
134+
const { activeElement } = document;
135+
const lastElement = getLastElement();
136+
const focusableList = getFocusNodeList(lastElement);
137+
const last = focusableList[focusableList.length - 1];
138+
139+
if (e.shiftKey && activeElement === focusableList[0]) {
140+
// Tab backward on first focusable element
141+
lastFocusElement = last;
142+
} else if (!e.shiftKey && activeElement === last) {
143+
// Tab forward on last focusable element
144+
lastFocusElement = focusableList[0];
145+
}
146+
}
147+
}
148+
149+
/**
150+
* Lock focus in the element.
151+
* It will force back to the first focusable element when focus leaves the element.
152+
*/
153+
export function lockFocus(element: HTMLElement): VoidFunction {
154+
if (element) {
155+
// Refresh focus elements
156+
focusElements = focusElements.filter(ele => ele !== element);
157+
focusElements.push(element);
158+
159+
// Just add event since it will de-duplicate
160+
window.addEventListener('focusin', syncFocus);
161+
window.addEventListener('keydown', onWindowKeyDown, true);
162+
syncFocus();
163+
}
164+
165+
// Always return unregister function
166+
return () => {
167+
lastFocusElement = null;
168+
focusElements = focusElements.filter(ele => ele !== element);
169+
if (focusElements.length === 0) {
170+
window.removeEventListener('focusin', syncFocus);
171+
window.removeEventListener('keydown', onWindowKeyDown, true);
172+
}
173+
};
174+
}
175+
176+
/**
177+
* Lock focus within an element.
178+
* When locked, focus will be restricted to focusable elements within the specified element.
179+
* If multiple elements are locked, only the last locked element will be effective.
180+
*/
181+
export function useLockFocus(
182+
lock: boolean,
183+
getElement: () => HTMLElement | null,
184+
) {
185+
useEffect(() => {
186+
if (lock) {
187+
const element = getElement();
188+
if (element) {
189+
return lockFocus(element);
190+
}
191+
}
192+
}, [lock]);
193+
}

tests/focus.test.ts renamed to tests/focus.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/* eslint-disable class-methods-use-this */
2+
import React, { useRef } from 'react';
3+
import { render } from '@testing-library/react';
24
import { spyElementPrototype } from '../src/test/domHook';
3-
import { getFocusNodeList, triggerFocus } from '../src/Dom/focus';
5+
import { getFocusNodeList, triggerFocus, useLockFocus } from '../src/Dom/focus';
46

57
describe('focus', () => {
68
beforeAll(() => {
@@ -56,4 +58,42 @@ describe('focus', () => {
5658
focusSpy.mockRestore();
5759
setSelectionRangeSpy.mockRestore();
5860
});
61+
62+
describe('useLockFocus', () => {
63+
const TestComponent: React.FC<{ lock: boolean }> = ({ lock }) => {
64+
const elementRef = useRef<HTMLDivElement>(null);
65+
useLockFocus(lock, () => elementRef.current);
66+
67+
return (
68+
<>
69+
<button data-testid="outer-button">Outer</button>
70+
<div ref={elementRef} data-testid="focus-container" tabIndex={0}>
71+
<input key="input1" data-testid="input1" />
72+
<button key="button1" data-testid="button1">
73+
Button
74+
</button>
75+
</div>
76+
</>
77+
);
78+
};
79+
80+
it('should restore focus to range when focusing other elements', () => {
81+
const { getByTestId } = render(<TestComponent lock={true} />);
82+
83+
const focusContainer = getByTestId('focus-container');
84+
const input1 = getByTestId('input1') as HTMLInputElement;
85+
86+
// Should focus to first focusable element after lock
87+
expect(document.activeElement).toBe(focusContainer);
88+
89+
// Focus inside container first
90+
input1.focus();
91+
expect(document.activeElement).toBe(input1);
92+
93+
// Focus outer button
94+
const outerButton = getByTestId('outer-button') as HTMLButtonElement;
95+
outerButton.focus();
96+
expect(document.activeElement).toBe(input1);
97+
});
98+
});
5999
});

0 commit comments

Comments
 (0)