@grafana/ui
Version:
Grafana Components Library
314 lines (311 loc) • 12 kB
JavaScript
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
import { cx } from '@emotion/css';
import { useMultipleSelection, useCombobox } from 'downshift';
import { useState, useMemo, useCallback } from 'react';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../themes/ThemeContext.mjs';
import { Icon } from '../Icon/Icon.mjs';
import { Box } from '../Layout/Box/Box.mjs';
import { Portal } from '../Portal/Portal.mjs';
import { Text } from '../Text/Text.mjs';
import { Tooltip } from '../Tooltip/Tooltip.mjs';
import { ComboboxList } from './ComboboxList.mjs';
import { SuffixIcon } from './SuffixIcon.mjs';
import { ValuePill } from './ValuePill.mjs';
import { itemToString } from './filter.mjs';
import { getComboboxStyles } from './getComboboxStyles.mjs';
import { getMultiComboboxStyles } from './getMultiComboboxStyles.mjs';
import { ALL_OPTION_VALUE } from './types.mjs';
import { useComboboxFloat } from './useComboboxFloat.mjs';
import { useMeasureMulti, MAX_SHOWN_ITEMS } from './useMeasureMulti.mjs';
import { useMultiInputAutoSize } from './useMultiInputAutoSize.mjs';
import { useOptions } from './useOptions.mjs';
const MultiCombobox = (props) => {
const {
placeholder,
onChange,
value,
width,
enableAllOption,
invalid,
disabled,
minWidth,
maxWidth,
isClearable,
createCustomValue = false
} = props;
const styles = useStyles2(getComboboxStyles);
const [inputValue, setInputValue] = useState("");
const allOptionItem = useMemo(() => {
return {
label: inputValue === "" ? t("multicombobox.all.title", "All") : t("multicombobox.all.title-filtered", "All (filtered)"),
// Type casting needed to make this work when T is a number
value: ALL_OPTION_VALUE
};
}, [inputValue]);
const {
options: baseOptions,
updateOptions,
asyncLoading,
asyncError
} = useOptions(props.options, createCustomValue);
const options = useMemo(() => {
const addAllOption = enableAllOption && baseOptions.length > 1;
return addAllOption ? [allOptionItem, ...baseOptions] : baseOptions;
}, [baseOptions, enableAllOption, allOptionItem]);
const loading = props.loading || asyncLoading;
const selectedItems = useMemo(() => {
if (!value) {
return [];
}
return getSelectedItemsFromValue(value, typeof props.options !== "function" ? props.options : baseOptions);
}, [value, props.options, baseOptions]);
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
selectedItems,
width,
disabled
);
const isOptionSelected = useCallback(
(item) => selectedItems.some((opt) => opt.value === item.value),
[selectedItems]
);
const { getSelectedItemProps, getDropdownProps, setSelectedItems, addSelectedItem, removeSelectedItem, reset } = useMultipleSelection({
selectedItems,
// initially selected items,
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
case useMultipleSelection.stateChangeTypes.FunctionAddSelectedItem:
case useMultipleSelection.stateChangeTypes.FunctionSetSelectedItems:
case useMultipleSelection.stateChangeTypes.FunctionReset:
onChange(newSelectedItems != null ? newSelectedItems : []);
break;
}
},
stateReducer: (state, actionAndChanges) => {
const { changes } = actionAndChanges;
return {
...changes,
/**
* TODO: Fix Hack!
* This prevents the menu from closing when the user unselects an item in the dropdown at the expense
* of breaking keyboard navigation.
*
* Downshift isn't really designed to keep selected items in the dropdown menu, so when you unselect an item
* in a multiselect, the stateReducer tries to move focus onto another item which causes the menu to be closed.
* This only seems to happen when you deselect the last item in the selectedItems list.
*
* Check out:
* - FunctionRemoveSelectedItem in the useMultipleSelection reducer https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/reducer.js#L75
* - The activeIndex useEffect in useMultipleSelection https://github.com/downshift-js/downshift/blob/master/src/hooks/useMultipleSelection/index.js#L68-L72
*
* Forcing the activeIndex to -999 both prevents the useEffect that changes the focus from triggering (value never changes)
* and prevents the if statement in useMultipleSelection from focusing anything.
*/
activeIndex: -999
};
}
});
const {
getToggleButtonProps,
//getLabelProps,
isOpen,
highlightedIndex,
getMenuProps,
getInputProps,
getItemProps
} = useCombobox({
items: options,
itemToString,
inputValue,
selectedItem: null,
stateReducer: (state, actionAndChanges) => {
const { type } = actionAndChanges;
let { changes } = actionAndChanges;
const menuBeingOpened = state.isOpen === false && changes.isOpen === true;
if (menuBeingOpened && changes.inputValue === state.inputValue) {
changes = {
...changes,
inputValue: ""
};
}
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
return {
...changes,
isOpen: true,
highlightedIndex: state.highlightedIndex
};
case useCombobox.stateChangeTypes.InputBlur:
setInputValue("");
default:
return changes;
}
},
onIsOpenChange: ({ isOpen: isOpen2, inputValue: inputValue2 }) => {
if (isOpen2 && inputValue2 === "") {
updateOptions(inputValue2);
}
},
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
if ((newSelectedItem == null ? void 0 : newSelectedItem.value) === ALL_OPTION_VALUE) {
const isAllFilteredSelected = selectedItems.length === options.length - 1;
const realOptions = options.slice(1);
let newSelectedItems = isAllFilteredSelected && inputValue === "" ? [] : realOptions;
if (!isAllFilteredSelected && inputValue !== "") {
newSelectedItems = [.../* @__PURE__ */ new Set([...selectedItems, ...realOptions])];
}
if (isAllFilteredSelected && inputValue !== "") {
const filteredSet = new Set(realOptions.map((item) => item.value));
newSelectedItems = selectedItems.filter((item) => !filteredSet.has(item.value));
}
setSelectedItems(newSelectedItems);
} else if (newSelectedItem && isOptionSelected(newSelectedItem)) {
removeSelectedItem(newSelectedItem);
} else if (newSelectedItem) {
addSelectedItem(newSelectedItem);
}
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue != null ? newInputValue : "");
updateOptions(newInputValue != null ? newInputValue : "");
break;
}
}
});
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(options, isOpen);
const multiStyles = useStyles2(
getMultiComboboxStyles,
isOpen,
invalid,
disabled,
width,
minWidth,
maxWidth,
isClearable
);
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
const { inputRef, inputWidth } = useMultiInputAutoSize(inputValue);
return /* @__PURE__ */ jsxs("div", { className: multiStyles.container, ref: containerRef, children: [
/* @__PURE__ */ jsx("div", { className: cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled }), ref: measureRef, children: /* @__PURE__ */ jsxs("span", { className: multiStyles.pillWrapper, children: [
visibleItems.map((item, index) => /* @__PURE__ */ jsx(
ValuePill,
{
disabled,
onRemove: () => {
removeSelectedItem(item);
},
...getSelectedItemProps({ selectedItem: item, index }),
children: itemToString(item)
},
`${item.value}${index}`
)),
selectedItems.length > visibleItems.length && /* @__PURE__ */ jsxs(Box, { display: "flex", direction: "row", marginLeft: 0.5, gap: 1, ref: counterMeasureRef, children: [
/* @__PURE__ */ jsx(Text, { children: "..." }),
/* @__PURE__ */ jsx(
Tooltip,
{
interactive: true,
content: /* @__PURE__ */ jsx(Fragment, { children: selectedItems.slice(visibleItems.length).map((item) => /* @__PURE__ */ jsx("div", { children: itemToString(item) }, item.value)) }),
children: /* @__PURE__ */ jsx("div", { className: multiStyles.restNumber, children: selectedItems.length - shownItems })
}
)
] }),
/* @__PURE__ */ jsx(
"input",
{
className: multiStyles.input,
...getInputProps(
getDropdownProps({
disabled,
preventKeyAction: isOpen,
placeholder: visibleItems.length === 0 ? placeholder : "",
ref: inputRef,
style: { width: inputWidth }
})
)
}
),
/* @__PURE__ */ jsxs("div", { className: multiStyles.suffix, ref: suffixMeasureRef, ...getToggleButtonProps(), children: [
isClearable && selectedItems.length > 0 && /* @__PURE__ */ jsx(
Icon,
{
name: "times",
className: styles.clear,
title: t("multicombobox.clear.title", "Clear all"),
tabIndex: 0,
role: "button",
onClick: (e) => {
e.stopPropagation();
reset();
},
onKeyDown: (e) => {
if (e.key === "Enter" || e.key === " ") {
reset();
}
}
}
),
/* @__PURE__ */ jsx(SuffixIcon, { isLoading: loading || false, isOpen })
] })
] }) }),
/* @__PURE__ */ jsx(Portal, { children: /* @__PURE__ */ jsx(
"div",
{
className: cx(styles.menu, !isOpen && styles.menuClosed),
style: {
...floatStyles,
width: floatStyles.width + 24
// account for checkbox
},
...getMenuProps({ ref: floatingRef }),
children: isOpen && /* @__PURE__ */ jsx(
ComboboxList,
{
options,
highlightedIndex,
selectedItems,
scrollRef,
getItemProps,
enableAllOption,
isMultiSelect: true,
error: asyncError
}
)
}
) })
] });
};
function getSelectedItemsFromValue(value, options) {
if (isComboboxOptions(value)) {
return value;
}
const valueMap = new Map(value.map((val, index) => [val, index]));
const resultingItems = [];
for (const option of options) {
const index = valueMap.get(option.value);
if (index !== void 0) {
resultingItems[index] = option;
valueMap.delete(option.value);
}
if (valueMap.size === 0) {
break;
}
}
for (const [val, index] of valueMap) {
resultingItems[index] = { value: val };
}
return resultingItems;
}
function isComboboxOptions(value) {
return typeof value[0] === "object";
}
export { MultiCombobox };
//# sourceMappingURL=MultiCombobox.mjs.map