Combobox: Tighten up storybook documentation (#100313)

* Add JSDoc comments to more props

* Remove in-dev decorator

* reword MDX documentation and add migration guide
This commit is contained in:
Josh Hunt 2025-02-14 15:34:44 +00:00 committed by GitHub
parent 37ee1c427d
commit 861686adaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 94 additions and 57 deletions

View File

@ -4,53 +4,66 @@ import { Combobox } from './Combobox';
<Meta title="MDX|Combobox" component={Combobox} />
## Usage
# Combobox
**Do**
A performant and accessible combobox component that supports both synchronous and asynchronous options loading. It provides type-ahead filtering, keyboard navigation, and virtual scrolling for handling large datasets efficiently.
- Use in inline query editors
- Use when you require async calls from a select input
**Use Combobox when you need:**
**Don't**
- A searchable dropdown with keyboard navigation
- Asynchronous loading of options (e.g., API calls)
- Support for large datasets (1000+ items)
- Type-ahead filtering functionality
- Custom value creation
- Inline form usage (e.g., query editors)
- Use the async functionality, when all items are only loaded on the initial load
- Use when fewer than 4 items are needed, as a `RadioButtonGroup` may be more suitable (not for inline use cases)
- Use this component if you need custom option styling
**Consider alternatives when:**
## ComboboxOption
- You have fewer than 4 options (consider `RadioButtonGroup` instead)
- You need complex custom option styling
The `ComboboxOption` currently supports 3 properties:
## Usage & Guidelines
- `label` - The text that is visible in the menu.
- `value` (required) - The value that is selected.
- `description` - A longer description that describes the choice.
### Options
If no `label` is given, `value` will be used as a display value.
Options are supplied through the `options` prop as either:
## Sizing
- An array of options for synchronous usage
- An async function that returns a promise resolving to options for user input.
The recommended way to set the width is by sizing the container element. This is so it may reflect a similar size as other inputs in the context.
Options can be an array of objects with seperate label and values, or an array of strings which will be used as both the label and value.
If that is not possible, the width can be set directly on the component, by setting a number, which is a multiple of `8px`.
While Combobox can handle large sets of options, you should consider both the user experience of searching through many options, and the performance of loading many options from an API.
For inline usage, such as in query editors, it may be useful to size the input based on the content. Set `width="auto"` to achieve this. In this case, it is also recommended to set `maxWidth` and `minWidth`.
### Async behaviour
## Async Usage
When using Combobox with options from a remote source as the user types, you can supply the `options` prop as an function that is called on each keypress with the current input value and returns a promise resolving to an array of options matching the input.
The `options` prop can accept an async function:
Consider the following when implementing async behaviour:
- When the menu opens, the `options` function is called with `''`, to load all options.
- When the user types, the `options` function is called with the current input value.
- Consumers should return filtered options matching the input. This is bested suited for APIs that support filtering/search.
- When the menu is opened with blank input (e.g. initial click with no selected value) the function will be called with an empty string.
- Consumers should only ever load top-n options from APIs using this async function. If your API does not support filtering, consider loading options yourself and just passing the sync options array in
- Combobox does not cache calls to the async function. If you need this, implement your own caching.
- Calls to the async function are debounced, so consumers should not need to implement this themselves.
Note: The calls are debounced. Old calls are invalidated when a new call is made.
### Value
## Unit testing
The `value` prop is used to set the selected value of the combobox. A scalar value (the value of options) is preferred over a full option object.
Writing unit tests with Combobox requires mocking the `getBoundingClientRect` method because of [the virtual list library](https://github.com/TanStack/virtual/issues/29#issuecomment-657519522)
When using async options with seperate labels and values, the `value` prop can be a full option object to ensure the correct label is displayed.
This code sets up the mocking before all tests:
### Sizing
```js
Combobox defaults to filling the width of its container to match other inputs. If that's not desired, set the `width` prop to control the exact input width.
For inline usage, such as in query editors, it may be useful to size the input based on the text content. Set width="auto" to achieve this. In this case, it is also recommended to set maxWidth and minWidth.
### Unit tests
The component requires mocking `getBoundingClientRect` because of virtualisation:
```typescript
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
width: 120,
@ -67,13 +80,9 @@ beforeAll(() => {
});
```
### Selecting an option
#### Select an option by mouse
To select an option, you can use any `*ByRole` methods, as Combobox has proper roles for accessibility.
#### Selecting option by clicking
```js
```jsx
render(<Combobox options={options} onChange={onChangeHandler} value={null} />);
const input = screen.getByRole('combobox');
@ -84,9 +93,9 @@ await userEvent.click(item);
expect(screen.getByDisplayValue('Option 1')).toBeInTheDocument();
```
#### Selecting option by typing
#### Select an option by keyboard
```js
```jsx
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
const input = screen.getByRole('combobox');
@ -96,6 +105,32 @@ await userEvent.keyboard('{ArrowDown}{Enter}');
expect(screen.getByDisplayValue('Option 3')).toBeInTheDocument();
```
## Migrating from Select
Combobox's API is similar to Select, but is greatly simplified. Any workarounds you may have implemented to workaround Select's slow performance are no longer necessary.
Some differences to note:
- Virtualisation is built in, so no separate `VirtualizedSelect` component is needed.
- Async behaviour is built in so a seperate `AsyncSelect` component is not needed
- `isLoading: boolean` has been renamed to `loading: boolean`
- `allowCustomValue` has been renamed to `createCustomValue`.
- When specifying `width="auto"`, `minWidth` is also required.
- Groups are not supported at this time.
- Many props used to control subtle behaviour have been removed to simplify the API and improve performance.
- Custom render props, or label as ReactNode is not supported at this time. Reach out if you have a hard requirement for this and we can discuss.
For all async behaviour, pass in a function that returns `Promise<ComboboxOption[]>` that will be called when the menu is opened, and on keypress.
```tsx
const loadOptions = useCallback(async (input: string) => {
const response = await fetch(`/api/options?query=${input}`);
return response.json();
}, []);
<Combobox options={loadOptions} />;
```
## Props
<ArgTypes of={Combobox} />

View File

@ -1,9 +1,8 @@
import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api';
import { Meta, StoryFn, StoryObj } from '@storybook/react';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Alert } from '../Alert/Alert';
import { Field } from '../Forms/Field';
import { Combobox, ComboboxProps } from './Combobox';
@ -64,7 +63,6 @@ const meta: Meta<PropsAndCustomArgs> = {
],
value: 'banana',
},
decorators: [InDevDecorator],
};
export default meta;
@ -257,17 +255,3 @@ export const PositioningTest: Story = {
);
},
};
function InDevDecorator(Story: React.ElementType) {
return (
<div>
<Alert title="This component is still in development!" severity="info">
Combobox is still in development and not able to be used externally.
<br />
Within the Grafana repo, it can be used by importing it from{' '}
<span style={{ fontFamily: 'monospace' }}>@grafana/ui/src/unstable</span>
</Alert>
<Story />
</div>
);
}

View File

@ -35,17 +35,32 @@ export interface ComboboxBaseProps<T extends string | number>
* Allows the user to set a value which is not in the list of options.
*/
createCustomValue?: boolean;
options: Array<ComboboxOption<T>> | ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
onChange: (option: ComboboxOption<T>) => void;
/**
* Most consumers should pass value in as a scalar string | number. However, sometimes with Async because we don't
* have the full options loaded to match the value to, consumers may also pass in an Option with a label to display.
* An array of options, or a function that returns a promise resolving to an array of options.
* If a function, it will be called when the menu is opened and on keypress with the current search query.
*/
options: Array<ComboboxOption<T>> | ((inputValue: string) => Promise<Array<ComboboxOption<T>>>);
/**
* onChange handler is called with the newly selected option.
*/
onChange: (option: ComboboxOption<T>) => void;
/**
* Current selected value. Most consumers should pass a scalar value (string | number). However, sometimes with Async
* it may be better to pass in an Option with a label to display.
*/
value?: T | ComboboxOption<T> | null;
/**
* Defaults to 100%. Number is a multiple of 8px. 'auto' will size the input to the content.
* Defaults to full width of container. Number is a multiple of the spacing unit. 'auto' will size the input to the content.
* */
width?: number | 'auto';
/**
* Called when the input loses focus.
*/
onBlur?: () => void;
}
@ -53,6 +68,9 @@ const RECOMMENDED_ITEMS_AMOUNT = 100_000;
type ClearableConditionals<T extends number | string> =
| {
/**
* Allow the user to clear the selected value. `null` is emitted from the onChange handler
*/
isClearable: true;
/**
* The onChange handler is called with `null` when clearing the Combobox.