UNPKG

@grafana/ui

Version:
372 lines (369 loc) • 14.7 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 { useFieldContext } from '../Forms/FieldContext.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'; "use strict"; const MultiCombobox = (props) => { const { placeholder, onChange, value, width, enableAllOption, invalid: invalidProp, disabled: disabledProp, loading: loadingProp, minWidth, maxWidth, isClearable, createCustomValue = false, customValueDescription, "aria-labelledby": ariaLabelledBy, "data-testid": dataTestId, prefixIcon, id: idProp, options: optionsProp } = props; const styles = useStyles2(getComboboxStyles); const [inputValue, setInputValue] = useState(""); const fieldContext = useFieldContext(); const id = idProp != null ? idProp : fieldContext.id; const disabled = disabledProp != null ? disabledProp : fieldContext.disabled; const invalid = invalidProp != null ? invalidProp : fieldContext.invalid; const ariaDescribedBy = fieldContext["aria-describedby"]; const { options: baseOptions, updateOptions, asyncLoading, asyncError } = useOptions(optionsProp, createCustomValue, customValueDescription); const loading = loadingProp || fieldContext.loading || asyncLoading; const selectedItems = useMemo(() => { if (!value) { return []; } return getSelectedItemsFromValue(value, typeof optionsProp !== "function" ? optionsProp : baseOptions); }, [value, optionsProp, baseOptions]); const allOptionItem = useMemo(() => { const isFiltered = inputValue !== ""; const realBaseOptions = baseOptions.filter((opt) => !opt.infoOption); let label; if (isFiltered) { const anyFilteredSelected = realBaseOptions.some((opt) => selectedItems.some((s) => s.value === opt.value)); label = anyFilteredSelected ? t("multicombobox.all.title-deselect-filtered", "Deselect all (filtered)") : t("multicombobox.all.title-select-filtered", "Select all (filtered)"); } else { label = selectedItems.length > 0 ? t("multicombobox.all.title-deselect", "Deselect all") : t("multicombobox.all.title-select", "Select all"); } return { label, value: ALL_OPTION_VALUE }; }, [inputValue, selectedItems, baseOptions]); const options = useMemo(() => { const addAllOption = enableAllOption && baseOptions.length > 1; return addAllOption ? [allOptionItem, ...baseOptions] : baseOptions; }, [baseOptions, enableAllOption, allOptionItem]); const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti( selectedItems, width, disabled ); const isOptionSelected = useCallback( (item) => selectedItems.some((opt) => opt.value === item.value), [selectedItems] ); const handleInputChange = useCallback((event) => { setInputValue(event.target.value); }, []); 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; default: 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 { isOpen, highlightedIndex, getMenuProps, getInputProps, getItemProps, getToggleButtonProps } = useCombobox({ items: options, itemToString, inputId: id, inputValue, selectedItem: null, isItemDisabled: (item) => !!(item == null ? void 0 : item.infoOption), 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.infoOption) { break; } if ((newSelectedItem == null ? void 0 : newSelectedItem.value) === ALL_OPTION_VALUE) { const isFiltered = inputValue !== ""; const realOptions = options.slice(1).filter((option) => !option.infoOption); if (isFiltered) { const filteredSet = new Set(realOptions.map((item) => item.value)); const anyFilteredSelected = selectedItems.some((item) => filteredSet.has(item.value)); if (anyFilteredSelected) { setSelectedItems(selectedItems.filter((item) => !filteredSet.has(item.value))); } else { setSelectedItems([.../* @__PURE__ */ new Set([...selectedItems, ...realOptions])]); } } else { setSelectedItems(selectedItems.length > 0 ? [] : realOptions); } } else if (newSelectedItem && isOptionSelected(newSelectedItem)) { const itemToRemove = selectedItems.find((item) => item.value === newSelectedItem.value); if (itemToRemove) { removeSelectedItem(itemToRemove); } } else if (newSelectedItem) { addSelectedItem(newSelectedItem); } break; case useCombobox.stateChangeTypes.InputChange: updateOptions(newInputValue != null ? newInputValue : ""); break; default: 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__ */ jsxs("div", { className: cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled }), ref: measureRef, children: [ prefixIcon && /* @__PURE__ */ jsx(Box, { marginLeft: 0.5, children: /* @__PURE__ */ jsx(Text, { color: "secondary", children: /* @__PURE__ */ jsx(Icon, { name: prefixIcon }) }) }), /* @__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 - visibleItems.length }) } ) ] }), /* @__PURE__ */ jsx( "input", { className: multiStyles.input, ...getInputProps({ ...getDropdownProps({ disabled, preventKeyAction: isOpen, placeholder: visibleItems.length === 0 ? placeholder : "", ref: inputRef, style: { width: inputWidth } }), "aria-describedby": ariaDescribedBy, // Description should be handled with the Field component "aria-labelledby": ariaLabelledBy, // Label should be handled with the Field component "data-testid": dataTestId, onChange: handleInputChange, onKeyDown: (event) => { if (event.key === "Escape" && isOpen) { event.stopPropagation(); } } }) } ), /* @__PURE__ */ jsxs("div", { className: multiStyles.suffix, ref: suffixMeasureRef, 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, ...getToggleButtonProps({ disabled, role: "button" }) } ) ] }) ] }) ] }), /* @__PURE__ */ jsx(Portal, { children: /* @__PURE__ */ jsx( "div", { className: cx(styles.menu, !isOpen && styles.menuClosed), style: { ...floatStyles, width: floatStyles.width + 24, // account for checkbox pointerEvents: "auto" // Override container's pointer-events: none }, ...getMenuProps({ ref: floatingRef }), children: isOpen && /* @__PURE__ */ jsx( ComboboxList, { loading, options, highlightedIndex, selectedItems, scrollRef, getItemProps, enableAllOption, isMultiSelect: true, error: asyncError } ) } ) }) ] }); }; function getSelectedItemsFromValue(value, options) { if (isComboboxOptions(value)) { return value; } const valueMap = /* @__PURE__ */ new Map(); let index = 0; for (const val of value) { if (!valueMap.has(val)) { valueMap.set(val, index); index++; } } const resultingItems = []; for (const option of options) { const index2 = valueMap.get(option.value); if (index2 !== void 0) { resultingItems[index2] = option; valueMap.delete(option.value); } if (valueMap.size === 0) { break; } } for (const [val, index2] of valueMap) { resultingItems[index2] = { value: val }; } return resultingItems; } function isComboboxOptions(value) { return typeof value[0] === "object"; } export { MultiCombobox }; //# sourceMappingURL=MultiCombobox.mjs.map