UNPKG

@grafana/ui

Version:
314 lines (311 loc) • 12 kB
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