mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
f063088188
commit
92491dd78c
@ -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(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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]);
|
||||||
|
};
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user