|
|
|
@@ -1,19 +1,19 @@
|
|
|
|
|
import { action } from '@storybook/addon-actions';
|
|
|
|
|
import { useArgs } from '@storybook/preview-api';
|
|
|
|
|
import { Meta, StoryFn, StoryObj } from '@storybook/react';
|
|
|
|
|
import React, { ComponentProps, useCallback, useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
import { SelectableValue } from '@grafana/data';
|
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
import { Alert } from '../Alert/Alert';
|
|
|
|
|
import { Field } from '../Forms/Field';
|
|
|
|
|
import { AsyncSelect } from '../Select/Select';
|
|
|
|
|
|
|
|
|
|
import { Combobox, ComboboxOption } from './Combobox';
|
|
|
|
|
import { Combobox, ComboboxOption, ComboboxProps } from './Combobox';
|
|
|
|
|
import mdx from './Combobox.mdx';
|
|
|
|
|
import { fakeSearchAPI, generateOptions } from './storyUtils';
|
|
|
|
|
|
|
|
|
|
type PropsAndCustomArgs<T extends string | number = string> = ComponentProps<typeof Combobox<T>> & {
|
|
|
|
|
type PropsAndCustomArgs<T extends string | number = string> = ComboboxProps<T> & {
|
|
|
|
|
numberOfOptions: number;
|
|
|
|
|
};
|
|
|
|
|
type Story<T extends string | number = string> = StoryObj<PropsAndCustomArgs<T>>;
|
|
|
|
|
|
|
|
|
|
const meta: Meta<PropsAndCustomArgs> = {
|
|
|
|
|
title: 'Forms/Combobox',
|
|
|
|
@@ -63,257 +63,200 @@ const meta: Meta<PropsAndCustomArgs> = {
|
|
|
|
|
],
|
|
|
|
|
value: 'banana',
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
render: (args) => <BasicWithState {...args} />,
|
|
|
|
|
decorators: [InDevDecorator],
|
|
|
|
|
};
|
|
|
|
|
export default meta;
|
|
|
|
|
|
|
|
|
|
const loadOptionsAction = action('options called');
|
|
|
|
|
const onChangeAction = action('onChange called');
|
|
|
|
|
|
|
|
|
|
const BaseCombobox: StoryFn<PropsAndCustomArgs> = (args) => {
|
|
|
|
|
const [dynamicArgs, setArgs] = useArgs();
|
|
|
|
|
|
|
|
|
|
const BasicWithState: StoryFn<PropsAndCustomArgs> = (args) => {
|
|
|
|
|
const [value, setValue] = useState<string | null>();
|
|
|
|
|
return (
|
|
|
|
|
<Field label="Test input" description="Input with a few options">
|
|
|
|
|
<Combobox
|
|
|
|
|
id="test-combobox"
|
|
|
|
|
{...args}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(val: ComboboxOption | null) => {
|
|
|
|
|
// TODO: Figure out how to update value on args
|
|
|
|
|
setValue(val?.value || null);
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
{...dynamicArgs}
|
|
|
|
|
onChange={(value: ComboboxOption | null) => {
|
|
|
|
|
setArgs({ value: value?.value || null });
|
|
|
|
|
onChangeAction(value);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Story = StoryObj<typeof Combobox>;
|
|
|
|
|
|
|
|
|
|
export const Basic: Story = {};
|
|
|
|
|
|
|
|
|
|
export async function generateOptions(amount: number): Promise<ComboboxOption[]> {
|
|
|
|
|
return Array.from({ length: amount }, (_, index) => ({
|
|
|
|
|
label: 'Option ' + index,
|
|
|
|
|
value: index.toString(),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ManyOptionsStory: StoryFn<PropsAndCustomArgs<string>> = ({ numberOfOptions, ...args }) => {
|
|
|
|
|
const [value, setValue] = useState<string | null>(null);
|
|
|
|
|
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
generateOptions(numberOfOptions).then((options) => {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
setOptions(options);
|
|
|
|
|
setValue(options[5].value);
|
|
|
|
|
});
|
|
|
|
|
}, 1000);
|
|
|
|
|
}, [numberOfOptions]);
|
|
|
|
|
|
|
|
|
|
const { onChange, ...rest } = args;
|
|
|
|
|
return (
|
|
|
|
|
<Combobox
|
|
|
|
|
{...rest}
|
|
|
|
|
loading={isLoading}
|
|
|
|
|
options={options}
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(opt: ComboboxOption | null) => {
|
|
|
|
|
setValue(opt?.value || null);
|
|
|
|
|
action('onChange')(opt);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
export const Basic: Story = {
|
|
|
|
|
render: BaseCombobox,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const AutoSize: StoryObj<PropsAndCustomArgs> = {
|
|
|
|
|
export const AutoSize: Story = {
|
|
|
|
|
args: {
|
|
|
|
|
width: 'auto',
|
|
|
|
|
minWidth: 5,
|
|
|
|
|
maxWidth: 200,
|
|
|
|
|
},
|
|
|
|
|
render: BaseCombobox,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ManyOptions: StoryObj<PropsAndCustomArgs> = {
|
|
|
|
|
export const CustomValue: Story = {
|
|
|
|
|
args: {
|
|
|
|
|
createCustomValue: true,
|
|
|
|
|
},
|
|
|
|
|
render: BaseCombobox,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ManyOptions: Story = {
|
|
|
|
|
args: {
|
|
|
|
|
numberOfOptions: 1e5,
|
|
|
|
|
options: undefined,
|
|
|
|
|
value: undefined,
|
|
|
|
|
},
|
|
|
|
|
render: ManyOptionsStory,
|
|
|
|
|
};
|
|
|
|
|
render: ({ numberOfOptions, ...args }: PropsAndCustomArgs) => {
|
|
|
|
|
const [dynamicArgs, setArgs] = useArgs();
|
|
|
|
|
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
|
|
|
|
|
|
|
|
|
export const CustomValue: StoryObj<PropsAndCustomArgs> = {
|
|
|
|
|
args: {
|
|
|
|
|
createCustomValue: true,
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
generateOptions(numberOfOptions).then((options) => {
|
|
|
|
|
setOptions(options);
|
|
|
|
|
setArgs({ value: options[5].value });
|
|
|
|
|
});
|
|
|
|
|
}, 1000);
|
|
|
|
|
}, [numberOfOptions, setArgs]);
|
|
|
|
|
|
|
|
|
|
const { onChange, ...rest } = args;
|
|
|
|
|
return (
|
|
|
|
|
<Field label="Test input" description={options.length ? 'Input with a few options' : 'Preparing options...'}>
|
|
|
|
|
<Combobox
|
|
|
|
|
{...rest}
|
|
|
|
|
{...dynamicArgs}
|
|
|
|
|
options={options}
|
|
|
|
|
onChange={(value: ComboboxOption | null) => {
|
|
|
|
|
setArgs({ value: value?.value || null });
|
|
|
|
|
onChangeAction(value);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadOptionsAction = action('loadOptions called');
|
|
|
|
|
const AsyncStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|
|
|
|
// Combobox
|
|
|
|
|
const [selectedOption, setSelectedOption] = useState<ComboboxOption<string> | null>(null);
|
|
|
|
|
function loadOptionsWithLabels(inputValue: string) {
|
|
|
|
|
loadOptionsAction(inputValue);
|
|
|
|
|
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AsyncSelect
|
|
|
|
|
const [asyncSelectValue, setAsyncSelectValue] = useState<SelectableValue<string> | null>(null);
|
|
|
|
|
export const AsyncOptionsWithLabels: Story = {
|
|
|
|
|
name: 'Async - values + labels',
|
|
|
|
|
args: {
|
|
|
|
|
options: loadOptionsWithLabels,
|
|
|
|
|
value: { label: 'Option 69', value: '69' },
|
|
|
|
|
placeholder: 'Select an option',
|
|
|
|
|
},
|
|
|
|
|
render: (args: PropsAndCustomArgs) => {
|
|
|
|
|
const [dynamicArgs, setArgs] = useArgs();
|
|
|
|
|
|
|
|
|
|
// This simulates a kind of search API call
|
|
|
|
|
const loadOptionsWithLabels = useCallback((inputValue: string) => {
|
|
|
|
|
loadOptionsAction(inputValue);
|
|
|
|
|
return fakeSearchAPI(`http://example.com/search?query=${inputValue}`);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadOptionsOnlyValues = useCallback((inputValue: string) => {
|
|
|
|
|
return fakeSearchAPI(`http://example.com/search?query=${inputValue}`).then((options) =>
|
|
|
|
|
options.map((opt) => ({ value: opt.label! }))
|
|
|
|
|
return (
|
|
|
|
|
<Field
|
|
|
|
|
label='Asynbc options fn returns objects like { label: "Option 69", value: "69" }'
|
|
|
|
|
description="Search for 'break' to see an error"
|
|
|
|
|
>
|
|
|
|
|
<Combobox
|
|
|
|
|
{...args}
|
|
|
|
|
{...dynamicArgs}
|
|
|
|
|
onChange={(val: ComboboxOption | null) => {
|
|
|
|
|
onChangeAction(val);
|
|
|
|
|
setArgs({ value: val });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadOptionsWithErrors = useCallback((inputValue: string) => {
|
|
|
|
|
if (inputValue.length % 2 === 0) {
|
|
|
|
|
return fakeSearchAPI(`http://example.com/search?query=${inputValue}`);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('Could not retrieve options');
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const { onChange, ...rest } = args;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Field
|
|
|
|
|
label="Options with labels"
|
|
|
|
|
description="This tests when options have both a label and a value. Consumers are required to pass in a full ComboboxOption as a value with a label"
|
|
|
|
|
>
|
|
|
|
|
<Combobox
|
|
|
|
|
{...rest}
|
|
|
|
|
id="test-combobox-one"
|
|
|
|
|
placeholder="Select an option"
|
|
|
|
|
options={loadOptionsWithLabels}
|
|
|
|
|
value={selectedOption}
|
|
|
|
|
onChange={(val: ComboboxOption | null) => {
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
setSelectedOption(val);
|
|
|
|
|
}}
|
|
|
|
|
createCustomValue={args.createCustomValue}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
<Field
|
|
|
|
|
label="Options without labels"
|
|
|
|
|
description="Or without labels, where consumer can just pass in a raw scalar value Value"
|
|
|
|
|
>
|
|
|
|
|
<Combobox
|
|
|
|
|
{...args}
|
|
|
|
|
id="test-combobox-two"
|
|
|
|
|
placeholder="Select an option"
|
|
|
|
|
options={loadOptionsOnlyValues}
|
|
|
|
|
value={selectedOption?.value ?? null}
|
|
|
|
|
onChange={(val: ComboboxOption | null) => {
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
setSelectedOption(val);
|
|
|
|
|
}}
|
|
|
|
|
createCustomValue={args.createCustomValue}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
<Field label="Async with error" description="An odd number of characters throws an error">
|
|
|
|
|
<Combobox
|
|
|
|
|
id="test-combobox-error"
|
|
|
|
|
placeholder="Select an option"
|
|
|
|
|
options={loadOptionsWithErrors}
|
|
|
|
|
value={selectedOption}
|
|
|
|
|
onChange={(val) => {
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
setSelectedOption(val);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
<Field label="Compared to AsyncSelect">
|
|
|
|
|
<AsyncSelect
|
|
|
|
|
id="test-async-select"
|
|
|
|
|
placeholder="Select an option"
|
|
|
|
|
loadOptions={loadOptionsWithLabels}
|
|
|
|
|
value={asyncSelectValue}
|
|
|
|
|
defaultOptions
|
|
|
|
|
onChange={(val) => {
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
setAsyncSelectValue(val);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
<Field label="Async with error" description="An odd number of characters throws an error">
|
|
|
|
|
<Combobox
|
|
|
|
|
{...args}
|
|
|
|
|
id="test-combobox-error"
|
|
|
|
|
placeholder="Select an option"
|
|
|
|
|
options={loadOptionsWithErrors}
|
|
|
|
|
value={selectedOption}
|
|
|
|
|
onChange={(val: ComboboxOption | null) => {
|
|
|
|
|
action('onChange')(val);
|
|
|
|
|
setSelectedOption(val);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const Async: StoryObj<PropsAndCustomArgs> = {
|
|
|
|
|
render: AsyncStory,
|
|
|
|
|
function loadOptionsOnlyValues(inputValue: string) {
|
|
|
|
|
loadOptionsAction(inputValue);
|
|
|
|
|
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`).then((options) =>
|
|
|
|
|
options.map((opt) => ({ value: opt.label! }))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const AsyncOptionsWithOnlyValues: Story = {
|
|
|
|
|
name: 'Async - values only',
|
|
|
|
|
args: {
|
|
|
|
|
options: loadOptionsOnlyValues,
|
|
|
|
|
value: { value: 'Option 69' },
|
|
|
|
|
placeholder: 'Select an option',
|
|
|
|
|
},
|
|
|
|
|
render: (args: PropsAndCustomArgs) => {
|
|
|
|
|
const [dynamicArgs, setArgs] = useArgs();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Field
|
|
|
|
|
label='Async options fn returns objects like { value: "69" }'
|
|
|
|
|
description="Search for 'break' to see an error"
|
|
|
|
|
>
|
|
|
|
|
<Combobox
|
|
|
|
|
{...args}
|
|
|
|
|
{...dynamicArgs}
|
|
|
|
|
onChange={(value: ComboboxOption | null) => {
|
|
|
|
|
onChangeAction(value);
|
|
|
|
|
setArgs({ value: value });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const noop = () => {};
|
|
|
|
|
const PositioningTestStory: StoryFn<PropsAndCustomArgs> = (args) => {
|
|
|
|
|
if (typeof args.options === 'function') {
|
|
|
|
|
throw new Error('This story does not support async options');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderColumnOfComboboxes(pos: string) {
|
|
|
|
|
export const PositioningTest: Story = {
|
|
|
|
|
render: (args: PropsAndCustomArgs) => {
|
|
|
|
|
if (typeof args.options === 'function') {
|
|
|
|
|
throw new Error('This story does not support async options');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderColumnOfComboboxes(pos: string) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
flex: 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} top`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} middle`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} bottom`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
|
|
|
|
// approx the height of the dev alert, and three margins. exact doesn't matter
|
|
|
|
|
minHeight: 'calc(100vh - (105px + 16px + 16px + 16px))',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
flex: 1,
|
|
|
|
|
gap: 32,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} top`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} middle`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
<Combobox {...args} placeholder={`${pos} bottom`} options={args.options} value={null} onChange={noop} />
|
|
|
|
|
{renderColumnOfComboboxes('Left')}
|
|
|
|
|
{renderColumnOfComboboxes('Middle')}
|
|
|
|
|
{renderColumnOfComboboxes('Right')}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
|
|
|
|
// approx the height of the dev alert, and three margins. exact doesn't matter
|
|
|
|
|
minHeight: 'calc(100vh - (105px + 16px + 16px + 16px))',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
gap: 32,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{renderColumnOfComboboxes('Left')}
|
|
|
|
|
{renderColumnOfComboboxes('Middle')}
|
|
|
|
|
{renderColumnOfComboboxes('Right')}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const PositioningTest: StoryObj<PropsAndCustomArgs> = {
|
|
|
|
|
render: PositioningTestStory,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default meta;
|
|
|
|
|
|
|
|
|
|
function InDevDecorator(Story: React.ElementType) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
@@ -327,28 +270,3 @@ function InDevDecorator(Story: React.ElementType) {
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fakeApiOptions: Array<ComboboxOption<string>>;
|
|
|
|
|
async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> {
|
|
|
|
|
const searchParams = new URL(urlString).searchParams;
|
|
|
|
|
|
|
|
|
|
if (!fakeApiOptions) {
|
|
|
|
|
fakeApiOptions = await generateOptions(1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const searchQuery = searchParams.get('query')?.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (!searchQuery || searchQuery.length === 0) {
|
|
|
|
|
return Promise.resolve(fakeApiOptions.slice(0, 10));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredOptions = Promise.resolve(
|
|
|
|
|
fakeApiOptions.filter((opt) => opt.label?.toLowerCase().includes(searchQuery))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const delay = searchQuery.length % 2 === 0 ? 200 : 1000;
|
|
|
|
|
|
|
|
|
|
return new Promise<Array<ComboboxOption<string>>>((resolve) => {
|
|
|
|
|
setTimeout(() => resolve(filteredOptions), delay);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|