diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index c3940a7fed1..511f1438a4d 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -27,7 +27,7 @@ export type ComboboxOption = { // TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async), // then the onChange handler emits ComboboxOption with the label as non-undefined. -interface ComboboxBaseProps +export interface ComboboxBaseProps extends Pick< InputProps, 'placeholder' | 'autoFocus' | 'id' | 'aria-labelledby' | 'disabled' | 'loading' | 'invalid' @@ -66,7 +66,7 @@ type ClearableConditionals = } | { isClearable?: false; onChange: (option: ComboboxOption) => void }; -type AutoSizeConditionals = +export type AutoSizeConditionals = | { width: 'auto'; /** @@ -86,7 +86,7 @@ type AutoSizeConditionals = type ComboboxProps = ComboboxBaseProps & AutoSizeConditionals & ClearableConditionals; -function itemToString(item?: ComboboxOption | null) { +export function itemToString(item?: ComboboxOption | null) { if (!item) { return ''; } diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx new file mode 100644 index 00000000000..0259c72d2ba --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MultiCombobox } from './MultiCombobox'; +import mdx from './MultiCombobox.mdx'; + +const meta: Meta = { + title: 'Forms/MultiCombobox', + component: MultiCombobox, + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + options: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, + ], + placeholder: 'Select multiple options...', + }, +}; diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.mdx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.mdx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx new file mode 100644 index 00000000000..f7d8d7de3bc --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -0,0 +1,138 @@ +import { useCombobox, useMultipleSelection } from 'downshift'; +import { useState } from 'react'; + +import { useStyles2 } from '../../themes'; +import { Checkbox } from '../Forms/Checkbox'; +import { Portal } from '../Portal/Portal'; + +import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox'; +import { OptionListItem } from './OptionListItem'; +import { ValuePill } from './ValuePill'; +import { getMultiComboboxStyles } from './getMultiComboboxStyles'; + +interface MultiComboboxBaseProps extends Omit, 'value' | 'onChange'> { + value?: string | Array>; + onChange: (items?: Array>) => void; +} + +type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; + +export const MultiCombobox = (props: MultiComboboxProps) => { + const { options, placeholder } = props; + + const multiStyles = useStyles2(getMultiComboboxStyles); + + const isAsync = typeof options === 'function'; + + const [items, _baseSetItems] = useState(isAsync ? [] : options); + const [isOpen, setIsOpen] = useState(false); + const [selectedItems, setSelectedItems] = useState>>([]); + + const [inputValue, setInputValue] = useState(''); + + const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ + selectedItems, //initally selected items, + onStateChange: ({ type, selectedItems: newSelectedItems }) => { + switch (type) { + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: + case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: + if (newSelectedItems) { + setSelectedItems(newSelectedItems); + } + break; + default: + break; + } + }, + }); + + const { + //getToggleButtonProps, + //getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + //selectedItem, + } = useCombobox({ + isOpen, + items, + itemToString, + inputValue, + //defaultHighlightedIndex: 0, + selectedItem: null, + + onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => { + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + if (newSelectedItem) { + const isAlreadySelected = selectedItems.some((opt) => opt.value === newSelectedItem.value); + if (!isAlreadySelected) { + setSelectedItems([...selectedItems, newSelectedItem]); + break; + } + removeSelectedItem(newSelectedItem); + } + break; + case useCombobox.stateChangeTypes.InputBlur: + setIsOpen(false); + setInputValue(''); + break; + case useCombobox.stateChangeTypes.InputChange: + setInputValue(newInputValue ?? ''); + break; + default: + break; + } + }, + }); + + return ( +
+ + {selectedItems.map((item, index) => ( + { + removeSelectedItem(item); + }} + key={`${item.value}${index}`} + {...getSelectedItemProps({ selectedItem: item, index })} + > + {itemToString(item)} + + ))} + + setIsOpen(true) }))} + /> +
+ + {isOpen && ( +
+ {items.map((item, index) => { + const itemProps = getItemProps({ item, index }); + const isSelected = selectedItems.some((opt) => opt.value === item.value); + return ( +
  • + {' '} + {/* Add styling with virtualization */} + + +
  • + ); + })} +
    + )} +
    +
    +
    + ); +}; diff --git a/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx new file mode 100644 index 00000000000..1138a60c282 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx @@ -0,0 +1,18 @@ +import { useStyles2 } from '../../themes'; + +import { ComboboxOption } from './Combobox'; +import { getComboboxStyles } from './getComboboxStyles'; + +interface Props { + option: ComboboxOption; +} + +export const OptionListItem = ({ option }: Props) => { + const styles = useStyles2(getComboboxStyles); + return ( +
    + {option.label ?? option.value} + {option.description && {option.description}} +
    + ); +}; diff --git a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx new file mode 100644 index 00000000000..3f1a2dce3d8 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx @@ -0,0 +1,50 @@ +import { css } from '@emotion/css'; +import { forwardRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { IconButton } from '../IconButton/IconButton'; + +interface ValuePillProps { + children: string; + onRemove: () => void; +} + +export const ValuePill = forwardRef(({ children, onRemove, ...rest }, ref) => { + const styles = useStyles2(getValuePillStyles); + return ( + + {children} + + { + e.stopPropagation(); + onRemove(); + }} + /> + + ); +}); + +const getValuePillStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'inline-flex', + gap: theme.spacing(0.5), + borderRadius: theme.shape.radius.default, + color: theme.colors.text.primary, + background: theme.colors.background.secondary, + padding: theme.spacing(0.25), + fontSize: theme.typography.bodySmall.fontSize, + }), + + separator: css({ + background: theme.colors.border.weak, + width: '2px', + marginLeft: theme.spacing(0.25), + height: '100%', + }), +}); diff --git a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts new file mode 100644 index 00000000000..43a8555048e --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts @@ -0,0 +1,43 @@ +import { css, cx } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { getFocusStyles } from '../../themes/mixins'; +import { getInputStyles } from '../Input/Input'; + +export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { + const inputStyles = getInputStyles({ theme }); + const focusStyles = getFocusStyles(theme); + + return { + wrapper: cx( + inputStyles.input, + css({ + display: 'flex', + gap: theme.spacing(0.5), + padding: theme.spacing(0.5), + '&:focus-within': { + ...focusStyles, + }, + }) + ), + input: css({ + border: 'none', + outline: 'none', + background: 'transparent', + flexGrow: 1, + minWidth: '0', + '&::placeholder': { + color: theme.colors.text.disabled, + }, + '&:focus': { + outline: 'none', + }, + }), + pillWrapper: css({ + display: 'inline-flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + }), + }; +};