-
-
Notifications
You must be signed in to change notification settings - Fork 262
Expand file tree
/
Copy pathuseAccessibility.ts
More file actions
368 lines (310 loc) · 9.43 KB
/
useAccessibility.ts
File metadata and controls
368 lines (310 loc) · 9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
import { getFocusNodeList } from 'rc-util/lib/Dom/focus';
import KeyCode from 'rc-util/lib/KeyCode';
import raf from 'rc-util/lib/raf';
import * as React from 'react';
import { getMenuId } from '../context/IdContext';
import type { MenuKey, MenuMode } from '../interface';
// destruct to reduce minify size
const { LEFT, RIGHT, UP, DOWN, ENTER, ESC, HOME, END } = KeyCode;
const ArrowKeys = [UP, DOWN, LEFT, RIGHT];
function getOffset(
mode: MenuMode,
isRootLevel: boolean,
isRtl: boolean,
which: number,
): {
offset?: number;
sibling?: boolean;
inlineTrigger?: boolean;
} {
const prev = 'prev' as const;
const next = 'next' as const;
const children = 'children' as const;
const parent = 'parent' as const;
// Inline enter is special that we use unique operation
if (mode === 'inline' && which === ENTER) {
return {
inlineTrigger: true,
};
}
type OffsetMap = Record<number, 'prev' | 'next' | 'children' | 'parent'>;
const inline: OffsetMap = {
[UP]: prev,
[DOWN]: next,
};
const horizontal: OffsetMap = {
[LEFT]: isRtl ? next : prev,
[RIGHT]: isRtl ? prev : next,
[DOWN]: children,
[ENTER]: children,
};
const vertical: OffsetMap = {
[UP]: prev,
[DOWN]: next,
[ENTER]: children,
[ESC]: parent,
[LEFT]: isRtl ? children : parent,
[RIGHT]: isRtl ? parent : children,
};
const offsets: Record<
string,
Record<number, 'prev' | 'next' | 'children' | 'parent'>
> = {
inline,
horizontal,
vertical,
inlineSub: inline,
horizontalSub: vertical,
verticalSub: vertical,
};
const type = offsets[`${mode}${isRootLevel ? '' : 'Sub'}`]?.[which];
switch (type) {
case prev:
return {
offset: -1,
sibling: true,
};
case next:
return {
offset: 1,
sibling: true,
};
case parent:
return {
offset: -1,
sibling: false,
};
case children:
return {
offset: 1,
sibling: false,
};
default:
return null;
}
}
function findContainerUL(element: HTMLElement): HTMLUListElement {
let current: HTMLElement = element;
while (current) {
if (current.getAttribute('data-menu-list')) {
return current as HTMLUListElement;
}
current = current.parentElement;
}
// Normally should not reach this line
/* istanbul ignore next */
return null;
}
/**
* Find focused element within element set provided
*/
function getFocusElement(
activeElement: HTMLElement,
elements: Set<HTMLElement>,
): HTMLElement {
let current = activeElement || document.activeElement;
while (current) {
if (elements.has(current as any)) {
return current as HTMLElement;
}
current = current.parentElement;
}
return null;
}
/**
* Get focusable elements from the element set under provided container
*/
export function getFocusableElements(
container: HTMLElement,
elements: Set<HTMLElement>,
) {
const list = getFocusNodeList(container, true);
return list.filter(ele => elements.has(ele));
}
function getNextFocusElement(
parentQueryContainer: HTMLElement,
elements: Set<HTMLElement>,
focusMenuElement?: HTMLElement,
offset: number = 1,
) {
// Key on the menu item will not get validate parent container
if (!parentQueryContainer) {
return null;
}
// List current level menu item elements
const sameLevelFocusableMenuElementList = getFocusableElements(
parentQueryContainer,
elements,
);
// Find next focus index
const count = sameLevelFocusableMenuElementList.length;
let focusIndex = sameLevelFocusableMenuElementList.findIndex(
ele => focusMenuElement === ele,
);
if (offset < 0) {
if (focusIndex === -1) {
focusIndex = count - 1;
} else {
focusIndex -= 1;
}
} else if (offset > 0) {
focusIndex += 1;
}
focusIndex = (focusIndex + count) % count;
// Focus menu item
return sameLevelFocusableMenuElementList[focusIndex];
}
export const refreshElements = (keys: MenuKey[], id: string) => {
const elements = new Set<HTMLElement>();
const key2element = new Map<MenuKey, HTMLElement>();
const element2key = new Map<HTMLElement, MenuKey>();
keys.forEach(key => {
const element = document.querySelector(
`[data-menu-id='${getMenuId(id, key)}']`,
) as HTMLElement;
if (element) {
elements.add(element);
element2key.set(element, key);
key2element.set(key, element);
}
});
return { elements, key2element, element2key };
};
export function useAccessibility<T extends HTMLElement>(
mode: MenuMode,
activeKey: MenuKey,
isRtl: boolean,
id: string,
containerRef: React.RefObject<HTMLUListElement>,
getKeys: () => MenuKey[],
getKeyPath: (key: MenuKey, includeOverflow?: boolean) => string[],
triggerActiveKey: (key: MenuKey) => void,
triggerAccessibilityOpen: (key: MenuKey, open?: boolean) => void,
originOnKeyDown?: React.KeyboardEventHandler<T>,
): React.KeyboardEventHandler<T> {
const rafRef = React.useRef<number>();
const activeRef = React.useRef<MenuKey>();
activeRef.current = activeKey;
const cleanRaf = () => {
raf.cancel(rafRef.current);
};
React.useEffect(
() => () => {
cleanRaf();
},
[],
);
return e => {
const { which } = e;
if ([...ArrowKeys, ENTER, ESC, HOME, END].includes(which)) {
const keys = getKeys();
let refreshedElements = refreshElements(keys, id);
const { elements, key2element, element2key } = refreshedElements;
// First we should find current focused MenuItem/SubMenu element
const activeElement = key2element.get(activeKey);
const focusMenuElement = getFocusElement(activeElement, elements);
const focusMenuKey = element2key.get(focusMenuElement);
const offsetObj = getOffset(
mode,
getKeyPath(focusMenuKey, true).length === 1,
isRtl,
which,
);
// Some mode do not have fully arrow operation like inline
if (!offsetObj && which !== HOME && which !== END) {
return;
}
// Arrow prevent default to avoid page scroll
if (ArrowKeys.includes(which) || [HOME, END].includes(which)) {
e.preventDefault();
}
const tryFocus = (menuElement: HTMLElement) => {
if (menuElement) {
let focusTargetElement = menuElement;
// Focus to link instead of menu item if possible
const link = menuElement.querySelector('a');
if (link?.getAttribute('href')) {
focusTargetElement = link;
}
const targetKey = element2key.get(menuElement);
triggerActiveKey(targetKey);
/**
* Do not `useEffect` here since `tryFocus` may trigger async
* which makes React sync update the `activeKey`
* that force render before `useRef` set the next activeKey
*/
cleanRaf();
rafRef.current = raf(() => {
if (activeRef.current === targetKey) {
focusTargetElement.focus();
}
});
}
};
if (
[HOME, END].includes(which) ||
offsetObj.sibling ||
!focusMenuElement
) {
// ========================== Sibling ==========================
// Find walkable focus menu element container
let parentQueryContainer: HTMLElement;
if (!focusMenuElement || mode === 'inline') {
parentQueryContainer = containerRef.current;
} else {
parentQueryContainer = findContainerUL(focusMenuElement);
}
// Get next focus element
let targetElement;
const focusableElements = getFocusableElements(
parentQueryContainer,
elements,
);
if (which === HOME) {
targetElement = focusableElements[0];
} else if (which === END) {
targetElement = focusableElements[focusableElements.length - 1];
} else {
targetElement = getNextFocusElement(
parentQueryContainer,
elements,
focusMenuElement,
offsetObj.offset,
);
}
// Focus menu item
tryFocus(targetElement);
// ======================= InlineTrigger =======================
} else if (offsetObj.inlineTrigger) {
// Inline trigger no need switch to sub menu item
triggerAccessibilityOpen(focusMenuKey);
// =========================== Level ===========================
} else if (offsetObj.offset > 0) {
triggerAccessibilityOpen(focusMenuKey, true);
cleanRaf();
rafRef.current = raf(() => {
// Async should resync elements
refreshedElements = refreshElements(keys, id);
const controlId = focusMenuElement.getAttribute('aria-controls');
const subQueryContainer = document.getElementById(controlId);
// Get sub focusable menu item
const targetElement = getNextFocusElement(
subQueryContainer,
refreshedElements.elements,
);
// Focus menu item
tryFocus(targetElement);
}, 5);
} else if (offsetObj.offset < 0) {
const keyPath = getKeyPath(focusMenuKey, true);
const parentKey = keyPath[keyPath.length - 2];
const parentMenuElement = key2element.get(parentKey);
// Focus menu item
triggerAccessibilityOpen(parentKey, false);
tryFocus(parentMenuElement);
}
}
// Pass origin key down event
originOnKeyDown?.(e);
};
}