mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
a05f539dd2
commit
8c2824cf3b
@ -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[]>([]);
|
||||
|
@ -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>
|
||||
|
17
packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx
Normal file
17
packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx
Normal 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} />;
|
||||
};
|
@ -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,
|
||||
},
|
||||
|
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue
Block a user