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 ManyOptionsStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e4, ...args }) => {
|
||||||
const [value, setValue] = useState<string[]>([]);
|
const [value, setValue] = useState<string[]>([]);
|
||||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||||
|
@ -10,13 +10,13 @@ import { Box } from '../Layout/Box/Box';
|
|||||||
import { Stack } from '../Layout/Stack/Stack';
|
import { Stack } from '../Layout/Stack/Stack';
|
||||||
import { Portal } from '../Portal/Portal';
|
import { Portal } from '../Portal/Portal';
|
||||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||||
import { Spinner } from '../Spinner/Spinner';
|
|
||||||
import { Text } from '../Text/Text';
|
import { Text } from '../Text/Text';
|
||||||
import { Tooltip } from '../Tooltip';
|
import { Tooltip } from '../Tooltip';
|
||||||
|
|
||||||
import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
|
import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
|
||||||
import { NotFoundError } from './MessageRows';
|
import { NotFoundError } from './MessageRows';
|
||||||
import { OptionListItem } from './OptionListItem';
|
import { OptionListItem } from './OptionListItem';
|
||||||
|
import { SuffixIcon } from './SuffixIcon';
|
||||||
import { ValuePill } from './ValuePill';
|
import { ValuePill } from './ValuePill';
|
||||||
import { itemFilter, itemToString } from './filter';
|
import { itemFilter, itemToString } from './filter';
|
||||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
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 { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||||
import { useComboboxFloat } from './useComboboxFloat';
|
import { useComboboxFloat } from './useComboboxFloat';
|
||||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||||
|
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
|
||||||
|
|
||||||
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>>;
|
||||||
@ -34,7 +35,19 @@ 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, 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 isAsync = typeof options === 'function';
|
||||||
|
|
||||||
const selectedItems = useMemo(() => {
|
const selectedItems = useMemo(() => {
|
||||||
@ -129,7 +142,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
//getToggleButtonProps,
|
getToggleButtonProps,
|
||||||
//getLabelProps,
|
//getLabelProps,
|
||||||
isOpen,
|
isOpen,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
@ -199,7 +212,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
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 = {
|
const virtualizerOptions = {
|
||||||
count: items.length,
|
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
|
// Selected items that show up in the input field
|
||||||
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
|
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
|
||||||
|
|
||||||
|
const { inputRef, inputWidth } = useMultiInputAutoSize(inputValue);
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div className={multiStyles.container} ref={containerRef}>
|
||||||
<div
|
<div className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })} ref={measureRef}>
|
||||||
style={{ width: width === 'auto' ? undefined : width }}
|
|
||||||
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
|
|
||||||
ref={measureRef}
|
|
||||||
>
|
|
||||||
<span className={multiStyles.pillWrapper}>
|
<span className={multiStyles.pillWrapper}>
|
||||||
{visibleItems.map((item, index) => (
|
{visibleItems.map((item, index) => (
|
||||||
<ValuePill
|
<ValuePill
|
||||||
@ -258,15 +268,16 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
|||||||
getDropdownProps({
|
getDropdownProps({
|
||||||
disabled,
|
disabled,
|
||||||
preventKeyAction: isOpen,
|
preventKeyAction: isOpen,
|
||||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
placeholder,
|
||||||
|
ref: inputRef,
|
||||||
|
style: { width: inputWidth },
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
|
||||||
<div className={multiStyles.suffix} ref={suffixMeasureRef}>
|
<div className={multiStyles.suffix} ref={suffixMeasureRef} {...getToggleButtonProps()}>
|
||||||
<Spinner inline={true} />
|
<SuffixIcon isLoading={loading || false} isOpen={isOpen} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Portal>
|
<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,
|
theme: GrafanaTheme2,
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
invalid?: boolean,
|
invalid?: boolean,
|
||||||
disabled?: boolean
|
disabled?: boolean,
|
||||||
|
width?: number | 'auto',
|
||||||
|
minWidth?: number,
|
||||||
|
maxWidth?: number
|
||||||
) => {
|
) => {
|
||||||
const inputStyles = getInputStyles({ theme, invalid });
|
const inputStyles = getInputStyles({ theme, invalid });
|
||||||
const focusStyles = getFocusStyles(theme);
|
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 {
|
return {
|
||||||
|
container: css({
|
||||||
|
width: width === 'auto' ? 'auto' : wrapperWidth,
|
||||||
|
minWidth: wrapperMinWidth,
|
||||||
|
maxWidth: wrapperMaxWidth,
|
||||||
|
display: width === 'auto' ? 'inline-block' : 'block',
|
||||||
|
}), // wraps everything
|
||||||
wrapper: cx(
|
wrapper: cx(
|
||||||
inputStyles.input,
|
inputStyles.input,
|
||||||
css({
|
css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
gap: theme.spacing(0.5),
|
gap: theme.spacing(0.5),
|
||||||
padding: theme.spacing(0.5),
|
padding: theme.spacing(0.5),
|
||||||
|
paddingRight: 28, // Account for suffix
|
||||||
'&:focus-within': {
|
'&:focus-within': {
|
||||||
...focusStyles,
|
...focusStyles,
|
||||||
},
|
},
|
||||||
@ -31,7 +46,8 @@ export const getMultiComboboxStyles = (
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
flexGrow: 1,
|
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': {
|
'&::placeholder': {
|
||||||
color: theme.colors.text.disabled,
|
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