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 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Option 4', value: 'option4' },
{ label: 'Option 5', value: 'option5' },
],
value: ['option2'],
placeholder: 'Select multiple options...',

View File

@ -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(<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'));
expect(await screen.findByText('d')).toBeInTheDocument();
});

View File

@ -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<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
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 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 selectedItems = useMemo(() => {
@ -30,11 +35,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
return getSelectedItemsFromValue<T>(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<T>) => selectedItems.some((opt) => opt.value === item.value),
[selectedItems]
@ -114,25 +121,60 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
},
});
const visibleItems = isOpen ? selectedItems : selectedItems.slice(0, shownItems);
return (
<div className={multiStyles.wrapper}>
<span className={multiStyles.pillWrapper}>
{selectedItems.map((item, index) => (
<ValuePill
onRemove={() => {
removeSelectedItem(item);
}}
key={`${item.value}${index}`}
{...getSelectedItemProps({ selectedItem: item, index })}
>
{itemToString(item)}
</ValuePill>
))}
</span>
<input
className={multiStyles.input}
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen, placeholder, onFocus: () => setIsOpen(true) }))}
/>
<div>
<div
style={{ width: width === 'auto' ? undefined : width }}
className={multiStyles.wrapper}
ref={measureRef}
onClick={() => selectedItems.length > 0 && setIsOpen(!isOpen)}
>
<span className={multiStyles.pillWrapper}>
{visibleItems.map((item, index) => (
<ValuePill
onRemove={() => {
removeSelectedItem(item);
}}
key={`${item.value}${index}`}
{...getSelectedItemProps({ selectedItem: item, index })}
>
{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()}>
<Portal>
{isOpen && (

View File

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

View File

@ -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,
},
}),
};
};

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 };
}