1 | import * as React from "react";
|
2 | import { createComponent } from "reakit-system/createComponent";
|
3 | import { createHook } from "reakit-system/createHook";
|
4 | import { useCreateElement } from "reakit-system/useCreateElement";
|
5 | import { useForkRef } from "reakit-utils/useForkRef";
|
6 | import { warning, useWarning } from "reakit-warning";
|
7 | import { getDocument } from "reakit-utils/getDocument";
|
8 | import { fireBlurEvent } from "reakit-utils/fireBlurEvent";
|
9 | import { fireKeyboardEvent } from "reakit-utils/fireKeyboardEvent";
|
10 | import { isSelfTarget } from "reakit-utils/isSelfTarget";
|
11 | import { useLiveRef } from "reakit-utils/useLiveRef";
|
12 | import { canUseDOM } from "reakit-utils/canUseDOM";
|
13 | import { getNextActiveElementOnBlur } from "reakit-utils/getNextActiveElementOnBlur";
|
14 | import { useTabbable, TabbableOptions, TabbableHTMLProps } from "../Tabbable";
|
15 | import { useRole } from "../Role/Role";
|
16 | import { CompositeStateReturn } from "./CompositeState";
|
17 | import { Item } from "./__utils/types";
|
18 | import { groupItems } from "./__utils/groupItems";
|
19 | import { flatten } from "./__utils/flatten";
|
20 | import { findFirstEnabledItem } from "./__utils/findFirstEnabledItem";
|
21 | import { reverse } from "./__utils/reverse";
|
22 | import { getCurrentId } from "./__utils/getCurrentId";
|
23 | import { findEnabledItemById } from "./__utils/findEnabledItemById";
|
24 | import { COMPOSITE_KEYS } from "./__keys";
|
25 | import { userFocus } from "./__utils/userFocus";
|
26 |
|
27 | export type CompositeOptions = TabbableOptions &
|
28 | Pick<
|
29 | Partial<CompositeStateReturn>,
|
30 | | "baseId"
|
31 | | "unstable_virtual"
|
32 | | "currentId"
|
33 | | "orientation"
|
34 | | "unstable_moves"
|
35 | | "wrap"
|
36 | | "groups"
|
37 | > &
|
38 | Pick<
|
39 | CompositeStateReturn,
|
40 | "items" | "setCurrentId" | "first" | "last" | "move"
|
41 | >;
|
42 |
|
43 | export type CompositeHTMLProps = TabbableHTMLProps;
|
44 |
|
45 | export type CompositeProps = CompositeOptions & CompositeHTMLProps;
|
46 |
|
47 | const isIE11 = canUseDOM && "msCrypto" in window;
|
48 |
|
49 | function canProxyKeyboardEvent(event: React.KeyboardEvent) {
|
50 | if (!isSelfTarget(event)) return false;
|
51 | if (event.metaKey) return false;
|
52 | if (event.key === "Tab") return false;
|
53 | return true;
|
54 | }
|
55 |
|
56 | function useKeyboardEventProxy(
|
57 | virtual?: boolean,
|
58 | currentItem?: Item,
|
59 | htmlEventHandler?: React.KeyboardEventHandler
|
60 | ) {
|
61 | const eventHandlerRef = useLiveRef(htmlEventHandler);
|
62 | return React.useCallback(
|
63 | (event: React.KeyboardEvent) => {
|
64 | eventHandlerRef.current?.(event);
|
65 | if (event.defaultPrevented) return;
|
66 | if (virtual && canProxyKeyboardEvent(event)) {
|
67 | const currentElement = currentItem?.ref.current;
|
68 | if (currentElement) {
|
69 | if (!fireKeyboardEvent(currentElement, event.type, event)) {
|
70 | event.preventDefault();
|
71 | }
|
72 |
|
73 |
|
74 |
|
75 | if (event.currentTarget.contains(currentElement)) {
|
76 | event.stopPropagation();
|
77 | }
|
78 | }
|
79 | }
|
80 | },
|
81 | [virtual, currentItem]
|
82 | );
|
83 | }
|
84 |
|
85 |
|
86 | function useActiveElementRef(elementRef: React.RefObject<HTMLElement>) {
|
87 | const activeElementRef = React.useRef<HTMLElement | null>(null);
|
88 | React.useEffect(() => {
|
89 | const document = getDocument(elementRef.current);
|
90 | const onFocus = (event: FocusEvent) => {
|
91 | const target = event.target as HTMLElement;
|
92 | activeElementRef.current = target;
|
93 | };
|
94 | document.addEventListener("focus", onFocus, true);
|
95 | return () => {
|
96 | document.removeEventListener("focus", onFocus, true);
|
97 | };
|
98 | }, []);
|
99 | return activeElementRef;
|
100 | }
|
101 |
|
102 | function findFirstEnabledItemInTheLastRow(items: Item[]) {
|
103 | return findFirstEnabledItem(flatten(reverse(groupItems(items))));
|
104 | }
|
105 |
|
106 | function isItem(items: Item[], element?: Element | EventTarget | null) {
|
107 | return items?.some((item) => !!element && item.ref.current === element);
|
108 | }
|
109 |
|
110 | function useScheduleUserFocus(currentItem?: Item) {
|
111 | const currentItemRef = useLiveRef(currentItem);
|
112 | const [scheduled, schedule] = React.useReducer((n: number) => n + 1, 0);
|
113 |
|
114 | React.useEffect(() => {
|
115 | const currentElement = currentItemRef.current?.ref.current;
|
116 | if (scheduled && currentElement) {
|
117 | userFocus(currentElement);
|
118 | }
|
119 | }, [scheduled]);
|
120 |
|
121 | return schedule;
|
122 | }
|
123 |
|
124 | export const useComposite = createHook<CompositeOptions, CompositeHTMLProps>({
|
125 | name: "Composite",
|
126 | compose: [useTabbable],
|
127 | keys: COMPOSITE_KEYS,
|
128 |
|
129 | useOptions(options) {
|
130 | return { ...options, currentId: getCurrentId(options) };
|
131 | },
|
132 |
|
133 | useProps(
|
134 | options,
|
135 | {
|
136 | ref: htmlRef,
|
137 | onFocusCapture: htmlOnFocusCapture,
|
138 | onFocus: htmlOnFocus,
|
139 | onBlurCapture: htmlOnBlurCapture,
|
140 | onKeyDown: htmlOnKeyDown,
|
141 | onKeyDownCapture: htmlOnKeyDownCapture,
|
142 | onKeyUpCapture: htmlOnKeyUpCapture,
|
143 | ...htmlProps
|
144 | }
|
145 | ) {
|
146 | const ref = React.useRef<HTMLElement>(null);
|
147 | const currentItem = findEnabledItemById(options.items, options.currentId);
|
148 | const previousElementRef = React.useRef<HTMLElement | null>(null);
|
149 | const onFocusCaptureRef = useLiveRef(htmlOnFocusCapture);
|
150 | const onFocusRef = useLiveRef(htmlOnFocus);
|
151 | const onBlurCaptureRef = useLiveRef(htmlOnBlurCapture);
|
152 | const onKeyDownRef = useLiveRef(htmlOnKeyDown);
|
153 | const scheduleUserFocus = useScheduleUserFocus(currentItem);
|
154 |
|
155 |
|
156 | const activeElementRef = isIE11 ? useActiveElementRef(ref) : undefined;
|
157 |
|
158 | React.useEffect(() => {
|
159 | const element = ref.current;
|
160 | if (options.unstable_moves && !currentItem) {
|
161 | warning(
|
162 | !element,
|
163 | "Can't focus composite component because `ref` wasn't passed to component.",
|
164 | "See https://reakit.io/docs/composite"
|
165 | );
|
166 |
|
167 |
|
168 | element?.focus();
|
169 | }
|
170 | }, [options.unstable_moves, currentItem]);
|
171 |
|
172 | const onKeyDownCapture = useKeyboardEventProxy(
|
173 | options.unstable_virtual,
|
174 | currentItem,
|
175 | htmlOnKeyDownCapture
|
176 | );
|
177 |
|
178 | const onKeyUpCapture = useKeyboardEventProxy(
|
179 | options.unstable_virtual,
|
180 | currentItem,
|
181 | htmlOnKeyUpCapture
|
182 | );
|
183 |
|
184 | const onFocusCapture = React.useCallback(
|
185 | (event: React.FocusEvent) => {
|
186 | onFocusCaptureRef.current?.(event);
|
187 | if (event.defaultPrevented) return;
|
188 | if (!options.unstable_virtual) return;
|
189 |
|
190 |
|
191 | const previousActiveElement =
|
192 | activeElementRef?.current ||
|
193 | (event.relatedTarget as HTMLElement | null);
|
194 | const previousActiveElementWasItem = isItem(
|
195 | options.items,
|
196 | previousActiveElement
|
197 | );
|
198 | if (isSelfTarget(event) && previousActiveElementWasItem) {
|
199 |
|
200 |
|
201 |
|
202 |
|
203 |
|
204 | event.stopPropagation();
|
205 |
|
206 |
|
207 |
|
208 | previousElementRef.current = previousActiveElement;
|
209 | }
|
210 | },
|
211 | [options.unstable_virtual, options.items]
|
212 | );
|
213 |
|
214 | const onFocus = React.useCallback(
|
215 | (event: React.FocusEvent) => {
|
216 | onFocusRef.current?.(event);
|
217 | if (event.defaultPrevented) return;
|
218 | if (options.unstable_virtual) {
|
219 | if (isSelfTarget(event)) {
|
220 |
|
221 |
|
222 |
|
223 |
|
224 |
|
225 |
|
226 |
|
227 |
|
228 | scheduleUserFocus();
|
229 | }
|
230 | } else if (isSelfTarget(event)) {
|
231 |
|
232 |
|
233 |
|
234 |
|
235 | options.setCurrentId?.(null);
|
236 | }
|
237 | },
|
238 | [options.unstable_virtual, options.setCurrentId]
|
239 | );
|
240 |
|
241 | const onBlurCapture = React.useCallback(
|
242 | (event: React.FocusEvent) => {
|
243 | onBlurCaptureRef.current?.(event);
|
244 | if (event.defaultPrevented) return;
|
245 | if (!options.unstable_virtual) return;
|
246 |
|
247 |
|
248 |
|
249 |
|
250 |
|
251 |
|
252 |
|
253 |
|
254 |
|
255 | const currentElement = currentItem?.ref.current || null;
|
256 | const nextActiveElement = getNextActiveElementOnBlur(event);
|
257 | const nextActiveElementIsItem = isItem(
|
258 | options.items,
|
259 | nextActiveElement
|
260 | );
|
261 | if (isSelfTarget(event) && nextActiveElementIsItem) {
|
262 |
|
263 |
|
264 | if (nextActiveElement === currentElement) {
|
265 |
|
266 |
|
267 |
|
268 |
|
269 |
|
270 |
|
271 |
|
272 |
|
273 |
|
274 |
|
275 | if (
|
276 | previousElementRef.current &&
|
277 | previousElementRef.current !== nextActiveElement
|
278 | ) {
|
279 |
|
280 |
|
281 |
|
282 | fireBlurEvent(previousElementRef.current, event);
|
283 | }
|
284 | } else if (currentElement) {
|
285 |
|
286 |
|
287 |
|
288 |
|
289 |
|
290 |
|
291 | fireBlurEvent(currentElement, event);
|
292 | }
|
293 |
|
294 |
|
295 | event.stopPropagation();
|
296 | } else {
|
297 | const targetIsItem = isItem(options.items, event.target);
|
298 | if (!targetIsItem && currentElement) {
|
299 |
|
300 |
|
301 |
|
302 |
|
303 |
|
304 | fireBlurEvent(currentElement, event);
|
305 | }
|
306 | }
|
307 | },
|
308 | [options.unstable_virtual, options.items, currentItem]
|
309 | );
|
310 |
|
311 | const onKeyDown = React.useCallback(
|
312 | (event: React.KeyboardEvent<HTMLElement>) => {
|
313 | onKeyDownRef.current?.(event);
|
314 | if (event.defaultPrevented) return;
|
315 | if (options.currentId !== null) return;
|
316 | if (!isSelfTarget(event)) return;
|
317 | const isVertical = options.orientation !== "horizontal";
|
318 | const isHorizontal = options.orientation !== "vertical";
|
319 | const isGrid = !!options.groups?.length;
|
320 | const up = () => {
|
321 | if (isGrid) {
|
322 | const item = findFirstEnabledItemInTheLastRow(options.items);
|
323 | if (item?.id) {
|
324 | options.move?.(item.id);
|
325 | }
|
326 | } else {
|
327 | options.last?.();
|
328 | }
|
329 | };
|
330 | const keyMap = {
|
331 | ArrowUp: (isGrid || isVertical) && up,
|
332 | ArrowRight: (isGrid || isHorizontal) && options.first,
|
333 | ArrowDown: (isGrid || isVertical) && options.first,
|
334 | ArrowLeft: (isGrid || isHorizontal) && options.last,
|
335 | Home: options.first,
|
336 | End: options.last,
|
337 | PageUp: options.first,
|
338 | PageDown: options.last,
|
339 | };
|
340 | const action = keyMap[event.key as keyof typeof keyMap];
|
341 | if (action) {
|
342 | event.preventDefault();
|
343 | action();
|
344 | }
|
345 | },
|
346 | [
|
347 | options.currentId,
|
348 | options.orientation,
|
349 | options.groups,
|
350 | options.items,
|
351 | options.move,
|
352 | options.last,
|
353 | options.first,
|
354 | ]
|
355 | );
|
356 |
|
357 | return {
|
358 | ref: useForkRef(ref, htmlRef),
|
359 | id: options.baseId,
|
360 | onFocus,
|
361 | onFocusCapture,
|
362 | onBlurCapture,
|
363 | onKeyDownCapture,
|
364 | onKeyDown,
|
365 | onKeyUpCapture,
|
366 | "aria-activedescendant": options.unstable_virtual
|
367 | ? currentItem?.id || undefined
|
368 | : undefined,
|
369 | ...htmlProps,
|
370 | };
|
371 | },
|
372 |
|
373 | useComposeProps(options, htmlProps) {
|
374 | htmlProps = useRole(options, htmlProps, true);
|
375 | const tabbableHTMLProps = useTabbable(options, htmlProps, true);
|
376 | if (options.unstable_virtual || options.currentId === null) {
|
377 |
|
378 |
|
379 |
|
380 | return { tabIndex: 0, ...tabbableHTMLProps };
|
381 | }
|
382 | return { ...htmlProps, ref: tabbableHTMLProps.ref };
|
383 | },
|
384 | });
|
385 |
|
386 | export const Composite = createComponent({
|
387 | as: "div",
|
388 | useHook: useComposite,
|
389 | useCreateElement: (type, props, children) => {
|
390 | useWarning(
|
391 | !props["aria-label"] && !props["aria-labelledby"],
|
392 | "You should provide either `aria-label` or `aria-labelledby` props.",
|
393 | "See https://reakit.io/docs/composite"
|
394 | );
|
395 | return useCreateElement(type, props, children);
|
396 | },
|
397 | });
|