Line data Source code
1 : /*
2 : SPDX-License-Identifier: MIT
3 :
4 : Copyright (c) 2019 Red Hat, Inc.
5 : */
6 :
7 : /* This is a copy of MultiTypeaheadSelect.tsx from
8 :
9 : https://github.com/patternfly/patternfly-react/blob/v5/packages/react-templates/src/components/Select/MultiTypeaheadSelect.tsx
10 :
11 : We don't use it directly from the @patternfly/react-templates node
12 : module since we want to add features to it, and also to isolate us
13 : from gratuitous upstream changes.
14 :
15 : Our changes:
16 :
17 : - The selection is controlled from the outside and not maintained
18 : as internal state. This is how things should work with React.
19 :
20 : - Changes are announced via incremental onAdd and onRemove
21 : handlers.
22 :
23 : - We use Labels instead of Chips, since we want colors.
24 :
25 : - The clear button clears the input text, not the selection.
26 :
27 : */
28 :
29 : /* eslint-disable */
30 :
31 24 : import cockpit from "cockpit";
32 24 : import React from 'react';
33 : import { MenuToggle, MenuToggleProps, MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
34 : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
35 : import { Select, SelectOption, SelectList, SelectOptionProps, SelectProps } from '@patternfly/react-core/dist/esm/components/Select/index.js';
36 : import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
37 : import { Label, LabelGroup, LabelProps } from "@patternfly/react-core/dist/esm/components/Label/index.js";
38 : import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
39 :
40 24 : const _ = cockpit.gettext;
41 :
42 : export interface MultiTypeaheadSelectOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
43 : /** Content of the select option. */
44 : content: string | number;
45 : /** Value of the select option. */
46 : value: string | number;
47 : /** Color */
48 : color?: LabelProps["color"];
49 : }
50 :
51 : export interface MultiTypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
52 : /** @hide Forwarded ref */
53 : innerRef?: React.Ref<any>;
54 : /** Options of the select. */
55 : options: MultiTypeaheadSelectOption[];
56 : /** Selected values */
57 : selected: (string | number)[];
58 : /** Callback triggered when an option is added. */
59 : onAdd: (value: (string | number)) => void;
60 : /** Callback triggered when an option is removed. */
61 : onRemove: (value: (string | number)) => void;
62 : /** Callback triggered when the select opens or closes. */
63 : onToggle?: (nextIsOpen: boolean) => void;
64 : /** Callback triggered when the text in the input field changes. */
65 : onInputChange?: (newValue: string) => void;
66 : /** Placeholder text for the select input. */
67 : placeholder?: string;
68 : /** Message to display when no options match the filter. */
69 : noOptionsFoundMessage?: string | ((filter: string) => string);
70 : /** Flag indicating the select should be disabled. */
71 : isDisabled?: boolean;
72 : /** Width of the toggle. */
73 : toggleWidth?: string;
74 : /** Additional props passed to the toggle. */
75 : toggleProps?: MenuToggleProps;
76 : }
77 :
78 14 : export const MultiTypeaheadSelectBase: React.FunctionComponent<MultiTypeaheadSelectProps> = ({
79 14 : innerRef,
80 14 : options,
81 14 : selected,
82 14 : onAdd,
83 14 : onRemove,
84 14 : onToggle,
85 14 : onInputChange,
86 14 : placeholder = '',
87 0 : noOptionsFoundMessage = _filter => _("No results found"),
88 14 : isDisabled = false,
89 14 : toggleWidth,
90 14 : toggleProps,
91 14 : ...props
92 14 : }: MultiTypeaheadSelectProps) => {
93 14 : const [isOpen, setIsOpen] = React.useState(false);
94 14 : const [inputValue, setInputValue] = React.useState<string>("");
95 14 : const [selectOptions, setSelectOptions] = React.useState<MultiTypeaheadSelectOption[]>(options);
96 14 : const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
97 14 : const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
98 14 : const textInputRef = React.useRef<HTMLInputElement>();
99 :
100 14 : const NO_RESULTS = 'no results';
101 :
102 1 : const openMenu = () => {
103 1 : onToggle && onToggle(true);
104 1 : setIsOpen(true);
105 1 : };
106 :
107 14 : React.useEffect(() => {
108 14 : let newSelectOptions: MultiTypeaheadSelectOption[] = options;
109 :
110 : // Filter menu items based on the text input value when one exists
111 2 : if (inputValue) {
112 1 : newSelectOptions = options.filter((option) =>
113 1 : String(option.content).toLowerCase().includes(inputValue.toLowerCase())
114 2 : );
115 :
116 : // When no options are found after filtering, display 'No results found'
117 2 : if (!newSelectOptions.length) {
118 2 : newSelectOptions = [
119 2 : {
120 2 : isAriaDisabled: true,
121 2 : isDisabled: true,
122 2 : content:
123 2 : typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(inputValue),
124 2 : value: NO_RESULTS
125 2 : }
126 2 : ];
127 2 : }
128 :
129 : // Open the menu when the input value changes and the new value is not empty
130 1 : if (!isOpen) {
131 1 : openMenu();
132 1 : }
133 2 : }
134 :
135 14 : setSelectOptions(newSelectOptions);
136 : // eslint-disable-next-line react-hooks/exhaustive-deps
137 14 : }, [inputValue, options]);
138 :
139 1 : const setActiveAndFocusedItem = (itemIndex: number) => {
140 1 : setFocusedItemIndex(itemIndex);
141 1 : const focusedItem = selectOptions[itemIndex];
142 1 : setActiveItemId(focusedItem.value as string);
143 1 : };
144 :
145 2 : const resetActiveAndFocusedItem = () => {
146 2 : setFocusedItemIndex(null);
147 2 : setActiveItemId(null);
148 2 : };
149 :
150 2 : const closeMenu = () => {
151 2 : onToggle && onToggle(false);
152 2 : setIsOpen(false);
153 2 : resetActiveAndFocusedItem();
154 2 : setInputValue('');
155 2 : };
156 :
157 1 : const onInputClick = () => {
158 1 : if (!isOpen) {
159 1 : openMenu();
160 0 : } else if (!inputValue) {
161 0 : closeMenu();
162 0 : }
163 1 : };
164 :
165 2 : const selectOption = (option: string | number) => {
166 2 : if (selected.includes(option))
167 1 : onRemove(option);
168 : else
169 2 : onAdd(option);
170 2 : };
171 :
172 2 : const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
173 2 : if (value && value !== NO_RESULTS) {
174 2 : selectOption(value);
175 2 : closeMenu();
176 2 : }
177 2 : };
178 :
179 1 : const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
180 1 : setInputValue(value);
181 1 : onInputChange && onInputChange(value);
182 :
183 1 : resetActiveAndFocusedItem();
184 1 : };
185 :
186 1 : const handleMenuArrowKeys = (key: string) => {
187 1 : let indexToFocus = 0;
188 :
189 0 : if (!isOpen) {
190 0 : openMenu();
191 0 : }
192 :
193 0 : if (selectOptions.every((option) => option.isDisabled)) {
194 0 : return;
195 0 : }
196 :
197 1 : if (key === 'ArrowUp') {
198 : // When no index is set or at the first index, focus to the last, otherwise decrement focus index
199 1 : if (focusedItemIndex === null || focusedItemIndex === 0) {
200 1 : indexToFocus = selectOptions.length - 1;
201 0 : } else {
202 0 : indexToFocus = focusedItemIndex - 1;
203 0 : }
204 :
205 : // Skip disabled options
206 0 : while (selectOptions[indexToFocus].isDisabled) {
207 0 : indexToFocus--;
208 0 : if (indexToFocus === -1) {
209 0 : indexToFocus = selectOptions.length - 1;
210 0 : }
211 0 : }
212 1 : }
213 :
214 1 : if (key === 'ArrowDown') {
215 : // When no index is set or at the last index, focus to the first, otherwise increment focus index
216 1 : if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) {
217 1 : indexToFocus = 0;
218 1 : } else {
219 1 : indexToFocus = focusedItemIndex + 1;
220 1 : }
221 :
222 : // Skip disabled options
223 0 : while (selectOptions[indexToFocus].isDisabled) {
224 0 : indexToFocus++;
225 0 : if (indexToFocus === selectOptions.length) {
226 0 : indexToFocus = 0;
227 0 : }
228 0 : }
229 1 : }
230 :
231 1 : setActiveAndFocusedItem(indexToFocus);
232 1 : };
233 :
234 1 : const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
235 1 : const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null;
236 :
237 1 : switch (event.key) {
238 1 : case 'Enter':
239 1 : if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
240 1 : selectOption(focusedItem?.value);
241 1 : }
242 :
243 0 : if (!isOpen) {
244 0 : onToggle && onToggle(true);
245 0 : setIsOpen(true);
246 0 : }
247 :
248 1 : break;
249 1 : case 'ArrowUp':
250 1 : case 'ArrowDown':
251 1 : event.preventDefault();
252 1 : handleMenuArrowKeys(event.key);
253 1 : break;
254 1 : }
255 1 : };
256 :
257 3 : const onToggleClick = () => {
258 2 : onToggle && onToggle(!isOpen);
259 3 : setIsOpen(!isOpen);
260 3 : textInputRef?.current?.focus();
261 3 : };
262 :
263 0 : const onClearButtonClick = (_ev: React.MouseEvent) => {
264 0 : setInputValue('');
265 0 : onInputChange && onInputChange('');
266 0 : resetActiveAndFocusedItem();
267 0 : textInputRef?.current?.focus();
268 0 : };
269 :
270 14 : const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
271 14 : <MenuToggle
272 14 : ref={toggleRef}
273 14 : variant="typeahead"
274 14 : onClick={onToggleClick}
275 14 : isExpanded={isOpen}
276 14 : isDisabled={isDisabled}
277 14 : isFullWidth
278 14 : style={
279 14 : {
280 14 : width: toggleWidth
281 14 : } as React.CSSProperties
282 : }
283 14 : {...toggleProps}
284 : >
285 14 : <TextInputGroup isPlain>
286 14 : <TextInputGroupMain
287 14 : value={inputValue}
288 14 : onClick={onInputClick}
289 14 : onChange={onTextInputChange}
290 14 : onKeyDown={onInputKeyDown}
291 14 : autoComplete="off"
292 14 : innerRef={textInputRef}
293 14 : placeholder={placeholder}
294 2 : {...(activeItemId && { 'aria-activedescendant': activeItemId })}
295 14 : role="combobox"
296 14 : isExpanded={isOpen}
297 14 : aria-controls="select-typeahead-listbox"
298 : >
299 14 : <LabelGroup numLabels={10}>
300 10 : {selected.map((selection) => {
301 10 : const option = options.find((o) => o.value === selection);
302 10 : if (!option)
303 1 : return null;
304 10 : const { content, color } = option;
305 1 : function onClose(ev: React.MouseEvent<Element, MouseEvent>) {
306 1 : ev.stopPropagation();
307 1 : onRemove(selection);
308 1 : }
309 10 : return (
310 10 : <Label key={selection}
311 5 : {...(!option.isDisabled ? { onClose } : { }) }
312 1 : {...(color ? { color } : { }) } >
313 10 : {content}
314 10 : </Label>
315 : );
316 10 : })}
317 14 : </LabelGroup>
318 14 : </TextInputGroupMain>
319 2 : <TextInputGroupUtilities {...(!inputValue ? { style: { display: 'none' } } : {})}>
320 14 : <Button icon={<TimesIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label={_("Clear input value")} />
321 14 : </TextInputGroupUtilities>
322 14 : </TextInputGroup>
323 14 : </MenuToggle>
324 : );
325 :
326 14 : return (
327 14 : <Select
328 14 : isOpen={isOpen}
329 14 : selected={selected}
330 14 : onSelect={_onSelect}
331 1 : onOpenChange={(isOpen) => {
332 1 : !isOpen && closeMenu();
333 1 : }}
334 14 : toggle={toggle}
335 14 : variant="typeahead"
336 14 : ref={innerRef}
337 14 : {...props}
338 : >
339 14 : <SelectList>
340 14 : {selectOptions.map((option, index) => {
341 14 : const { content, value, ...props } = option;
342 :
343 14 : return (
344 14 : <SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
345 14 : {content}
346 14 : </SelectOption>
347 : );
348 14 : })}
349 14 : </SelectList>
350 14 : </Select>
351 : );
352 14 : };
353 :
354 24 : MultiTypeaheadSelectBase.displayName = 'MultiTypeaheadSelectBase';
355 :
356 14 : export const MultiTypeaheadSelect = React.forwardRef((props: MultiTypeaheadSelectProps, ref: React.Ref<any>) => (
357 14 : <MultiTypeaheadSelectBase {...props} innerRef={ref} />
358 24 : ));
359 :
360 24 : MultiTypeaheadSelect.displayName = 'MultiTypeaheadSelect';
|