MultiCombobox: Autosize (#99510)

* Add input auto resizing

* Initial auotsize

* Initial implementation

* Remove px

* Remove unused import

* Handle backspace and support the width prop

* Make sizing work with useComboboxFloat

* Remove unused expression

* Add supoport for min and max width

* Change space for clicking
This commit is contained in:
Tobias Skarhed 2025-01-28 11:03:08 +01:00 committed by GitHub
parent a05f539dd2
commit 8c2824cf3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 113 additions and 18 deletions

View File

@ -48,6 +48,24 @@ export const Basic: Story = {
},
};
export const AutoSize: Story = {
args: { ...commonArgs, width: 'auto', minWidth: 20 },
render: (args) => {
const [{ value }, setArgs] = useArgs();
return (
<MultiCombobox
{...args}
value={value}
onChange={(val) => {
action('onChange')(val);
setArgs({ value: val });
}}
/>
);
},
};
const ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
const [value, setValue] = useState<string[]>([]);
const [options, setOptions] = useState<ComboboxOption[]>([]);

View File

@ -10,13 +10,13 @@ import { Box } from '../Layout/Box/Box';
import { Stack } from '../Layout/Stack/Stack';
import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { Spinner } from '../Spinner/Spinner';
import { Text } from '../Text/Text';
import { Tooltip } from '../Tooltip';
import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
import { NotFoundError } from './MessageRows';
import { OptionListItem } from './OptionListItem';
import { SuffixIcon } from './SuffixIcon';
import { ValuePill } from './ValuePill';
import { itemFilter, itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
@ -24,6 +24,7 @@ import { getMultiComboboxStyles } from './getMultiComboboxStyles';
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
import { useComboboxFloat } from './useComboboxFloat';
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>;
@ -34,7 +35,19 @@ 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, width, enableAllOption, invalid, loading, disabled } = props;
const {
options,
placeholder,
onChange,
value,
width,
enableAllOption,
invalid,
loading,
disabled,
minWidth,
maxWidth,
} = props;
const isAsync = typeof options === 'function';
const selectedItems = useMemo(() => {
@ -129,7 +142,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
});
const {
//getToggleButtonProps,
getToggleButtonProps,
//getLabelProps,
isOpen,
highlightedIndex,
@ -199,7 +212,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
});
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled);
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled, width, minWidth, maxWidth);
const virtualizerOptions = {
count: items.length,
@ -214,13 +227,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
// Selected items that show up in the input field
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
const { inputRef, inputWidth } = useMultiInputAutoSize(inputValue);
return (
<div ref={containerRef}>
<div
style={{ width: width === 'auto' ? undefined : width }}
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
ref={measureRef}
>
<div className={multiStyles.container} ref={containerRef}>
<div className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })} ref={measureRef}>
<span className={multiStyles.pillWrapper}>
{visibleItems.map((item, index) => (
<ValuePill
@ -258,15 +268,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
getDropdownProps({
disabled,
preventKeyAction: isOpen,
placeholder: selectedItems.length > 0 ? undefined : placeholder,
placeholder,
ref: inputRef,
style: { width: inputWidth },
})
)}
/>
{loading && (
<div className={multiStyles.suffix} ref={suffixMeasureRef}>
<Spinner inline={true} />
</div>
)}
<div className={multiStyles.suffix} ref={suffixMeasureRef} {...getToggleButtonProps()}>
<SuffixIcon isLoading={loading || false} isOpen={isOpen} />
</div>
</span>
</div>
<Portal>

View File

@ -0,0 +1,17 @@
import { Icon } from '../Icon/Icon';
interface Props {
isLoading: boolean;
isOpen: boolean;
}
export const SuffixIcon = ({ isLoading, isOpen }: Props) => {
const suffixIcon = isLoading
? 'spinner'
: // If it's loading, show loading icon. Otherwise, icon indicating menu state
isOpen
? 'search'
: 'angle-down';
return <Icon name={suffixIcon} />;
};

View File

@ -9,18 +9,33 @@ export const getMultiComboboxStyles = (
theme: GrafanaTheme2,
isOpen: boolean,
invalid?: boolean,
disabled?: boolean
disabled?: boolean,
width?: number | 'auto',
minWidth?: number,
maxWidth?: number
) => {
const inputStyles = getInputStyles({ theme, invalid });
const focusStyles = getFocusStyles(theme);
const wrapperWidth = width && width !== 'auto' ? theme.spacing(width) : '100%';
const wrapperMinWidth = minWidth ? theme.spacing(minWidth) : '';
const wrapperMaxWidth = maxWidth ? theme.spacing(maxWidth) : '';
return {
container: css({
width: width === 'auto' ? 'auto' : wrapperWidth,
minWidth: wrapperMinWidth,
maxWidth: wrapperMaxWidth,
display: width === 'auto' ? 'inline-block' : 'block',
}), // wraps everything
wrapper: cx(
inputStyles.input,
css({
display: 'flex',
width: '100%',
gap: theme.spacing(0.5),
padding: theme.spacing(0.5),
paddingRight: 28, // Account for suffix
'&:focus-within': {
...focusStyles,
},
@ -31,7 +46,8 @@ export const getMultiComboboxStyles = (
outline: 'none',
background: 'transparent',
flexGrow: 1,
minWidth: '0',
maxWidth: '100%',
minWidth: 40, // This is a bit arbitrary, but is used to leave some space for clicking. This will override the minWidth property
'&::placeholder': {
color: theme.colors.text.disabled,
},

View File

@ -0,0 +1,33 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { measureText } from '../../utils';
export function useMultiInputAutoSize(inputValue: string) {
const inputRef = useRef<HTMLInputElement>(null);
const initialInputWidth = useRef<number>(0); // Store initial width to prevent resizing on backspace
const [inputWidth, setInputWidth] = useState<string>('');
useLayoutEffect(() => {
if (inputRef.current && inputValue == null && initialInputWidth.current === 0) {
initialInputWidth.current = inputRef?.current.getBoundingClientRect().width;
}
if (!inputRef.current || inputValue == null) {
setInputWidth('');
return;
}
const fontSize = window.getComputedStyle(inputRef.current).fontSize;
const textWidth = measureText(inputRef.current.value || '', parseInt(fontSize, 10)).width;
if (textWidth < initialInputWidth.current) {
// Let input fill all space before resizing
setInputWidth('');
} else {
// Add pixels to prevent clipping
setInputWidth(`${textWidth + 5}px`);
}
}, [inputValue]);
return { inputRef, inputWidth };
}