diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.mdx b/packages/grafana-ui/src/components/Combobox/Combobox.mdx index 7c93072b57a..b4b5e8eba17 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.mdx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.mdx @@ -4,53 +4,66 @@ import { Combobox } from './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(); 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(); 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` 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(); +}, []); + +; +``` + ## Props diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx index 5575d21df0e..fe3c9bca3f5 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.story.tsx @@ -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 = { ], value: 'banana', }, - decorators: [InDevDecorator], }; export default meta; @@ -257,17 +255,3 @@ export const PositioningTest: Story = { ); }, }; - -function InDevDecorator(Story: React.ElementType) { - return ( -
- - Combobox is still in development and not able to be used externally. -
- Within the Grafana repo, it can be used by importing it from{' '} - @grafana/ui/src/unstable -
- -
- ); -} diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 62f231975e3..f69d15b3038 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -35,17 +35,32 @@ export interface ComboboxBaseProps * Allows the user to set a value which is not in the list of options. */ createCustomValue?: boolean; - options: Array> | ((inputValue: string) => Promise>>); - onChange: (option: ComboboxOption) => 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> | ((inputValue: string) => Promise>>); + + /** + * onChange handler is called with the newly selected option. + */ + onChange: (option: ComboboxOption) => 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 | 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 = | { + /** + * 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.