MultiCombobox: Add truncation behavior (#97691)

This commit is contained in:
Joao Silva 2024-12-19 11:25:44 +00:00 committed by GitHub
parent a06779614e
commit 989ee681f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 146 additions and 26 deletions

View File

@ -14,6 +14,8 @@ const commonArgs = {
{ label: 'Option 1', value: 'option1' }, { label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' }, { label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' }, { label: 'Option 3', value: 'option3' },
{ label: 'Option 4', value: 'option4' },
{ label: 'Option 5', value: 'option5' },
], ],
value: ['option2'], value: ['option2'],
placeholder: 'Select multiple options...', placeholder: 'Select multiple options...',

View File

@ -86,13 +86,13 @@ describe('MultiCombobox', () => {
expect(onChange).toHaveBeenNthCalledWith(3, [third]); 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 = [ const options = [
{ label: 'A', value: 'a' }, { label: 'A', value: 'a' },
{ label: 'B', value: 'b' }, { label: 'B', value: 'b' },
{ label: 'C', value: 'c' }, { label: 'C', value: 'c' },
]; ];
render(<MultiCombobox options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />); render(<MultiCombobox width={200} options={options} value={['a', 'd', 'c']} onChange={jest.fn()} />);
await user.click(screen.getByRole('combobox')); await user.click(screen.getByRole('combobox'));
expect(await screen.findByText('d')).toBeInTheDocument(); expect(await screen.findByText('d')).toBeInTheDocument();
}); });

View File

@ -1,14 +1,19 @@
import { cx } from '@emotion/css';
import { useCombobox, useMultipleSelection } from 'downshift'; import { useCombobox, useMultipleSelection } from 'downshift';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { Checkbox } from '../Forms/Checkbox'; import { Checkbox } from '../Forms/Checkbox';
import { Box } from '../Layout/Box/Box';
import { Portal } from '../Portal/Portal'; import { Portal } from '../Portal/Portal';
import { Text } from '../Text/Text';
import { Tooltip } from '../Tooltip';
import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox'; import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox';
import { OptionListItem } from './OptionListItem'; import { OptionListItem } from './OptionListItem';
import { ValuePill } from './ValuePill'; import { ValuePill } from './ValuePill';
import { getMultiComboboxStyles } from './getMultiComboboxStyles'; import { getMultiComboboxStyles } from './getMultiComboboxStyles';
import { useMeasureMulti } from './useMeasureMulti';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> { interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>; value?: T[] | Array<ComboboxOption<T>>;
@ -18,7 +23,7 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals; export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => { export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
const { options, placeholder, onChange, value } = props; const { options, placeholder, onChange, value, width } = props;
const isAsync = typeof options === 'function'; const isAsync = typeof options === 'function';
const selectedItems = useMemo(() => { const selectedItems = useMemo(() => {
@ -30,11 +35,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
return getSelectedItemsFromValue<T>(value, options); return getSelectedItemsFromValue<T>(value, options);
}, [value, options, isAsync]); }, [value, options, isAsync]);
const multiStyles = useStyles2(getMultiComboboxStyles);
const [items, _baseSetItems] = useState(isAsync ? [] : options); const [items, _baseSetItems] = useState(isAsync ? [] : options);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen);
const { measureRef, suffixMeasureRef, shownItems } = useMeasureMulti(selectedItems, width);
const isOptionSelected = useCallback( const isOptionSelected = useCallback(
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value), (item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
[selectedItems] [selectedItems]
@ -114,25 +121,60 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}, },
}); });
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
return ( return (
<div className={multiStyles.wrapper}> <div>
<span className={multiStyles.pillWrapper}> <div
{selectedItems.map((item, index) => ( style={{ width: width === 'auto' ? undefined : width }}
<ValuePill className={multiStyles.wrapper}
onRemove={() => { ref={measureRef}
removeSelectedItem(item); onClick={() => selectedItems.length > 0 && setIsOpen(!isOpen)}
}} >
key={`${item.value}${index}`} <span className={multiStyles.pillWrapper}>
{...getSelectedItemProps({ selectedItem: item, index })} {visibleItems.map((item, index) => (
> <ValuePill
{itemToString(item)} onRemove={() => {
</ValuePill> removeSelectedItem(item);
))} }}
</span> key={`${item.value}${index}`}
<input {...getSelectedItemProps({ selectedItem: item, index })}
className={multiStyles.input} >
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen, placeholder, onFocus: () => setIsOpen(true) }))} {itemToString(item)}
/> </ValuePill>
))}
{selectedItems.length > shownItems && !isOpen && (
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={suffixMeasureRef}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<Text>...</Text>
<Tooltip
interactive
content={
<>
{selectedItems.slice(shownItems).map((item) => (
<div key={item.value}>{itemToString(item)}</div>
))}
</>
}
>
<div className={multiStyles.restNumber}>{selectedItems.length - shownItems}</div>
</Tooltip>
</Box>
)}
<input
className={cx(multiStyles.input, {
[multiStyles.inputClosed]: !isOpen && selectedItems.length > 0,
})}
{...getInputProps(
getDropdownProps({
preventKeyAction: isOpen,
placeholder: selectedItems.length > 0 ? undefined : placeholder,
onFocus: () => setIsOpen(true),
})
)}
/>
</span>
</div>
<div {...getMenuProps()}> <div {...getMenuProps()}>
<Portal> <Portal>
{isOpen && ( {isOpen && (

View File

@ -15,7 +15,7 @@ export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(({ children
const styles = useStyles2(getValuePillStyles); const styles = useStyles2(getValuePillStyles);
return ( return (
<span className={styles.wrapper} {...rest} ref={ref}> <span className={styles.wrapper} {...rest} ref={ref}>
{children} <span className={styles.text}>{children}</span>
<span className={styles.separator} /> <span className={styles.separator} />
<IconButton <IconButton
name="times" name="times"
@ -39,6 +39,18 @@ const getValuePillStyles = (theme: GrafanaTheme2) => ({
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
padding: theme.spacing(0.25), padding: theme.spacing(0.25),
fontSize: theme.typography.bodySmall.fontSize, 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({ separator: css({

View File

@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { getFocusStyles } from '../../themes/mixins'; import { getFocusStyles } from '../../themes/mixins';
import { getInputStyles } from '../Input/Input'; import { getInputStyles } from '../Input/Input';
export const getMultiComboboxStyles = (theme: GrafanaTheme2) => { export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => {
const inputStyles = getInputStyles({ theme }); const inputStyles = getInputStyles({ theme });
const focusStyles = getFocusStyles(theme); const focusStyles = getFocusStyles(theme);
@ -34,10 +34,30 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2) => {
outline: 'none', outline: 'none',
}, },
}), }),
inputClosed: css({
width: 0,
flexGrow: 0,
paddingLeft: 0,
paddingRight: 0,
}),
pillWrapper: css({ pillWrapper: css({
display: 'inline-flex', display: 'inline-flex',
flexWrap: 'wrap', flexWrap: isOpen ? 'wrap' : 'nowrap',
flexGrow: 1,
minWidth: '50px',
gap: theme.spacing(0.5), 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,
},
}),
}; };
}; };

View File

@ -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<T extends string | number>(
selectedItems: Array<ComboboxOption<T>>,
width?: number | 'auto'
) {
const [shownItems, setShownItems] = useState<number>(selectedItems.length);
const [measureRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [suffixMeasureRef, { width: suffixWidth }] = useMeasure<HTMLDivElement>();
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 };
}