From 92491dd78cb1c6e8c6e2a939e47b80ff4194afdf Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:51:25 +0200 Subject: [PATCH] New Select: Semi-dynamic option width support (#92284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix button role and input id * Use static height and dynamic width * Estimate dynamic width * Extract constant * Remove unused code * Extract dynamic width into a hook * Remove console.log * Add comment to the constants * Update packages/grafana-ui/src/components/Combobox/Combobox.tsx Co-authored-by: Laura Fernández * Update packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts --------- Co-authored-by: Laura Fernández --- .../Combobox/Combobox.internal.story.tsx | 34 ++-- .../src/components/Combobox/Combobox.tsx | 176 +++++++++++++----- .../components/Combobox/getComboboxStyles.ts | 24 ++- 3 files changed, 168 insertions(+), 66 deletions(-) diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx index 05a331b7606..e93dcdf4128 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx @@ -3,6 +3,8 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Chance } from 'chance'; import { ComponentProps, useEffect, useState } from 'react'; +import { Field } from '../Forms/Field'; + import { Combobox, Option, Value } from './Combobox'; const chance = new Chance(); @@ -15,11 +17,18 @@ const meta: Meta = { args: { loading: undefined, invalid: undefined, + width: 30, placeholder: 'Select an option...', options: [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Carrot', value: 'carrot' }, + // Long label to test overflow + { + label: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + value: 'long-text', + }, { label: 'Dill', value: 'dill' }, { label: 'Eggplant', value: 'eggplant' }, { label: 'Fennel', value: 'fennel' }, @@ -40,14 +49,17 @@ const meta: Meta = { const BasicWithState: StoryFn = (args) => { const [value, setValue] = useState(args.value); return ( - { - setValue(val?.value || null); - action('onChange')(val); - }} - /> + + { + setValue(val?.value || null); + action('onChange')(val); + }} + /> + ); }; @@ -56,10 +68,10 @@ type Story = StoryObj; export const Basic: Story = {}; async function generateOptions(amount: number): Promise { - return Array.from({ length: amount }, () => ({ - label: chance.name(), + return Array.from({ length: amount }, (_, index) => ({ + label: chance.sentence({ words: index % 5 }), value: chance.guid(), - description: chance.sentence(), + //description: chance.sentence(), })); } diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index c553bcc8ef0..f3739086a24 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -1,8 +1,8 @@ import { cx } from '@emotion/css'; -import { autoUpdate, flip, useFloating } from '@floating-ui/react'; +import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useStyles2 } from '../../themes'; import { t } from '../../utils/i18n'; @@ -19,7 +19,7 @@ export type Option = { }; interface ComboboxProps - extends Omit { + extends Omit { onChange: (val: Option | null) => void; value: Value | null; options: Option[]; @@ -42,11 +42,16 @@ function itemFilter(inputValue: string) { } function estimateSize() { - return 60; + return 45; } -export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => { - const MIN_WIDTH = 400; +const MIN_HEIGHT = 400; +// On every 100th index we will recalculate the width of the popover. +const INDEX_WIDTH_CALCULATION = 100; +// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. +const WIDTH_MULTIPLIER = 7.3; + +export const Combobox = ({ options, onChange, value, id, ...restProps }: ComboboxProps) => { const [items, setItems] = useState(options); const selectedItemIndex = useMemo( () => options.findIndex((option) => option.value === value) || null, @@ -55,42 +60,57 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro const selectedItem = selectedItemIndex ? options[selectedItemIndex] : null; const inputRef = useRef(null); - const floatingRef = useRef(null); - const styles = useStyles2(getComboboxStyles); + const floatingRef = useRef(null); - const rowVirtualizer = useVirtualizer({ + const styles = useStyles2(getComboboxStyles); + const [popoverMaxWidth, setPopoverMaxWidth] = useState(undefined); + const [popoverWidth, setPopoverWidth] = useState(undefined); + + const virtualizerOptions = { count: items.length, getScrollElement: () => floatingRef.current, estimateSize, - overscan: 2, - }); + overscan: 4, + }; - const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex, setInputValue, selectItem } = - useCombobox({ - items, - itemToString, - selectedItem, - defaultHighlightedIndex: selectedItemIndex ?? undefined, - scrollIntoView: () => {}, - onInputValueChange: ({ inputValue }) => { - setItems(options.filter(itemFilter(inputValue))); - }, - onIsOpenChange: ({ isOpen }) => { - // Default to displaying all values when opening - if (isOpen) { - setItems(options); - return; - } - }, - onSelectedItemChange: ({ selectedItem }) => { - onChange(selectedItem); - }, - onHighlightedIndexChange: ({ highlightedIndex, type }) => { - if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { - rowVirtualizer.scrollToIndex(highlightedIndex); - } - }, - }); + const rowVirtualizer = useVirtualizer(virtualizerOptions); + + const { + getInputProps, + getMenuProps, + getItemProps, + isOpen, + highlightedIndex, + setInputValue, + selectItem, + openMenu, + closeMenu, + } = useCombobox({ + inputId: id, + items, + itemToString, + selectedItem, + defaultHighlightedIndex: selectedItemIndex ?? undefined, + scrollIntoView: () => {}, + onInputValueChange: ({ inputValue }) => { + setItems(options.filter(itemFilter(inputValue))); + }, + onIsOpenChange: ({ isOpen }) => { + // Default to displaying all values when opening + if (isOpen) { + setItems(options); + return; + } + }, + onSelectedItemChange: ({ selectedItem }) => { + onChange(selectedItem); + }, + onHighlightedIndexChange: ({ highlightedIndex, type }) => { + if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { + rowVirtualizer.scrollToIndex(highlightedIndex); + } + }, + }); const onBlur = useCallback(() => { setInputValue(selectedItem?.label ?? ''); @@ -100,21 +120,27 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro const middleware = [ flip({ // see https://floating-ui.com/docs/flip#combining-with-shift - crossAxis: false, + crossAxis: true, boundary: document.body, - fallbackPlacements: ['top'], + }), + size({ + apply({ availableWidth }) { + setPopoverMaxWidth(availableWidth); + }, }), ]; const elements = { reference: inputRef.current, floating: floatingRef.current }; const { floatingStyles } = useFloating({ open: isOpen, - placement: 'bottom', + placement: 'bottom-start', middleware, elements, whileElementsMounted: autoUpdate, }); - const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_WIDTH; + const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_HEIGHT; + + useDynamicWidth(items, rowVirtualizer.range, setPopoverWidth); return (
@@ -127,6 +153,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro className={styles.clear} title={t('combobox.clear.title', 'Clear value')} tabIndex={0} + role="button" onClick={() => { selectItem(null); }} @@ -137,7 +164,16 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro }} /> )} - + { + if (isOpen) { + closeMenu(); + } else { + openMenu(); + } + }} + /> } {...restProps} @@ -153,7 +189,12 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro />
{isOpen && ( @@ -162,20 +203,20 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro return (
  • - {items[virtualRow.index].label} + {items[virtualRow.index].label} {items[virtualRow.index].description && ( {items[virtualRow.index].description} )} @@ -189,3 +230,46 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
    ); }; + +const useDynamicWidth = ( + items: Option[], + range: { startIndex: number; endIndex: number } | null, + setPopoverWidth: { (value: SetStateAction): void } +) => { + useEffect(() => { + if (range === null) { + return; + } + const startVisibleIndex = range?.startIndex; + const endVisibleIndex = range?.endIndex; + + if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') { + return; + } + + // Scroll down and default case + if ( + startVisibleIndex === 0 || + (startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION) + ) { + let maxLength = 0; + const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION); + + for (let i = startVisibleIndex; i < calculationEnd; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) { + // Scroll up case + let maxLength = 0; + const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION); + + for (let i = calculationStart; i < endVisibleIndex; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } + }, [items, range, setPopoverWidth]); +}; diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index c46df1a87ee..06fa657821c 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -22,16 +22,16 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { }), option: css({ label: 'grafana-select-option', + padding: '8px', position: 'absolute', - top: 0, - left: 0, - width: '100%', + display: 'flex', + alignItems: 'center', + flexDirection: 'row', + flexShrink: 0, whiteSpace: 'nowrap', + width: '100%', + overflow: 'hidden', cursor: 'pointer', - borderLeft: '2px solid transparent', - padding: theme.spacing.x1, - boxSizing: 'border-box', - height: 'auto', '&:hover': { background: theme.colors.action.hover, '@media (forced-colors: active), (prefers-contrast: more)': { @@ -45,14 +45,21 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { fontWeight: theme.typography.fontWeightMedium, flexDirection: 'column', flexGrow: 1, + overflow: 'hidden', + }), + optionLabel: css({ + label: 'grafana-select-option-label', + textOverflow: 'ellipsis', + overflow: 'hidden', }), optionDescription: css({ label: 'grafana-select-option-description', fontWeight: 'normal', fontSize: theme.typography.bodySmall.fontSize, color: theme.colors.text.secondary, - whiteSpace: 'normal', lineHeight: theme.typography.body.lineHeight, + textOverflow: 'ellipsis', + overflow: 'hidden', }), optionFocused: css({ label: 'grafana-select-option-focused', @@ -71,7 +78,6 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { display: 'block', height: '100%', position: 'absolute', - transform: 'translateX(-50%)', width: theme.spacing(0.5), left: 0, top: 0,