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