ComboBox: Add loading state to dropdown and prefixIcon (#112967)

This commit is contained in:
Tom Ratcliffe
2025-10-28 15:22:18 +00:00
committed by GitHub
parent 8863ed9d6f
commit f3e7576f0c
9 changed files with 76 additions and 10 deletions
@@ -6,6 +6,7 @@ import { Field } from '../Forms/Field';
import { Combobox } from './Combobox';
import { ComboboxOption } from './types';
import { DEBOUNCE_TIME_MS } from './useOptions';
// Mock data for the Combobox options
const options: ComboboxOption[] = [
@@ -613,6 +614,23 @@ describe('Combobox', () => {
expect(onChangeHandler).not.toHaveBeenCalled();
expect(input).toHaveValue('Option 1');
});
it('shows loading message', async () => {
const loadingMessage = 'Loading options...';
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
render(<Combobox options={asyncOptions} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
await act(async () => jest.advanceTimersByTime(0));
expect(await screen.findByText(loadingMessage)).toBeInTheDocument();
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
expect(screen.queryByText(loadingMessage)).not.toBeInTheDocument();
});
});
});
@@ -1,7 +1,7 @@
import { cx } from '@emotion/css';
import { useVirtualizer, type Range } from '@tanstack/react-virtual';
import { useCombobox } from 'downshift';
import React, { useCallback, useId, useMemo } from 'react';
import React, { ComponentProps, useCallback, useId, useMemo } from 'react';
import { t } from '@grafana/i18n';
@@ -60,6 +60,11 @@ interface ComboboxStaticProps<T extends string | number>
* Called when the input loses focus.
*/
onBlur?: () => void;
/**
* Icon to display at the start of the ComboBox input
*/
prefixIcon?: ComponentProps<typeof Icon>['name'];
}
interface ClearableProps<T extends string | number> {
@@ -137,6 +142,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
disabled,
portalContainer,
invalid,
prefixIcon,
} = props;
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
@@ -376,6 +382,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
{...(isAutoSize ? { minWidth, maxWidth } : {})}
autoFocus={autoFocus}
onBlur={onBlur}
prefix={prefixIcon && <Icon name={prefixIcon} />}
disabled={disabled}
invalid={invalid}
className={styles.input}
@@ -402,6 +409,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
>
{isOpen && (
<ComboboxList
loading={loading}
options={filteredOptions}
highlightedIndex={highlightedIndex}
selectedItems={selectedItem ? [selectedItem] : []}
@@ -7,7 +7,7 @@ import { useStyles2 } from '../../themes/ThemeContext';
import { Checkbox } from '../Forms/Checkbox';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { AsyncError, NotFoundError } from './MessageRows';
import { AsyncError, LoadingOptions, 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';
@@ -23,6 +23,7 @@ interface ComboboxListProps<T extends string | number> {
enableAllOption?: boolean;
isMultiSelect?: boolean;
error?: boolean;
loading?: boolean;
}
export const ComboboxList = <T extends string | number>({
@@ -34,6 +35,7 @@ export const ComboboxList = <T extends string | number>({
enableAllOption,
isMultiSelect = false,
error = false,
loading = false,
}: ComboboxListProps<T>) => {
const styles = useStyles2(getComboboxStyles);
@@ -161,7 +163,8 @@ export const ComboboxList = <T extends string | number>({
<div aria-live="polite">
{error && <AsyncError />}
{options.length === 0 && !error && <NotFoundError />}
{!loading && options.length === 0 && !error && <NotFoundError />}
{loading && options.length === 0 && <LoadingOptions />}
</div>
</ScrollContainer>
);
@@ -22,6 +22,12 @@ export const NotFoundError = () => (
</MessageRow>
);
export const LoadingOptions = () => (
<MessageRow>
<Trans i18nKey="combobox.options.loading">Loading options...</Trans>
</MessageRow>
);
const MessageRow = ({ children }: { children: ReactNode }) => {
return (
<Box padding={2}>
@@ -4,6 +4,7 @@ import React from 'react';
import { MultiCombobox, MultiComboboxProps } from './MultiCombobox';
import { ComboboxOption } from './types';
import { DEBOUNCE_TIME_MS } from './useOptions';
describe('MultiCombobox', () => {
beforeAll(() => {
@@ -330,7 +331,7 @@ describe('MultiCombobox', () => {
await user.click(input);
// Debounce
await act(async () => jest.advanceTimersByTime(200));
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
expect(asyncOptions).toHaveBeenCalled();
});
@@ -380,10 +381,10 @@ describe('MultiCombobox', () => {
await user.click(input);
await user.keyboard('a');
act(() => jest.advanceTimersByTime(200)); // Skip debounce
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS)); // Skip debounce
await user.keyboard('b');
act(() => jest.advanceTimersByTime(200)); // Skip debounce
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS)); // Skip debounce
await user.keyboard('c');
act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored
@@ -422,7 +423,7 @@ describe('MultiCombobox', () => {
act(() => jest.advanceTimersByTime(10));
await user.keyboard('c');
act(() => jest.advanceTimersByTime(200));
act(() => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
const item = await screen.findByRole('option', { name: 'Option 3' });
expect(item).toBeInTheDocument();
@@ -439,7 +440,7 @@ describe('MultiCombobox', () => {
await user.click(input);
// Debounce
await act(async () => jest.advanceTimersByTime(200));
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
// Click on Option 1 to deselect it (it should already be selected via value prop)
const item = await screen.findByRole('option', { name: 'Option 1' });
@@ -484,7 +485,7 @@ describe('MultiCombobox', () => {
await user.click(input);
// Wait for async options to load
await act(async () => jest.advanceTimersByTime(200));
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
// Integration A should be selected (shown as pill)
const pillRemoveButton = screen.getByRole('button', { name: 'Remove Integration A' });
@@ -500,6 +501,23 @@ describe('MultiCombobox', () => {
// The pill should be removed
expect(screen.queryByRole('button', { name: 'Remove Integration A' })).not.toBeInTheDocument();
});
it('shows loading message', async () => {
const loadingMessage = 'Loading options...';
const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions));
render(<MultiCombobox options={asyncOptions} value={['Option 1']} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
await user.click(input);
await act(async () => jest.advanceTimersByTime(0));
expect(await screen.findByText(loadingMessage)).toBeInTheDocument();
await act(async () => jest.advanceTimersByTime(DEBOUNCE_TIME_MS));
expect(screen.queryByText(loadingMessage)).not.toBeInTheDocument();
});
});
});
@@ -51,6 +51,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
'aria-labelledby': ariaLabelledBy,
'data-testid': dataTestId,
portalContainer,
prefixIcon,
} = props;
const styles = useStyles2(getComboboxStyles);
@@ -267,6 +268,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
return (
<div className={multiStyles.container} ref={containerRef}>
<div className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })} ref={measureRef}>
{prefixIcon && (
<Box marginLeft={0.5}>
<Text color="secondary">
<Icon name={prefixIcon} />
</Text>
</Box>
)}
<span className={multiStyles.pillWrapper}>
{visibleItems.map((item, index) => (
<ValuePill
@@ -347,6 +355,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
>
{isOpen && (
<ComboboxList
loading={loading}
options={options}
highlightedIndex={highlightedIndex}
selectedItems={selectedItems}
@@ -33,6 +33,7 @@ export const getMultiComboboxStyles = (
inputStyles.input,
css({
display: 'flex',
alignItems: 'center',
width: '100%',
gap: theme.spacing(0.5),
padding: theme.spacing(0.5),
@@ -16,6 +16,8 @@ type AsyncOptions<T extends string | number> =
const asyncNoop = () => Promise.resolve([]);
export const DEBOUNCE_TIME_MS = 200;
/**
* Abstracts away sync/async options for combobox components.
* It also filters options based on the user's input.
@@ -49,7 +51,7 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
}
}
});
}, 200),
}, DEBOUNCE_TIME_MS),
[loadOptions]
);
+1
View File
@@ -4120,6 +4120,7 @@
"description": "Use custom value"
},
"options": {
"loading": "Loading options...",
"no-found": "No options found."
}
},