UNPKG

11.1 kBPlain TextView Raw
1import * as React from "react";
2import { createComponent } from "reakit-system/createComponent";
3import { createHook } from "reakit-system/createHook";
4import { warning } from "reakit-warning";
5import { useForkRef } from "reakit-utils/useForkRef";
6import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
7import { getDocument } from "reakit-utils/getDocument";
8import { isTextField } from "reakit-utils/isTextField";
9import { useLiveRef } from "reakit-utils/useLiveRef";
10import { isPortalEvent } from "reakit-utils/isPortalEvent";
11import { isSelfTarget } from "reakit-utils/isSelfTarget";
12import { ensureFocus } from "reakit-utils/ensureFocus";
13import {
14 ClickableOptions,
15 ClickableHTMLProps,
16 useClickable,
17} from "../Clickable/Clickable";
18import {
19 unstable_useId,
20 unstable_IdOptions,
21 unstable_IdHTMLProps,
22} from "../Id/Id";
23import { CompositeStateReturn } from "./CompositeState";
24import { setTextFieldValue } from "./__utils/setTextFieldValue";
25import { getCurrentId } from "./__utils/getCurrentId";
26import { Item } from "./__utils/types";
27import { COMPOSITE_ITEM_KEYS } from "./__keys";
28import { userFocus, setUserFocus, hasUserFocus } from "./__utils/userFocus";
29
30export type CompositeItemOptions = ClickableOptions &
31 unstable_IdOptions &
32 Pick<
33 Partial<CompositeStateReturn>,
34 | "unstable_virtual"
35 | "baseId"
36 | "orientation"
37 | "unstable_moves"
38 | "unstable_hasActiveWidget"
39 > &
40 Pick<
41 CompositeStateReturn,
42 | "items"
43 | "currentId"
44 | "registerItem"
45 | "unregisterItem"
46 | "setCurrentId"
47 | "next"
48 | "previous"
49 | "up"
50 | "down"
51 | "first"
52 | "last"
53 >;
54
55export type CompositeItemHTMLProps = ClickableHTMLProps & unstable_IdHTMLProps;
56
57export type CompositeItemProps = CompositeItemOptions & CompositeItemHTMLProps;
58
59function getWidget(itemElement: Element) {
60 return itemElement.querySelector<HTMLElement>("[data-composite-item-widget]");
61}
62
63function useItem(options: CompositeItemOptions) {
64 return React.useMemo(
65 () => options.items?.find((item) => options.id && item.id === options.id),
66 [options.items, options.id]
67 );
68}
69
70function targetIsAnotherItem(event: React.SyntheticEvent, items: Item[]) {
71 if (isSelfTarget(event)) return false;
72 for (const item of items) {
73 if (item.ref.current === event.target) {
74 return true;
75 }
76 }
77 return false;
78}
79
80export const useCompositeItem = createHook<
81 CompositeItemOptions,
82 CompositeItemHTMLProps
83>({
84 name: "CompositeItem",
85 compose: [useClickable, unstable_useId],
86 keys: COMPOSITE_ITEM_KEYS,
87
88 propsAreEqual(prev, next) {
89 if (!next.id || prev.id !== next.id) {
90 return useClickable.unstable_propsAreEqual(prev, next);
91 }
92 const {
93 currentId: prevCurrentId,
94 unstable_moves: prevMoves,
95 ...prevProps
96 } = prev;
97 const {
98 currentId: nextCurrentId,
99 unstable_moves: nextMoves,
100 ...nextProps
101 } = next;
102 if (nextCurrentId !== prevCurrentId) {
103 if (next.id === nextCurrentId || next.id === prevCurrentId) {
104 return false;
105 }
106 } else if (prevMoves !== nextMoves) {
107 return false;
108 }
109 return useClickable.unstable_propsAreEqual(prevProps, nextProps);
110 },
111
112 useOptions(options) {
113 return {
114 ...options,
115 id: options.id,
116 currentId: getCurrentId(options),
117 unstable_clickOnSpace: options.unstable_hasActiveWidget
118 ? false
119 : options.unstable_clickOnSpace,
120 };
121 },
122
123 useProps(
124 options,
125 {
126 ref: htmlRef,
127 tabIndex: htmlTabIndex = 0,
128 onMouseDown: htmlOnMouseDown,
129 onFocus: htmlOnFocus,
130 onBlurCapture: htmlOnBlurCapture,
131 onKeyDown: htmlOnKeyDown,
132 onClick: htmlOnClick,
133 ...htmlProps
134 }
135 ) {
136 const ref = React.useRef<HTMLElement>(null);
137 const { id } = options;
138 const trulyDisabled = options.disabled && !options.focusable;
139 const isCurrentItem = options.currentId === id;
140 const isCurrentItemRef = useLiveRef(isCurrentItem);
141 const hasFocusedComposite = React.useRef(false);
142 const item = useItem(options);
143 const onMouseDownRef = useLiveRef(htmlOnMouseDown);
144 const onFocusRef = useLiveRef(htmlOnFocus);
145 const onBlurCaptureRef = useLiveRef(htmlOnBlurCapture);
146 const onKeyDownRef = useLiveRef(htmlOnKeyDown);
147 const onClickRef = useLiveRef(htmlOnClick);
148 const shouldTabIndex =
149 (!options.unstable_virtual &&
150 !options.unstable_hasActiveWidget &&
151 isCurrentItem) ||
152 // We don't want to set tabIndex="-1" when using CompositeItem as a
153 // standalone component, without state props.
154 !options.items?.length;
155
156 React.useEffect(() => {
157 if (!id) return undefined;
158 options.registerItem?.({ id, ref, disabled: !!trulyDisabled });
159 return () => {
160 options.unregisterItem?.(id);
161 };
162 }, [id, trulyDisabled, options.registerItem, options.unregisterItem]);
163
164 React.useEffect(() => {
165 const element = ref.current;
166 if (!element) {
167 warning(
168 true,
169 "Can't focus composite item component because `ref` wasn't passed to component.",
170 "See https://reakit.io/docs/composite"
171 );
172 return;
173 }
174 // `moves` will be incremented whenever next, previous, up, down, first,
175 // last or move have been called. This means that the composite item will
176 // be focused whenever some of these functions are called. We're using
177 // isCurrentItemRef instead of isCurrentItem because we don't want to
178 // focus the item if isCurrentItem changes (and options.moves doesn't).
179 if (options.unstable_moves && isCurrentItemRef.current) {
180 userFocus(element);
181 }
182 }, [options.unstable_moves]);
183
184 const onMouseDown = React.useCallback(
185 (event: React.MouseEvent<HTMLElement>) => {
186 onMouseDownRef.current?.(event);
187 setUserFocus(event.currentTarget, true);
188 },
189 []
190 );
191
192 const onFocus = React.useCallback(
193 (event: React.FocusEvent<HTMLElement>) => {
194 const shouldFocusComposite = hasUserFocus(event.currentTarget);
195 setUserFocus(event.currentTarget, false);
196 onFocusRef.current?.(event);
197 if (event.defaultPrevented) return;
198 if (isPortalEvent(event)) return;
199 if (!id) return;
200 if (targetIsAnotherItem(event, options.items)) return;
201 options.setCurrentId?.(id);
202 // When using aria-activedescendant, we want to make sure that the
203 // composite container receives focus, not the composite item.
204 // But we don't want to do this if the target is another focusable
205 // element inside the composite item, such as CompositeItemWidget.
206 if (
207 shouldFocusComposite &&
208 options.unstable_virtual &&
209 options.baseId &&
210 isSelfTarget(event)
211 ) {
212 const { target } = event;
213 const composite = getDocument(target).getElementById(options.baseId);
214 if (composite) {
215 hasFocusedComposite.current = true;
216 ensureFocus(composite);
217 }
218 }
219 },
220 [
221 id,
222 options.items,
223 options.setCurrentId,
224 options.unstable_virtual,
225 options.baseId,
226 ]
227 );
228
229 const onBlurCapture = React.useCallback(
230 (event: React.FocusEvent<HTMLElement>) => {
231 onBlurCaptureRef.current?.(event);
232 if (event.defaultPrevented) return;
233 if (options.unstable_virtual && hasFocusedComposite.current) {
234 // When hasFocusedComposite is true, composite has been focused right
235 // after focusing this item. This is an intermediate blur event, so
236 // we ignore it.
237 hasFocusedComposite.current = false;
238 event.preventDefault();
239 event.stopPropagation();
240 }
241 },
242 [options.unstable_virtual]
243 );
244
245 const onKeyDown = React.useCallback(
246 (event: React.KeyboardEvent<HTMLElement>) => {
247 if (!isSelfTarget(event)) return;
248 const isVertical = options.orientation !== "horizontal";
249 const isHorizontal = options.orientation !== "vertical";
250 const isGrid = !!item?.groupId;
251 const keyMap = {
252 ArrowUp: (isGrid || isVertical) && options.up,
253 ArrowRight: (isGrid || isHorizontal) && options.next,
254 ArrowDown: (isGrid || isVertical) && options.down,
255 ArrowLeft: (isGrid || isHorizontal) && options.previous,
256 Home: () => {
257 if (!isGrid || event.ctrlKey) {
258 options.first?.();
259 } else {
260 options.previous?.(true);
261 }
262 },
263 End: () => {
264 if (!isGrid || event.ctrlKey) {
265 options.last?.();
266 } else {
267 options.next?.(true);
268 }
269 },
270 PageUp: () => {
271 if (isGrid) {
272 options.up?.(true);
273 } else {
274 options.first?.();
275 }
276 },
277 PageDown: () => {
278 if (isGrid) {
279 options.down?.(true);
280 } else {
281 options.last?.();
282 }
283 },
284 };
285 const action = keyMap[event.key as keyof typeof keyMap];
286 if (action) {
287 event.preventDefault();
288 action();
289 return;
290 }
291 onKeyDownRef.current?.(event);
292 if (event.defaultPrevented) return;
293 if (event.key.length === 1 && event.key !== " ") {
294 const widget = getWidget(event.currentTarget);
295 if (widget && isTextField(widget)) {
296 widget.focus();
297 setTextFieldValue(widget, "");
298 }
299 } else if (event.key === "Delete" || event.key === "Backspace") {
300 const widget = getWidget(event.currentTarget);
301 if (widget && isTextField(widget)) {
302 event.preventDefault();
303 setTextFieldValue(widget, "");
304 }
305 }
306 },
307 [
308 options.orientation,
309 item,
310 options.up,
311 options.next,
312 options.down,
313 options.previous,
314 options.first,
315 options.last,
316 ]
317 );
318
319 const onClick = React.useCallback(
320 (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
321 onClickRef.current?.(event);
322 if (event.defaultPrevented) return;
323 const element = event.currentTarget;
324 const widget = getWidget(element);
325 if (widget && !hasFocusWithin(widget)) {
326 // If there's a widget inside the composite item, we make sure it's
327 // focused when pressing enter, space or clicking on the composite item.
328 widget.focus();
329 }
330 },
331 []
332 );
333
334 return {
335 ref: useForkRef(ref, htmlRef),
336 id,
337 tabIndex: shouldTabIndex ? htmlTabIndex : -1,
338 "aria-selected":
339 options.unstable_virtual && isCurrentItem ? true : undefined,
340 onMouseDown,
341 onFocus,
342 onBlurCapture,
343 onKeyDown,
344 onClick,
345 ...htmlProps,
346 };
347 },
348});
349
350export const CompositeItem = createComponent({
351 as: "button",
352 memo: true,
353 useHook: useCompositeItem,
354});