SPDX-License-Identifier: MIT
Copyright (c) 2019 Red Hat, Inc.
*/
https://github.com/patternfly/patternfly-react/blob/v5/packages/react-templates/src/components/Select/TypeaheadSelect.tsx
We don't use it directly from the @patternfly/react-templates node
module since we want to add features to it, and also to isolate us
from gratuitous upstream changes.
Our changes:
- There is a new "selectedIsTrusted" option to say that the the
"selected" value should always be assumed to be right even if it
is not in the list of "selectOptions".
This option is automatically set to "true" when isCreatable is
true. Thus, when allowing creation of things, you don't need to
put artificial entries into selectOptions for things that don't
yet exist.
Setting selectIsTrusted to true is also useful when the
"selected" and "selectOptions" properties are produced
asynchronously from each other. Maybe you know "selected" already
but "selectOptions" isn't ready yet.
- When not actively filtering, the "clear" button is only shown
when there is also a onClearSelection function (and when
something is selected). Without such a function, nothing will
change when hitting the clear button.
- Allow dividers.
[
...
{ decorator: "divider", key: "..." },
...
]
- Allow headers.
[
...
{ decorator: "header", content: _("Nice things"), key: "..." }
{ value: "icecream", content: _("Icecream") },
...
]
Note that PatternFly uses SelectGroup and MenuGroup instead of
headers, but their recursive nature makes them harder to
implement here, mostly because of how keyboard navigation is
done. And there is no visual nesting going on anyway. Keeping the
options a flat list is just all around easier.
- Support for a footer.
*/
import cockpit from "cockpit";
import React from 'react';
import { MenuToggle, MenuToggleProps, MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/index.js';
import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
import { Divider } from '@patternfly/react-core/dist/esm/components/Divider/index.js';
import { MenuFooter } from '@patternfly/react-core/dist/esm/components/Menu/index.js';
import { Select, SelectOption, SelectList, SelectOptionProps, SelectProps } from '@patternfly/react-core/dist/esm/components/Select/index.js';
import { TextInputGroup, TextInputGroupMain, TextInputGroupUtilities } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
import RhMicronsCloseIcon from '@patternfly/react-icons/dist/esm/icons/rh-microns-close-icon';
import "cockpit-components-select.scss";
const _ = cockpit.gettext;
export interface TypeaheadSelectDividerOption {
decorator: "divider";
key: string | number;
};
export interface TypeaheadSelectHeaderOption {
decorator: "header";
content: string | number;
key: string | number;
};
export interface TypeaheadSelectMenuOption extends Omit<SelectOptionProps, 'content' | 'isSelected'> {
decorator?: undefined;
content: string | number;
value: string | number;
isSelected?: boolean;
}
export type TypeaheadSelectOption = TypeaheadSelectMenuOption |
TypeaheadSelectDividerOption |
TypeaheadSelectHeaderOption;
export interface TypeaheadSelectProps extends Omit<SelectProps, 'toggle' | 'onSelect'> {
innerRef?: React.Ref<any>;
selectOptions: TypeaheadSelectOption[];
selectedIsTrusted?: boolean;
onSelect?: (
_event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
selection: string | number
) => void;
onToggle?: (nextIsOpen: boolean) => void;
onInputChange?: (newValue: string) => void;
filterFunction?: (filterValue: string, options: TypeaheadSelectOption[]) => TypeaheadSelectOption[];
onClearSelection?: () => void;
placeholder?: string;
isCreatable?: boolean;
isCreateOptionOnTop?: boolean;
createOptionMessage?: string | ((newValue: string) => string);
noOptionsAvailableMessage?: string;
noOptionsFoundMessage?: string | ((filter: string) => string);
isDisabled?: boolean;
footer?: React.ReactNode;
toggleWidth?: string;
toggleProps?: MenuToggleProps;
}
const defaultFilterFunction = (filterValue: string, options: TypeaheadSelectOption[]) => {
const filtered = options.filter((o) => {
return o.decorator || String(o.content).toLowerCase().includes(filterValue.toLowerCase());
});
const filtered2 = filtered.filter((o, i) => {
if (o.decorator === "header" && (i >= filtered.length - 1 || filtered[i + 1].decorator))
return false;
if (o.decorator === "divider" && (i <= 0 || filtered[i - 1].decorator))
return false;
return true;
});
if (filtered2.length > 0 && filtered2[filtered2.length-1].decorator === "divider")
filtered2.pop();
return filtered2;
};
export const TypeaheadSelectBase: React.FunctionComponent<TypeaheadSelectProps> = ({
innerRef,
selectOptions,
selectedIsTrusted,
onSelect,
onToggle,
onInputChange,
filterFunction = defaultFilterFunction,
onClearSelection,
placeholder = _("Select an option"),
noOptionsAvailableMessage = _("No results found"),
noOptionsFoundMessage = _("No results found"),
isCreatable = false,
isCreateOptionOnTop = false,
createOptionMessage = "",
isDisabled = false,
footer = null,
toggleWidth,
toggleProps,
...props
}: TypeaheadSelectProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [filterValue, setFilterValue] = React.useState<string>('');
const [isFiltering, setIsFiltering] = React.useState<boolean>(false);
const [focusedItemIndex, setFocusedItemIndex] = React.useState<number | null>(null);
const [activeItemId, setActiveItemId] = React.useState<string | null>(null);
const textInputRef = React.useRef<HTMLInputElement>();
const NO_RESULTS = 'no results';
if (isCreatable && !createOptionMessage)
throw "isCreatable requires createOptionMessage";
if (isCreatable)
selectedIsTrusted = true;
const isMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !o.decorator;
const isEnabledMenu = (o: TypeaheadSelectOption): o is TypeaheadSelectMenuOption => !(o.decorator || o.isDisabled);
const selected = React.useMemo(
() => {
let res = selectOptions?.find((o): o is TypeaheadSelectMenuOption =>
(isEnabledMenu(o) &&
(o.value === props.selected || !!o.isSelected)));
if (!res && props.selected && selectedIsTrusted)
res = { value: props.selected, content: props.selected };
return res;
},
[props.selected, selectOptions]
);
const filteredSelections = React.useMemo(() => {
let newSelectOptions: TypeaheadSelectOption[] = selectOptions;
if (isFiltering && filterValue) {
newSelectOptions = filterFunction(filterValue, selectOptions);
if (
isCreatable &&
filterValue.trim() &&
!newSelectOptions.find((o) => isMenu(o) && String(o.content).toLowerCase() === filterValue.toLowerCase())
) {
const createOption = {
content: typeof createOptionMessage === 'string' ? createOptionMessage : createOptionMessage(filterValue),
value: filterValue
};
newSelectOptions = isCreateOptionOnTop
? [createOption, ...newSelectOptions]
: [...newSelectOptions, createOption];
}
if (!newSelectOptions.length) {
newSelectOptions = [
{
isAriaDisabled: true,
isDisabled: true,
content:
typeof noOptionsFoundMessage === 'string' ? noOptionsFoundMessage : noOptionsFoundMessage(filterValue),
value: NO_RESULTS
}
];
}
}
if (!newSelectOptions.length) {
newSelectOptions = [
{
isAriaDisabled: true,
isDisabled: true,
content: noOptionsAvailableMessage,
value: NO_RESULTS
}
];
}
return newSelectOptions;
}, [
isFiltering,
filterValue,
filterFunction,
selectOptions,
noOptionsFoundMessage,
isCreatable,
isCreateOptionOnTop,
createOptionMessage,
noOptionsAvailableMessage
]);
React.useEffect(() => {
if (isFiltering) {
openMenu();
}
}, [isFiltering]);
const setActiveAndFocusedItem = (itemIndex: number) => {
setFocusedItemIndex(itemIndex);
const focusedItem = filteredSelections[itemIndex] as TypeaheadSelectMenuOption;
setActiveItemId(String(focusedItem.value));
};
const resetActiveAndFocusedItem = () => {
setFocusedItemIndex(null);
setActiveItemId(null);
};
const openMenu = () => {
if (!isOpen) {
onToggle && onToggle(true);
setIsOpen(true);
setTimeout(() => {
textInputRef.current?.focus();
}, 100);
}
};
const closeMenu = () => {
onToggle && onToggle(false);
setIsOpen(false);
resetActiveAndFocusedItem();
setIsFiltering(false);
setFilterValue(String(selected?.content ?? ''));
};
const onInputClick = () => {
if (!isOpen) {
openMenu();
} else if (isFiltering) {
closeMenu();
}
};
const selectOption = (
_event: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<HTMLInputElement> | undefined,
option: TypeaheadSelectMenuOption
) => {
onSelect && onSelect(_event, option.value);
closeMenu();
};
const _onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
if (value && value !== NO_RESULTS) {
const optionToSelect = selectOptions.find(
(option): option is TypeaheadSelectMenuOption => isMenu(option) && option.value === value);
if (optionToSelect) {
selectOption(_event, optionToSelect);
} else if (isCreatable) {
selectOption(_event, { value, content: value });
}
}
};
const onTextInputChange = (_event: React.FormEvent<HTMLInputElement>, value: string) => {
setIsFiltering(true);
setFilterValue(value || '');
onInputChange && onInputChange(value);
resetActiveAndFocusedItem();
};
const handleMenuArrowKeys = (key: string) => {
let indexToFocus = 0;
openMenu();
if (filteredSelections.every(o => !isEnabledMenu(o))) {
return;
}
if (key === 'ArrowUp') {
if (focusedItemIndex === null || focusedItemIndex === 0) {
indexToFocus = filteredSelections.length - 1;
} else {
indexToFocus = focusedItemIndex - 1;
}
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus--;
if (indexToFocus === -1) {
indexToFocus = filteredSelections.length - 1;
}
}
}
if (key === 'ArrowDown') {
if (focusedItemIndex === null || focusedItemIndex === filteredSelections.length - 1) {
indexToFocus = 0;
} else {
indexToFocus = focusedItemIndex + 1;
}
while (!isEnabledMenu(filteredSelections[indexToFocus])) {
indexToFocus++;
if (indexToFocus === filteredSelections.length) {
indexToFocus = 0;
}
}
}
setActiveAndFocusedItem(indexToFocus);
};
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const focusedItem = (focusedItemIndex !== null ? filteredSelections[focusedItemIndex] : null) as TypeaheadSelectMenuOption | null;
switch (event.key) {
case 'Enter':
if (isOpen && focusedItem && focusedItem.value !== NO_RESULTS && !focusedItem.isAriaDisabled) {
selectOption(event, focusedItem);
}
openMenu();
break;
case 'ArrowUp':
case 'ArrowDown':
event.preventDefault();
handleMenuArrowKeys(event.key);
break;
}
};
const onToggleClick = () => {
if (!isOpen) {
openMenu();
} else {
closeMenu();
}
textInputRef.current?.focus();
};
const onClearButtonClick = () => {
if (selected && onSelect) {
onSelect(undefined, selected.value);
}
setFilterValue('');
onInputChange && onInputChange('');
setIsFiltering(false);
resetActiveAndFocusedItem();
textInputRef.current?.focus();
onClearSelection && onClearSelection();
};
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="typeahead"
aria-label="Typeahead menu toggle"
onClick={onToggleClick}
isExpanded={isOpen}
isDisabled={isDisabled}
isFullWidth
style={
{
width: toggleWidth
} as React.CSSProperties
}
{...toggleProps}
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={isFiltering ? filterValue : (selected?.content ?? '')}
onClick={onInputClick}
onChange={onTextInputChange}
onKeyDown={onInputKeyDown}
autoComplete="off"
innerRef={textInputRef}
placeholder={placeholder}
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
role="combobox"
isExpanded={isOpen}
aria-controls="select-typeahead-listbox"
/>
<TextInputGroupUtilities
{...(!(isFiltering && filterValue) && !(selected && onClearSelection) ? { style: { display: 'none' } } : {})}
>
<Button icon={<RhMicronsCloseIcon aria-hidden />} variant="plain" onClick={onClearButtonClick} aria-label="Clear input value" />
</TextInputGroupUtilities>
</TextInputGroup>
</MenuToggle>
);
return (
<Select
isOpen={isOpen}
selected={selected}
onSelect={_onSelect}
onOpenChange={(isOpen) => !isOpen && closeMenu()}
toggle={toggle}
variant="typeahead"
ref={innerRef}
{...props}
>
<SelectList>
{filteredSelections.map((option, index) => {
if (option.decorator === "divider")
return <Divider key={option.key} component="li" />;
if (option.decorator === "header") {
return (
<SelectOption key={option.key}
isDisabled
className="ct-select-header">
{option.content}
</SelectOption>
);
}
const { content, value, ...props } = option;
return (
<SelectOption key={value} value={value} isFocused={focusedItemIndex === index} {...props}>
{content}
</SelectOption>
);
})}
</SelectList>
{ footer && <MenuFooter>{footer}</MenuFooter> }
</Select>
);
};
TypeaheadSelectBase.displayName = 'TypeaheadSelectBase';
export const TypeaheadSelect = React.forwardRef((props: TypeaheadSelectProps, ref: React.Ref<any>) => (
<TypeaheadSelectBase {...props} innerRef={ref} />
));
TypeaheadSelect.displayName = 'TypeaheadSelect';