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