UNPKG

11.6 kBPlain TextView Raw
1import * as React from "react";
2import { createComponent } from "reakit-system/createComponent";
3import { createHook } from "reakit-system/createHook";
4import { useLiveRef } from "reakit-utils/useLiveRef";
5import { useForkRef } from "reakit-utils/useForkRef";
6import { warning } from "reakit-warning";
7import { useUpdateEffect } from "reakit-utils/useUpdateEffect";
8import {
9 CompositeOptions,
10 CompositeHTMLProps,
11 useComposite,
12} from "../Composite/Composite";
13import { COMBOBOX_KEYS } from "./__keys";
14import { unstable_ComboboxStateReturn } from "./ComboboxState";
15import { getMenuId } from "./__utils/getMenuId";
16
17function getControls(baseId: string, ariaControls?: string) {
18 const menuId = getMenuId(baseId);
19 if (ariaControls) {
20 return `${ariaControls} ${menuId}`;
21 }
22 return menuId;
23}
24
25function getAutocomplete(options: unstable_ComboboxOptions) {
26 if (options.list && options.inline) return "both";
27 if (options.list) return "list";
28 if (options.inline) return "inline";
29 return "none";
30}
31
32function isFirstItemAutoSelected(
33 items: unstable_ComboboxOptions["items"],
34 autoSelect: unstable_ComboboxOptions["autoSelect"],
35 currentId: unstable_ComboboxOptions["currentId"]
36) {
37 if (!autoSelect) return false;
38 const firstItem = items.find((item) => !item.disabled);
39 return currentId && firstItem?.id === currentId;
40}
41
42function hasCompletionString(inputValue: string, currentValue?: string) {
43 return (
44 !!currentValue &&
45 currentValue.length > inputValue.length &&
46 currentValue.toLowerCase().indexOf(inputValue.toLowerCase()) === 0
47 );
48}
49
50function getCompletionString(inputValue: string, currentValue?: string) {
51 if (!currentValue) return "";
52 const index = currentValue.toLowerCase().indexOf(inputValue.toLowerCase());
53 return currentValue.slice(index + inputValue.length);
54}
55
56function useValue(options: unstable_ComboboxOptions) {
57 return React.useMemo(() => {
58 if (!options.inline) {
59 return options.inputValue;
60 }
61 const firstItemAutoSelected = isFirstItemAutoSelected(
62 options.items,
63 options.autoSelect,
64 options.currentId
65 );
66 if (firstItemAutoSelected) {
67 if (hasCompletionString(options.inputValue, options.currentValue)) {
68 return (
69 options.inputValue +
70 getCompletionString(options.inputValue, options.currentValue)
71 );
72 }
73 return options.inputValue;
74 }
75 return options.currentValue || options.inputValue;
76 }, [
77 options.inline,
78 options.inputValue,
79 options.autoSelect,
80 options.items,
81 options.currentId,
82 options.currentValue,
83 ]);
84}
85
86function getFirstEnabledItemId(items: unstable_ComboboxOptions["items"]) {
87 return items.find((item) => !item.disabled)?.id;
88}
89
90export const unstable_useCombobox = createHook<
91 unstable_ComboboxOptions,
92 unstable_ComboboxHTMLProps
93>({
94 name: "Combobox",
95 compose: useComposite,
96 keys: COMBOBOX_KEYS,
97
98 useOptions({ menuRole = "listbox", hideOnEsc = true, ...options }) {
99 return { menuRole, hideOnEsc, ...options };
100 },
101
102 useProps(
103 options,
104 {
105 ref: htmlRef,
106 onKeyDown: htmlOnKeyDown,
107 onKeyPress: htmlOnKeyPress,
108 onChange: htmlOnChange,
109 onClick: htmlOnClick,
110 onBlur: htmlOnBlur,
111 "aria-controls": ariaControls,
112 ...htmlProps
113 }
114 ) {
115 const ref = React.useRef<HTMLInputElement>(null);
116 const [updated, update] = React.useReducer(() => ({}), {});
117 const onKeyDownRef = useLiveRef(htmlOnKeyDown);
118 const onKeyPressRef = useLiveRef(htmlOnKeyPress);
119 const onChangeRef = useLiveRef(htmlOnChange);
120 const onClickRef = useLiveRef(htmlOnClick);
121 const onBlurRef = useLiveRef(htmlOnBlur);
122 const value = useValue(options);
123 const hasInsertedTextRef = React.useRef(false);
124
125 // Completion string
126 React.useEffect(() => {
127 if (!options.inline) return;
128 if (!options.autoSelect) return;
129 if (!options.currentValue) return;
130 if (options.currentId !== getFirstEnabledItemId(options.items)) return;
131 if (!hasCompletionString(options.inputValue, options.currentValue)) {
132 return;
133 }
134 const element = ref.current;
135 warning(
136 !element,
137 "Can't auto select combobox because `ref` wasn't passed to the component",
138 "See https://reakit.io/docs/combobox"
139 );
140 element?.setSelectionRange(
141 options.inputValue.length,
142 options.currentValue.length
143 );
144 }, [
145 updated,
146 options.inline,
147 options.autoSelect,
148 options.currentValue,
149 options.inputValue,
150 options.currentId,
151 options.items,
152 ]);
153
154 // Auto select on type
155 useUpdateEffect(() => {
156 if (
157 options.autoSelect &&
158 options.items.length &&
159 hasInsertedTextRef.current
160 ) {
161 // If autoSelect is set to true and the last change was a text
162 // insertion, we want to automatically focus on the first suggestion.
163 // This effect will run both when inputValue changes and when items
164 // change so we can also catch async items.
165 options.setCurrentId(undefined);
166 } else {
167 // Without autoSelect, we'll always blur the combobox option and move
168 // focus onto the combobox input.
169 options.setCurrentId(null);
170 }
171 }, [
172 options.items,
173 options.inputValue,
174 options.autoSelect,
175 options.setCurrentId,
176 ]);
177
178 const onKeyDown = React.useCallback(
179 (event: React.KeyboardEvent<HTMLInputElement>) => {
180 onKeyDownRef.current?.(event);
181 // Resets the reference on key down so we can figure it out later on
182 // key press.
183 hasInsertedTextRef.current = false;
184 if (event.defaultPrevented) return;
185 if (event.key === "Escape" && options.hideOnEsc) {
186 options.hide?.();
187 }
188 },
189 [options.hideOnEsc, options.hide]
190 );
191
192 const onKeyPress = React.useCallback(
193 (event: React.KeyboardEvent<HTMLInputElement>) => {
194 onKeyPressRef.current?.(event);
195 // onKeyPress will catch only printable character presses, so we skip
196 // text removal and paste.
197 hasInsertedTextRef.current = true;
198 },
199 []
200 );
201
202 const onChange = React.useCallback(
203 (event: React.ChangeEvent<HTMLInputElement>) => {
204 onChangeRef.current?.(event);
205 if (event.defaultPrevented) return;
206 options.show?.();
207 options.setInputValue?.(event.target.value);
208 update();
209 if (!options.autoSelect || !hasInsertedTextRef.current) {
210 // If autoSelect is not set or it's not an insertion of text, focus
211 // on the combobox input after changing the value.
212 options.setCurrentId?.(null);
213 } else {
214 // Selects first item
215 options.setCurrentId?.(undefined);
216 }
217 },
218 [
219 options.show,
220 options.autoSelect,
221 options.setCurrentId,
222 options.setInputValue,
223 ]
224 );
225
226 const onClick = React.useCallback(
227 (event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
228 onClickRef.current?.(event);
229 if (event.defaultPrevented) return;
230 // https://github.com/reakit/reakit/issues/808
231 if (!options.minValueLength || value.length >= options.minValueLength) {
232 options.show?.();
233 }
234 options.setCurrentId?.(null);
235 options.setInputValue(value);
236 },
237 [
238 options.show,
239 options.setCurrentId,
240 options.setInputValue,
241 options.minValueLength,
242 value,
243 ]
244 );
245
246 const onBlur = React.useCallback(
247 (event: React.FocusEvent<HTMLInputElement>) => {
248 onBlurRef.current?.(event);
249 if (event.defaultPrevented) return;
250 options.setInputValue(value);
251 },
252 [options.setInputValue, value]
253 );
254
255 return {
256 ref: useForkRef(ref, useForkRef(options.unstable_referenceRef, htmlRef)),
257 role: "combobox",
258 autoComplete: "off",
259 "aria-controls": getControls(options.baseId, ariaControls),
260 "aria-haspopup": options.menuRole,
261 "aria-expanded": options.visible,
262 "aria-autocomplete": getAutocomplete(options),
263 value,
264 onKeyDown,
265 onKeyPress,
266 onChange,
267 onClick,
268 onBlur,
269 ...htmlProps,
270 };
271 },
272
273 useComposeProps(
274 options,
275 {
276 onKeyUp,
277 onKeyDownCapture: htmlOnKeyDownCapture,
278 onKeyDown: htmlOnKeyDown,
279 ...htmlProps
280 }
281 ) {
282 const compositeHTMLProps = useComposite(options, htmlProps, true);
283 const onKeyDownCaptureRef = useLiveRef(htmlOnKeyDownCapture);
284 const onKeyDownRef = useLiveRef(htmlOnKeyDown);
285
286 const onKeyDownCapture = React.useCallback(
287 (event: React.KeyboardEvent<HTMLInputElement>) => {
288 onKeyDownCaptureRef.current?.(event);
289 if (event.defaultPrevented) return;
290 if (options.menuRole !== "grid") {
291 // If menu is a one-dimensional list and there's an option with
292 // focus, we don't want Home/End and printable characters to perform
293 // actions on the option, only on the combobox input.
294 if (event.key === "Home") return;
295 if (event.key === "End") return;
296 }
297 if (event.key.length === 1) return;
298 // Composite's onKeyDownCapture will proxy this event to the active
299 // item.
300 compositeHTMLProps.onKeyDownCapture?.(event);
301 },
302 [options.menuRole, compositeHTMLProps.onKeyDownCapture]
303 );
304
305 const onKeyDown = React.useCallback(
306 (event: React.KeyboardEvent<HTMLInputElement>) => {
307 onKeyDownRef.current?.(event);
308 if (event.defaultPrevented) return;
309 const onlyInputHasFocus = options.currentId === null;
310 if (!onlyInputHasFocus) return;
311 // Do not perform list actions when pressing horizontal arrow keys when
312 // focusing the combobox input while no option has focus.
313 if (event.key === "ArrowLeft") return;
314 if (event.key === "ArrowRight") return;
315 if (event.key === "Home") return;
316 if (event.key === "End") return;
317 if (
318 !event.ctrlKey &&
319 !event.altKey &&
320 !event.shiftKey &&
321 !event.metaKey &&
322 (event.key === "ArrowUp" ||
323 event.key === "ArrowDown" ||
324 event.key.length === 1)
325 ) {
326 // Up/Down arrow keys and printable characters should open the
327 // combobox popover.
328 options.show?.();
329 }
330 compositeHTMLProps.onKeyDown?.(event);
331 },
332 [options.currentId, options.show, compositeHTMLProps.onKeyDown]
333 );
334
335 return {
336 ...compositeHTMLProps,
337 onKeyDownCapture,
338 onKeyDown,
339 onKeyUp,
340 };
341 },
342});
343
344export const unstable_Combobox = createComponent({
345 as: "input",
346 memo: true,
347 useHook: unstable_useCombobox,
348});
349
350export type unstable_ComboboxOptions = CompositeOptions &
351 Pick<
352 Partial<unstable_ComboboxStateReturn>,
353 | "currentValue"
354 | "menuRole"
355 | "list"
356 | "inline"
357 | "autoSelect"
358 | "visible"
359 | "show"
360 | "hide"
361 | "unstable_referenceRef"
362 | "minValueLength"
363 > &
364 Pick<
365 unstable_ComboboxStateReturn,
366 "baseId" | "inputValue" | "setInputValue"
367 > & {
368 /**
369 * When enabled, user can hide the combobox popover by pressing
370 * <kbd>Esc</kbd> while focusing on the combobox input.
371 * @default true
372 */
373 hideOnEsc?: boolean;
374 };
375
376export type unstable_ComboboxHTMLProps = CompositeHTMLProps &
377 React.InputHTMLAttributes<any>;
378
379export type unstable_ComboboxProps = unstable_ComboboxOptions &
380 unstable_ComboboxHTMLProps;