From 989ee681f8452cae1ca5597dccc2d9cbc904aba2 Mon Sep 17 00:00:00 2001 From: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:25:44 +0000 Subject: [PATCH] MultiCombobox: Add truncation behavior (#97691) --- .../Combobox/MultiCombobox.internal.story.tsx | 2 + .../Combobox/MultiCombobox.test.tsx | 4 +- .../src/components/Combobox/MultiCombobox.tsx | 84 ++++++++++++++----- .../src/components/Combobox/ValuePill.tsx | 14 +++- .../Combobox/getMultiComboboxStyles.ts | 24 +++++- .../components/Combobox/useMeasureMulti.ts | 44 ++++++++++ 6 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 0e3a94b354a..948fd1632cc 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -14,6 +14,8 @@ const commonArgs = { { label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' }, + { label: 'Option 4', value: 'option4' }, + { label: 'Option 5', value: 'option5' }, ], value: ['option2'], placeholder: 'Select multiple options...', diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx index f0545b13b94..8b4129b3937 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.test.tsx @@ -86,13 +86,13 @@ describe('MultiCombobox', () => { expect(onChange).toHaveBeenNthCalledWith(3, [third]); }); - it('should be able to render a valie that is not in the options', async () => { + it('should be able to render a value that is not in the options', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; - render(); + render(); await user.click(screen.getByRole('combobox')); expect(await screen.findByText('d')).toBeInTheDocument(); }); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index a8b769ad647..7ca4a521a44 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -1,14 +1,19 @@ +import { cx } from '@emotion/css'; import { useCombobox, useMultipleSelection } from 'downshift'; import { useCallback, useMemo, useState } from 'react'; import { useStyles2 } from '../../themes'; import { Checkbox } from '../Forms/Checkbox'; +import { Box } from '../Layout/Box/Box'; import { Portal } from '../Portal/Portal'; +import { Text } from '../Text/Text'; +import { Tooltip } from '../Tooltip'; import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox'; import { OptionListItem } from './OptionListItem'; import { ValuePill } from './ValuePill'; import { getMultiComboboxStyles } from './getMultiComboboxStyles'; +import { useMeasureMulti } from './useMeasureMulti'; interface MultiComboboxBaseProps extends Omit, 'value' | 'onChange'> { value?: T[] | Array>; @@ -18,7 +23,7 @@ interface MultiComboboxBaseProps extends Omit = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - const { options, placeholder, onChange, value } = props; + const { options, placeholder, onChange, value, width } = props; const isAsync = typeof options === 'function'; const selectedItems = useMemo(() => { @@ -30,11 +35,13 @@ export const MultiCombobox = (props: MultiComboboxPro return getSelectedItemsFromValue(value, options); }, [value, options, isAsync]); - const multiStyles = useStyles2(getMultiComboboxStyles); - const [items, _baseSetItems] = useState(isAsync ? [] : options); const [isOpen, setIsOpen] = useState(false); + const multiStyles = useStyles2(getMultiComboboxStyles, isOpen); + + const { measureRef, suffixMeasureRef, shownItems } = useMeasureMulti(selectedItems, width); + const isOptionSelected = useCallback( (item: ComboboxOption) => selectedItems.some((opt) => opt.value === item.value), [selectedItems] @@ -114,25 +121,60 @@ export const MultiCombobox = (props: MultiComboboxPro }, }); + const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems); + return ( -
- - {selectedItems.map((item, index) => ( - { - removeSelectedItem(item); - }} - key={`${item.value}${index}`} - {...getSelectedItemProps({ selectedItem: item, index })} - > - {itemToString(item)} - - ))} - - setIsOpen(true) }))} - /> +
+
selectedItems.length > 0 && setIsOpen(!isOpen)} + > + + {visibleItems.map((item, index) => ( + { + removeSelectedItem(item); + }} + key={`${item.value}${index}`} + {...getSelectedItemProps({ selectedItem: item, index })} + > + {itemToString(item)} + + ))} + {selectedItems.length > shownItems && !isOpen && ( + + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + ... + + {selectedItems.slice(shownItems).map((item) => ( +
{itemToString(item)}
+ ))} + + } + > +
{selectedItems.length - shownItems}
+
+
+ )} + 0, + })} + {...getInputProps( + getDropdownProps({ + preventKeyAction: isOpen, + placeholder: selectedItems.length > 0 ? undefined : placeholder, + onFocus: () => setIsOpen(true), + }) + )} + /> +
+
{isOpen && ( diff --git a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx index 3f1a2dce3d8..789436938b8 100644 --- a/packages/grafana-ui/src/components/Combobox/ValuePill.tsx +++ b/packages/grafana-ui/src/components/Combobox/ValuePill.tsx @@ -15,7 +15,7 @@ export const ValuePill = forwardRef(({ children const styles = useStyles2(getValuePillStyles); return ( - {children} + {children} ({ background: theme.colors.background.secondary, padding: theme.spacing(0.25), fontSize: theme.typography.bodySmall.fontSize, + flexShrink: 0, + minWidth: '50px', + + '&:first-child:has(+ div)': { + flexShrink: 1, + }, + }), + + text: css({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }), separator: css({ diff --git a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts index 43a8555048e..4f7551ddf2e 100644 --- a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts @@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { getFocusStyles } from '../../themes/mixins'; import { getInputStyles } from '../Input/Input'; -export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { +export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => { const inputStyles = getInputStyles({ theme }); const focusStyles = getFocusStyles(theme); @@ -34,10 +34,30 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { outline: 'none', }, }), + inputClosed: css({ + width: 0, + flexGrow: 0, + paddingLeft: 0, + paddingRight: 0, + }), pillWrapper: css({ display: 'inline-flex', - flexWrap: 'wrap', + flexWrap: isOpen ? 'wrap' : 'nowrap', + flexGrow: 1, + minWidth: '50px', gap: theme.spacing(0.5), }), + restNumber: css({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(0, 1), + borderRadius: theme.shape.radius.default, + backgroundColor: theme.colors.background.secondary, + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), }; }; diff --git a/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts b/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts new file mode 100644 index 00000000000..83a5b0e2871 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { useMeasure } from 'react-use'; + +import { measureText } from '../../utils'; + +import { ComboboxOption } from './Combobox'; + +const FONT_SIZE = 12; +const EXTRA_PILL_SIZE = 50; + +/** + * Updates the number of shown items in the multi combobox based on the available width. + */ +export function useMeasureMulti( + selectedItems: Array>, + width?: number | 'auto' +) { + const [shownItems, setShownItems] = useState(selectedItems.length); + const [measureRef, { width: containerWidth }] = useMeasure(); + const [suffixMeasureRef, { width: suffixWidth }] = useMeasure(); + + const finalWidth = width && width !== 'auto' ? width : containerWidth; + + useEffect(() => { + const maxWidth = finalWidth - suffixWidth; + let currWidth = 0; + for (let i = 0; i < selectedItems.length; i++) { + // Measure text width and add size of padding, separator and close button + currWidth += measureText(selectedItems[i].label || '', FONT_SIZE).width + EXTRA_PILL_SIZE; + if (currWidth > maxWidth) { + // If there is no space for that item, show the current number of items, + // but always show at least 1 item + setShownItems(i || 1); + break; + } + if (i === selectedItems.length - 1) { + // If it is the last item, show all items + setShownItems(selectedItems.length); + } + } + }, [finalWidth, suffixWidth, selectedItems, setShownItems]); + + return { measureRef, suffixMeasureRef, shownItems }; +}