Skip to content

Commit 8c5083f

Browse files
authored
Merge branch 'main' into WD-35803-track-featured-snap
2 parents 4453e84 + 80b21a5 commit 8c5083f

13 files changed

Lines changed: 755 additions & 198 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { FC, useEffect, useReducer, useRef } from "react";
2+
import Downshift, { DownshiftState, StateChangeOptions } from "downshift";
3+
import { Icon } from "@canonical/react-components";
4+
5+
export interface ComboBoxItem {
6+
value: string;
7+
label: string;
8+
}
9+
10+
export interface ComboBoxProps {
11+
options: ComboBoxItem[];
12+
value: ComboBoxItem["value"];
13+
required?: boolean;
14+
onChange?: (value: ComboBoxItem["value"] | null) => void;
15+
onSearch?: (item: ComboBoxItem, inputValue: string) => boolean;
16+
placeholder?: string;
17+
label?: string;
18+
labelClassName?: string;
19+
}
20+
21+
interface ComboBoxState extends DownshiftState<ComboBoxItem> {
22+
filteredOptions: ComboBoxItem[];
23+
showAllOptions: boolean;
24+
}
25+
26+
// default filter function, case-insensitive partial match between item.label and inputValue
27+
const defaultFilter: ComboBoxProps["onSearch"] = (
28+
item: ComboBoxItem,
29+
inputValue: string,
30+
) => item.label.toLowerCase().includes(inputValue.toLowerCase());
31+
32+
const ComboBox: FC<ComboBoxProps> = ({
33+
options,
34+
value,
35+
onChange,
36+
required,
37+
label,
38+
labelClassName,
39+
placeholder,
40+
onSearch,
41+
}) => {
42+
onSearch = onSearch ?? defaultFilter; // if no filter is provided use the default one
43+
44+
const inputRef = useRef<HTMLInputElement>(null);
45+
46+
const [comboBoxState, dispatch] = useReducer<
47+
ComboBoxState,
48+
Pick<ComboBoxProps, "options" | "value">,
49+
[StateChangeOptions<ComboBoxItem>]
50+
>(
51+
(prevState, action) => {
52+
const { type, ...changes } = action;
53+
54+
const nextState = { ...prevState, ...changes };
55+
56+
// This is an improvement from the UX POV
57+
// When opening the combobox dropdown to see all the options we have two cases where
58+
// we should show all options:
59+
// 1. inputValue is empty; this is trivial
60+
// 2. we have previously selected an item and we're opening the dropdown without
61+
// changing inputValue.
62+
// In the second case that would mean we already have an inputValue.
63+
// Normally this would filter out all elements that aren't the selected one, and the
64+
// user would have to clear the text in the input box before searching and selecting
65+
// a new element, which is awful from a UX POV...
66+
// To avoid this, we explicitly check if the user typed anything; as soon as they do the
67+
// filtering behavior is enabled again. When the dropdown closes we go back to showing
68+
// all options again
69+
const inputValueEmpty = !nextState.inputValue;
70+
const userHasTypedAfterOpening = nextState.isOpen
71+
? prevState.showAllOptions && !Object.hasOwn(changes, "inputValue")
72+
: true;
73+
nextState.showAllOptions = inputValueEmpty || userHasTypedAfterOpening;
74+
75+
// filter the options when inputValue changes
76+
if (Object.hasOwn(changes, "inputValue")) {
77+
nextState.filteredOptions = options.filter(
78+
(item) =>
79+
!nextState.inputValue ||
80+
nextState.showAllOptions ||
81+
onSearch(item, nextState.inputValue),
82+
);
83+
}
84+
85+
// Everything below is patching out some of Downshift's behavior that doesn't match the
86+
// WAI Combobox pattern description
87+
// for more info, see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
88+
89+
if (Object.hasOwn(changes, "isOpen")) {
90+
// When opening the dropdown with an option already selected, the default highlighted
91+
// option should be the one that's been selected previously
92+
const selectedIndex = nextState.filteredOptions.findIndex(
93+
(item) => item.label === nextState.inputValue,
94+
);
95+
nextState.highlightedIndex =
96+
selectedIndex !== -1 ? selectedIndex : null;
97+
98+
// Opening the dropdown should always bring focus on the input
99+
if (changes.isOpen) inputRef.current?.focus();
100+
}
101+
102+
// every time inputValue changes, the first option should be highlighted
103+
if (
104+
Object.hasOwn(changes, "inputValue") &&
105+
nextState.filteredOptions.length > 0
106+
) {
107+
nextState.highlightedIndex = 0;
108+
}
109+
110+
// fixes for certain keyboard interactions
111+
switch (type) {
112+
// Downshift closes the dropdown when pressing the "escape" key, but also resets the
113+
// selected value to `initialValue`; if Downshift doesn't get a default value prop it
114+
// resets the value entirely. This implementation falls in the latter case.
115+
// The correct behavior is:
116+
// 1. pressing "escape" with the popup open means "close the popup"
117+
// 2. pressing "escape" with the popup closed means "reset the state"
118+
// Also, if the combobox is marked as `required` the user shouldn't be able to reset
119+
// the value, so we reinstate the previous selected value in this case as well.
120+
case Downshift.stateChangeTypes.keyDownEscape: {
121+
if (prevState.isOpen || required) {
122+
nextState.inputValue = prevState.selectedItem?.label ?? "";
123+
nextState.selectedItem = prevState.selectedItem;
124+
}
125+
break;
126+
}
127+
128+
// tab-ing after highlighting an option means selecting it; Downshift doesn't do this,
129+
// instead it just closes the dropdown and resets the previous state
130+
case Downshift.stateChangeTypes.blurInput: {
131+
if (prevState.isOpen && prevState.highlightedIndex !== null) {
132+
nextState.selectedItem =
133+
prevState.filteredOptions[prevState.highlightedIndex];
134+
nextState.inputValue = nextState.selectedItem.label;
135+
}
136+
break;
137+
}
138+
139+
default: {
140+
break;
141+
}
142+
}
143+
144+
return nextState;
145+
},
146+
{ options, value },
147+
// init function finds the selected item based on the options and value props
148+
({ options, value }) => {
149+
const selectedItem = options.find((item) => item.value === value);
150+
return {
151+
selectedItem: selectedItem ?? null,
152+
inputValue: selectedItem?.label ?? "",
153+
filteredOptions: options,
154+
isOpen: false,
155+
highlightedIndex: null,
156+
showAllOptions: true,
157+
};
158+
},
159+
);
160+
161+
// part of comboBoxState is based on the props, we must keep the state in sync with them
162+
useEffect(() => {
163+
const selectedItem = options.find((item) => item.value === value);
164+
165+
dispatch({
166+
type: Downshift.stateChangeTypes.unknown,
167+
selectedItem: selectedItem ?? null,
168+
inputValue: selectedItem?.label ?? "",
169+
});
170+
}, [value, options]);
171+
172+
const firstRenderRef = useRef(true);
173+
174+
// Run the onChange callback when we change the selectedItem. We don't pass the callback as a
175+
// Downshift prop because it wouldn't run when we set the selectedItem inside the state reducer
176+
useEffect(() => {
177+
if (firstRenderRef.current) {
178+
firstRenderRef.current = false;
179+
return;
180+
}
181+
182+
if (onChange) {
183+
onChange?.(comboBoxState.selectedItem?.value ?? null);
184+
}
185+
}, [comboBoxState.selectedItem?.value]);
186+
187+
return (
188+
<Downshift<ComboBoxItem>
189+
{...comboBoxState}
190+
itemToString={(item) => (item ? item.label : "")}
191+
onStateChange={(changes) => dispatch(changes)}
192+
>
193+
{({
194+
getInputProps,
195+
getItemProps,
196+
getLabelProps,
197+
getMenuProps,
198+
getToggleButtonProps,
199+
getRootProps,
200+
toggleMenu,
201+
}) => (
202+
<div className="p-combobox">
203+
<div>
204+
<label
205+
{...getLabelProps()}
206+
className={`p-combobox__label ${labelClassName ?? ""}`}
207+
>
208+
{label ?? "Select"}
209+
</label>
210+
<div
211+
// we created a root element that doesn't match what Downshift normally expects, so
212+
// we pass this option to suppress an annoying error
213+
{...getRootProps({}, { suppressRefError: true })}
214+
className="p-combobox__controls"
215+
>
216+
<input
217+
{...getInputProps({ ref: inputRef })}
218+
type="search"
219+
className="p-combobox__input"
220+
placeholder={placeholder}
221+
onClick={() => toggleMenu()}
222+
/>
223+
<button
224+
{...getToggleButtonProps()}
225+
className="p-combobox__toggle p-button--base has-icon"
226+
tabIndex={-1}
227+
>
228+
<Icon
229+
name={comboBoxState.isOpen ? "chevron-up" : "chevron-down"}
230+
/>
231+
</button>
232+
</div>
233+
</div>
234+
<ul
235+
className={`p-combobox__options-panel ${comboBoxState.isOpen ? "active" : ""}`}
236+
{...getMenuProps()}
237+
>
238+
{comboBoxState.isOpen &&
239+
comboBoxState.filteredOptions.map((item) => (
240+
<li
241+
{...getItemProps({ item })}
242+
key={item.value}
243+
className="p-combobox__option"
244+
>
245+
{item.label}
246+
</li>
247+
))}
248+
</ul>
249+
</div>
250+
)}
251+
</Downshift>
252+
);
253+
};
254+
255+
export default ComboBox;

0 commit comments

Comments
 (0)