1 | import * as React from "react";
|
2 | import { createComponent } from "reakit-system/createComponent";
|
3 | import { createHook } from "reakit-system/createHook";
|
4 | import { warning } from "reakit-warning";
|
5 | import { useForkRef } from "reakit-utils/useForkRef";
|
6 | import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
|
7 | import { getDocument } from "reakit-utils/getDocument";
|
8 | import { isTextField } from "reakit-utils/isTextField";
|
9 | import { useLiveRef } from "reakit-utils/useLiveRef";
|
10 | import { isPortalEvent } from "reakit-utils/isPortalEvent";
|
11 | import { isSelfTarget } from "reakit-utils/isSelfTarget";
|
12 | import { ensureFocus } from "reakit-utils/ensureFocus";
|
13 | import {
|
14 | ClickableOptions,
|
15 | ClickableHTMLProps,
|
16 | useClickable,
|
17 | } from "../Clickable/Clickable";
|
18 | import {
|
19 | unstable_useId,
|
20 | unstable_IdOptions,
|
21 | unstable_IdHTMLProps,
|
22 | } from "../Id/Id";
|
23 | import { CompositeStateReturn } from "./CompositeState";
|
24 | import { setTextFieldValue } from "./__utils/setTextFieldValue";
|
25 | import { getCurrentId } from "./__utils/getCurrentId";
|
26 | import { Item } from "./__utils/types";
|
27 | import { COMPOSITE_ITEM_KEYS } from "./__keys";
|
28 | import { userFocus, setUserFocus, hasUserFocus } from "./__utils/userFocus";
|
29 |
|
30 | export 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 |
|
55 | export type CompositeItemHTMLProps = ClickableHTMLProps & unstable_IdHTMLProps;
|
56 |
|
57 | export type CompositeItemProps = CompositeItemOptions & CompositeItemHTMLProps;
|
58 |
|
59 | function getWidget(itemElement: Element) {
|
60 | return itemElement.querySelector<HTMLElement>("[data-composite-item-widget]");
|
61 | }
|
62 |
|
63 | function 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 |
|
70 | function 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 |
|
80 | export 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 |
|
153 |
|
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 |
|
175 |
|
176 |
|
177 |
|
178 |
|
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 |
|
203 |
|
204 |
|
205 |
|
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 |
|
235 |
|
236 |
|
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 |
|
327 |
|
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 |
|
350 | export const CompositeItem = createComponent({
|
351 | as: "button",
|
352 | memo: true,
|
353 | useHook: useCompositeItem,
|
354 | });
|