Skip to content

Commit c1eaa75

Browse files
zombieJclaudegemini-code-assist[bot]
authored
feat: add ignored element support for focus lock (#727)
* 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 <noreply@anthropic.com> * Update src/Dom/focus.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor: simplify isIgnoredElement logic Simplify the isIgnoredElement function for better readability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 595856a commit c1eaa75

File tree

2 files changed

+89
-4
lines changed

2 files changed

+89
-4
lines changed

src/Dom/focus.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect } from 'react';
22
import isVisible from './isVisible';
3+
import useId from '../hooks/useId';
34

45
type DisabledElement =
56
| HTMLLinkElement
@@ -102,11 +103,37 @@ export function triggerFocus(
102103
// ======================================================
103104
let lastFocusElement: HTMLElement | null = null;
104105
let focusElements: HTMLElement[] = [];
106+
// Map stable ID to lock element
107+
const idToElementMap = new Map<string, HTMLElement>();
108+
// Map stable ID to ignored element
109+
const ignoredElementMap = new Map<string, HTMLElement | null>();
105110

106111
function getLastElement() {
107112
return focusElements[focusElements.length - 1];
108113
}
109114

115+
function isIgnoredElement(element: Element | null): boolean {
116+
const lastElement = getLastElement();
117+
118+
if (element && lastElement) {
119+
// Find the ID that maps to the last element
120+
let lockId: string | undefined;
121+
for (const [id, ele] of idToElementMap.entries()) {
122+
if (ele === lastElement) {
123+
lockId = id;
124+
break;
125+
}
126+
}
127+
128+
const ignoredEle = ignoredElementMap.get(lockId);
129+
return (
130+
!!ignoredEle && (ignoredEle === element || ignoredEle.contains(element))
131+
);
132+
}
133+
134+
return false;
135+
}
136+
110137
function hasFocus(element: HTMLElement) {
111138
const { activeElement } = document;
112139
return element === activeElement || element.contains(activeElement);
@@ -116,6 +143,11 @@ function syncFocus() {
116143
const lastElement = getLastElement();
117144
const { activeElement } = document;
118145

146+
// If current focus is on an ignored element, don't force it back
147+
if (isIgnoredElement(activeElement as HTMLElement)) {
148+
return;
149+
}
150+
119151
if (lastElement && !hasFocus(lastElement)) {
120152
const focusableList = getFocusNodeList(lastElement);
121153

@@ -149,9 +181,13 @@ function onWindowKeyDown(e: KeyboardEvent) {
149181
/**
150182
* Lock focus in the element.
151183
* It will force back to the first focusable element when focus leaves the element.
184+
* @param id - A stable ID for this lock instance
152185
*/
153-
export function lockFocus(element: HTMLElement): VoidFunction {
186+
export function lockFocus(element: HTMLElement, id: string): VoidFunction {
154187
if (element) {
188+
// Store the mapping between ID and element
189+
idToElementMap.set(id, element);
190+
155191
// Refresh focus elements
156192
focusElements = focusElements.filter(ele => ele !== element);
157193
focusElements.push(element);
@@ -166,6 +202,8 @@ export function lockFocus(element: HTMLElement): VoidFunction {
166202
return () => {
167203
lastFocusElement = null;
168204
focusElements = focusElements.filter(ele => ele !== element);
205+
idToElementMap.delete(id);
206+
ignoredElementMap.delete(id);
169207
if (focusElements.length === 0) {
170208
window.removeEventListener('focusin', syncFocus);
171209
window.removeEventListener('keydown', onWindowKeyDown, true);
@@ -177,17 +215,29 @@ export function lockFocus(element: HTMLElement): VoidFunction {
177215
* Lock focus within an element.
178216
* When locked, focus will be restricted to focusable elements within the specified element.
179217
* If multiple elements are locked, only the last locked element will be effective.
218+
* @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.
180219
*/
181220
export function useLockFocus(
182221
lock: boolean,
183222
getElement: () => HTMLElement | null,
184-
) {
223+
): [ignoreElement: (ele: HTMLElement) => void] {
224+
const id = useId();
225+
185226
useEffect(() => {
186227
if (lock) {
187228
const element = getElement();
188229
if (element) {
189-
return lockFocus(element);
230+
return lockFocus(element, id);
190231
}
191232
}
192-
}, [lock]);
233+
}, [lock, id]);
234+
235+
const ignoreElement = (ele: HTMLElement) => {
236+
if (ele) {
237+
// Set the ignored element using stable ID
238+
ignoredElementMap.set(id, ele);
239+
}
240+
};
241+
242+
return [ignoreElement];
193243
}

tests/focus.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,39 @@ describe('focus', () => {
9696
expect(document.activeElement).toBe(input1);
9797
});
9898
});
99+
100+
it('ignoreElement should allow focus on ignored elements', () => {
101+
let capturedIgnoreElement: ((ele: HTMLElement) => void) | null = null;
102+
103+
const TestComponent: React.FC = () => {
104+
const elementRef = useRef<HTMLDivElement>(null);
105+
const [ignoreElement] = useLockFocus(true, () => elementRef.current);
106+
107+
if (ignoreElement && !capturedIgnoreElement) {
108+
capturedIgnoreElement = ignoreElement;
109+
}
110+
111+
return (
112+
<>
113+
<button data-testid="ignored-button">Ignored</button>
114+
<div ref={elementRef} data-testid="focus-container" tabIndex={0}>
115+
<input key="input1" data-testid="input1" />
116+
</div>
117+
</>
118+
);
119+
};
120+
121+
const { getByTestId } = render(<TestComponent />);
122+
123+
const ignoredButton = getByTestId('ignored-button');
124+
125+
// Mark the button as ignored
126+
if (capturedIgnoreElement) {
127+
capturedIgnoreElement(ignoredButton);
128+
}
129+
130+
// Focus should be allowed on the ignored button
131+
ignoredButton.focus();
132+
expect(document.activeElement).toBe(ignoredButton);
133+
});
99134
});

0 commit comments

Comments
 (0)