Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 04c06b6

Browse files
authored
Improve RovingTabIndex & Room List filtering performance (#6987)
1 parent 39e61c4 commit 04c06b6

File tree

9 files changed

+469
-325
lines changed

9 files changed

+469
-325
lines changed

Diff for: src/accessibility/RovingTabIndex.tsx

+87-66
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import React, {
2424
useReducer,
2525
Reducer,
2626
Dispatch,
27+
RefObject,
2728
} from "react";
2829

2930
import { Key } from "../Keyboard";
@@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext<IContext>({
6364
});
6465
RovingTabIndexContext.displayName = "RovingTabIndexContext";
6566

66-
enum Type {
67+
export enum Type {
6768
Register = "REGISTER",
6869
Unregister = "UNREGISTER",
6970
SetFocus = "SET_FOCUS",
@@ -76,73 +77,67 @@ interface IAction {
7677
};
7778
}
7879

79-
const reducer = (state: IState, action: IAction) => {
80+
export const reducer = (state: IState, action: IAction) => {
8081
switch (action.type) {
8182
case Type.Register: {
82-
if (state.refs.length === 0) {
83-
// Our list of refs was empty, set activeRef to this first item
84-
return {
85-
...state,
86-
activeRef: action.payload.ref,
87-
refs: [action.payload.ref],
88-
};
89-
}
90-
91-
if (state.refs.includes(action.payload.ref)) {
92-
return state; // already in refs, this should not happen
83+
let left = 0;
84+
let right = state.refs.length - 1;
85+
let index = state.refs.length; // by default append to the end
86+
87+
// do a binary search to find the right slot
88+
while (left <= right) {
89+
index = Math.floor((left + right) / 2);
90+
const ref = state.refs[index];
91+
92+
if (ref === action.payload.ref) {
93+
return state; // already in refs, this should not happen
94+
}
95+
96+
if (action.payload.ref.current.compareDocumentPosition(ref.current) & DOCUMENT_POSITION_PRECEDING) {
97+
left = ++index;
98+
} else {
99+
right = index - 1;
100+
}
93101
}
94102

95-
// find the index of the first ref which is not preceding this one in DOM order
96-
let newIndex = state.refs.findIndex(ref => {
97-
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
98-
});
99-
100-
if (newIndex < 0) {
101-
newIndex = state.refs.length; // append to the end
103+
if (!state.activeRef) {
104+
// Our list of refs was empty, set activeRef to this first item
105+
state.activeRef = action.payload.ref;
102106
}
103107

104108
// update the refs list
105-
return {
106-
...state,
107-
refs: [
108-
...state.refs.slice(0, newIndex),
109-
action.payload.ref,
110-
...state.refs.slice(newIndex),
111-
],
112-
};
109+
if (index < state.refs.length) {
110+
state.refs.splice(index, 0, action.payload.ref);
111+
} else {
112+
state.refs.push(action.payload.ref);
113+
}
114+
return { ...state };
113115
}
116+
114117
case Type.Unregister: {
115-
// filter out the ref which we are removing
116-
const refs = state.refs.filter(r => r !== action.payload.ref);
118+
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
117119

118-
if (refs.length === state.refs.length) {
120+
if (oldIndex === -1) {
119121
return state; // already removed, this should not happen
120122
}
121123

122-
if (state.activeRef === action.payload.ref) {
124+
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
123125
// we just removed the active ref, need to replace it
124126
// pick the ref which is now in the index the old ref was in
125-
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
126-
return {
127-
...state,
128-
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
129-
refs,
130-
};
127+
const len = state.refs.length;
128+
state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
131129
}
132130

133131
// update the refs list
134-
return {
135-
...state,
136-
refs,
137-
};
132+
return { ...state };
138133
}
134+
139135
case Type.SetFocus: {
140136
// update active ref
141-
return {
142-
...state,
143-
activeRef: action.payload.ref,
144-
};
137+
state.activeRef = action.payload.ref;
138+
return { ...state };
145139
}
140+
146141
default:
147142
return state;
148143
}
@@ -151,13 +146,40 @@ const reducer = (state: IState, action: IAction) => {
151146
interface IProps {
152147
handleHomeEnd?: boolean;
153148
handleUpDown?: boolean;
149+
handleLeftRight?: boolean;
154150
children(renderProps: {
155151
onKeyDownHandler(ev: React.KeyboardEvent);
156152
});
157153
onKeyDown?(ev: React.KeyboardEvent, state: IState);
158154
}
159155

160-
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
156+
export const findSiblingElement = (
157+
refs: RefObject<HTMLElement>[],
158+
startIndex: number,
159+
backwards = false,
160+
): RefObject<HTMLElement> => {
161+
if (backwards) {
162+
for (let i = startIndex; i < refs.length && i >= 0; i--) {
163+
if (refs[i].current.offsetParent !== null) {
164+
return refs[i];
165+
}
166+
}
167+
} else {
168+
for (let i = startIndex; i < refs.length && i >= 0; i++) {
169+
if (refs[i].current.offsetParent !== null) {
170+
return refs[i];
171+
}
172+
}
173+
}
174+
};
175+
176+
export const RovingTabIndexProvider: React.FC<IProps> = ({
177+
children,
178+
handleHomeEnd,
179+
handleUpDown,
180+
handleLeftRight,
181+
onKeyDown,
182+
}) => {
161183
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
162184
activeRef: null,
163185
refs: [],
@@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
166188
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
167189

168190
const onKeyDownHandler = useCallback((ev) => {
191+
if (onKeyDown) {
192+
onKeyDown(ev, context.state);
193+
if (ev.defaultPrevented) {
194+
return;
195+
}
196+
}
197+
169198
let handled = false;
170199
// Don't interfere with input default keydown behaviour
171200
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
@@ -174,43 +203,37 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
174203
case Key.HOME:
175204
if (handleHomeEnd) {
176205
handled = true;
177-
// move focus to first item
178-
if (context.state.refs.length > 0) {
179-
context.state.refs[0].current.focus();
180-
}
206+
// move focus to first (visible) item
207+
findSiblingElement(context.state.refs, 0)?.current?.focus();
181208
}
182209
break;
183210

184211
case Key.END:
185212
if (handleHomeEnd) {
186213
handled = true;
187-
// move focus to last item
188-
if (context.state.refs.length > 0) {
189-
context.state.refs[context.state.refs.length - 1].current.focus();
190-
}
214+
// move focus to last (visible) item
215+
findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
191216
}
192217
break;
193218

194219
case Key.ARROW_UP:
195-
if (handleUpDown) {
220+
case Key.ARROW_RIGHT:
221+
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_RIGHT && handleLeftRight)) {
196222
handled = true;
197223
if (context.state.refs.length > 0) {
198224
const idx = context.state.refs.indexOf(context.state.activeRef);
199-
if (idx > 0) {
200-
context.state.refs[idx - 1].current.focus();
201-
}
225+
findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
202226
}
203227
}
204228
break;
205229

206230
case Key.ARROW_DOWN:
207-
if (handleUpDown) {
231+
case Key.ARROW_LEFT:
232+
if ((ev.key === Key.ARROW_DOWN && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
208233
handled = true;
209234
if (context.state.refs.length > 0) {
210235
const idx = context.state.refs.indexOf(context.state.activeRef);
211-
if (idx < context.state.refs.length - 1) {
212-
context.state.refs[idx + 1].current.focus();
213-
}
236+
findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
214237
}
215238
}
216239
break;
@@ -220,10 +243,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
220243
if (handled) {
221244
ev.preventDefault();
222245
ev.stopPropagation();
223-
} else if (onKeyDown) {
224-
return onKeyDown(ev, context.state);
225246
}
226-
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
247+
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
227248

228249
return <RovingTabIndexContext.Provider value={context}>
229250
{ children({ onKeyDownHandler }) }

Diff for: src/accessibility/Toolbar.tsx

+2-11
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ limitations under the License.
1616

1717
import React from "react";
1818

19-
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
19+
import { RovingTabIndexProvider } from "./RovingTabIndex";
2020
import { Key } from "../Keyboard";
2121

2222
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
@@ -26,7 +26,7 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
2626
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
2727
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
2828
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
29-
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
29+
const onKeyDown = (ev: React.KeyboardEvent) => {
3030
const target = ev.target as HTMLElement;
3131
// Don't interfere with input default keydown behaviour
3232
if (target.tagName === "INPUT") return;
@@ -42,15 +42,6 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
4242
}
4343
break;
4444

45-
case Key.ARROW_LEFT:
46-
case Key.ARROW_RIGHT:
47-
if (state.refs.length > 0) {
48-
const i = state.refs.findIndex(r => r === state.activeRef);
49-
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
50-
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
51-
}
52-
break;
53-
5445
default:
5546
handled = false;
5647
}

Diff for: src/components/structures/ContextMenu.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
249249
let handled = true;
250250

251251
switch (ev.key) {
252+
// XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils
253+
// to inherit proper handling of unmount edge cases
252254
case Key.TAB:
253255
case Key.ESCAPE:
254256
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />

0 commit comments

Comments
 (0)