@purpurds/autocomplete 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import React, {
2
+ Children,
3
+ ComponentPropsWithRef,
4
+ ForwardedRef,
5
+ forwardRef,
6
+ isValidElement,
7
+ ReactElement,
8
+ ReactNode,
9
+ ReactPortal,
10
+ } from "react";
11
+ import { checkmark, Icon } from "@purpurds/icon";
12
+ import { Paragraph } from "@purpurds/paragraph";
13
+ import c from "classnames/bind";
14
+
15
+ import styles from "./listbox.module.scss";
16
+
17
+ export * from "./useAutocomplete";
18
+ const cx = c.bind(styles);
19
+
20
+ const rootClassName = "purpur-listbox";
21
+
22
+ type ListboxProps = Omit<ComponentPropsWithRef<"ul">, "role"> & {
23
+ "data-testid"?: string;
24
+ "aria-label": NonNullable<ComponentPropsWithRef<"ul">["aria-label"]>;
25
+ "aria-expanded": NonNullable<ComponentPropsWithRef<"ul">["aria-expanded"]>;
26
+ };
27
+
28
+ const Listbox = forwardRef(
29
+ ({ children, ...listboxProps }: ListboxProps, ref: ForwardedRef<HTMLUListElement>) => (
30
+ <ul
31
+ {...listboxProps}
32
+ ref={ref}
33
+ className={cx(rootClassName, listboxProps.className)}
34
+ role="listbox"
35
+ >
36
+ {Children.toArray(children).filter(isListboxItem)}
37
+ </ul>
38
+ )
39
+ );
40
+ Listbox.displayName = "ListBox";
41
+
42
+ type ListboxItemProps = Omit<ComponentPropsWithRef<"li">, "role"> & {
43
+ "data-testid"?: string;
44
+ highlighted?: boolean;
45
+ hovered?: boolean;
46
+ key?: string;
47
+ selected?: boolean;
48
+ disabled?: boolean;
49
+ noninteractive?: boolean;
50
+ };
51
+
52
+ const ListboxItem = forwardRef((props: ListboxItemProps, ref: ForwardedRef<HTMLLIElement>) => {
53
+ const { disabled, highlighted, hovered, selected, children, noninteractive, ...liProps } = props;
54
+ const className = cx(`${rootClassName}-item`, liProps.className, {
55
+ [`${rootClassName}-item--highlighted`]: highlighted,
56
+ [`${rootClassName}-item--selected`]: selected,
57
+ [`${rootClassName}-item--hovered`]: hovered,
58
+ [`${rootClassName}-item--disabled`]: disabled,
59
+ [`${rootClassName}-item--noninteractive`]: noninteractive,
60
+ });
61
+
62
+ return (
63
+ <li
64
+ {...liProps}
65
+ ref={ref}
66
+ className={className}
67
+ aria-selected={!!selected}
68
+ role="option"
69
+ aria-disabled={!!disabled}
70
+ >
71
+ {typeof children === "string" ? <Paragraph>{children}</Paragraph> : children}
72
+ {selected && <Icon svg={checkmark} size="xs" className={cx(`${rootClassName}-item__icon`)} />}
73
+ </li>
74
+ );
75
+ });
76
+ ListboxItem.displayName = "ListBoxItem";
77
+
78
+ function isListboxItem(
79
+ child:
80
+ | ReactElement
81
+ | Iterable<ReactNode>
82
+ | ReactPortal
83
+ | string
84
+ | number
85
+ | boolean
86
+ | null
87
+ | undefined
88
+ ): child is ReactElement<ListboxItemProps> {
89
+ return isValidElement<ListboxItemProps>(child) && child?.type === ListboxItem;
90
+ }
91
+
92
+ const Root = Listbox;
93
+ const Item = ListboxItem;
94
+
95
+ export { Item, Root };
96
+ export type { ListboxItemProps, ListboxProps };
@@ -0,0 +1,302 @@
1
+ import { ComponentPropsWithRef, CSSProperties, ReactNode, useRef, useState } from "react";
2
+
3
+ import { ListboxItemProps, ListboxProps } from "./listbox";
4
+ import { useMutableRefObject, useOnClickOutside } from "./utils";
5
+
6
+ export type Option = {
7
+ label: string;
8
+ id: string;
9
+ value?: string;
10
+ disabled?: boolean;
11
+ };
12
+
13
+ export type UseAutocompleteOptions<T extends Option> = {
14
+ /*
15
+ * Set to highlight the first option in the listbox when the input is focused.
16
+ */
17
+ highlightFirstOption?: boolean;
18
+ /*
19
+ * The default input value. Only to use when the input is uncontrolled.
20
+ */
21
+ defaultInputValue?: string;
22
+ /*
23
+ * The input value. Use this to control the input value.
24
+ */
25
+ inputValue?: string;
26
+ /*
27
+ * Invoked for each option. Use to control which options are shown.
28
+ */
29
+ filterOption?: (inputValue: string | undefined, option: T) => boolean;
30
+ /*
31
+ * Id will be used to prefix id:s of all elements.
32
+ */
33
+ id: string;
34
+ /*
35
+ * The label of the listbox (dropdown) displaying the options.
36
+ */
37
+ listboxLabel: string;
38
+ /*
39
+ * The height of the listbox. Number will be interpreted as px value. defaults to `calc(2 * var(--purpur-spacing-1200))`.
40
+ */
41
+ listboxMaxHeight?: string | number;
42
+ /*
43
+ * Shown when there are no options to show. Will be displayed inside a listbox item.
44
+ */
45
+ noOptionsText?: ReactNode;
46
+ /*
47
+ * Event handler invoked when the input value changes.
48
+ */
49
+ onInputChange?: (value: string) => void;
50
+ /*
51
+ * Set to open the listbox when input gets focus.
52
+ */
53
+ openOnFocus?: boolean;
54
+ /*
55
+ * Event handler invoked when an option is selected.
56
+ */
57
+ onSelect?: (option: T | undefined) => void;
58
+ /*
59
+ * The list of options. Could include custom props.
60
+ */
61
+ options: T[];
62
+ /*
63
+ * The selected option.
64
+ */
65
+ selectedOption?: T;
66
+ ["data-testid"]?: string;
67
+ };
68
+
69
+ export const useAutocomplete = <T extends Option>({
70
+ highlightFirstOption,
71
+ defaultInputValue,
72
+ inputValue,
73
+ filterOption,
74
+ id,
75
+ listboxLabel,
76
+ listboxMaxHeight,
77
+ onInputChange,
78
+ openOnFocus,
79
+ noOptionsText,
80
+ onSelect,
81
+ options,
82
+ selectedOption,
83
+ ["data-testid"]: dataTestid,
84
+ }: UseAutocompleteOptions<T>) => {
85
+ const [internalInputValue, setInternalInputValue] = useState(
86
+ (typeof inputValue === "string" ? inputValue : defaultInputValue) || selectedOption?.label
87
+ );
88
+ const definiteInputValue = typeof inputValue === "string" ? inputValue : internalInputValue;
89
+ const [highlightedOption, setHighlightedOption] = useState<
90
+ (T & { isSetByClickEvent?: false }) | undefined
91
+ >(highlightFirstOption ? options[0] : undefined);
92
+ const inputRef = useRef<HTMLInputElement>(null);
93
+ const internalRef = useMutableRefObject<HTMLDivElement | null>(null);
94
+ const listboxRef = useRef<HTMLUListElement>(null);
95
+ const optionRefs = useRef<Record<string, HTMLLIElement>>({});
96
+ const [listboxIsOpen, setListboxIsOpen] = useState(false);
97
+
98
+ function getTestId(name: string) {
99
+ return dataTestid ? `${dataTestid}-${name}` : undefined;
100
+ }
101
+
102
+ const closeListbox = () => {
103
+ setListboxIsOpen(false);
104
+ setHighlightedOption(undefined);
105
+ };
106
+
107
+ useOnClickOutside(internalRef.current, closeListbox);
108
+
109
+ const openListbox = ({ eventType }: { eventType: "CLICK" | "KEYBOARD" }) => {
110
+ setListboxIsOpen(true);
111
+ if (selectedOption) {
112
+ requestAnimationFrame(() => {
113
+ const isSetByClickEvent = eventType === "CLICK";
114
+ setHighlightedOption({ ...selectedOption, isSetByClickEvent });
115
+ scrollOptionIntoView(optionRefs.current[selectedOption.id]);
116
+ });
117
+ }
118
+ };
119
+
120
+ const filterOptions = (searchTerm: string | undefined) => {
121
+ if (filterOption) {
122
+ return options.filter((option) => filterOption(searchTerm, option));
123
+ }
124
+
125
+ if (!searchTerm) {
126
+ return options;
127
+ }
128
+
129
+ const searchTermChunks = searchTerm.toUpperCase().split(" ") || [];
130
+ return options.filter((option) =>
131
+ searchTermChunks.every((chunk) =>
132
+ (option.value || option.label).toUpperCase().includes(chunk)
133
+ )
134
+ );
135
+ };
136
+
137
+ const getOptionsToShow = (searchTerm: string | undefined): (T | undefined)[] => {
138
+ return selectedOption && selectedOption?.label === searchTerm
139
+ ? options
140
+ : filterOptions(searchTerm);
141
+ };
142
+
143
+ const optionsToShow = getOptionsToShow(definiteInputValue);
144
+
145
+ const populateInputField = (value: string) => {
146
+ onInputChange?.(value);
147
+ setInternalInputValue(value);
148
+ };
149
+
150
+ const scrollOptionIntoView = (option: HTMLLIElement | undefined) => {
151
+ if (option) {
152
+ const optionRect = option.getBoundingClientRect();
153
+ const listboxRect = listboxRef.current?.getBoundingClientRect() || { top: 0, bottom: 0 };
154
+ const isOptionOutsideView =
155
+ optionRect.top < listboxRect.top || optionRect.bottom > listboxRect.bottom;
156
+
157
+ if (isOptionOutsideView) {
158
+ option.scrollIntoView({ block: "nearest" });
159
+ }
160
+ }
161
+ };
162
+
163
+ const selectOption = (option: T | undefined) => {
164
+ if (option) {
165
+ inputRef.current?.focus();
166
+ populateInputField(option.label);
167
+ onSelect?.(option);
168
+ }
169
+ closeListbox();
170
+ };
171
+
172
+ const findNextOption = (key: "ArrowUp" | "ArrowDown"): T | undefined => {
173
+ const index =
174
+ highlightFirstOption && !highlightedOption
175
+ ? 0
176
+ : optionsToShow.findIndex((option) => option && highlightedOption?.id === option.id);
177
+
178
+ const optionsLength = optionsToShow.length;
179
+
180
+ return key === "ArrowDown"
181
+ ? optionsToShow[(index ?? -1) + 1] || optionsToShow[0]
182
+ : optionsToShow[(index ?? optionsLength) - 1] || optionsToShow[optionsLength - 1];
183
+ };
184
+
185
+ const showListbox = listboxIsOpen && (!!optionsToShow.length || !!noOptionsText);
186
+
187
+ const highlightNextOption = (key: "ArrowUp" | "ArrowDown") => {
188
+ !showListbox && openListbox({ eventType: "KEYBOARD" });
189
+ const nextOption = findNextOption(key);
190
+
191
+ setHighlightedOption(nextOption);
192
+ nextOption && scrollOptionIntoView(optionRefs.current[nextOption.id]);
193
+ };
194
+
195
+ const handleOnKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
196
+ switch (e.key) {
197
+ case "ArrowUp":
198
+ case "ArrowDown":
199
+ e.preventDefault(); // Preventing default to not move cursor in input
200
+ highlightNextOption(e.key);
201
+ break;
202
+ case "Enter": {
203
+ const optionToSelect = highlightedOption || (highlightFirstOption ? options[0] : undefined);
204
+ showListbox && selectOption(optionToSelect);
205
+ !showListbox && openListbox({ eventType: "KEYBOARD" });
206
+ break;
207
+ }
208
+ case "Escape":
209
+ case "Tab":
210
+ closeListbox();
211
+ break;
212
+ }
213
+ };
214
+
215
+ const handleOnChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
216
+ const nextOptionsToShow = getOptionsToShow(e.target.value);
217
+ populateInputField(e.target.value);
218
+ setHighlightedOption(undefined);
219
+ !showListbox && openListbox({ eventType: "KEYBOARD" });
220
+ highlightFirstOption &&
221
+ nextOptionsToShow[0] &&
222
+ scrollOptionIntoView(optionRefs.current[nextOptionsToShow[0].id]);
223
+ };
224
+
225
+ const handleOnMouseDown: React.MouseEventHandler<HTMLInputElement> = () => {
226
+ showListbox ? closeListbox() : openListbox({ eventType: "CLICK" });
227
+ };
228
+
229
+ const handleOnFocus = () => {
230
+ !listboxIsOpen && openOnFocus && openListbox({ eventType: "KEYBOARD" });
231
+ inputRef.current?.select();
232
+ };
233
+
234
+ const listboxStyle: CSSProperties = {
235
+ maxHeight: typeof listboxMaxHeight === "number" ? `${listboxMaxHeight}px` : listboxMaxHeight,
236
+ };
237
+
238
+ const listboxProps: ListboxProps = {
239
+ "aria-label": listboxLabel,
240
+ "aria-expanded": showListbox,
241
+ "data-testid": getTestId("listbox"),
242
+ id: `${id}-listbox`,
243
+ ref: listboxRef,
244
+ onMouseLeave: () => setHighlightedOption(undefined),
245
+ style: listboxMaxHeight ? listboxStyle : undefined,
246
+ };
247
+
248
+ const createListboxItemId = (option: T) => `${id}-listbox-item-${option.id}`;
249
+
250
+ const getListBoxItemProps = (option: T, index: number): ListboxItemProps => {
251
+ const handleOnMouseMove = () =>
252
+ option.id !== highlightedOption?.id &&
253
+ setHighlightedOption({ ...option, isSetByClickEvent: true });
254
+
255
+ const highlighted =
256
+ (option.id === highlightedOption?.id ||
257
+ (!!highlightFirstOption && !highlightedOption && index === 0)) &&
258
+ !highlightedOption?.isSetByClickEvent;
259
+
260
+ return {
261
+ "data-testid": getTestId(`listbox-item-${option.id}`),
262
+ id: createListboxItemId(option),
263
+ key: option.id,
264
+ onMouseMove: handleOnMouseMove,
265
+ onMouseUp: () => selectOption(option),
266
+ ref: (el) => el && (optionRefs.current[option.id] = el),
267
+ tabIndex: -1,
268
+ selected: option.id === selectedOption?.id,
269
+ disabled: option.disabled,
270
+ highlighted,
271
+ hovered: option.id === highlightedOption?.id && !!highlightedOption?.isSetByClickEvent,
272
+ };
273
+ };
274
+
275
+ const inputProps: ComponentPropsWithRef<"input"> & { "data-testid"?: string } = {
276
+ "aria-activedescendant": highlightedOption ? createListboxItemId(highlightedOption) : undefined,
277
+ "aria-autocomplete": "list",
278
+ "aria-controls": listboxProps.id,
279
+ "aria-expanded": showListbox,
280
+ "data-testid": getTestId("input"),
281
+ autoComplete: "off",
282
+ id: `${id}-input`,
283
+ onChange: handleOnChange,
284
+ onMouseDown: handleOnMouseDown,
285
+ onFocus: handleOnFocus,
286
+ onKeyDown: handleOnKeyDown,
287
+ ref: inputRef,
288
+ role: "combobox",
289
+ value: definiteInputValue,
290
+ };
291
+
292
+ return {
293
+ id,
294
+ inputProps,
295
+ internalRef,
296
+ optionsToShow,
297
+ showListbox,
298
+ noOptionsText,
299
+ getListBoxItemProps,
300
+ listboxProps,
301
+ };
302
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { MutableRefObject, useCallback, useEffect, useRef } from "react";
2
+
3
+ // Used to "merge" "intersection types". Used to get comments on the props to the docs.
4
+ export type Prettify<T> = {
5
+ [K in keyof T]: T[K];
6
+ } & {}; // eslint-disable-line @typescript-eslint/ban-types
7
+
8
+ export const useMutableRefObject = <T>(value: T): MutableRefObject<T> => {
9
+ return useRef<T>(value) as MutableRefObject<T>;
10
+ };
11
+
12
+ export const useOnClickOutside = (element: HTMLElement | null, callback: () => void) => {
13
+ const handleClickOutside = useCallback(
14
+ (event: MouseEvent) => {
15
+ if (element && !element.contains(event.target as Node)) {
16
+ callback();
17
+ }
18
+ },
19
+ [callback, element]
20
+ );
21
+
22
+ useEffect(() => {
23
+ document.addEventListener("mousedown", handleClickOutside);
24
+ return () => {
25
+ document.removeEventListener("mousedown", handleClickOutside);
26
+ };
27
+ }, [handleClickOutside]);
28
+ };