import type { Meta, StoryObj } from '@storybook/react-webpack5'; import { fn, type Mock } from 'storybook/test'; import { Calendar, ChevronDown } from '@transferwise/icons'; import { Flag } from '@wise/art'; import { clsx } from 'clsx'; import { useState } from 'react'; import Button from '../../../button'; import { getMonthNames } from '../../../common/dateUtils'; import Drawer from '../../../drawer'; import { Field } from '../../../field/Field'; import Modal from '../../../modal'; import { SelectInput, type SelectInputItem, SelectInputOptionContent, type SelectInputProps, SelectInputTriggerButton, } from '..'; /** * A searchable, accessible combobox for selecting a single or multiple values. * Supports flat and grouped items, optional filtering, custom triggers, a clear button, * and a custom footer. Renders a popover on desktop and a bottom sheet on mobile. * Lists with 50+ items are automatically virtualised. */ const meta = { title: 'Forms/SelectInput', component: SelectInput, args: { onFilterChange: fn() satisfies Mock, onChange: fn() satisfies Mock, onClose: fn() satisfies Mock, onOpen: fn() satisfies Mock, }, argTypes: { parentId: { table: { category: 'WDS internal' }, }, renderTrigger: { table: { defaultValue: { summary: 'Default trigger (InputGroup + ButtonInput)' }, }, }, // Events onClear: { table: { category: 'Events' }, }, onFilterChange: { table: { category: 'Events' }, }, onChange: { table: { category: 'Events' }, }, onClose: { table: { category: 'Events' }, }, onOpen: { table: { category: 'Events' }, }, }, parameters: { actions: { argTypesRegex: '' }, }, } satisfies Meta; export default meta; type Story = StoryObj>; interface Month { id: number; name: string; } const months: Month[] = getMonthNames('en-US').map((name, index) => ({ id: index + 1, name, })); interface Currency { code: string; name: string; countries?: string[]; } const popularCurrencies: Currency[] = [ { code: 'USD', name: 'United States Dollar', countries: ['Hong Kong', 'Saudi Arabia'], }, { code: 'EUR', name: 'Euro', countries: ['Spain', 'Germany', 'France', 'Austria', 'Estonia'], }, { code: 'GBP', name: 'British pound', countries: ['England', 'Scotland', 'Wales'], }, ]; const otherCurrencies: Currency[] = [ { code: 'CAD', name: 'Canadian dollar', countries: ['Canada'], }, { code: 'AUD', name: 'Australian dollar', }, { code: 'JPY', name: 'Japanese yen', countries: ['Japan'], }, ]; const allCurrencies: Currency[] = [...popularCurrencies, ...otherCurrencies].sort((a, b) => a.code.localeCompare(b.code), ); function currencyOption(currency: Currency) { return { type: 'option', value: currency, filterMatchers: [currency.code, currency.name, ...(currency.countries ?? [])], } satisfies SelectInputItem; } const CurrenciesArgs = { items: [ { type: 'group', label: 'Popular currencies', options: popularCurrencies.map((currency) => currencyOption(currency)), }, { type: 'group', label: 'All currencies', options: allCurrencies.map((currency) => currencyOption(currency)), }, ], defaultValue: popularCurrencies[0], renderValue: (currency, withinTrigger) => ( } /> ), renderFooter: ({ resultsEmpty, queryNormalized }) => resultsEmpty && queryNormalized != null && /^[a-z]{3}$/u.test(queryNormalized) ? ( <> It is not possible to use {queryNormalized.toUpperCase()} yet.{' '} event.preventDefault()}> Email me when it is available. ) : ( <> Cannot find it?{' '} event.preventDefault()}> Request the currency you need, {' '} and we will notify you once it is available. ), filterable: true, filterPlaceholder: 'Type a currency / country', size: 'lg', } satisfies Story['args']; const previewArgGroup = { table: { category: 'Storybook Preview', type: { summary: undefined } }, }; type PlaygroundArgs = { size?: 'sm' | 'md' | 'lg'; filterable?: boolean; filterPlaceholder?: string; disabled?: boolean; placeholder?: string; multiple?: boolean; previewWithGroups: boolean; previewClearable: boolean; previewWithFooter: boolean; }; export const Playground: StoryObj = { argTypes: { previewWithGroups: { name: 'Preview with groups', control: 'boolean', ...previewArgGroup, }, previewClearable: { name: 'Preview with clear button', control: 'boolean', ...previewArgGroup, }, previewWithFooter: { name: 'Preview with footer', control: 'boolean', ...previewArgGroup, }, }, args: { disabled: false, multiple: false, filterable: true, filterPlaceholder: 'Type a currency / country', placeholder: 'Select a currency', size: 'lg', previewWithGroups: true, previewClearable: true, previewWithFooter: true, }, render: function Render({ previewWithGroups, previewClearable, previewWithFooter, multiple, filterPlaceholder, ...args }) { const [singleValue, setSingleValue] = useState(undefined); const [multiValue, setMultiValue] = useState([]); const items: SelectInputItem[] = previewWithGroups ? [ { type: 'group', label: 'Popular currencies', options: popularCurrencies.map(currencyOption), }, { type: 'group', label: 'All currencies', options: allCurrencies.map(currencyOption) }, ] : allCurrencies.map(currencyOption); const renderValue = (currency: Currency, withinTrigger: boolean) => { if (multiple && withinTrigger) return currency.code; return ( } /> ); }; const renderFooter = previewWithFooter ? ({ resultsEmpty, queryNormalized, }: { resultsEmpty: boolean; queryNormalized: string | null | undefined; }) => resultsEmpty && queryNormalized != null && /^[a-z]{3}$/u.test(queryNormalized) ? ( <>It is not possible to use {queryNormalized.toUpperCase()} yet. ) : ( <> Cannot find it?{' '} e.preventDefault()}> Request the currency ) : undefined; if (multiple) { return ( {...(args as SelectInputProps)} multiple items={items} value={multiValue} renderValue={renderValue} renderFooter={renderFooter} filterPlaceholder={filterPlaceholder} onClear={previewClearable ? () => setMultiValue([]) : undefined} onChange={setMultiValue} /> ); } return ( {...(args as SelectInputProps)} items={items} value={singleValue} renderValue={renderValue} renderFooter={renderFooter} filterPlaceholder={filterPlaceholder} onClear={previewClearable ? () => setSingleValue(undefined) : undefined} onChange={setSingleValue} /> ); }, parameters: { docs: { canvas: { sourceState: 'hidden' }, }, }, }; /** * The simplest usage: a flat list of options with no icons or grouping. * `renderValue` controls what is shown in both the trigger and each dropdown row. */ export const Basic: Story = { args: { placeholder: 'Month', items: months.map((month) => ({ type: 'option', value: month, })), renderValue: (month) => , }, render: function Render({ onChange, onClear, ...args }) { const [selectedMonth, setSelectedMonth] = useState(null); return ( { setSelectedMonth(month); onChange?.(month); }} onClear={() => { setSelectedMonth(null); onClear?.(); }} /> ); }, parameters: { docs: { source: { code: ` } value={selectedMonth} onChange={setSelectedMonth} />`, }, }, }, }; /** * A real-world currency selector with grouped items, flag icons, filterable search, * and a custom footer. `renderValue` receives a `withinTrigger` boolean to render * a compact version inside the trigger/selected state (code only, no full currency name). */ export const Grouping: Story = { args: CurrenciesArgs, parameters: { docs: { source: { code: ` ({ type: 'option', value: c, filterMatchers: [c.code, c.name, ...(c.countries ?? [])], })), }, // ... more groups ]} defaultValue={popularCurrencies[0]} renderValue={(currency, withinTrigger) => ( } /> )} renderFooter={({ resultsEmpty }) => resultsEmpty ? ( <>Cannot find it? Request the currency you need. ) : null } />`, }, }, }, }; /** * When `multiple` is `true`, `value` and `onChange` operate on arrays. Use the * `withinTrigger` argument in `renderValue` to show a compact summary inside the * trigger (e.g. currency codes joined by commas). */ export const MultiSelect: Story = { argTypes: { multiple: { table: { disable: true } }, }, args: { ...CurrenciesArgs, multiple: true, placeholder: 'Choose currencies…', defaultValue: [popularCurrencies[0]], renderValue: (currency, withinTrigger) => withinTrigger ? ( currency.code ) : ( } /> ), }, parameters: { docs: { source: { code: ` withinTrigger ? currency.code : ( } /> ) } />`, }, }, }, }; /** * Groups can have an `action` button (e.g. "Select all" / "Deselect all") rendered * next to the group heading. The label should update dynamically based on whether * all items in that group are selected. */ export const WithGroupActions: Story = { argTypes: { multiple: { table: { disable: true } }, }, args: { ...MultiSelect.args, }, render: function Render(args) { const [selectedItems, setSelectedItems] = useState([]); const allSelected = (items: Currency[]) => { const selectedSet = new Set(selectedItems); return items.every((item) => selectedSet.has(item)); }; const toggleItems = (items: Currency[]) => { setSelectedItems((currentItems) => allSelected(items) ? currentItems.filter((item) => !items.includes(item)) : [...new Set([...currentItems, ...items])], ); }; return ( currencyOption(currency)), action: { label: allSelected(popularCurrencies) ? 'Deselect all' : 'Select all', onClick: () => { toggleItems(popularCurrencies); }, }, }, { type: 'group', label: 'Other currencies', options: otherCurrencies.map((currency) => currencyOption(currency)), action: { label: allSelected(otherCurrencies) ? 'Deselect all' : 'Select all', onClick: () => { toggleItems(otherCurrencies); }, }, }, ]} value={selectedItems} onChange={(currencies) => { setSelectedItems(currencies); }} onClear={() => { setSelectedItems([]); }} /> ); }, parameters: { docs: { source: { code: ` toggleItems(popularCurrencies), }, }, // ... more groups ]} value={selectedItems} onChange={setSelectedItems} onClear={() => setSelectedItems([])} />`, }, }, }, }; /** Pass `onClear` to show a clear button when a value is selected. */ export const WithClear: Story = { argTypes: { onClear: { table: { disable: true } }, }, args: { ...CurrenciesArgs, onClear: fn() satisfies Mock, }, render: function Render({ onChange, onClear, defaultValue: _dv, ...args }) { const [value, setValue] = useState(popularCurrencies[0]); return ( { setValue(currency); onChange?.(currency); }} onClear={() => { setValue(undefined); onClear?.(); }} /> ); }, parameters: { docs: { source: { code: ` ( } /> )} onClear={() => setValue(undefined)} />`, }, }, }, }; /** * Use `renderTrigger` with `SelectInputTriggerButton` to fully customise the trigger * appearance. The interactive element **must** be `SelectInputTriggerButton` to receive * the correct ARIA attributes (`aria-expanded`, `aria-haspopup`, `aria-controls`). */ export const CustomTrigger: Story = { args: { placeholder: 'Month', items: months.map((month) => ({ type: 'option', value: month, })), renderValue: (month, withinTrigger) => withinTrigger ? month.name : , renderTrigger: ({ content, className }) => ( {content} ), }, parameters: { docs: { source: { code: ` withinTrigger ? month.name : } renderTrigger={({ content, className }) => ( {content} )} />`, }, }, }, }; /** * Combines grouped items, `separator` items, disabled options, and filterable search. * `SelectInputOptionContent` supports `title`, `note` (inline), and * `description` (below-title) for rich two-line option layouts. */ export const Advanced: Story = { args: { placeholder: 'Month', items: [months.slice(0, 3), months.slice(3, 6), months.slice(6, 9), months.slice(9, 12)] .flatMap>((quarterMonths, quarterIndex) => [ { type: 'group', label: `Quarter #${quarterIndex + 1}`, options: quarterMonths.map((month, monthIndex) => ({ type: 'option', value: month, filterMatchers: [month.name], disabled: monthIndex % 6 === 2, })), }, { type: 'separator' }, ]) .slice(0, -1), renderValue: (month) => ( } /> ), filterable: true, filterPlaceholder: "Type a month's name", }, parameters: { docs: { source: { code: ` ( } /> )} />`, }, }, }, }; /** * Lists with 50+ items are automatically virtualised using a windowed renderer. * This example renders 1,000 numbered options with multi-select enabled. */ export const Virtualization: Story = { args: { multiple: true, items: Array.from({ length: 1000 }, (_, index) => ({ type: 'option', value: String(index + 1), })), renderValue: (value, withinTrigger) => withinTrigger ? ( value ) : ( ), filterable: true, }, parameters: { docs: { source: { code: ` ({ type: 'option', value: String(i + 1), }))} renderValue={(value, withinTrigger) => withinTrigger ? value : } />`, }, }, }, }; /** The `disabled` prop prevents all interaction. */ export const Disabled: Story = { argTypes: { disabled: { table: { disable: true } }, id: { table: { disable: true } }, name: { table: { disable: true } }, multiple: { table: { disable: true } }, placeholder: { table: { disable: true } }, items: { table: { disable: true } }, autocomplete: { table: { disable: true } }, defaultValue: { table: { disable: true } }, value: { table: { disable: true } }, compareValues: { table: { disable: true } }, renderValue: { table: { disable: true } }, renderFooter: { table: { disable: true } }, renderTrigger: { table: { disable: true } }, filterable: { table: { disable: true } }, filterPlaceholder: { table: { disable: true } }, sortFilteredOptions: { table: { disable: true } }, className: { table: { disable: true } }, UNSAFE_triggerButtonProps: { table: { disable: true } }, triggerRef: { table: { disable: true } }, parentId: { table: { disable: true } }, onFilterChange: { table: { disable: true } }, onChange: { table: { disable: true } }, onClose: { table: { disable: true } }, onOpen: { table: { disable: true } }, onClear: { table: { disable: true } }, }, args: { ...CurrenciesArgs, disabled: true, }, parameters: { docs: { source: { code: ` ( } /> )} />`, }, }, }, }; /** Three size variants: `sm`, `md`, and `lg`. */ export const Sizes: Story = { argTypes: { size: { table: { disable: true } }, }, render: (args) => (
{...args} size="sm" placeholder="Small" items={popularCurrencies.map(currencyOption)} renderValue={(c) => } /> {...args} size="md" placeholder="Medium" items={popularCurrencies.map(currencyOption)} renderValue={(c) => } /> {...args} size="lg" placeholder="Large (default)" items={popularCurrencies.map(currencyOption)} renderValue={(c) => } />
), parameters: { docs: { source: { code: ` `, }, }, }, }; /** * "Commit on close" pattern: stage selections locally and apply them only when the * dropdown closes. Useful when each selection change is expensive (e.g. an API call) * or when the UX requires confirmation before updating external state. */ export const WithCommitOnClose: Story = { argTypes: { onClose: { table: { disable: true } }, }, render: function Render(args) { const [committedValues, setCommittedValues] = useState([popularCurrencies[0]]); const [stagedValues, setStagedValues] = useState([popularCurrencies[0]]); return (

Applied: {committedValues.map((c) => c.code).join(', ') || 'none'}

{...args} multiple filterable filterPlaceholder="Type a currency / country" size="lg" placeholder="Choose currencies…" items={[ { type: 'group', label: 'Popular currencies', options: popularCurrencies.map(currencyOption), }, { type: 'group', label: 'All currencies', options: allCurrencies.map(currencyOption), }, ]} renderValue={(currency, withinTrigger) => withinTrigger ? ( currency.code ) : ( } /> ) } value={stagedValues} onChange={setStagedValues} onClose={() => setCommittedValues(stagedValues)} onClear={() => { setStagedValues([]); setCommittedValues([]); }} />
); }, parameters: { docs: { source: { code: `const [committedValues, setCommittedValues] = useState([]); const [stagedValues, setStagedValues] = useState([]); setCommittedValues(stagedValues)} onClear={() => { setStagedValues([]); setCommittedValues([]); }} // ...items, renderValue />`, }, }, }, }; /** * Wrap with `Field` to inherit error state and label association automatically. * `Field` injects `aria-describedby` on the trigger pointing to the validation * message — no manual wiring required. */ export const WithinField: Story = { args: { placeholder: "Today's Month", items: months.map((month) => ({ type: 'option', value: month, })), renderValue: (month) => , }, render: function Render({ onChange, ...args }) { const currentMonthIndex = new Date().getMonth(); const defaultMonth = months[currentMonthIndex === 0 ? 2 : 0]; const [selectedMonth, setSelectedMonth] = useState(defaultMonth); const isCorrect = selectedMonth.id === currentMonthIndex + 1; return ( { setSelectedMonth(month); onChange?.(month); }} /> ); }, parameters: { docs: { source: { code: `const isCorrect = selectedMonth.id === currentMonthIndex + 1; } value={selectedMonth} onChange={setSelectedMonth} /> `, }, }, }, }; export const WithinDrawer: Story = { args: CurrenciesArgs, decorators: [ (Story, context) => { const [open, setOpen] = useState(context.viewMode === 'story'); return ( <> setOpen(false)} > ); }, ], parameters: { docs: { source: { code: ` setOpen(false)}> ( } /> )} onChange={setSelectedCurrency} /> `, }, }, }, }; export const WithinModal: Story = { args: CurrenciesArgs, decorators: [ (Story, context) => { const [open, setOpen] = useState(context.viewMode === 'story'); return ( <> } onClose={() => setOpen(false)} /> ); }, ], parameters: { docs: { source: { code: ` setOpen(false)} body={ ( } /> )} onChange={setSelectedCurrency} /> } />`, }, }, }, }; interface Country { code: string; name: string; } const countries: Country[] = [ { code: 'US', name: 'United States' }, { code: 'GB', name: 'United Kingdom' }, { code: 'CA', name: 'Canada' }, { code: 'AU', name: 'Australia' }, { code: 'DE', name: 'Germany' }, { code: 'FR', name: 'France' }, { code: 'JP', name: 'Japan' }, { code: 'BR', name: 'Brazil' }, { code: 'IN', name: 'India' }, { code: 'CN', name: 'China' }, { code: 'IT', name: 'Italy' }, { code: 'ES', name: 'Spain' }, { code: 'NL', name: 'Netherlands' }, { code: 'CH', name: 'Switzerland' }, { code: 'SE', name: 'Sweden' }, { code: 'MX', name: 'Mexico' }, ]; function countryOption(country: Country) { return { type: 'option', value: country.code, filterMatchers: [country.code, country.name], } satisfies SelectInputItem; } /** * Use `name` and `autocomplete` to integrate with the browser's native autofill. * The component renders a hidden `` that carries the selected value, * making it submittable inside a `
` and detectable by password managers. */ export const WithAutocomplete: Story = { args: { name: 'country', autocomplete: 'country-name', placeholder: 'Select your country', items: countries.map(countryOption), renderValue: (countryCode, withinTrigger) => { const country = countries.find((c) => c.code === countryCode); return ( } /> ); }, filterable: true, filterPlaceholder: 'Type a country name', size: 'lg', }, render: function Render({ onChange, onClear, ...args }) { const [selectedCountry, setSelectedCountry] = useState(undefined); return ( e.preventDefault()}> { setSelectedCountry(country); onChange?.(country); }} onClear={() => { setSelectedCountry(undefined); onClear?.(); }} /> ); }, parameters: { docs: { source: { code: `
( } /> )} value={selectedCountry} onChange={setSelectedCountry} />
`, }, }, }, }; interface CountryWithCurrency extends Country { keywords: string[]; } const countriesWithCurrency: CountryWithCurrency[] = [ { code: 'AD', name: 'Andorra', keywords: ['united states dollar'] }, { code: 'DE', name: 'Germany', keywords: ['EUR'] }, { code: 'NR', name: 'New United Republic', keywords: ['NUR'] }, { code: 'US', name: 'United States', keywords: ['USD'] }, { code: 'ZM', name: 'Zambia', keywords: ['USD', 'united states dollar'] }, ]; function countryWithCurrencyOption(country: CountryWithCurrency) { return { type: 'option', value: country, filterMatchers: [country.name, country.code, ...country.keywords], } satisfies SelectInputItem; } export const WithBuiltInSearchResultSorting: Story = { args: { items: countriesWithCurrency.map(countryWithCurrencyOption), compareValues: 'code', renderValue: (country) => ( } /> ), value: countriesWithCurrency[0], filterable: true, filterPlaceholder: 'Type a currency / country', sortFilteredOptions: SelectInput.sortByRelevance((value) => value.name), size: 'lg', } satisfies Story['args'], decorators: [ (Story) => (

This example uses the built-in SelectInput.sortByRelevance helper to sort filtered results by relevance (You can implement your own). This one prioritises: exact matches → starts with → contains → alphabetical.

Try searching for "united" to see the sorting tiers in action:
1. United States — name starts with "United"
2. New United Republic — name contains "United"
3. Andorra, Zambia — match only via keywords

), ], parameters: { docs: { source: { code: ` value.name)} items={countries.map((country) => ({ type: 'option', value: country, filterMatchers: [country.name, country.code, ...country.keywords], }))} renderValue={(country) => ( } /> )} />`, }, }, }, };