Combobox: Unify menu/list into ComboboxList component (#102677)

* tighten up, lots of multi select stuff still broken

* add 2px margin

* fix All

* put async error message back

* translations

* fix styling
This commit is contained in:
Josh Hunt
2025-03-31 16:54:05 +01:00
committed by GitHub
parent 1ebcb3f604
commit 54fca9380b
9 changed files with 253 additions and 265 deletions
@@ -514,11 +514,9 @@ describe('Combobox', () => {
});
const customItem = screen.getByRole('option');
const customValue = customItem.getElementsByTagName('span')[0].textContent;
const customDescription = customItem.getElementsByTagName('span')[1].textContent;
expect(customItem).toBeInTheDocument();
expect(customValue).toBe('fir');
expect(customDescription).toBe('Use custom value');
expect(customItem).toHaveTextContent('fir');
expect(customItem).toHaveTextContent('Use custom value');
});
it('should display message when there is an error loading async options', async () => {
@@ -9,9 +9,8 @@ import { Icon } from '../Icon/Icon';
import { AutoSizeInput } from '../Input/AutoSizeInput';
import { Input, Props as InputProps } from '../Input/Input';
import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { AsyncError, NotFoundError } from './MessageRows';
import { ComboboxList } from './ComboboxList';
import { itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { ComboboxOption } from './types';
@@ -375,82 +374,14 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
})}
>
{isOpen && (
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
{!asyncError && (
<div style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow, index, allVirtualRows) => {
const item = filteredOptions[virtualRow.index];
const startingNewGroup = isNewGroup(item, filteredOptions[virtualRow.index - 1]);
// Find the item that renders the group header. It can be this same item if this is rendering it.
const groupHeaderIndex = allVirtualRows.find((row) => {
const rowItem = filteredOptions[row.index];
return rowItem.group === item.group;
});
const groupHeaderItem = groupHeaderIndex && filteredOptions[groupHeaderIndex.index];
const itemId = `${baseId}-option-${item.value}`;
// If we're rendering the group header, this is the ID for it. Otherwise its used on
// the option for aria-describedby.
const groupHeaderId = groupHeaderItem
? `${baseId}-option-group-${groupHeaderItem.value}`
: undefined;
return (
// Wrapping div should have no styling other than virtual list positioning.
// It's children (header and option) should appear as flat list items.
<div
key={item.value}
className={styles.listItem}
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{startingNewGroup && (
<div
role="presentation"
id={groupHeaderId}
className={cx(
styles.newOptionGroup,
item.group && styles.newOptionGroupLabel,
virtualRow.index === 0 && styles.newOptionGroupNoBorder
)}
>
{item.group}
</div>
)}
<div
className={cx(
styles.option,
styles.optionBasic,
selectedItem && item.value === selectedItem.value && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused
)}
{...getItemProps({
item: item,
index: virtualRow.index,
id: itemId,
'aria-describedby': groupHeaderId,
})}
>
<div className={styles.optionBody}>
<span className={styles.optionLabel}>{item.label ?? item.value}</span>
{item.description && <span className={styles.optionDescription}>{item.description}</span>}
</div>
</div>
</div>
);
})}
</div>
)}
<div aria-live="polite">
{asyncError && <AsyncError />}
{filteredOptions.length === 0 && !asyncError && <NotFoundError />}
</div>
</ScrollContainer>
<ComboboxList
options={filteredOptions}
highlightedIndex={highlightedIndex}
selectedItems={selectedItem ? [selectedItem] : []}
scrollRef={scrollRef}
getItemProps={getItemProps}
error={asyncError}
/>
)}
</div>
</Portal>
@@ -0,0 +1,162 @@
import { cx } from '@emotion/css';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { UseComboboxPropGetters } from 'downshift';
import { useCallback } from 'react';
import { useStyles2 } from '../../themes';
import { Checkbox } from '../Forms/Checkbox';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { AsyncError, NotFoundError } from './MessageRows';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
import { isNewGroup } from './utils';
export const VIRTUAL_OVERSCAN_ITEMS = 4;
interface ComboboxListProps<T extends string | number> {
options: Array<ComboboxOption<T>>;
highlightedIndex: number | null;
selectedItems?: Array<ComboboxOption<T>>;
scrollRef: React.RefObject<HTMLDivElement>;
getItemProps: UseComboboxPropGetters<ComboboxOption<T>>['getItemProps'];
enableAllOption?: boolean;
isMultiSelect?: boolean;
error?: boolean;
}
export const ComboboxList = <T extends string | number>({
options,
highlightedIndex,
selectedItems = [],
scrollRef,
getItemProps,
enableAllOption,
isMultiSelect = false,
error = false,
}: ComboboxListProps<T>) => {
const styles = useStyles2(getComboboxStyles);
const estimateSize = useCallback(
(index: number) => {
const firstGroupItem = isNewGroup(options[index], index > 0 ? options[index - 1] : undefined);
const hasDescription = 'description' in options[index];
const hasGroup = 'group' in options[index];
let itemHeight = MENU_OPTION_HEIGHT;
if (hasDescription) {
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
}
if (firstGroupItem && hasGroup) {
itemHeight += MENU_OPTION_HEIGHT;
}
return itemHeight;
},
[options]
);
const rowVirtualizer = useVirtualizer({
count: options.length,
getScrollElement: () => scrollRef.current,
estimateSize,
overscan: VIRTUAL_OVERSCAN_ITEMS,
});
const isOptionSelected = useCallback(
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
[selectedItems]
);
const allItemsSelected = enableAllOption && selectedItems.length === options.length - 1;
return (
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
<div style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow, index, allVirtualRows) => {
const item = options[virtualRow.index];
const startingNewGroup = isNewGroup(item, options[virtualRow.index - 1]);
// Find the item that renders the group header. It can be this same item if this is rendering it.
const groupHeaderIndex = allVirtualRows.find((row) => {
const rowItem = options[row.index];
return rowItem.group === item.group;
});
const groupHeaderItem = groupHeaderIndex && options[groupHeaderIndex.index];
const itemId = `combobox-option-${item.value}`;
// If we're rendering the group header, this is the ID for it. Otherwise its used on
// the option for aria-describedby.
const groupHeaderId = groupHeaderItem ? `combobox-option-group-${groupHeaderItem.value}` : undefined;
return (
// Wrapping div should have no styling other than virtual list positioning.
// It's children (header and option) should appear as flat list items.
<div
key={item.value}
className={styles.listItem}
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Group header */}
{startingNewGroup && (
<div
role="presentation"
id={groupHeaderId}
className={cx(
styles.optionGroupHeader,
item.group && styles.optionGroupLabel,
virtualRow.index === 0 && styles.optionFirstGroupHeader
)}
>
{item.group}
</div>
)}
{/* Option */}
<div
className={cx(
styles.option,
!isMultiSelect && isOptionSelected(item) && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused
)}
{...getItemProps({
item: item,
index: virtualRow.index,
id: itemId,
'aria-describedby': groupHeaderId,
})}
>
{isMultiSelect && (
<div className={styles.optionAccessory}>
<Checkbox
key={itemId}
value={allItemsSelected || isOptionSelected(item)}
indeterminate={item.value === ALL_OPTION_VALUE && selectedItems.length > 0 && !allItemsSelected}
aria-labelledby={itemId}
onClick={(e) => {
e.stopPropagation();
}}
/>
</div>
)}
<div className={styles.optionBody}>
<div className={styles.optionLabel}>{item.label ?? item.value}</div>
{item.description && <div className={styles.optionDescription}>{item.description}</div>}
</div>
</div>
</div>
);
})}
</div>
<div aria-live="polite">
{error && <AsyncError />}
{options.length === 0 && !error && <NotFoundError />}
</div>
</ScrollContainer>
);
};
@@ -25,11 +25,15 @@ const onChangeAction = action('onChange called');
const commonArgs = {
options: [
{ label: 'wasd - 1', value: 'option1' },
{ label: 'wasd - 2', value: 'option2' },
{ label: 'wasd - 3', value: 'option3' },
{ label: 'asdf - 1', value: 'option4' },
{ label: 'asdf - 2', value: 'option5' },
{ label: 'Australia', value: 'option1' },
{ label: 'Austria', value: 'option2' },
{ label: 'Fiji', value: 'option3' },
{ label: 'Iceland', value: 'option4' },
{ label: 'Ireland', value: 'option5' },
{ label: 'Finland', value: 'option6' },
{ label: 'The Netherlands', value: 'option7' },
{ label: 'Switzerland', value: 'option8' },
{ label: 'United Kingdom of Great Britain and Northern Ireland ', value: 'option9' },
],
value: ['option2'],
placeholder: 'Select multiple options...',
@@ -1,33 +1,27 @@
import { cx } from '@emotion/css';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCombobox, useMultipleSelection } from 'downshift';
import { useCallback, useMemo, useState } from 'react';
import { useStyles2 } from '../../themes';
import { t } from '../../utils/i18n';
import { Checkbox } from '../Forms/Checkbox';
import { Icon } from '../Icon/Icon';
import { Box } from '../Layout/Box/Box';
import { Stack } from '../Layout/Stack/Stack';
import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
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 { ComboboxBaseProps, AutoSizeConditionals } from './Combobox';
import { ComboboxList } from './ComboboxList';
import { SuffixIcon } from './SuffixIcon';
import { ValuePill } from './ValuePill';
import { itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { getComboboxStyles } from './getComboboxStyles';
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';
import { useOptions } from './useOptions';
import { isNewGroup } from './utils';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>;
@@ -67,7 +61,12 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
}, [inputValue]);
// Handle async options and the 'All' option
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options, createCustomValue);
const {
options: baseOptions,
updateOptions,
asyncLoading,
asyncError,
} = useOptions(props.options, createCustomValue);
const options = useMemo(() => {
// Only add the 'All' option if there's more than 1 option
const addAllOption = enableAllOption && baseOptions.length > 1;
@@ -244,26 +243,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
isClearable
);
const virtualizerOptions = {
count: options.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index: number) => {
const firstGroupItem = isNewGroup(options[index], index > 0 ? options[index - 1] : undefined);
const hasDescription = 'description' in options[index];
let itemHeight = MENU_OPTION_HEIGHT;
if (hasDescription) {
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
}
if (firstGroupItem) {
itemHeight += MENU_OPTION_HEIGHT;
}
return itemHeight;
},
overscan: VIRTUAL_OVERSCAN_ITEMS,
};
const rowVirtualizer = useVirtualizer(virtualizerOptions);
// Selected items that show up in the input field
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
@@ -341,79 +320,23 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
<Portal>
<div
className={cx(styles.menu, !isOpen && styles.menuClosed)}
style={{ ...floatStyles }}
style={{
...floatStyles,
width: floatStyles.width + 24, // account for checkbox
}}
{...getMenuProps({ ref: floatingRef })}
>
{isOpen && (
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startingNewGroup = isNewGroup(options[virtualRow.index], options[virtualRow.index - 1]);
const index = virtualRow.index;
const item = options[index];
const itemProps = getItemProps({ item, index });
const isSelected = isOptionSelected(item);
const id = 'multicombobox-option-' + item.value.toString();
const groupHeaderid = 'multicombobox-option-group-' + item.value.toString();
const isAll = item.value === ALL_OPTION_VALUE;
// TODO: fix bug where if the search filtered items list is the
// same length, but different, than the selected items (ask tobias)
const allItemsSelected =
options[0]?.value === ALL_OPTION_VALUE && selectedItems.length === options.length - 1;
return (
<li
key={`${item.value}-${index}`}
data-index={index}
{...itemProps}
className={styles.optionBasic}
style={{ height: virtualRow.size, transform: `translateY(${virtualRow.start}px)` }}
>
<Stack direction="column" justifyContent="space-between" width={'100%'} height={'100%'} gap={0}>
{startingNewGroup && (
<div className={styles.optionGroup}>
<OptionListItem
label={item.group ?? t('combobox.group.undefined', 'No group')}
id={groupHeaderid}
isGroup={true}
/>
</div>
)}
<div
className={cx(styles.option, {
[styles.optionFocused]: highlightedIndex === index,
})}
>
<Stack direction="row" alignItems="center">
<Checkbox
key={id}
value={allItemsSelected || isSelected}
indeterminate={isAll && selectedItems.length > 0 && !allItemsSelected}
aria-labelledby={id}
onClick={(e) => {
e.stopPropagation();
}}
/>
<OptionListItem
label={
isAll
? (item.label ?? item.value.toString()) +
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
: (item.label ?? item.value.toString())
}
description={item?.description}
id={id}
/>
</Stack>
</div>
</Stack>
</li>
);
})}
</ul>
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
</ScrollContainer>
<ComboboxList
options={options}
highlightedIndex={highlightedIndex}
selectedItems={selectedItems}
scrollRef={scrollRef}
getItemProps={getItemProps}
enableAllOption={enableAllOption}
isMultiSelect={true}
error={asyncError}
/>
)}
</div>
</Portal>
@@ -1,25 +0,0 @@
import { cx } from '@emotion/css';
import { ReactNode } from 'react';
import { useStyles2 } from '../../themes';
import { getComboboxStyles } from './getComboboxStyles';
interface Props {
label: ReactNode;
description?: ReactNode;
id: string;
isGroup?: boolean;
}
export const OptionListItem = ({ label, description, id, isGroup = false }: Props) => {
const styles = useStyles2(getComboboxStyles);
return (
<div className={styles.optionBody} aria-disabled={isGroup}>
<span className={cx(styles.optionLabel, { [styles.optionLabelGroup]: isGroup })} id={id}>
{label}
</span>
{description && <span className={styles.optionDescription}>{description}</span>}
</div>
);
};
@@ -5,13 +5,16 @@ import { GrafanaTheme2 } from '@grafana/data';
// We need a px font size to accurately measure the width of items.
// This should be in sync with the body font size in the theme.
export const MENU_ITEM_FONT_SIZE = 14;
export const MENU_ITEM_DESCRIPTION_FONT_SIZE = 12;
export const MENU_ITEM_FONT_WEIGHT = 500;
export const MENU_ITEM_PADDING = 8;
export const MENU_ITEM_GAP = 2;
export const MENU_ITEM_LINE_HEIGHT = 1.5;
// Used with Downshift to get the height of each item
export const MENU_OPTION_HEIGHT = MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
export const MENU_OPTION_HEIGHT_DESCRIPTION = MENU_OPTION_HEIGHT + MENU_ITEM_LINE_HEIGHT * MENU_ITEM_FONT_SIZE;
export const MENU_OPTION_HEIGHT = MENU_ITEM_GAP + MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
export const MENU_OPTION_HEIGHT_DESCRIPTION =
MENU_OPTION_HEIGHT + MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
export const POPOVER_MAX_HEIGHT = MENU_OPTION_HEIGHT * 8.5;
export const getComboboxStyles = (theme: GrafanaTheme2) => {
@@ -33,20 +36,24 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
listStyle: 'none',
}),
// New class for on the virtual list item. Should be on the wrapper around the group header and option.
// The wrapper around the group header and option, not the option itself.
// Should not contain visual styling itself.
listItem: css({
label: 'list-item',
width: '100%',
position: 'absolute',
width: '100%',
}),
// New class used in single combobox group headers
newOptionGroup: css({
optionGroupHeader: css({
label: 'combobox-new-option-group',
borderTop: `1px solid ${theme.colors.border.weak}`,
}),
newOptionGroupLabel: css({
optionFirstGroupHeader: css({
borderTop: 'none',
}),
optionGroupLabel: css({
textOverflow: 'ellipsis',
overflow: 'hidden',
letterSpacing: 0,
@@ -56,26 +63,20 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
padding: MENU_ITEM_PADDING,
}),
newOptionGroupNoBorder: css({
borderTop: 'none',
}),
optionBasic: css({
label: 'combobox-option',
position: 'absolute',
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
flexShrink: 0,
whiteSpace: 'nowrap',
width: '100%',
overflow: 'hidden',
}),
option: css({
padding: MENU_ITEM_PADDING,
cursor: 'pointer',
borderRadius: theme.shape.radius.default,
label: 'combobox-option',
position: 'relative', // for the selection gradient to grab to
display: 'flex',
width: '100%',
gap: theme.spacing(1),
alignItems: 'center',
padding: MENU_ITEM_PADDING,
marginBottom: MENU_ITEM_GAP,
borderRadius: theme.shape.radius.default,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
cursor: 'pointer',
'&:hover': {
background: theme.colors.action.hover,
'@media (forced-colors: active), (prefers-contrast: more)': {
@@ -84,45 +85,40 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
},
}),
/** @deprecated - only used in multicombobox, will refactor it */
optionGroup: css({
cursor: 'default',
padding: MENU_ITEM_PADDING,
borderTop: `1px solid ${theme.colors.border.weak}`,
optionAccessory: css({
label: 'combobox-option-accessory',
height: MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT, // Ensure the accessory doesn't make the option too tall
}),
optionBody: css({
label: 'combobox-option-body',
display: 'flex',
fontWeight: theme.typography.fontWeightMedium,
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}),
optionLabel: css({
label: 'combobox-option-label',
textOverflow: 'ellipsis',
overflow: 'hidden',
fontSize: MENU_ITEM_FONT_SIZE,
fontWeight: MENU_ITEM_FONT_WEIGHT,
lineHeight: MENU_ITEM_LINE_HEIGHT,
letterSpacing: 0, // pr todo: text in grafana has a slightly different letter spacing, which causes measureText() to be ~5% off
}),
/** @deprecated - only used in multicombobox, will refactor it */
optionLabelGroup: css({
label: 'combobox-option-label-group',
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightLight,
}),
optionDescription: css({
label: 'combobox-option-description',
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
lineHeight: MENU_ITEM_LINE_HEIGHT,
letterSpacing: 0,
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
optionDescription: css({
label: 'combobox-option-description',
color: theme.colors.text.secondary,
fontSize: MENU_ITEM_DESCRIPTION_FONT_SIZE,
fontWeight: theme.typography.fontWeightRegular,
lineHeight: MENU_ITEM_LINE_HEIGHT,
letterSpacing: 0,
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
optionFocused: css({
label: 'combobox-option-focused',
// top: 0,
@@ -18,6 +18,8 @@ const WIDTH_CALCULATION_LIMIT_ITEMS = 100_000;
// Clearance around the popover to prevent it from being too close to the edge of the viewport
const POPOVER_PADDING = 16;
const SCROLL_CONTAINER_PADDING = 8;
export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>, isOpen: boolean) => {
const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
@@ -70,7 +72,7 @@ export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>,
const size = measureText(longestItem, MENU_ITEM_FONT_SIZE, MENU_ITEM_FONT_WEIGHT).width;
return size + MENU_ITEM_PADDING * 2 + scrollbarWidth;
return size + SCROLL_CONTAINER_PADDING + MENU_ITEM_PADDING * 2 + scrollbarWidth;
}, [items, scrollbarWidth]);
const floatStyles = {
-3
View File
@@ -1135,9 +1135,6 @@
"custom-value": {
"description": "Use custom value"
},
"group": {
"undefined": "No group"
},
"options": {
"no-found": "No options found."
}