From 8c2824cf3b0ab8fa46d01b9b10aca400679e9215 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:03:08 +0100 Subject: [PATCH] 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 --- .../Combobox/MultiCombobox.internal.story.tsx | 18 ++++++++ .../src/components/Combobox/MultiCombobox.tsx | 43 ++++++++++++------- .../src/components/Combobox/SuffixIcon.tsx | 17 ++++++++ .../Combobox/getMultiComboboxStyles.ts | 20 ++++++++- .../Combobox/useMultiInputAutoSize.tsx | 33 ++++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx create mode 100644 packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 0dc4f2e9128..5d27d2ac594 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -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 ( + { + action('onChange')(val); + setArgs({ value: val }); + }} + /> + ); + }, +}; + const ManyOptionsStory: StoryFn = ({ numberOfOptions = 1e4, ...args }) => { const [value, setValue] = useState([]); const [options, setOptions] = useState([]); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 0cede71af9e..4b819ab4a28 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -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 extends Omit, 'value' | 'onChange'> { value?: T[] | Array>; @@ -34,7 +35,19 @@ interface MultiComboboxBaseProps extends Omit = MultiComboboxBaseProps & AutoSizeConditionals; export const MultiCombobox = (props: MultiComboboxProps) => { - 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 = (props: MultiComboboxPro }); const { - //getToggleButtonProps, + getToggleButtonProps, //getLabelProps, isOpen, highlightedIndex, @@ -199,7 +212,7 @@ export const MultiCombobox = (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 = (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 ( -
-
+
+
{visibleItems.map((item, index) => ( (props: MultiComboboxPro getDropdownProps({ disabled, preventKeyAction: isOpen, - placeholder: selectedItems.length > 0 ? undefined : placeholder, + placeholder, + ref: inputRef, + style: { width: inputWidth }, }) )} /> - {loading && ( -
- -
- )} + +
+ +
diff --git a/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx b/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx new file mode 100644 index 00000000000..04fa29b699b --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/SuffixIcon.tsx @@ -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 ; +}; diff --git a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts index 4347acdb0e7..06bd2dd2a22 100644 --- a/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts @@ -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, }, diff --git a/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx b/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx new file mode 100644 index 00000000000..30ade93a632 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useMultiInputAutoSize.tsx @@ -0,0 +1,33 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +import { measureText } from '../../utils'; + +export function useMultiInputAutoSize(inputValue: string) { + const inputRef = useRef(null); + const initialInputWidth = useRef(0); // Store initial width to prevent resizing on backspace + const [inputWidth, setInputWidth] = useState(''); + + 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 }; +}