New Select: Semi-dynamic option width support (#92284)

* 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 <laura.fernandez@grafana.com>

* Update packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts

---------

Co-authored-by: Laura Fernández <laura.fernandez@grafana.com>
This commit is contained in:
Tobias Skarhed 2024-08-27 15:51:25 +02:00 committed by GitHub
parent f063088188
commit 92491dd78c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 168 additions and 66 deletions

View File

@ -3,6 +3,8 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Chance } from 'chance'; import { Chance } from 'chance';
import { ComponentProps, useEffect, useState } from 'react'; import { ComponentProps, useEffect, useState } from 'react';
import { Field } from '../Forms/Field';
import { Combobox, Option, Value } from './Combobox'; import { Combobox, Option, Value } from './Combobox';
const chance = new Chance(); const chance = new Chance();
@ -15,11 +17,18 @@ const meta: Meta<PropsAndCustomArgs> = {
args: { args: {
loading: undefined, loading: undefined,
invalid: undefined, invalid: undefined,
width: 30,
placeholder: 'Select an option...', placeholder: 'Select an option...',
options: [ options: [
{ label: 'Apple', value: 'apple' }, { label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' }, { label: 'Banana', value: 'banana' },
{ label: 'Carrot', value: 'carrot' }, { 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: 'Dill', value: 'dill' },
{ label: 'Eggplant', value: 'eggplant' }, { label: 'Eggplant', value: 'eggplant' },
{ label: 'Fennel', value: 'fennel' }, { label: 'Fennel', value: 'fennel' },
@ -40,14 +49,17 @@ const meta: Meta<PropsAndCustomArgs> = {
const BasicWithState: StoryFn<typeof Combobox> = (args) => { const BasicWithState: StoryFn<typeof Combobox> = (args) => {
const [value, setValue] = useState(args.value); const [value, setValue] = useState(args.value);
return ( return (
<Combobox <Field label="Test input" description="Input with a few options">
{...args} <Combobox
value={value} id="test-combobox"
onChange={(val) => { {...args}
setValue(val?.value || null); value={value}
action('onChange')(val); onChange={(val) => {
}} setValue(val?.value || null);
/> action('onChange')(val);
}}
/>
</Field>
); );
}; };
@ -56,10 +68,10 @@ type Story = StoryObj<typeof Combobox>;
export const Basic: Story = {}; export const Basic: Story = {};
async function generateOptions(amount: number): Promise<Option[]> { async function generateOptions(amount: number): Promise<Option[]> {
return Array.from({ length: amount }, () => ({ return Array.from({ length: amount }, (_, index) => ({
label: chance.name(), label: chance.sentence({ words: index % 5 }),
value: chance.guid(), value: chance.guid(),
description: chance.sentence(), //description: chance.sentence(),
})); }));
} }

View File

@ -1,8 +1,8 @@
import { cx } from '@emotion/css'; 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 { useVirtualizer } from '@tanstack/react-virtual';
import { useCombobox } from 'downshift'; 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 { useStyles2 } from '../../themes';
import { t } from '../../utils/i18n'; import { t } from '../../utils/i18n';
@ -19,7 +19,7 @@ export type Option = {
}; };
interface ComboboxProps interface ComboboxProps
extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> { extends Omit<InputProps, 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
onChange: (val: Option | null) => void; onChange: (val: Option | null) => void;
value: Value | null; value: Value | null;
options: Option[]; options: Option[];
@ -42,11 +42,16 @@ function itemFilter(inputValue: string) {
} }
function estimateSize() { function estimateSize() {
return 60; return 45;
} }
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => { const MIN_HEIGHT = 400;
const MIN_WIDTH = 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 [items, setItems] = useState(options);
const selectedItemIndex = useMemo( const selectedItemIndex = useMemo(
() => options.findIndex((option) => option.value === value) || null, () => 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 selectedItem = selectedItemIndex ? options[selectedItemIndex] : null;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef(null); const floatingRef = useRef<HTMLDivElement>(null);
const styles = useStyles2(getComboboxStyles);
const rowVirtualizer = useVirtualizer({ const styles = useStyles2(getComboboxStyles);
const [popoverMaxWidth, setPopoverMaxWidth] = useState<number | undefined>(undefined);
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const virtualizerOptions = {
count: items.length, count: items.length,
getScrollElement: () => floatingRef.current, getScrollElement: () => floatingRef.current,
estimateSize, estimateSize,
overscan: 2, overscan: 4,
}); };
const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex, setInputValue, selectItem } = const rowVirtualizer = useVirtualizer(virtualizerOptions);
useCombobox({
items, const {
itemToString, getInputProps,
selectedItem, getMenuProps,
defaultHighlightedIndex: selectedItemIndex ?? undefined, getItemProps,
scrollIntoView: () => {}, isOpen,
onInputValueChange: ({ inputValue }) => { highlightedIndex,
setItems(options.filter(itemFilter(inputValue))); setInputValue,
}, selectItem,
onIsOpenChange: ({ isOpen }) => { openMenu,
// Default to displaying all values when opening closeMenu,
if (isOpen) { } = useCombobox({
setItems(options); inputId: id,
return; items,
} itemToString,
}, selectedItem,
onSelectedItemChange: ({ selectedItem }) => { defaultHighlightedIndex: selectedItemIndex ?? undefined,
onChange(selectedItem); scrollIntoView: () => {},
}, onInputValueChange: ({ inputValue }) => {
onHighlightedIndexChange: ({ highlightedIndex, type }) => { setItems(options.filter(itemFilter(inputValue)));
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { },
rowVirtualizer.scrollToIndex(highlightedIndex); 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(() => { const onBlur = useCallback(() => {
setInputValue(selectedItem?.label ?? ''); setInputValue(selectedItem?.label ?? '');
@ -100,21 +120,27 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
const middleware = [ const middleware = [
flip({ flip({
// see https://floating-ui.com/docs/flip#combining-with-shift // see https://floating-ui.com/docs/flip#combining-with-shift
crossAxis: false, crossAxis: true,
boundary: document.body, boundary: document.body,
fallbackPlacements: ['top'], }),
size({
apply({ availableWidth }) {
setPopoverMaxWidth(availableWidth);
},
}), }),
]; ];
const elements = { reference: inputRef.current, floating: floatingRef.current }; const elements = { reference: inputRef.current, floating: floatingRef.current };
const { floatingStyles } = useFloating({ const { floatingStyles } = useFloating({
open: isOpen, open: isOpen,
placement: 'bottom', placement: 'bottom-start',
middleware, middleware,
elements, elements,
whileElementsMounted: autoUpdate, whileElementsMounted: autoUpdate,
}); });
const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_WIDTH; const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_HEIGHT;
useDynamicWidth(items, rowVirtualizer.range, setPopoverWidth);
return ( return (
<div> <div>
@ -127,6 +153,7 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
className={styles.clear} className={styles.clear}
title={t('combobox.clear.title', 'Clear value')} title={t('combobox.clear.title', 'Clear value')}
tabIndex={0} tabIndex={0}
role="button"
onClick={() => { onClick={() => {
selectItem(null); selectItem(null);
}} }}
@ -137,7 +164,16 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
}} }}
/> />
)} )}
<Icon name={isOpen ? 'search' : 'angle-down'} /> <Icon
name={isOpen ? 'search' : 'angle-down'}
onClick={() => {
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}}
/>
</> </>
} }
{...restProps} {...restProps}
@ -153,7 +189,12 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
/> />
<div <div
className={cx(styles.menu, hasMinHeight && styles.menuHeight)} className={cx(styles.menu, hasMinHeight && styles.menuHeight)}
style={{ ...floatingStyles, width: elements.reference?.getBoundingClientRect().width }} style={{
...floatingStyles,
maxWidth: popoverMaxWidth,
minWidth: inputRef.current?.offsetWidth,
width: popoverWidth,
}}
{...getMenuProps({ ref: floatingRef })} {...getMenuProps({ ref: floatingRef })}
> >
{isOpen && ( {isOpen && (
@ -162,20 +203,20 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
return ( return (
<li <li
key={items[virtualRow.index].value} key={items[virtualRow.index].value}
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={cx( className={cx(
styles.option, styles.option,
selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected, selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused highlightedIndex === virtualRow.index && styles.optionFocused
)} )}
style={{ style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
}} }}
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
> >
<div className={styles.optionBody}> <div className={styles.optionBody}>
<span>{items[virtualRow.index].label}</span> <span className={styles.optionLabel}>{items[virtualRow.index].label}</span>
{items[virtualRow.index].description && ( {items[virtualRow.index].description && (
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span> <span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
)} )}
@ -189,3 +230,46 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
</div> </div>
); );
}; };
const useDynamicWidth = (
items: Option[],
range: { startIndex: number; endIndex: number } | null,
setPopoverWidth: { (value: SetStateAction<number | undefined>): 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]);
};

View File

@ -22,16 +22,16 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
}), }),
option: css({ option: css({
label: 'grafana-select-option', label: 'grafana-select-option',
padding: '8px',
position: 'absolute', position: 'absolute',
top: 0, display: 'flex',
left: 0, alignItems: 'center',
width: '100%', flexDirection: 'row',
flexShrink: 0,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
width: '100%',
overflow: 'hidden',
cursor: 'pointer', cursor: 'pointer',
borderLeft: '2px solid transparent',
padding: theme.spacing.x1,
boxSizing: 'border-box',
height: 'auto',
'&:hover': { '&:hover': {
background: theme.colors.action.hover, background: theme.colors.action.hover,
'@media (forced-colors: active), (prefers-contrast: more)': { '@media (forced-colors: active), (prefers-contrast: more)': {
@ -45,14 +45,21 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
flexDirection: 'column', flexDirection: 'column',
flexGrow: 1, flexGrow: 1,
overflow: 'hidden',
}),
optionLabel: css({
label: 'grafana-select-option-label',
textOverflow: 'ellipsis',
overflow: 'hidden',
}), }),
optionDescription: css({ optionDescription: css({
label: 'grafana-select-option-description', label: 'grafana-select-option-description',
fontWeight: 'normal', fontWeight: 'normal',
fontSize: theme.typography.bodySmall.fontSize, fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
whiteSpace: 'normal',
lineHeight: theme.typography.body.lineHeight, lineHeight: theme.typography.body.lineHeight,
textOverflow: 'ellipsis',
overflow: 'hidden',
}), }),
optionFocused: css({ optionFocused: css({
label: 'grafana-select-option-focused', label: 'grafana-select-option-focused',
@ -71,7 +78,6 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
display: 'block', display: 'block',
height: '100%', height: '100%',
position: 'absolute', position: 'absolute',
transform: 'translateX(-50%)',
width: theme.spacing(0.5), width: theme.spacing(0.5),
left: 0, left: 0,
top: 0, top: 0,