Skip to content

Commit 18ef975

Browse files
authored
Merge pull request #28452 from element-hq/midhun/fix-spotlight-1
2 parents ca239fe + ec96d33 commit 18ef975

File tree

14 files changed

+270
-212
lines changed

14 files changed

+270
-212
lines changed

src/accessibility/RovingTabIndex.tsx

Lines changed: 94 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ import React, {
1010
createContext,
1111
useCallback,
1212
useContext,
13-
useEffect,
1413
useMemo,
1514
useRef,
1615
useReducer,
1716
Reducer,
1817
Dispatch,
1918
RefObject,
2019
ReactNode,
20+
RefCallback,
2121
} from "react";
2222

2323
import { getKeyBindingsManager } from "../KeyBindingsManager";
2424
import { KeyBindingAction } from "./KeyboardShortcuts";
25-
import { FocusHandler, Ref } from "./roving/types";
25+
import { FocusHandler } from "./roving/types";
2626

2727
/**
2828
* Module to simplify implementing the Roving TabIndex accessibility technique
@@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
4949
}
5050

5151
export interface IState {
52-
activeRef?: Ref;
53-
refs: Ref[];
52+
activeNode?: HTMLElement;
53+
nodes: HTMLElement[];
5454
}
5555

5656
export interface IContext {
@@ -60,7 +60,7 @@ export interface IContext {
6060

6161
export const RovingTabIndexContext = createContext<IContext>({
6262
state: {
63-
refs: [], // list of refs in DOM order
63+
nodes: [], // list of nodes in DOM order
6464
},
6565
dispatch: () => {},
6666
});
@@ -76,7 +76,7 @@ export enum Type {
7676
export interface IAction {
7777
type: Exclude<Type, Type.Update>;
7878
payload: {
79-
ref: Ref;
79+
node: HTMLElement;
8080
};
8181
}
8282

@@ -87,12 +87,12 @@ interface UpdateAction {
8787

8888
type Action = IAction | UpdateAction;
8989

90-
const refSorter = (a: Ref, b: Ref): number => {
90+
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
9191
if (a === b) {
9292
return 0;
9393
}
9494

95-
const position = a.current!.compareDocumentPosition(b.current!);
95+
const position = a.compareDocumentPosition(b);
9696

9797
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
9898
return -1;
@@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
106106
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
107107
switch (action.type) {
108108
case Type.Register: {
109-
if (!state.activeRef) {
110-
// Our list of refs was empty, set activeRef to this first item
111-
state.activeRef = action.payload.ref;
109+
if (!state.activeNode) {
110+
// Our list of nodes was empty, set activeNode to this first item
111+
state.activeNode = action.payload.node;
112112
}
113113

114+
if (state.nodes.includes(action.payload.node)) return state;
115+
114116
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
115-
state.refs.push(action.payload.ref);
116-
state.refs.sort(refSorter);
117+
state.nodes.push(action.payload.node);
118+
state.nodes.sort(nodeSorter);
117119

118120
return { ...state };
119121
}
120122

121123
case Type.Unregister: {
122-
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
124+
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
123125

124126
if (oldIndex === -1) {
125127
return state; // already removed, this should not happen
126128
}
127129

128-
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
129-
// we just removed the active ref, need to replace it
130-
// pick the ref closest to the index the old ref was in
131-
if (oldIndex >= state.refs.length) {
132-
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
130+
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
131+
// we just removed the active node, need to replace it
132+
// pick the node closest to the index the old node was in
133+
if (oldIndex >= state.nodes.length) {
134+
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
133135
} else {
134-
state.activeRef =
135-
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
136+
state.activeNode =
137+
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
136138
}
137139
if (document.activeElement === document.body) {
138140
// if the focus got reverted to the body then the user was likely focused on the unmounted element
139-
setTimeout(() => state.activeRef?.current?.focus(), 0);
141+
setTimeout(() => state.activeNode?.focus(), 0);
140142
}
141143
}
142144

143-
// update the refs list
145+
// update the nodes list
144146
return { ...state };
145147
}
146148

147149
case Type.SetFocus: {
148-
// if the ref doesn't change just return the same object reference to skip a re-render
149-
if (state.activeRef === action.payload.ref) return state;
150-
// update active ref
151-
state.activeRef = action.payload.ref;
150+
// if the node doesn't change just return the same object reference to skip a re-render
151+
if (state.activeNode === action.payload.node) return state;
152+
// update active node
153+
state.activeNode = action.payload.node;
152154
return { ...state };
153155
}
154156

155157
case Type.Update: {
156-
state.refs.sort(refSorter);
158+
state.nodes.sort(nodeSorter);
157159
return { ...state };
158160
}
159161

@@ -174,28 +176,28 @@ interface IProps {
174176
}
175177

176178
export const findSiblingElement = (
177-
refs: RefObject<HTMLElement>[],
179+
nodes: HTMLElement[],
178180
startIndex: number,
179181
backwards = false,
180182
loop = false,
181-
): RefObject<HTMLElement> | undefined => {
183+
): HTMLElement | undefined => {
182184
if (backwards) {
183-
for (let i = startIndex; i < refs.length && i >= 0; i--) {
184-
if (refs[i].current?.offsetParent !== null) {
185-
return refs[i];
185+
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
186+
if (nodes[i]?.offsetParent !== null) {
187+
return nodes[i];
186188
}
187189
}
188190
if (loop) {
189-
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false);
191+
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
190192
}
191193
} else {
192-
for (let i = startIndex; i < refs.length && i >= 0; i++) {
193-
if (refs[i].current?.offsetParent !== null) {
194-
return refs[i];
194+
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
195+
if (nodes[i]?.offsetParent !== null) {
196+
return nodes[i];
195197
}
196198
}
197199
if (loop) {
198-
return findSiblingElement(refs.slice(0, startIndex), 0, false, false);
200+
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
199201
}
200202
}
201203
};
@@ -211,7 +213,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
211213
onKeyDown,
212214
}) => {
213215
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
214-
refs: [],
216+
nodes: [],
215217
});
216218

217219
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
@@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
227229

228230
let handled = false;
229231
const action = getKeyBindingsManager().getAccessibilityAction(ev);
230-
let focusRef: RefObject<HTMLElement> | undefined;
232+
let focusNode: HTMLElement | undefined;
231233
// Don't interfere with input default keydown behaviour
232234
// but allow people to move focus from it with Tab.
233235
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
234236
switch (action) {
235237
case KeyBindingAction.Tab:
236238
handled = true;
237-
if (context.state.refs.length > 0) {
238-
const idx = context.state.refs.indexOf(context.state.activeRef!);
239-
focusRef = findSiblingElement(
240-
context.state.refs,
239+
if (context.state.nodes.length > 0) {
240+
const idx = context.state.nodes.indexOf(context.state.activeNode!);
241+
focusNode = findSiblingElement(
242+
context.state.nodes,
241243
idx + (ev.shiftKey ? -1 : 1),
242244
ev.shiftKey,
243245
);
@@ -251,15 +253,15 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
251253
if (handleHomeEnd) {
252254
handled = true;
253255
// move focus to first (visible) item
254-
focusRef = findSiblingElement(context.state.refs, 0);
256+
focusNode = findSiblingElement(context.state.nodes, 0);
255257
}
256258
break;
257259

258260
case KeyBindingAction.End:
259261
if (handleHomeEnd) {
260262
handled = true;
261263
// move focus to last (visible) item
262-
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
264+
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
263265
}
264266
break;
265267

@@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
270272
(action === KeyBindingAction.ArrowRight && handleLeftRight)
271273
) {
272274
handled = true;
273-
if (context.state.refs.length > 0) {
274-
const idx = context.state.refs.indexOf(context.state.activeRef!);
275-
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
275+
if (context.state.nodes.length > 0) {
276+
const idx = context.state.nodes.indexOf(context.state.activeNode!);
277+
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
276278
}
277279
}
278280
break;
@@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
284286
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
285287
) {
286288
handled = true;
287-
if (context.state.refs.length > 0) {
288-
const idx = context.state.refs.indexOf(context.state.activeRef!);
289-
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
289+
if (context.state.nodes.length > 0) {
290+
const idx = context.state.nodes.indexOf(context.state.activeNode!);
291+
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
290292
}
291293
}
292294
break;
@@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
298300
ev.stopPropagation();
299301
}
300302

301-
if (focusRef) {
302-
focusRef.current?.focus();
303+
if (focusNode) {
304+
focusNode?.focus();
303305
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
304306
dispatch({
305307
type: Type.SetFocus,
306308
payload: {
307-
ref: focusRef,
309+
node: focusNode,
308310
},
309311
});
310312
if (scrollIntoView) {
311-
focusRef.current?.scrollIntoView(scrollIntoView);
313+
focusNode?.scrollIntoView(scrollIntoView);
312314
}
313315
}
314316
},
@@ -337,46 +339,61 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
337339
);
338340
};
339341

340-
// Hook to register a roving tab index
341-
// inputRef parameter specifies the ref to use
342-
// onFocus should be called when the index gained focus in any manner
343-
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
344-
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
342+
/**
343+
* Hook to register a roving tab index.
344+
*
345+
* inputRef is an optional argument; when passed this ref points to the DOM element
346+
* to which the callback ref is attached.
347+
*
348+
* Returns:
349+
* onFocus should be called when the index gained focus in any manner.
350+
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
351+
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
352+
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
353+
*
354+
* nodeRef = inputRef when inputRef argument is provided.
355+
*/
345356
export const useRovingTabIndex = <T extends HTMLElement>(
346357
inputRef?: RefObject<T>,
347-
): [FocusHandler, boolean, RefObject<T>] => {
358+
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
348359
const context = useContext(RovingTabIndexContext);
349-
let ref = useRef<T>(null);
360+
361+
let nodeRef = useRef<T | null>(null);
350362

351363
if (inputRef) {
352364
// if we are given a ref, use it instead of ours
353-
ref = inputRef;
365+
nodeRef = inputRef;
354366
}
355367

356-
// setup (after refs)
357-
useEffect(() => {
358-
context.dispatch({
359-
type: Type.Register,
360-
payload: { ref },
361-
});
362-
// teardown
363-
return () => {
368+
const ref = useCallback((node: T | null) => {
369+
if (node) {
370+
nodeRef.current = node;
371+
context.dispatch({
372+
type: Type.Register,
373+
payload: { node },
374+
});
375+
} else {
364376
context.dispatch({
365377
type: Type.Unregister,
366-
payload: { ref },
378+
payload: { node: nodeRef.current! },
367379
});
368-
};
380+
nodeRef.current = null;
381+
}
369382
}, []); // eslint-disable-line react-hooks/exhaustive-deps
370383

371384
const onFocus = useCallback(() => {
385+
if (!nodeRef.current) {
386+
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
387+
return;
388+
}
372389
context.dispatch({
373390
type: Type.SetFocus,
374-
payload: { ref },
391+
payload: { node: nodeRef.current },
375392
});
376393
}, []); // eslint-disable-line react-hooks/exhaustive-deps
377394

378-
const isActive = context.state.activeRef === ref;
379-
return [onFocus, isActive, ref];
395+
const isActive = context.state.activeNode === nodeRef.current;
396+
return [onFocus, isActive, ref, nodeRef];
380397
};
381398

382399
// re-export the semantic helper components for simplicity

src/accessibility/roving/RovingTabIndexWrapper.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import React, { ReactElement } from "react";
9+
import React, { ReactElement, RefCallback } from "react";
1010

1111
import { useRovingTabIndex } from "../RovingTabIndex";
1212
import { FocusHandler, Ref } from "./types";
1313

1414
interface IProps {
1515
inputRef?: Ref;
16-
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>;
16+
children(renderProps: {
17+
onFocus: FocusHandler;
18+
isActive: boolean;
19+
ref: RefCallback<HTMLElement>;
20+
}): ReactElement<any, any>;
1721
}
1822

1923
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.

0 commit comments

Comments
 (0)