New Select: Try it out with some core components (#92826)
* Export Combobox and add portalling * Use floatingui fixed strategy and fix z-index * Check non null * Make value string type only * Try with fiscal year setting * Use combobox for WeekStartPicker * Improve screen reader handling * Fix faulty import * Fix type issue * Fix failing tests and export unstable * Rename option and remove export * Use comboboxMockSetup function * Add support for number type * menuClosed styles to emotion
This commit is contained in:
@@ -6,7 +6,7 @@ import React, { ComponentProps, useEffect, useState } from 'react';
|
||||
import { Alert } from '../Alert/Alert';
|
||||
import { Field } from '../Forms/Field';
|
||||
|
||||
import { Combobox, Option, Value } from './Combobox';
|
||||
import { Combobox, ComboboxOption } from './Combobox';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
@@ -69,7 +69,7 @@ type Story = StoryObj<typeof Combobox>;
|
||||
|
||||
export const Basic: Story = {};
|
||||
|
||||
async function generateOptions(amount: number): Promise<Option[]> {
|
||||
async function generateOptions(amount: number): Promise<ComboboxOption[]> {
|
||||
return Array.from({ length: amount }, (_, index) => ({
|
||||
label: chance.sentence({ words: index % 5 }),
|
||||
value: chance.guid(),
|
||||
@@ -78,8 +78,8 @@ async function generateOptions(amount: number): Promise<Option[]> {
|
||||
}
|
||||
|
||||
const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...args }) => {
|
||||
const [value, setValue] = useState<Value | null>(null);
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { Combobox, Option } from './Combobox';
|
||||
import { Combobox, ComboboxOption } from './Combobox';
|
||||
|
||||
// Mock data for the Combobox options
|
||||
const options: Option[] = [
|
||||
const options: ComboboxOption[] = [
|
||||
{ label: 'Option 1', value: '1' },
|
||||
{ label: 'Option 2', value: '2' },
|
||||
{ label: 'Option 3', value: '3', description: 'This is option 3' },
|
||||
@@ -73,9 +73,10 @@ describe('Combobox', () => {
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}');
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(options[1]);
|
||||
expect(screen.queryByDisplayValue('Option 2')).toBeInTheDocument();
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}'); // Focus is at index 0 to start with
|
||||
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(options[2]);
|
||||
expect(screen.queryByDisplayValue('Option 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears selected value', async () => {
|
||||
@@ -91,4 +92,44 @@ describe('Combobox', () => {
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByDisplayValue('Option 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('create custom value', () => {
|
||||
it('should allow creating a custom value', async () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.type(input, 'custom value');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
|
||||
expect(onChangeHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: 'custom value', value: 'custom value' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should proivde custom string when all options are numbers', async () => {
|
||||
const options = [
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3', value: 3 },
|
||||
];
|
||||
|
||||
const onChangeHandler = jest.fn();
|
||||
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} createCustomValue />);
|
||||
const input = screen.getByRole('combobox');
|
||||
|
||||
await userEvent.type(input, 'custom value');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(screen.getByDisplayValue('custom value')).toBeInTheDocument();
|
||||
expect(typeof onChangeHandler.mock.calls[0][0].value === 'string').toBeTruthy();
|
||||
expect(typeof onChangeHandler.mock.calls[0][0].value === 'number').toBeFalsy();
|
||||
|
||||
await userEvent.click(input);
|
||||
await userEvent.keyboard('{Enter}'); // Select 1 as the first option
|
||||
expect(typeof onChangeHandler.mock.calls[1][0].value === 'string').toBeFalsy();
|
||||
expect(typeof onChangeHandler.mock.calls[1][0].value === 'number').toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
|
||||
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCombobox } from 'downshift';
|
||||
import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SetStateAction, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
@@ -11,30 +11,29 @@ import { Input, Props as InputProps } from '../Input/Input';
|
||||
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
export type Value = string | number;
|
||||
export type Option = {
|
||||
export type ComboboxOption<T extends string | number = string> = {
|
||||
label: string;
|
||||
value: Value;
|
||||
value: T;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
interface ComboboxProps
|
||||
interface ComboboxProps<T extends string | number>
|
||||
extends Omit<InputProps, 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
|
||||
onChange: (val: Option | null) => void;
|
||||
value: Value | null;
|
||||
options: Option[];
|
||||
isClearable?: boolean;
|
||||
createCustomValue?: boolean;
|
||||
options: Array<ComboboxOption<T>>;
|
||||
onChange: (option: ComboboxOption<T> | null) => void;
|
||||
value: T | null;
|
||||
}
|
||||
|
||||
function itemToString(item: Option | null) {
|
||||
return item?.label ?? item?.value?.toString() ?? '';
|
||||
function itemToString(item: ComboboxOption<string | number> | null) {
|
||||
return item?.label ?? item?.value.toString() ?? '';
|
||||
}
|
||||
|
||||
function itemFilter(inputValue: string) {
|
||||
function itemFilter<T extends string | number>(inputValue: string) {
|
||||
const lowerCasedInputValue = inputValue.toLowerCase();
|
||||
|
||||
return (item: Option) => {
|
||||
return (item: ComboboxOption<T>) => {
|
||||
return (
|
||||
!inputValue ||
|
||||
item?.label?.toLowerCase().includes(lowerCasedInputValue) ||
|
||||
@@ -53,15 +52,21 @@ 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 = ({
|
||||
/**
|
||||
* A performant Select replacement.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const Combobox = <T extends string | number>({
|
||||
options,
|
||||
onChange,
|
||||
value,
|
||||
isClearable = false,
|
||||
createCustomValue = false,
|
||||
id,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
...restProps
|
||||
}: ComboboxProps) => {
|
||||
}: ComboboxProps<T>) => {
|
||||
const [items, setItems] = useState(options);
|
||||
|
||||
const selectedItemIndex = useMemo(() => {
|
||||
@@ -78,7 +83,7 @@ export const Combobox = ({
|
||||
}, [options, value]);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (selectedItemIndex) {
|
||||
if (selectedItemIndex !== null) {
|
||||
return options[selectedItemIndex];
|
||||
}
|
||||
|
||||
@@ -95,6 +100,9 @@ export const Combobox = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`;
|
||||
const labelId = `downshift-${useId().replace(/:/g, '--')}-label`;
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
const [popoverMaxWidth, setPopoverMaxWidth] = useState<number | undefined>(undefined);
|
||||
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
|
||||
@@ -119,26 +127,28 @@ export const Combobox = ({
|
||||
closeMenu,
|
||||
selectItem,
|
||||
} = useCombobox({
|
||||
menuId,
|
||||
labelId,
|
||||
inputId: id,
|
||||
items,
|
||||
itemToString,
|
||||
selectedItem,
|
||||
onSelectedItemChange: ({ selectedItem, inputValue }) => {
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
onChange(selectedItem);
|
||||
},
|
||||
defaultHighlightedIndex: selectedItemIndex ?? undefined,
|
||||
defaultHighlightedIndex: selectedItemIndex ?? 0,
|
||||
scrollIntoView: () => {},
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
const filteredItems = options.filter(itemFilter(inputValue));
|
||||
if (createCustomValue && inputValue && filteredItems.findIndex((opt) => opt.label === inputValue) === -1) {
|
||||
setItems([
|
||||
...filteredItems,
|
||||
{
|
||||
label: inputValue,
|
||||
value: inputValue,
|
||||
description: t('combobox.custom-value.create', 'Create custom value'),
|
||||
},
|
||||
]);
|
||||
const customValueOption: ComboboxOption<T> = {
|
||||
label: inputValue,
|
||||
// @ts-ignore Type casting needed to make this work when T is a number
|
||||
value: inputValue as unknown as T,
|
||||
description: t('combobox.custom-value.create', 'Create custom value'),
|
||||
};
|
||||
|
||||
setItems([...filteredItems, customValueOption]);
|
||||
return;
|
||||
} else {
|
||||
setItems(filteredItems);
|
||||
@@ -177,6 +187,7 @@ export const Combobox = ({
|
||||
];
|
||||
const elements = { reference: inputRef.current, floating: floatingRef.current };
|
||||
const { floatingStyles } = useFloating({
|
||||
strategy: 'fixed',
|
||||
open: isOpen,
|
||||
placement: 'bottom-start',
|
||||
middleware,
|
||||
@@ -231,17 +242,21 @@ export const Combobox = ({
|
||||
*/
|
||||
onChange: () => {},
|
||||
onBlur,
|
||||
'aria-labelledby': ariaLabelledBy, // Label should be handled with the Field component
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={cx(styles.menu, hasMinHeight && styles.menuHeight)}
|
||||
className={cx(styles.menu, hasMinHeight && styles.menuHeight, !isOpen && styles.menuClosed)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
maxWidth: popoverMaxWidth,
|
||||
minWidth: inputRef.current?.offsetWidth,
|
||||
width: popoverWidth,
|
||||
}}
|
||||
{...getMenuProps({ ref: floatingRef })}
|
||||
{...getMenuProps({
|
||||
ref: floatingRef,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
})}
|
||||
>
|
||||
{isOpen && (
|
||||
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
@@ -259,7 +274,10 @@ export const Combobox = ({
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
|
||||
{...getItemProps({
|
||||
item: items[virtualRow.index],
|
||||
index: virtualRow.index,
|
||||
})}
|
||||
>
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel}>{items[virtualRow.index].label}</span>
|
||||
@@ -278,7 +296,7 @@ export const Combobox = ({
|
||||
};
|
||||
|
||||
const useDynamicWidth = (
|
||||
items: Option[],
|
||||
items: Array<ComboboxOption<string | number>>,
|
||||
range: { startIndex: number; endIndex: number } | null,
|
||||
setPopoverWidth: { (value: SetStateAction<number | undefined>): void }
|
||||
) => {
|
||||
|
||||
@@ -4,12 +4,14 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
menuClosed: css({
|
||||
display: 'none',
|
||||
}),
|
||||
menu: css({
|
||||
label: 'grafana-select-menu',
|
||||
background: theme.components.dropdown.background,
|
||||
boxShadow: theme.shadows.z3,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
zIndex: theme.zIndex.dropdown,
|
||||
}),
|
||||
menuHeight: css({
|
||||
height: 400,
|
||||
|
||||
+4
-5
@@ -9,8 +9,8 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { useStyles2 } from '../../../themes';
|
||||
import { t, Trans } from '../../../utils/i18n';
|
||||
import { Button } from '../../Button';
|
||||
import { Combobox } from '../../Combobox/Combobox';
|
||||
import { Field } from '../../Forms/Field';
|
||||
import { Select } from '../../Select/Select';
|
||||
import { Tab, TabContent, TabsBar } from '../../Tabs';
|
||||
import { TimeZonePicker } from '../TimeZonePicker';
|
||||
import { TimeZoneDescription } from '../TimeZonePicker/TimeZoneDescription';
|
||||
@@ -142,13 +142,12 @@ export const TimePickerFooter = (props: Props) => {
|
||||
className={style.fiscalYearField}
|
||||
label={t('time-picker.footer.fiscal-year-start', 'Fiscal year start month')}
|
||||
>
|
||||
<Select
|
||||
value={fiscalYearStartMonth}
|
||||
menuShouldPortal={false}
|
||||
<Combobox
|
||||
value={fiscalYearStartMonth ?? null}
|
||||
options={monthOptions}
|
||||
onChange={(value) => {
|
||||
if (onChangeFiscalYearStartMonth) {
|
||||
onChangeFiscalYearStartMonth(value.value ?? 0);
|
||||
onChangeFiscalYearStartMonth(value?.value ?? 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { Select } from '../Select/Select';
|
||||
import { Combobox, ComboboxOption } from '../Combobox/Combobox';
|
||||
|
||||
export interface Props {
|
||||
onChange: (weekStart: string) => void;
|
||||
@@ -16,7 +15,7 @@ export interface Props {
|
||||
}
|
||||
|
||||
export type WeekStart = 'saturday' | 'sunday' | 'monday';
|
||||
const weekStarts: Array<SelectableValue<WeekStart | ''>> = [
|
||||
const weekStarts: ComboboxOption[] = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'saturday', label: 'Saturday' },
|
||||
{ value: 'sunday', label: 'Sunday' },
|
||||
@@ -39,21 +38,22 @@ export const WeekStartPicker = (props: Props) => {
|
||||
const { onChange, width, autoFocus = false, onBlur, value, disabled = false, inputId } = props;
|
||||
|
||||
const onChangeWeekStart = useCallback(
|
||||
(selectable: SelectableValue<string>) => {
|
||||
if (selectable.value !== undefined) {
|
||||
(selectable: ComboboxOption | null) => {
|
||||
if (selectable && selectable.value !== undefined) {
|
||||
onChange(selectable.value);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const selected = useMemo(() => weekStarts.find((item) => item.value === value)?.value ?? null, [value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
inputId={inputId}
|
||||
value={weekStarts.find((item) => item.value === value)?.value}
|
||||
<Combobox
|
||||
id={inputId}
|
||||
value={selected}
|
||||
placeholder={selectors.components.WeekStartPicker.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
openMenuOnFocus={true}
|
||||
width={width}
|
||||
options={weekStarts}
|
||||
onChange={onChangeWeekStart}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SelectableValue, TimeOption } from '@grafana/data';
|
||||
import { TimeOption } from '@grafana/data';
|
||||
|
||||
import { ComboboxOption } from '../Combobox/Combobox';
|
||||
|
||||
export const quickOptions: TimeOption[] = [
|
||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
|
||||
@@ -39,7 +41,7 @@ export const quickOptions: TimeOption[] = [
|
||||
{ from: 'now/fy', to: 'now/fy', display: 'This fiscal year' },
|
||||
];
|
||||
|
||||
export const monthOptions: Array<SelectableValue<number>> = [
|
||||
export const monthOptions: Array<ComboboxOption<number>> = [
|
||||
{ label: 'January', value: 0 },
|
||||
{ label: 'February', value: 1 },
|
||||
{ label: 'March', value: 2 },
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
*/
|
||||
|
||||
export * from './utils/skeleton';
|
||||
export * from './components/Combobox/Combobox';
|
||||
|
||||
Reference in New Issue
Block a user