mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MultiCombobox: Add truncation behavior (#97691)
This commit is contained in:
parent
a06779614e
commit
989ee681f8
@ -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...',
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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 && (
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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 };
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user