UNPKG

15.6 kBPlain TextView Raw
1import * as React from "react";
2import { createComponent } from "reakit-system/createComponent";
3import { createHook } from "reakit-system/createHook";
4import { useCreateElement } from "reakit-system/useCreateElement";
5import { useForkRef } from "reakit-utils/useForkRef";
6import { warning, useWarning } from "reakit-warning";
7import { getDocument } from "reakit-utils/getDocument";
8import { fireBlurEvent } from "reakit-utils/fireBlurEvent";
9import { fireKeyboardEvent } from "reakit-utils/fireKeyboardEvent";
10import { isSelfTarget } from "reakit-utils/isSelfTarget";
11import { useLiveRef } from "reakit-utils/useLiveRef";
12import { canUseDOM } from "reakit-utils/canUseDOM";
13import { getNextActiveElementOnBlur } from "reakit-utils/getNextActiveElementOnBlur";
14import { useTabbable, TabbableOptions, TabbableHTMLProps } from "../Tabbable";
15import { useRole } from "../Role/Role";
16import { CompositeStateReturn } from "./CompositeState";
17import { Item } from "./__utils/types";
18import { groupItems } from "./__utils/groupItems";
19import { flatten } from "./__utils/flatten";
20import { findFirstEnabledItem } from "./__utils/findFirstEnabledItem";
21import { reverse } from "./__utils/reverse";
22import { getCurrentId } from "./__utils/getCurrentId";
23import { findEnabledItemById } from "./__utils/findEnabledItemById";
24import { COMPOSITE_KEYS } from "./__keys";
25import { userFocus } from "./__utils/userFocus";
26
27export 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
43export type CompositeHTMLProps = TabbableHTMLProps;
44
45export type CompositeProps = CompositeOptions & CompositeHTMLProps;
46
47const isIE11 = canUseDOM && "msCrypto" in window;
48
49function 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
56function 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 // The event will be triggered on the composite item and then
73 // propagated up to this composite element again, so we can pretend
74 // that it wasn't called on this component in the first place.
75 if (event.currentTarget.contains(currentElement)) {
76 event.stopPropagation();
77 }
78 }
79 }
80 },
81 [virtual, currentItem]
82 );
83}
84
85// istanbul ignore next
86function 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
102function findFirstEnabledItemInTheLastRow(items: Item[]) {
103 return findFirstEnabledItem(flatten(reverse(groupItems(items))));
104}
105
106function isItem(items: Item[], element?: Element | EventTarget | null) {
107 return items?.some((item) => !!element && item.ref.current === element);
108}
109
110function 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
124export 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 // IE 11 doesn't support event.relatedTarget, so we use the active element
155 // ref instead.
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 // If composite.move(null) has been called, the composite container
167 // will receive focus.
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 // IE11 doesn't support event.relatedTarget, so we use the active
190 // element ref instead.
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 // Composite has been focused as a result of an item receiving focus.
200 // The composite item will move focus back to the composite
201 // container. In this case, we don't want to propagate this
202 // additional event nor call the onFocus handler passed to
203 // <Composite onFocus={...} />.
204 event.stopPropagation();
205 // We keep track of the previous active item element so we can
206 // manually fire a blur event on it later when the focus is moved to
207 // another item on the onBlurCapture event below.
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 // This means that the composite element has been focused while the
221 // composite item has not. For example, by clicking on the
222 // composite element without touching any item, or by tabbing into
223 // the composite element. In this case, we want to trigger focus on
224 // the item, just like it would happen with roving tabindex.
225 // When it receives focus, the composite item will put focus back
226 // on the composite element, in which case hasItemWithFocus will be
227 // true.
228 scheduleUserFocus();
229 }
230 } else if (isSelfTarget(event)) {
231 // When the roving tabindex composite gets intentionally focused (for
232 // example, by clicking directly on it, and not on an item), we make
233 // sure to set the current id to null (which means the composite
234 // itself is focused).
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 // When virtual is set to true, we move focus from the composite
247 // container (this component) to the composite item that is being
248 // selected. Then we move focus back to the composite container. This
249 // is so we can provide the same API as the roving tabindex method,
250 // which means people can attach onFocus/onBlur handlers on the
251 // CompositeItem component regardless of whether it's virtual or not.
252 // This sequence of blurring and focusing items and composite may be
253 // confusing, so we ignore intermediate focus and blurs by stopping its
254 // propagation and not calling the passed onBlur handler (htmlOnBlur).
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 // This is an intermediate blur event: blurring the composite
263 // container to focus an item (nextActiveElement).
264 if (nextActiveElement === currentElement) {
265 // The next active element will be the same as the current item in
266 // the state in two scenarios:
267 // - Moving focus with keyboard: the state is updated before the
268 // blur event is triggered, so here the current item is already
269 // pointing to the next active element.
270 // - Clicking on the current active item with a pointer: this
271 // will trigger blur on the composite element and then the next
272 // active element will be the same as the current item. Clicking on
273 // an item other than the current one doesn't end up here as the
274 // currentItem state will be updated only after it.
275 if (
276 previousElementRef.current &&
277 previousElementRef.current !== nextActiveElement
278 ) {
279 // If there's a previous active item and it's not a click action,
280 // then we fire a blur event on it so it will work just like if
281 // it had DOM focus before (like when using roving tabindex).
282 fireBlurEvent(previousElementRef.current, event);
283 }
284 } else if (currentElement) {
285 // This will be true when the next active element is not the
286 // current element, but there's a current item. This will only
287 // happen when clicking with a pointer on a different item, when
288 // there's already an item selected, in which case currentElement
289 // is the item that is getting blurred, and nextActiveElement is
290 // the item that is being clicked.
291 fireBlurEvent(currentElement, event);
292 }
293 // We want to ignore intermediate blur events, so we stop its
294 // propagation and return early so onFocus will not be called.
295 event.stopPropagation();
296 } else {
297 const targetIsItem = isItem(options.items, event.target);
298 if (!targetIsItem && currentElement) {
299 // If target is not a composite item, it may be the composite
300 // element itself (isSelfTarget) or a tabbable element inside the
301 // composite widget. This may be triggered by clicking outside the
302 // composite widget or by tabbing out of it. In either cases we
303 // want to fire a blur event on the current item.
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 // Composite will only be tabbable by default if the focus is managed
378 // using aria-activedescendant, which requires DOM focus on the container
379 // element (the composite)
380 return { tabIndex: 0, ...tabbableHTMLProps };
381 }
382 return { ...htmlProps, ref: tabbableHTMLProps.ref };
383 },
384});
385
386export 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});