diff --git a/.nx/version-plans/version-plan-1781295720164.md b/.nx/version-plans/version-plan-1781295720164.md new file mode 100644 index 00000000000..04a1e56bb9c --- /dev/null +++ b/.nx/version-plans/version-plan-1781295720164.md @@ -0,0 +1,5 @@ +--- +gamut: minor +--- + +feat(SelectDropdown): add isCreateable prop + remove SearchIcon diff --git a/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md b/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md index c135ed355c7..6ba8f776698 100644 --- a/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md @@ -26,6 +26,22 @@ For typical product forms, prefer `GridForm` (declarative `fields`, `LayoutGrid` --- +## SelectDropdown vs Select + +Use `Select` for standard single-select fields where bundle size matters and no special styling is needed. Use `SelectDropdown` when the design calls for any of: + +- Styled dropdown menu (react-select appearance) +- Search / typeahead +- Multi-select with tags +- Creatable options +- Option icons, subtitles, right labels, abbreviations, or grouped options + +`SelectDropdown` carries a larger JS dependency (react-select); don't reach for it as a default drop-in for `Select`. + +For full SelectDropdown API detail — controlled vs uncontrolled patterns, creatable options, react-select action metadata — use [`gamut-select-dropdown`](../gamut-select-dropdown/SKILL.md). Generic `FormGroup` wiring (labels, errors, live regions) still applies as documented below. + +--- + ## `FormGroup` (baseline) `packages/gamut/src/Form/elements/FormGroup.tsx` diff --git a/packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md b/packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md new file mode 100644 index 00000000000..c89f05ec983 --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md @@ -0,0 +1,237 @@ +--- +name: gamut-select-dropdown +description: Use when implementing or auditing SelectDropdown — single/multi modes, controlled vs uncontrolled value, creatable options, FormGroup wiring, and onChange contract. Pair with gamut-forms for error live regions, ConnectedForm, and field-level validation. +--- + +# Gamut SelectDropdown + +Styled dropdown built on react-select. + +Source: `@codecademy/gamut` — [SelectDropdown.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx) + +See also: [`gamut-forms`](../gamut-forms/SKILL.md) — FormGroup wiring, error regions, and validation UX. + +Storybook: [Atoms / FormInputs / SelectDropdown](https://gamut.codecademy.com/?path=/docs-atoms-forminputs-selectdropdown--docs) + +--- + +## When to use SelectDropdown vs Select + +Use `Select` for standard single-select forms with minimal bundle cost. Use `SelectDropdown` when designs specify the styled dropdown menu, search, multi-select tags, creatable options, icons, groups, or abbreviations. `SelectDropdown` has a larger JavaScript dependency (react-select). + +--- + +## Options + +`options` accepts an array of plain strings or option objects. `value` is always a string and references an option's `value`. + +The table below shows the keys and value types for an option object: + +| Field | Type | Required | Notes | +| -------------- | --------------------------------- | -------- | -------------------------------------------------------------------- | +| `label` | `string` | yes | Display text | +| `value` | `string` | yes | Unique string; what `value` / `string[]` reference | +| `disabled` | `boolean` | no | Option cannot be selected | +| `subtitle` | `string` | no | Secondary text below the label | +| `rightLabel` | `string` | no | Text on the right side of the option | +| `icon` | icon component from `gamut-icons` | no | A `@codecademy/gamut-icons` component | +| `abbreviation` | `string` | no | Short text shown in the input while the full label shows in the menu | + +Grouped options: `{ label, options: [...], divider? }` (extends react-select `GroupBase`; `divider` draws a rule above the group). + +--- + +## Controlled vs uncontrolled + +`SelectDropdown` does **not** accept `defaultValue`. + +| Mode | Uncontrolled | Controlled | +| ---------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- | +| Single | Not supported | `value` (string) + update in `onChange` | +| Multi | Omit `value` or pass non-array (`undefined`, `''`) | `value: string[]` + update in `onChange` | +| Creatable single | Not supported | Same as single; `onCreateOption` appends to `options` | +| Creatable multi | Omit `value`; `onCreateOption` for options | `value: string[]`; update in `onChange` on every change including `create-option` | + +Single-select selection is derived from the `value` prop only — internal state is not kept. Multi-select without `value: string[]` keeps selection in internal `multiValues`. + +**Controlled creatable multi pitfall:** Updating `options` alone without syncing `value` in `onChange` clears selection when options re-render. + +### When to use uncontrolled (multi only) + +Uncontrolled multi is appropriate when: + +- No other part of the UI needs to react to the current selection (no live summary, no dependent field, no enabled/disabled button). +- You only need the value at form submission — via `FormData`, a submit handler reading the DOM, or react-hook-form's `getValues`. +- Simplicity is the priority; omitting `value` means one less piece of state to manage. + +```tsx +// Good fit: a "tags" field where only the submitted array matters + setTagOptions((prev) => [...prev, v])} +/> +``` + +### When to use controlled + +Use controlled when: + +- Another part of the UI must reflect the current selection in real time (summary text, a filtered list, an enable/disable condition). +- You need to pre-populate from an API response, reset on cancel, or sync with a form library like react-hook-form. +- You are using single-select (the only supported mode for single). + +```tsx +// Good fit: pre-populate from API, clear on cancel, show live summary +const [selected, setSelected] = useState(initialValues); + + setSelected(opts.map((o) => o.value))} +/> +

Selected: {selected.join(', ') || 'none'}

+``` + +--- + +## onChange contract + +`onChange` receives option object(s), not `event.target.value`: + +```tsx +// Single +onChange={(option) => setValue(option.value)} + +// Multi +onChange={(selected) => setValue(selected.map((o) => o.value))} +``` + +Second argument is react-select `ActionMeta`. For creatable creates: `meta.action === 'create-option'`. Do **not** pass `onCreateOption` to react-select directly — Gamut invokes it from `changeHandler` while still forwarding `create-option` to consumer `onChange`. + +--- + +## Creatable + +- `isCreatable` forces `isSearchable: true` (TypeScript enforces this). +- `onCreateOption(inputValue)` — convenience hook to append to `options`. +- `onChange(selected, meta)` — use `meta.action === 'create-option'` to sync controlled `value` and `options` together. +- `isValidNewOption` — return `false` to hide the Add row. +- `validationMessage` — replaces menu "No options" text; mirror in `FormGroup` `error` for field-level feedback. + +**Validation after blur:** react-select clears input on blur before `onBlur` fires, so the value is gone by the time you'd validate it. Store the last typed value in a ref and re-validate from it on `input-blur`: + +```tsx +const lastInput = useRef(''); + + { + if (action === 'input-change') lastInput.current = value; + if (action === 'input-blur') validate(lastInput.current); + }} +/>; +``` + +--- + +## FormGroup wiring + +- `FormGroup` `htmlFor` must match control `id` (not `name`). Alternatively, pass `htmlFor` directly on SelectDropdown and it becomes `id` downstream. +- Pass `name` on SelectDropdown (required for forms). +- Rely on FormGroupLabel's `htmlFor`/`id` connection for the accessible label — this is the standard HTML pattern and preferred method. Do not use `aria-label` when a FormGroupLabel is present, as it overrides the visible label. +- Pass `error` boolean when FormGroup has an error. +- Generic FormGroup live-region behavior: see [`gamut-forms`](../gamut-forms/SKILL.md). + +```tsx + + setValue(option.value)} + /> + +``` + +--- + +## Styling & layout props + +| Prop | Type | Default | Notes | +| ------------------- | ------------------------ | -------- | --------------------------------------------------------- | +| `size` | `'small' \| 'medium'` | `medium` | Control height/density | +| `shownOptionsLimit` | `1`–`6` | `6` | Visible options before the menu scrolls | +| `inputWidth` | `string \| number` | — | Width of the input independent of the menu | +| `dropdownWidth` | `string \| number` | — | Width of the menu independent of the input | +| `menuAlignment` | `'left' \| 'right'` | `left` | Menu edge alignment | +| `zIndex` | `number` | auto | Menu z-index | +| `inputProps` | `{ hidden?, combobox? }` | — | `data-*` / `aria-*` only, forwarded to the input elements | + +--- + +## Examples + +### Single (controlled) + +```tsx +const [value, setValue] = useState('us'); + + setValue(option.value)} +/>; +``` + +### Multi (uncontrolled) + +```tsx + console.log(selected)} +/> +``` + +### Creatable multi (uncontrolled) + +```tsx +const [options, setOptions] = useState(['Apple', 'Banana']); + + setOptions((prev) => [...prev, v])} +/>; +``` + +### Creatable multi (controlled) + +```tsx +const [options, setOptions] = useState(['Apple', 'Banana']); +const [value, setValue] = useState([]); + + { + setValue(selected.map((o) => o.value)); + if (meta.action === 'create-option' && meta.option) { + setOptions((prev) => [...prev, meta.option.value]); + } + }} +/>; +``` diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index f31839eaa46..d2a3e8d36d7 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -1,63 +1,21 @@ import { useTheme } from '@emotion/react'; -import { - KeyboardEvent, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; +import { useId, useMemo, useRef, useState } from 'react'; import * as React from 'react'; -import { Options as OptionsType, StylesConfig } from 'react-select'; +import { StylesConfig } from 'react-select'; -import { parseOptions, SelectOptionBase } from '../utils'; +import { onFocus } from './core/accessibility'; +import { defaultComponents } from './core/constants'; +import { getMemoizedStyles } from './core/styles'; +import { resolveNoOptionsMessage } from './core/utils'; import { - AbbreviatedSingleValue, - CustomContainer, - CustomInput, - CustomValueContainer, - DropdownButton, formatGroupLabel, formatOptionLabel, - IconOption, - MultiValueRemoveButton, - MultiValueWithColorMode, - onFocus, - RemoveAllButton, SelectDropdownContext, TypedReactSelect, } from './elements'; -import { getMemoizedStyles } from './styles'; -import { - OptionStrict, - SelectDropdownGroup, - SelectDropdownProps, -} from './types'; -import { - filterValueFromOptions, - isMultipleSelectProps, - isOptionsGrouped, - isSingleSelectProps, - removeValueFromSelectedOptions, -} from './utils'; - -const defaultProps = { - name: undefined, - components: { - DropdownIndicator: DropdownButton, - IndicatorSeparator: () => null, - ClearIndicator: RemoveAllButton, - SelectContainer: CustomContainer, - ValueContainer: CustomValueContainer, - MultiValue: MultiValueWithColorMode, - MultiValueRemove: MultiValueRemoveButton, - Option: IconOption, - SingleValue: AbbreviatedSingleValue, - Input: CustomInput, - }, -}; -const onChangeAction = 'select-option'; +import { useSelectHandlers } from './hooks/useSelectHandlers'; +import { useSelectOptions } from './hooks/useSelectOptions'; +import { SelectDropdownProps } from './types'; /** * A flexible dropdown select component built on top of react-select. @@ -109,138 +67,55 @@ export const SelectDropdown: React.FC = ({ disabled, dropdownWidth, error, + formatCreateLabel = (inputValue: string) => `Add "${inputValue}"`, id, inputProps, inputWidth, - isSearchable = false, + isCreatable = false, + isSearchable: isSearchableProp = false, + isValidNewOption, menuAlignment = 'left', multiple, name, onChange, + onCreateOption, + onInputChange, options, placeholder = 'Select an option', shownOptionsLimit = 6, size, + validationMessage, value, zIndex, ...rest }) => { + // isSearchable is forced true when isCreatable is true (CreatableSelect requires a text input) + const isSearchable = isCreatable || isSearchableProp; const rawInputId = useId(); const inputId = name ?? `${id}-select-dropdown-${rawInputId}`; - const [activated, setActivated] = useState(false); - const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined); - - // these are used to programatically manage the focus state of our multi-select options + 'Remove all' button const removeAllButtonRef = useRef(null); const selectInputRef = useRef(null); - const selectOptions = useMemo((): - | SelectOptionBase[] - | SelectDropdownGroup[] => { - if ( - !options || - (Array.isArray(options) && !options.length) || - (typeof options === 'object' && - !Array.isArray(options) && - Object.keys(options).length === 0) - ) { - return []; - } - - if (isOptionsGrouped(options)) { - return options; - } - - return parseOptions({ options, id, size }); - }, [options, id, size]); - - const parsedValue = useMemo(() => { - if (isOptionsGrouped(selectOptions)) { - for (const group of selectOptions) { - if (group.options) { - const foundOption = group.options.find( - (option) => option.value === value - ); - if (foundOption) return foundOption; - } - } - return undefined; - } - - return selectOptions.find((option) => option.value === value); - }, [selectOptions, value]); - - const [multiValues, setMultiValues] = useState( - multiple && // To keep this efficient for non-multiSelect - filterValueFromOptions( - selectOptions, - value, - isOptionsGrouped(selectOptions) - ) - ); + const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined); - // If the caller changes the initial value, let's update our value to match. - useEffect(() => { - const newMultiValues = filterValueFromOptions( + const { selectOptions, parsedValue } = useSelectOptions({ + options, + id, + size, + value: value as string | string[] | undefined, + }); + + const { activated, multiValues, changeHandler, keyPressHandler } = + useSelectHandlers({ + onChange, + multiple, + onCreateOption, selectOptions, value, - isOptionsGrouped(selectOptions) - ); - if (newMultiValues !== multiValues) setMultiValues(newMultiValues); - - // - // We only update this when our passed in options or value changes, not multiValues. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, value]); - - const changeHandler = useCallback( - (optionEvent: OptionStrict | OptionsType) => { - setActivated(true); - - // We have to do this because the version of typescript we have doesn't have the transitivity of these type guards yet. But, we will soon! - // Should probably come with: https://codecademy.atlassian.net/browse/GM-354 - const onChangeProps = { onChange, multiple }; - - if (isSingleSelectProps(onChangeProps)) { - const singleOptionEvent = optionEvent as OptionStrict; - - onChangeProps.onChange?.(singleOptionEvent, { - action: onChangeAction, - option: singleOptionEvent, - }); - } - - if (isMultipleSelectProps(onChangeProps)) { - setMultiValues(optionEvent as OptionStrict[]); - - onChangeProps.onChange?.(optionEvent as OptionsType, { - action: onChangeAction, - option: undefined, // At the moment this isn't used, but when multi select is built for real, boom (https://codecademy.atlassian.net/browse/GM-354) - }); - } - }, - [onChange, multiple] - ); - - const keyPressHandler = (e: KeyboardEvent) => { - if (multiple && e.key === 'Enter' && currentFocusedValue && multiValues) { - const newMultiValues = removeValueFromSelectedOptions( - multiValues, - currentFocusedValue - ); - - if (newMultiValues !== multiValues) setMultiValues(newMultiValues); - } - if ( - removeAllButtonRef.current !== null && - e.key === 'ArrowRight' && - multiValues && - currentFocusedValue === multiValues[multiValues.length - 1].value - ) { - removeAllButtonRef.current.focus(); - } - }; + currentFocusedValue, + removeAllButtonRef, + }); const theme = useTheme(); const memoizedStyles = useMemo((): StylesConfig => { @@ -257,26 +132,28 @@ export const SelectDropdown: React.FC = ({ }} > option.disabled} isSearchable={isSearchable} + isValidNewOption={isValidNewOption} menuAlignment={menuAlignment} name={name} + noOptionsMessage={resolveNoOptionsMessage(validationMessage)} options={selectOptions} placeholder={placeholder} selectRef={selectInputRef} @@ -285,7 +162,8 @@ export const SelectDropdown: React.FC = ({ styles={memoizedStyles} value={multiple ? multiValues : parsedValue} onChange={changeHandler} - onKeyDown={multiple ? (e) => keyPressHandler(e) : undefined} + onInputChange={onInputChange} + onKeyDown={multiple ? keyPressHandler : undefined} {...rest} /> diff --git a/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx b/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx index 6e3ce321144..dcc9f3270d3 100644 --- a/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx +++ b/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx @@ -1,13 +1,72 @@ import { SelectOptionBase } from '../../utils'; -import { ExtendedOption, SelectDropdownGroup } from '../types'; import { filterValueFromOptions, + getCreatedOptionValue, isOptionGroup, isOptionsGrouped, removeValueFromSelectedOptions, -} from '../utils'; +} from '../core/utils'; +import { ExtendedOption, SelectDropdownGroup } from '../types'; describe('SelectDropdown Utils', () => { + describe('getCreatedOptionValue', () => { + it('returns actionMeta.option.value when present', () => { + expect( + getCreatedOptionValue( + { label: 'Purple', value: 'purple' }, + { + action: 'create-option', + option: { label: 'Purple', value: 'purple' }, + }, + false + ) + ).toBe('purple'); + }); + + it('falls back to single-select optionEvent value when actionMeta.option is missing', () => { + expect( + getCreatedOptionValue( + { label: 'Purple', value: 'purple' }, + { action: 'create-option' }, + false + ) + ).toBe('purple'); + }); + + it('falls back to multi-select __isNew__ option when actionMeta.option is missing', () => { + expect( + getCreatedOptionValue( + [ + { label: 'Red', value: 'red' }, + { label: 'Purple', value: 'purple', __isNew__: true }, + ], + { action: 'create-option' }, + true + ) + ).toBe('purple'); + }); + + it('returns undefined when no created value can be resolved', () => { + expect( + getCreatedOptionValue( + [{ label: 'Red', value: 'red' }], + { action: 'create-option' }, + true + ) + ).toBeUndefined(); + }); + + it('returns undefined for an empty actionMeta.option.value', () => { + expect( + getCreatedOptionValue( + { label: '', value: '' }, + { action: 'create-option', option: { label: '', value: '' } }, + false + ) + ).toBeUndefined(); + }); + }); + describe('isOptionGroup', () => { it('returns true for valid group objects', () => { const groupOption: SelectDropdownGroup = { diff --git a/packages/gamut/src/Form/SelectDropdown/core/accessibility.ts b/packages/gamut/src/Form/SelectDropdown/core/accessibility.ts new file mode 100644 index 00000000000..0ab3a71fb69 --- /dev/null +++ b/packages/gamut/src/Form/SelectDropdown/core/accessibility.ts @@ -0,0 +1,14 @@ +import { AriaOnFocus } from 'react-select'; + +import { ExtendedOption } from '../types'; + +export const onFocus: AriaOnFocus = ({ + focused: { label, subtitle, rightLabel, disabled }, +}) => { + const formattedSubtitle = `, ${subtitle}`; + const formattedRightLabel = `, ${rightLabel}`; + + return `You are currently focused on option ${label}${ + subtitle ? formattedSubtitle : '' + } ${rightLabel ? formattedRightLabel : ''}${disabled ? ', disabled' : ''}`; +}; diff --git a/packages/gamut/src/Form/SelectDropdown/core/constants.ts b/packages/gamut/src/Form/SelectDropdown/core/constants.ts new file mode 100644 index 00000000000..c21286fab5b --- /dev/null +++ b/packages/gamut/src/Form/SelectDropdown/core/constants.ts @@ -0,0 +1,26 @@ +import { + AbbreviatedSingleValue, + CustomContainer, + CustomInput, + CustomValueContainer, + DropdownButton, + IconOption, + MultiValueRemoveButton, + MultiValueWithColorMode, + RemoveAllButton, +} from '../elements'; + +export const defaultComponents = { + DropdownIndicator: DropdownButton, + IndicatorSeparator: () => null, + ClearIndicator: RemoveAllButton, + SelectContainer: CustomContainer, + ValueContainer: CustomValueContainer, + MultiValue: MultiValueWithColorMode, + MultiValueRemove: MultiValueRemoveButton, + Option: IconOption, + SingleValue: AbbreviatedSingleValue, + Input: CustomInput, +}; + +export const ON_CHANGE_ACTION = 'select-option' as const; diff --git a/packages/gamut/src/Form/SelectDropdown/styles.ts b/packages/gamut/src/Form/SelectDropdown/core/styles.ts similarity index 81% rename from packages/gamut/src/Form/SelectDropdown/styles.ts rename to packages/gamut/src/Form/SelectDropdown/core/styles.ts index ba9178c113c..605a73b7e8c 100644 --- a/packages/gamut/src/Form/SelectDropdown/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/core/styles.ts @@ -11,16 +11,16 @@ import { tagBaseStyles, tagLabelFontSize, tagLabelPadding, -} from '../../Tag/styles'; +} from '../../../Tag/styles'; import { formBaseComponentStyles, formBaseFieldStylesObject, formFieldDisabledStyles, formFieldPaddingStyles, InputSelectors, -} from '../styles'; -import { ControlState, OptionState } from './types'; -import { BaseSelectComponentProps } from './types/styles'; +} from '../../styles'; +import { ControlState, OptionState } from '../types'; +import { BaseSelectComponentProps } from '../types/styles'; const selectDropdownStyles = css({ ...formBaseFieldStylesObject, @@ -86,7 +86,7 @@ const textColor = css({ }); const placeholderColor = css({ - color: 'text-disabled', + color: 'text-secondary', }); export const getMemoizedStyles = ( @@ -150,6 +150,8 @@ export const getMemoizedStyles = ( ...provided, ...dropdownBorderStyles(zIndex)({ theme }), ...dropdownBorderStates({ error: state.selectProps.error, theme }), + // Drop react-select's default menu drop shadow; the border above defines the edge. + boxShadow: 'none', ...(dropdownWidth ? { minWidth: dropdownWidth, @@ -211,14 +213,34 @@ export const getMemoizedStyles = ( backgroundColor: theme.colors['secondary-hover'], }, }), - option: (provided, state: OptionState) => ({ - ...getOptionBackground(state.isSelected, state.isFocused)({ theme }), - alignItems: 'center', - color: state.isDisabled ? 'text-disabled' : 'default', - cursor: state.isDisabled ? 'not-allowed' : 'pointer', - display: 'flex', - padding: state.selectProps.size === 'small' ? '3px 14px' : '11px 14px', + noOptionsMessage: (provided) => ({ + ...provided, + color: theme.colors['text-secondary'], }), + option: (provided, state: OptionState) => { + const isNew = state.data?.__isNew__; + const isSmall = state.selectProps.size === 'small'; + return { + ...getOptionBackground(state.isSelected, state.isFocused)({ theme }), + alignItems: 'center', + color: state.isDisabled + ? theme.colors['text-disabled'] + : isNew + ? theme.colors.primary + : theme.colors.text, + cursor: state.isDisabled ? 'not-allowed' : 'pointer', + display: 'flex', + padding: isSmall ? '3px 14px' : '11px 14px', + ...(isNew && { + // Gradient creates the 1px divider line at the top edge of the option background + backgroundImage: `linear-gradient(${theme.colors['text-disabled']} 1px, transparent 1px)`, + backgroundPosition: '0 0', + backgroundRepeat: 'no-repeat', + backgroundSize: '100% 1px', + paddingTop: isSmall ? '11px' : '19px', + }), + }; + }, placeholder: (provided) => ({ ...provided, ...placeholderColor({ theme }), diff --git a/packages/gamut/src/Form/SelectDropdown/utils.tsx b/packages/gamut/src/Form/SelectDropdown/core/utils.ts similarity index 63% rename from packages/gamut/src/Form/SelectDropdown/utils.tsx rename to packages/gamut/src/Form/SelectDropdown/core/utils.ts index bcf0fd7ac87..a32f70b491f 100644 --- a/packages/gamut/src/Form/SelectDropdown/utils.tsx +++ b/packages/gamut/src/Form/SelectDropdown/core/utils.ts @@ -1,13 +1,17 @@ -import { SelectOptionBase } from '../utils'; +import * as React from 'react'; +import { ActionMeta, Options as OptionsType } from 'react-select'; + +import { SelectOptionBase } from '../../utils'; import { BaseOnChangeProps, ExtendedOption, MultiSelectDropdownProps, + OptionStrict, SelectDropdownGroup, SelectDropdownOptions, SelectDropdownProps, SingleSelectDropdownProps, -} from './types'; +} from '../types'; export const isMultipleSelectProps = ( props: BaseOnChangeProps @@ -17,6 +21,32 @@ export const isSingleSelectProps = ( props: BaseOnChangeProps ): props is SingleSelectDropdownProps => !props.multiple; +type CreatableOption = OptionStrict & { __isNew__?: boolean }; + +/** + * Resolves the value for a newly created option from react-select action metadata + * or the onChange option payload. Returns undefined when no reliable value exists. + */ +export const getCreatedOptionValue = ( + optionEvent: OptionStrict | OptionsType, + actionMeta: ActionMeta, + multiple?: boolean +): string | undefined => { + const metaValue = actionMeta.option?.value; + if (metaValue) return metaValue; + + if (!multiple) { + const { value } = optionEvent as OptionStrict; + return value || undefined; + } + + const newOption = (optionEvent as OptionsType).find( + (option) => (option as CreatableOption).__isNew__ + ); + + return newOption?.value || undefined; +}; + export const isOptionGroup = (obj: unknown): obj is SelectDropdownGroup => obj != null && typeof obj === 'object' && @@ -68,6 +98,18 @@ export const filterValueFromOptions = ( * @param value - The value or values to remove * @returns New array with the specified values removed */ +export const resolveNoOptionsMessage = ( + validationMessage: SelectDropdownProps['validationMessage'] +): ((obj: { inputValue: string }) => React.ReactNode) | undefined => { + if (validationMessage === undefined) return undefined; + if (typeof validationMessage === 'function') { + return validationMessage as (obj: { + inputValue: string; + }) => React.ReactNode; + } + return () => validationMessage; +}; + export const removeValueFromSelectedOptions = ( selectedOptions: ExtendedOption[] | SelectOptionBase[], value: SelectDropdownProps['value'] diff --git a/packages/gamut/src/Form/SelectDropdown/elements/constants.ts b/packages/gamut/src/Form/SelectDropdown/elements/constants.ts index 7d34a7f0fb0..f12c8d6c3f2 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/constants.ts +++ b/packages/gamut/src/Form/SelectDropdown/elements/constants.ts @@ -3,7 +3,6 @@ import { CloseIcon, MiniChevronDownIcon, MiniDeleteIcon, - SearchIcon, } from '@codecademy/gamut-icons'; export const iconSize = { small: 12, medium: 16 }; @@ -18,14 +17,6 @@ export const indicatorIcons = { size: iconSize.medium, icon: ArrowChevronDownIcon, }, - smallSearchable: { - size: iconSize.small, - icon: SearchIcon, - }, - mediumSearchable: { - size: iconSize.medium, - icon: SearchIcon, - }, smallRemove: { size: iconSize.small, icon: MiniDeleteIcon, diff --git a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx b/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx index 47dd615a032..cc3f12cdc64 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx @@ -4,6 +4,7 @@ import ReactSelect, { GroupBase, Props, } from 'react-select'; +import CreatableSelect from 'react-select/creatable'; import { CustomSelectComponentProps, @@ -120,7 +121,11 @@ export const CustomInput = ({ /** * Typed wrapper around react-select component. - * Provides type safety for the underlying react-select implementation. + * Renders CreatableSelect when isCreatable is true, ReactSelect otherwise. + * Creatable-only props (formatCreateLabel, isValidNewOption) are stripped from + * the non-creatable path so they don't reach ReactSelect. `onCreateOption` is + * handled in SelectDropdown's changeHandler — do not pass it to CreatableSelect + * or react-select will skip onChange on create. */ export function TypedReactSelect< OptionType, @@ -128,7 +133,20 @@ export function TypedReactSelect< GroupType extends GroupBase = GroupBase >({ selectRef, + isCreatable, + formatCreateLabel, + isValidNewOption, ...props }: Props & TypedReactSelectProps) { + if (isCreatable) { + return ( + + ); + } return ; } diff --git a/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx b/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx index 3152ed97e2c..e2985b60b3f 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx @@ -1,47 +1,19 @@ -import { css, theme } from '@codecademy/gamut-styles'; -import styled from '@emotion/styled'; -import { KeyboardEvent, useContext } from 'react'; -import { - AriaOnFocus, - components as SelectDropdownElements, -} from 'react-select'; +import { components as SelectDropdownElements } from 'react-select'; -import { ExtendedOption, SizedIndicatorProps } from '../types'; +import { SizedIndicatorProps } from '../types'; import { indicatorIcons } from './constants'; -import { SelectDropdownContext } from './containers'; const { DropdownIndicator } = SelectDropdownElements; -/** - * Generates accessible focus messages for screen readers. - * Provides detailed information about the currently focused option. - * - * @param params - Object containing the focused option details - * @returns Formatted accessibility message - */ -export const onFocus: AriaOnFocus = ({ - focused: { label, subtitle, rightLabel, disabled }, -}) => { - const formattedSubtitle = `, ${subtitle}`; - const formattedRightLabel = `, ${rightLabel}`; - - const msg = `You are currently focused on option ${label}${ - subtitle ? formattedSubtitle : '' - } ${rightLabel ? formattedRightLabel : ''}${disabled ? ', disabled' : ''}`; - - return msg; -}; - /** * Custom dropdown indicator that shows either a chevron or search icon. * The icon type depends on whether the select is searchable or not. */ export const DropdownButton = (props: SizedIndicatorProps) => { - const { size, isSearchable } = props.selectProps; + const { size } = props.selectProps; const color = props.isDisabled ? 'text-disabled' : 'text'; const iconSize = size ?? 'medium'; - const iconType = isSearchable ? 'Searchable' : 'Chevron'; - const { ...iconProps } = indicatorIcons[`${iconSize}${iconType}`]; + const { ...iconProps } = indicatorIcons[`${iconSize}Chevron`]; const { icon: IndicatorIcon } = iconProps; return ( @@ -50,64 +22,3 @@ export const DropdownButton = (props: SizedIndicatorProps) => { ); }; - -const CustomStyledRemoveAllDiv = styled('div')( - css({ - '&:focus': { - outline: `2px solid ${theme.colors.primary}`, - }, - '&:focus-visible': { - outline: `2px solid ${theme.colors.primary}`, - }, - }) -); - -/** - * Custom remove all button for multi-select mode. - * Provides keyboard navigation and accessible removal of all selected values. - */ -export const RemoveAllButton = (props: SizedIndicatorProps) => { - const { - getStyles, - innerProps: { ...restInnerProps }, - selectProps: { size }, - } = props; - - const { removeAllButtonRef, selectInputRef } = useContext( - SelectDropdownContext - ); - - const iconSize = size ?? 'medium'; - const { ...iconProps } = indicatorIcons[`${iconSize}Remove`]; - const { icon: IndicatorIcon } = iconProps; - - const onKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter' && restInnerProps.onMouseDown) { - restInnerProps.onMouseDown(e as any); - } - - if ( - selectInputRef?.current && - (e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'ArrowDown') - ) { - selectInputRef?.current.focus(); - } - }; - - const style = getStyles('clearIndicator', props) as React.CSSProperties; - - return ( - - - - ); -}; diff --git a/packages/gamut/src/Form/SelectDropdown/elements/index.ts b/packages/gamut/src/Form/SelectDropdown/elements/index.ts index a4d695a56fd..7b9dd9e40c7 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/index.ts +++ b/packages/gamut/src/Form/SelectDropdown/elements/index.ts @@ -6,7 +6,8 @@ export { RemoveAllButton, } from './multi-value'; -export { DropdownButton, onFocus } from './controls'; +export { DropdownButton } from './controls'; +export { onFocus } from '../core/accessibility'; export { SelectDropdownContext, diff --git a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx index b3df15e2c0a..2a3bf1ce154 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/options.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/options.tsx @@ -46,13 +46,15 @@ const IconOptionLabel: React.FC< /** * Custom option component that displays a check icon for selected items. * Also manages ARIA attributes for accessibility. + * Skips the check icon for react-select/creatable's "Add" row (__isNew__). */ export const IconOption = ({ children, ...rest }: CustomSelectComponentProps) => { const { size } = rest.selectProps; - const { isFocused, innerProps } = rest; + const { isFocused, innerProps, data } = rest; + const isNew = (data as any)?.__isNew__; return ( {children} - {rest?.isSelected && ( + {!isNew && rest?.isSelected && ( )} diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts b/packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts new file mode 100644 index 00000000000..ff779df7cea --- /dev/null +++ b/packages/gamut/src/Form/SelectDropdown/hooks/useSelectHandlers.ts @@ -0,0 +1,142 @@ +import { KeyboardEvent, useCallback, useEffect, useState } from 'react'; +import * as React from 'react'; +import { ActionMeta, Options as OptionsType } from 'react-select'; + +import { SelectOptionBase } from '../../utils'; +import { ON_CHANGE_ACTION } from '../core/constants'; +import { + filterValueFromOptions, + getCreatedOptionValue, + isMultipleSelectProps, + isOptionsGrouped, + isSingleSelectProps, + removeValueFromSelectedOptions, +} from '../core/utils'; +import { + MultiSelectDropdownProps, + OptionStrict, + SelectDropdownGroup, + SelectDropdownProps, + SingleSelectDropdownProps, +} from '../types'; + +interface UseSelectHandlersArgs { + onChange?: + | SingleSelectDropdownProps['onChange'] + | MultiSelectDropdownProps['onChange']; + multiple?: boolean; + onCreateOption?: (inputValue: string) => void; + selectOptions: SelectOptionBase[] | SelectDropdownGroup[]; + value?: SelectDropdownProps['value']; + currentFocusedValue: unknown; + removeAllButtonRef: React.MutableRefObject; +} + +interface UseSelectHandlersReturn { + activated: boolean; + multiValues: OptionStrict[] | false; + changeHandler: ( + optionEvent: OptionStrict | OptionsType, + actionMeta: ActionMeta + ) => void; + keyPressHandler: (e: KeyboardEvent) => void; +} + +export const useSelectHandlers = ({ + onChange, + multiple, + onCreateOption, + selectOptions, + value, + currentFocusedValue, + removeAllButtonRef, +}: UseSelectHandlersArgs): UseSelectHandlersReturn => { + const [activated, setActivated] = useState(false); + const [multiValues, setMultiValues] = useState( + multiple + ? filterValueFromOptions( + selectOptions, + value, + isOptionsGrouped(selectOptions) + ) + : false + ); + + // Sync multi-select value from props when controlled (`value` is a string[]). + // Uncontrolled multi (`value` undefined or '') keeps selection in local state. + useEffect(() => { + if (!multiple || !Array.isArray(value)) return; + + const newMultiValues = filterValueFromOptions( + selectOptions, + value, + isOptionsGrouped(selectOptions) + ); + if (newMultiValues !== multiValues) setMultiValues(newMultiValues); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectOptions, value, multiple]); + + const changeHandler = useCallback( + ( + optionEvent: OptionStrict | OptionsType, + actionMeta: ActionMeta + ) => { + setActivated(true); + + if (actionMeta.action === 'create-option') { + const createdValue = getCreatedOptionValue( + optionEvent, + actionMeta, + multiple + ); + if (createdValue) onCreateOption?.(createdValue); + } + + const onChangeProps = { onChange, multiple }; + const forwardedMeta: ActionMeta = + actionMeta.action === 'create-option' + ? actionMeta + : { + action: ON_CHANGE_ACTION, + option: isMultipleSelectProps(onChangeProps) + ? undefined + : (optionEvent as OptionStrict), + }; + + if (isSingleSelectProps(onChangeProps)) { + onChangeProps.onChange?.(optionEvent as OptionStrict, forwardedMeta); + } + + if (isMultipleSelectProps(onChangeProps)) { + setMultiValues(optionEvent as OptionStrict[]); + onChangeProps.onChange?.( + optionEvent as OptionsType, + forwardedMeta + ); + } + }, + [onChange, multiple, onCreateOption] + ); + + const keyPressHandler = (e: KeyboardEvent) => { + if (multiple && e.key === 'Enter' && currentFocusedValue && multiValues) { + const newMultiValues = removeValueFromSelectedOptions( + multiValues, + currentFocusedValue as SelectDropdownProps['value'] + ); + if (newMultiValues !== multiValues) setMultiValues(newMultiValues); + } + + if ( + removeAllButtonRef.current !== null && + e.key === 'ArrowRight' && + multiValues && + currentFocusedValue === multiValues[multiValues.length - 1].value + ) { + removeAllButtonRef.current.focus(); + } + }; + + return { activated, multiValues, changeHandler, keyPressHandler }; +}; diff --git a/packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts b/packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts new file mode 100644 index 00000000000..200bcb40744 --- /dev/null +++ b/packages/gamut/src/Form/SelectDropdown/hooks/useSelectOptions.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; + +import { parseOptions, SelectOptionBase } from '../../utils'; +import { isOptionsGrouped } from '../core/utils'; +import { + SelectDropdownGroup, + SelectDropdownOptions, + SelectDropdownSizes, +} from '../types'; + +interface UseSelectOptionsArgs { + options?: SelectDropdownOptions | SelectDropdownGroup[]; + id?: string; + size?: SelectDropdownSizes['size']; + value?: string | string[]; +} + +interface UseSelectOptionsReturn { + selectOptions: SelectOptionBase[] | SelectDropdownGroup[]; + parsedValue: SelectOptionBase | undefined; +} + +export const useSelectOptions = ({ + options, + id, + size, + value, +}: UseSelectOptionsArgs): UseSelectOptionsReturn => { + const selectOptions = useMemo((): + | SelectOptionBase[] + | SelectDropdownGroup[] => { + if ( + !options || + (Array.isArray(options) && !options.length) || + (typeof options === 'object' && + !Array.isArray(options) && + Object.keys(options).length === 0) + ) { + return []; + } + + if (isOptionsGrouped(options)) { + return options; + } + + return parseOptions({ options, id, size }); + }, [options, id, size]); + + const parsedValue = useMemo(() => { + if (isOptionsGrouped(selectOptions)) { + for (const group of selectOptions) { + if (group.options) { + const foundOption = group.options.find( + (option) => option.value === value + ); + if (foundOption) return foundOption as SelectOptionBase; + } + } + return undefined; + } + + return selectOptions.find((option) => option.value === value); + }, [selectOptions, value]); + + return { selectOptions, parsedValue }; +}; diff --git a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts index d5c89453083..91c18d7e057 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts @@ -1,5 +1,5 @@ import { Ref, SelectHTMLAttributes } from 'react'; -import { Props as NamedProps } from 'react-select'; +import { Options as OptionsType, Props as NamedProps } from 'react-select'; import { SelectComponentProps } from '../../inputs/Select'; import { @@ -58,6 +58,7 @@ export interface SelectDropdownCoreProps | 'theme' | 'onChange' | 'multiple' + | 'isSearchable' >, Pick< SelectHTMLAttributes, @@ -73,6 +74,43 @@ export interface SelectDropdownCoreProps placeholder?: string; /** Array of options or option groups to display in the dropdown */ options?: SelectDropdownOptions | SelectDropdownGroup[]; + /** + * Allows users to create new options by typing a value not in the options list. + * When true, isSearchable is automatically set to true. + * Pair with onCreateOption to persist new options. + */ + isCreatable?: boolean; + /** + * Called when the user confirms a new option via the "Add" row. + * Convenience callback for persisting the new value to your `options` list. + * Selection updates are delivered through `onChange` with `action: 'create-option'`. + */ + onCreateOption?: (inputValue: string) => void; + /** + * Customises the label shown in the "Add" row. + * Defaults to: (inputValue) => `Add "${inputValue}"`. + */ + formatCreateLabel?: (inputValue: string) => React.ReactNode; + /** + * Controls when the "Add" row is visible. + * Receives the current input, selected values, and all options. + * Defaults to react-select's built-in logic (hidden when input matches an existing option label). + * Use cases: minimum-length gating, pattern validation, case-insensitive dedup, max-items cap. + */ + isValidNewOption?: ( + inputValue: string, + value: OptionsType, + options: OptionsType + ) => boolean; + /** + * Customizes the message shown inside the dropdown menu when no option matches + * the current input (react-select's "No options" state). Useful for surfacing + * validation/error text directly in the dropdown. Accepts a node, or a function + * receiving the current input value. + */ + validationMessage?: + | React.ReactNode + | ((obj: { inputValue: string }) => React.ReactNode); } /** @@ -97,13 +135,24 @@ export interface MultiSelectDropdownProps extends SelectDropdownCoreProps { onChange?: NamedProps['onChange']; } +/** + * Enforces that isSearchable cannot be false when isCreatable is true. + * Creatable mode requires the search input so users can type new option values. + */ +type CreatableConstraint = + | { isCreatable?: false | undefined; isSearchable?: boolean } + | { isCreatable: true; isSearchable?: true }; + /** * Union type for all SelectDropdown prop variants. - * Supports both single and multi-select modes through discriminated union. + * Supports both single and multi-select modes through discriminated union, + * intersected with CreatableConstraint to enforce isSearchable compatibility. */ -export type SelectDropdownProps = +export type SelectDropdownProps = ( | SingleSelectDropdownProps - | MultiSelectDropdownProps; + | MultiSelectDropdownProps +) & + CreatableConstraint; /** * Base interface for onChange-related props. @@ -120,9 +169,13 @@ export interface BaseOnChangeProps { /** * Props for the typed React Select component wrapper. - * Extends ReactSelectAdditionalProps with an optional ref. + * Extends ReactSelectAdditionalProps with an optional ref and creatable flag. */ -export interface TypedReactSelectProps extends ReactSelectAdditionalProps { +export interface TypedReactSelectProps + extends ReactSelectAdditionalProps, + Pick { /** Optional ref to the underlying react-select component */ selectRef?: Ref; + /** When true, renders CreatableSelect instead of ReactSelect */ + isCreatable?: boolean; } diff --git a/packages/gamut/src/Form/SelectDropdown/types/internal.ts b/packages/gamut/src/Form/SelectDropdown/types/internal.ts index 8a151e2102c..9284f1db249 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/internal.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/internal.ts @@ -17,7 +17,7 @@ export type InternalSelectProps = { * Ref type for programmatic focus management. * Used for managing focus on select input and remove all button. */ -export type ProgramaticFocusRef = +export type ProgrammaticFocusRef = | React.MutableRefObject | React.MutableRefObject; @@ -31,9 +31,9 @@ export interface SelectDropdownContextValueTypes { /** Function to update the currently focused value */ setCurrentFocusedValue?: React.Dispatch>; /** Ref to the select input for programmatic focus */ - selectInputRef?: ProgramaticFocusRef; + selectInputRef?: ProgrammaticFocusRef; /** Ref to the remove all button for programmatic focus */ - removeAllButtonRef?: ProgramaticFocusRef; + removeAllButtonRef?: ProgrammaticFocusRef; } /** diff --git a/packages/gamut/src/Form/SelectDropdown/types/styles.ts b/packages/gamut/src/Form/SelectDropdown/types/styles.ts index 3be7acbd824..59d3abb84d2 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/styles.ts @@ -1,6 +1,6 @@ import { StyleProps } from '@codecademy/variance'; -import { conditionalBorderStates } from '../styles'; +import { conditionalBorderStates } from '../core/styles'; import { InternalInputsProps } from './component-props'; /** @@ -83,4 +83,6 @@ export type OptionState = BaseSelectComponentProps & InteractionStates & { /** Whether the option is selected */ isSelected: boolean; + /** Option data — includes __isNew__ for react-select/creatable's "Add" row */ + data?: { __isNew__?: boolean }; }; diff --git a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx index 3b068aaaa93..90cd8bc274a 100644 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx @@ -1,6 +1,7 @@ import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; -import { act } from 'react'; +import { act, useState } from 'react'; import { openDropdown, @@ -11,6 +12,59 @@ import { } from '../__fixtures__/utils'; import { SelectDropdown } from '../SelectDropdown'; +const CreatableMultiHarness = () => { + const [options, setOptions] = useState(['Apple', 'Banana']); + + return ( + + setOptions((prev) => [...prev, inputValue]) + } + /> + ); +}; + +const ControlledCreatableMultiHarness = ({ + onChange, + onCreateOption, +}: { + onChange?: jest.Mock; + onCreateOption?: jest.Mock; +}) => { + const [options, setOptions] = useState(['Apple', 'Banana']); + const [value, setValue] = useState(['Apple']); + + return ( + { + setValue(selected.map((option) => option.value)); + + if (meta.action === 'create-option' && meta.option) { + setOptions((prev) => [...prev, meta.option.value]); + } + + onChange?.(selected, meta); + }} + onCreateOption={onCreateOption} + /> + ); +}; + +const renderCreatableMulti = setupRtl(CreatableMultiHarness, {}); +const renderControlledCreatableMulti = setupRtl( + ControlledCreatableMultiHarness, + {} +); + /** There is a state pollution issue with SelectDropdown and jest which is why these are broken up into their own file. * Ticket to fix: https://skillsoftdev.atlassian.net/browse/GM-1297 */ @@ -50,359 +104,611 @@ const renderView = setupRtl(SelectDropdown, { }); describe('SelectDropdown', () => { - it('sets the id prop on the select tag', () => { - const { view } = renderView(); + describe('default', () => { + it('sets the id prop on the select tag', () => { + const { view } = renderView(); - expect(view.getByRole('combobox')).toHaveAttribute('id', 'colors'); - }); + expect(view.getByRole('combobox')).toHaveAttribute('id', 'colors'); + }); - it.each([ - ['array', selectOptions], - ['object', selectOptionsObject], - ])('renders options when options is an %s', async (_, options) => { - const { view } = renderView({ options }); + it.each([ + ['array', selectOptions], + ['object', selectOptionsObject], + ])('renders options when options is an %s', async (_, options) => { + const { view } = renderView({ options }); - await openDropdown(view); + await openDropdown(view); - view.getByText('green'); - }); + view.getByText('green'); + }); - it('renders a small dropdown when size is "small"', () => { - const { view } = renderView({ size: 'small' }); - view.getByTitle('Mini Chevron Down Icon'); - }); + it('renders a small dropdown when size is "small"', () => { + const { view } = renderView({ size: 'small' }); + view.getByTitle('Mini Chevron Down Icon'); + }); - it('renders a medium dropdown when size is "medium"', () => { - const { view } = renderView({ size: 'medium' }); - view.getByTitle('Arrow Chevron Down Icon'); - }); + it('renders a medium dropdown when size is "medium"', () => { + const { view } = renderView({ size: 'medium' }); + view.getByTitle('Arrow Chevron Down Icon'); + }); - it('renders a medium dropdown by default', () => { - const { view } = renderView(); - view.getByTitle('Arrow Chevron Down Icon'); - }); + it('renders a medium dropdown by default', () => { + const { view } = renderView(); + view.getByTitle('Arrow Chevron Down Icon'); + }); - it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified', async () => { - const { view } = renderView({ shownOptionsLimit: 4 }); + it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified', async () => { + const { view } = renderView({ shownOptionsLimit: 4 }); - await openDropdown(view); + await openDropdown(view); - expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '12rem' }); - }); - it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified + size is "small"', async () => { - const { view } = renderView({ - size: 'small', - shownOptionsLimit: 4, + expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '12rem' }); }); + it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified + size is "small"', async () => { + const { view } = renderView({ + size: 'small', + shownOptionsLimit: 4, + }); - await openDropdown(view); - - expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '8rem' }); - }); + await openDropdown(view); - it('renders a dropdown with icons', async () => { - const { view } = renderView({ options: optionsIconsArray }); + expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '8rem' }); + }); - await openDropdown(view); + it('renders a dropdown with icons', async () => { + const { view } = renderView({ options: optionsIconsArray }); - optionsIconsArray.forEach((icon) => expect(view.getByTitle(icon.label))); - }); + await openDropdown(view); - it('displays icon in selected value when option has icon', async () => { - const { view } = renderView({ - options: optionsIconsArray, - value: 'one', + optionsIconsArray.forEach((icon) => expect(view.getByTitle(icon.label))); }); - expect(view.getByTitle('Data Transfer Vertical Icon')).toBeInTheDocument(); - const selectedValueContainer = view.getByRole('combobox').closest('div'); - expect(selectedValueContainer).toHaveTextContent( - 'Data Transfer Vertical Icon' - ); - }); + it('displays icon in selected value when option has icon', async () => { + const { view } = renderView({ + options: optionsIconsArray, + value: 'one', + }); - it('function passed to onInputChanges is called on input change', async () => { - const onInputChange = jest.fn(); - const { view } = renderView({ onInputChange }); + expect( + view.getByTitle('Data Transfer Vertical Icon') + ).toBeInTheDocument(); + const selectedValueContainer = view.getByRole('combobox').closest('div'); + expect(selectedValueContainer).toHaveTextContent( + 'Data Transfer Vertical Icon' + ); + }); - await openDropdown(view); + it('function passed to onInputChanges is called on input change', async () => { + const onInputChange = jest.fn(); + const { view } = renderView({ onInputChange }); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); + await openDropdown(view); - expect(onInputChange).toHaveBeenCalled(); - }); + await act(async () => { + await userEvent.click(view.getByText('red')); + }); - it('works with multiple selection', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - onChange, + expect(onInputChange).toHaveBeenCalled(); }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); - }); + it('works with multiple selection', async () => { + const onChange = jest.fn(); + const { view } = renderView({ + multiple: true, + onChange, + }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('green')); - }); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('red')); + }); - view.getByText('red'); - view.getByText('green'); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('green')); + }); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'red', - value: 'red', - }, - ], - { - action: 'select-option', - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ + view.getByText('red'); + view.getByText('green'); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenNthCalledWith( + 1, + [ + { + label: 'red', + value: 'red', + }, + ], { - label: 'red', - value: 'red', - }, + action: 'select-option', + } + ); + expect(onChange).toHaveBeenNthCalledWith( + 2, + [ + { + label: 'red', + value: 'red', + }, + { + label: 'green', + value: 'green', + }, + ], { - label: 'green', - value: 'green', - }, - ], - { - action: 'select-option', - } - ); - }); - - it('displays abbreviations in multiselect mode', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - options: optionsWithAbbreviations, - onChange, + action: 'select-option', + } + ); }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('United States of America')); - }); + it('displays abbreviations in multiselect mode', async () => { + const onChange = jest.fn(); + const { view } = renderView({ + multiple: true, + options: optionsWithAbbreviations, + onChange, + }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('United Kingdom')); - }); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('United States of America')); + }); - view.getByText('USA'); - view.getByText('UK'); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('United Kingdom')); + }); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - key: 'usa', - size: undefined, - }, - ], - { - action: 'select-option', - option: undefined, - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ + view.getByText('USA'); + view.getByText('UK'); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenNthCalledWith( + 1, + [ + { + label: 'United States of America', + value: 'usa', + abbreviation: 'USA', + key: 'usa', + size: undefined, + }, + ], { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - key: 'usa', - size: undefined, - }, + action: 'select-option', + option: undefined, + } + ); + expect(onChange).toHaveBeenNthCalledWith( + 2, + [ + { + label: 'United States of America', + value: 'usa', + abbreviation: 'USA', + key: 'usa', + size: undefined, + }, + { + label: 'United Kingdom', + value: 'uk', + abbreviation: 'UK', + key: 'uk', + size: undefined, + }, + ], { - label: 'United Kingdom', - value: 'uk', - abbreviation: 'UK', - key: 'uk', - size: undefined, - }, - ], - { - action: 'select-option', - option: undefined, - } - ); - }); + action: 'select-option', + option: undefined, + } + ); + }); - describe('inputProps functionality', () => { - it('should apply combobox and hidden props in non-searchable mode (default)', () => { - const { view } = renderView({ - isSearchable: false, - inputProps: { - hidden: { - 'data-form-field': 'test-field', - 'data-hidden-attr': 'hidden-value', + describe('inputProps functionality', () => { + it('should apply combobox and hidden props in non-searchable mode (default)', () => { + const { view } = renderView({ + isSearchable: false, + inputProps: { + hidden: { + 'data-form-field': 'test-field', + 'data-hidden-attr': 'hidden-value', + }, + combobox: { + 'data-testid': 'non-searchable-combobox', + 'data-custom-attr': 'custom-value', + }, }, - combobox: { - 'data-testid': 'non-searchable-combobox', - 'data-custom-attr': 'custom-value', + }); + + const comboboxInput = view.getByRole('combobox'); + expect(comboboxInput).toHaveAttribute( + 'data-testid', + 'non-searchable-combobox' + ); + expect(comboboxInput).toHaveAttribute( + 'data-custom-attr', + 'custom-value' + ); + + const hiddenInput = view.container.querySelector( + 'input[type="hidden"][data-form-field="test-field"]' + ); + expect(hiddenInput).toHaveAttribute('data-form-field', 'test-field'); + expect(hiddenInput).toHaveAttribute('data-hidden-attr', 'hidden-value'); + }); + + it('should apply combobox and hidden props in searchable mode', () => { + const { view } = renderView({ + isSearchable: true, + inputProps: { + hidden: { + 'data-form-field': 'searchable-field', + 'data-hidden-attr': 'searchable-hidden-value', + }, + combobox: { + 'data-testid': 'searchable-combobox', + 'data-custom-attr': 'searchable-custom-value', + }, }, - }, + }); + + const comboboxInput = view.getByRole('combobox'); + expect(comboboxInput).toHaveAttribute( + 'data-testid', + 'searchable-combobox' + ); + expect(comboboxInput).toHaveAttribute( + 'data-custom-attr', + 'searchable-custom-value' + ); + + const hiddenInput = view.container.querySelector( + 'input[type="hidden"][data-form-field="searchable-field"]' + ); + expect(hiddenInput).toHaveAttribute( + 'data-form-field', + 'searchable-field' + ); + expect(hiddenInput).toHaveAttribute( + 'data-hidden-attr', + 'searchable-hidden-value' + ); }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'non-searchable-combobox' - ); - expect(comboboxInput).toHaveAttribute('data-custom-attr', 'custom-value'); + it('should work with only combobox props (no hidden props)', () => { + const { view } = renderView({ + isSearchable: false, + inputProps: { + combobox: { + 'data-testid': 'combobox-only', + 'data-aria-label': 'Custom combobox label', + }, + }, + }); + + const comboboxInput = view.getByRole('combobox'); + expect(comboboxInput).toHaveAttribute('data-testid', 'combobox-only'); + expect(comboboxInput).toHaveAttribute( + 'data-aria-label', + 'Custom combobox label' + ); + }); - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="test-field"]' - ); - expect(hiddenInput).toHaveAttribute('data-form-field', 'test-field'); - expect(hiddenInput).toHaveAttribute('data-hidden-attr', 'hidden-value'); - }); + it('should work with only hidden props (no combobox props)', () => { + const { view } = renderView({ + isSearchable: true, + inputProps: { + hidden: { + 'data-form-field': 'hidden-only', + 'data-validation': 'required', + }, + }, + }); + + const comboboxInput = view.getByRole('combobox'); + // Should not have any combobox-specific attributes + expect(comboboxInput).not.toHaveAttribute('data-form-field'); + expect(comboboxInput).not.toHaveAttribute('data-validation'); + + const hiddenInput = view.container.querySelector( + 'input[type="hidden"][data-form-field="hidden-only"]' + ); + expect(hiddenInput).toHaveAttribute('data-form-field', 'hidden-only'); + expect(hiddenInput).toHaveAttribute('data-validation', 'required'); + }); - it('should apply combobox and hidden props in searchable mode', () => { - const { view } = renderView({ - isSearchable: true, - inputProps: { - hidden: { - 'data-form-field': 'searchable-field', - 'data-hidden-attr': 'searchable-hidden-value', + it('should handle multiple data attributes in combobox props', () => { + const { view } = renderView({ + isSearchable: false, + inputProps: { + combobox: { + 'data-testid': 'multi-attr-test', + 'data-cy': 'combobox-element', + 'data-analytics': 'user-interaction', + 'data-tracking': 'form-field', + 'aria-describedby': 'help-text', + }, }, - combobox: { - 'data-testid': 'searchable-combobox', - 'data-custom-attr': 'searchable-custom-value', + }); + + const comboboxInput = view.getByRole('combobox'); + expect(comboboxInput).toHaveAttribute('data-testid', 'multi-attr-test'); + expect(comboboxInput).toHaveAttribute('data-cy', 'combobox-element'); + expect(comboboxInput).toHaveAttribute( + 'data-analytics', + 'user-interaction' + ); + expect(comboboxInput).toHaveAttribute('data-tracking', 'form-field'); + expect(comboboxInput).toHaveAttribute('aria-describedby', 'help-text'); + }); + + it('should handle boolean and number values in combobox props', () => { + const { view } = renderView({ + isSearchable: true, + inputProps: { + combobox: { + 'data-testid': 'type-test', + 'data-boolean-true': true, + 'data-boolean-false': false, + 'data-number': 42, + 'data-zero': 0, + }, }, - }, + }); + + const comboboxInput = view.getByRole('combobox'); + expect(comboboxInput).toHaveAttribute('data-testid', 'type-test'); + expect(comboboxInput).toHaveAttribute('data-boolean-true', 'true'); + expect(comboboxInput).toHaveAttribute('data-boolean-false', 'false'); + expect(comboboxInput).toHaveAttribute('data-number', '42'); + expect(comboboxInput).toHaveAttribute('data-zero', '0'); }); + }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'searchable-combobox' - ); - expect(comboboxInput).toHaveAttribute( - 'data-custom-attr', - 'searchable-custom-value' - ); + describe('validationMessage', () => { + it('renders custom node text in place of the default "No options" state', async () => { + const { view } = renderView({ + options: [], + validationMessage: 'No fruits available', + }); - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="searchable-field"]' - ); - expect(hiddenInput).toHaveAttribute( - 'data-form-field', - 'searchable-field' - ); - expect(hiddenInput).toHaveAttribute( - 'data-hidden-attr', - 'searchable-hidden-value' - ); + await openDropdown(view); + + expect(view.getByText('No fruits available')).toBeInTheDocument(); + expect(view.queryByText('No options')).not.toBeInTheDocument(); + }); + }); + }); + + describe('isCreatable', () => { + it('shows the "Add" row when input does not match any existing option', async () => { + const { view } = renderView({ isCreatable: true }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'purple'); + }); + + expect(view.getByText('Add "purple"')).toBeInTheDocument(); }); - it('should work with only combobox props (no hidden props)', () => { - const { view } = renderView({ - isSearchable: false, - inputProps: { - combobox: { - 'data-testid': 'combobox-only', - 'data-aria-label': 'Custom combobox label', - }, - }, + it('does not show the "Add" row when input matches an existing option', async () => { + const { view } = renderView({ isCreatable: true }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'red'); }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'combobox-only'); - expect(comboboxInput).toHaveAttribute( - 'data-aria-label', - 'Custom combobox label' - ); + expect(view.queryByText('Add "red"')).not.toBeInTheDocument(); }); - it('should work with only hidden props (no combobox props)', () => { + it('fires onCreateOption with the typed value when the "Add" row is selected', async () => { + const onCreateOption = jest.fn(); + const { view } = renderView({ isCreatable: true, onCreateOption }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'purple'); + }); + + await act(async () => { + await userEvent.click(view.getByText('Add "purple"')); + }); + + expect(onCreateOption).toHaveBeenCalledWith('purple'); + }); + + it('fires onChange with create-option when the "Add" row is selected in multi mode', async () => { + const onChange = jest.fn(); const { view } = renderView({ - isSearchable: true, - inputProps: { - hidden: { - 'data-form-field': 'hidden-only', - 'data-validation': 'required', - }, - }, + isCreatable: true, + multiple: true, + onChange, + }); + + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('red')); }); - const comboboxInput = view.getByRole('combobox'); - // Should not have any combobox-specific attributes - expect(comboboxInput).not.toHaveAttribute('data-form-field'); - expect(comboboxInput).not.toHaveAttribute('data-validation'); + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'purple'); + }); - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="hidden-only"]' + await act(async () => { + await userEvent.click(view.getByText('Add "purple"')); + }); + + expect(onChange).toHaveBeenLastCalledWith( + [ + { label: 'red', value: 'red' }, + { label: 'purple', value: 'purple', __isNew__: true }, + ], + expect.objectContaining({ + action: 'create-option', + option: expect.objectContaining({ value: 'purple' }), + }) ); - expect(hiddenInput).toHaveAttribute('data-form-field', 'hidden-only'); - expect(hiddenInput).toHaveAttribute('data-validation', 'required'); }); - it('should handle multiple data attributes in combobox props', () => { + it('respects a custom formatCreateLabel', async () => { const { view } = renderView({ - isSearchable: false, - inputProps: { - combobox: { - 'data-testid': 'multi-attr-test', - 'data-cy': 'combobox-element', - 'data-analytics': 'user-interaction', - 'data-tracking': 'form-field', - 'aria-describedby': 'help-text', - }, - }, + isCreatable: true, + formatCreateLabel: (v: string) => `Create tag: "${v}"`, }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'multi-attr-test'); - expect(comboboxInput).toHaveAttribute('data-cy', 'combobox-element'); - expect(comboboxInput).toHaveAttribute( - 'data-analytics', - 'user-interaction' - ); - expect(comboboxInput).toHaveAttribute('data-tracking', 'form-field'); - expect(comboboxInput).toHaveAttribute('aria-describedby', 'help-text'); + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'purple'); + }); + + expect(view.getByText('Create tag: "purple"')).toBeInTheDocument(); }); - it('should handle boolean and number values in combobox props', () => { + it('hides the "Add" row when isValidNewOption returns false', async () => { const { view } = renderView({ - isSearchable: true, - inputProps: { - combobox: { - 'data-testid': 'type-test', - 'data-boolean-true': true, - 'data-boolean-false': false, - 'data-number': 42, - 'data-zero': 0, - }, - }, + isCreatable: true, + isValidNewOption: () => false, + }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'anything'); + }); + + expect(view.queryByText('Add "anything"')).not.toBeInTheDocument(); + }); + + it('clears the typed text when the input blurs', async () => { + const { view } = renderView({ isCreatable: true }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'pur'); + }); + + await act(async () => { + fireEvent.focusOut(combobox); + }); + + expect(combobox).toHaveValue(''); + }); + + it('forwards onInputChange to the consumer when the input blurs', async () => { + const onInputChange = jest.fn(); + const { view } = renderView({ isCreatable: true, onInputChange }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'pur'); + }); + + onInputChange.mockClear(); + + await act(async () => { + fireEvent.focusOut(combobox); }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'type-test'); - expect(comboboxInput).toHaveAttribute('data-boolean-true', 'true'); - expect(comboboxInput).toHaveAttribute('data-boolean-false', 'false'); - expect(comboboxInput).toHaveAttribute('data-number', '42'); - expect(comboboxInput).toHaveAttribute('data-zero', '0'); + expect(onInputChange).toHaveBeenCalledWith('', { + action: 'input-blur', + prevInputValue: 'pur', + }); + }); + + it('clears the typed text after an option is created', async () => { + const { view } = renderView({ isCreatable: true }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'purple'); + }); + + await act(async () => { + await userEvent.click(view.getByText('Add "purple"')); + }); + + expect(combobox).toHaveValue(''); + }); + + it('keeps existing multi selections when a new option is created', async () => { + const { view } = renderCreatableMulti(); + + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('Apple')); + }); + + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('Banana')); + }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'Cherry'); + }); + + await act(async () => { + await userEvent.click(view.getByText('Add "Cherry"')); + }); + + expect(view.getByText('Apple')).toBeInTheDocument(); + expect(view.getByText('Banana')).toBeInTheDocument(); + expect(view.getByText('Cherry')).toBeInTheDocument(); + }); + + it('keeps controlled multi selections when a new option is created', async () => { + const onChange = jest.fn(); + const { view } = renderControlledCreatableMulti({ onChange }); + + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('Banana')); + }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'Cherry'); + }); + + await act(async () => { + await userEvent.click(view.getByText('Add "Cherry"')); + }); + + expect(view.getByText('Apple')).toBeInTheDocument(); + expect(view.getByText('Banana')).toBeInTheDocument(); + expect(view.getByText('Cherry')).toBeInTheDocument(); + + expect(onChange).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ value: 'Apple' }), + expect.objectContaining({ value: 'Banana' }), + expect.objectContaining({ value: 'Cherry' }), + ]), + expect.objectContaining({ action: 'create-option' }) + ); + }); + + describe('validationMessage', () => { + it('supports a function that receives the current input value', async () => { + const { view } = renderView({ + isCreatable: true, + isValidNewOption: () => false, + options: [], + validationMessage: ({ inputValue }: { inputValue: string }) => + `No match for "${inputValue}"`, + }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'kiwi'); + }); + + expect(view.getByText('No match for "kiwi"')).toBeInTheDocument(); + }); }); }); }); diff --git a/packages/gamut/src/Form/styles/index.ts b/packages/gamut/src/Form/styles/index.ts index 51d46d8fac2..758c4a1fd65 100644 --- a/packages/gamut/src/Form/styles/index.ts +++ b/packages/gamut/src/Form/styles/index.ts @@ -1,4 +1,4 @@ export * from './shared-system-props'; export * from './Checkbox-styles'; export * from './Radio-styles'; -export * from '../SelectDropdown/styles'; +export * from '../SelectDropdown/core/styles'; diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 57d4b1b123d..0e5039ff81c 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx @@ -2,6 +2,8 @@ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; import { Callout, ComponentHeader, LinkTo } from '~styleguide/blocks'; +import { ControlledModeTable } from './controlledModeTable'; +import { CreatablePropsTable } from './creatablePropsTable'; import * as SelectDropdownStories from './SelectDropdown.stories'; export const parameters = { @@ -15,7 +17,7 @@ export const parameters = { source: { repo: 'gamut', githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/inputs/SelectDropdown.tsx', + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx', }, }; @@ -24,200 +26,200 @@ export const parameters = { ## Usage -Use SelectDropdown to pick from exclusive options within a styled dropdown. +Use `SelectDropdown` when designs call for a styled dropdown menu, searchable options, multi-select tags, creatable options, or rich option content (icons, subtitles, grouping). It carries a larger JavaScript dependency than the standard `Select` component (react-select), so prefer `Select` for simple single-select fields where that extra weight isn't justified by the design. -`SelectDropdown` has a larger JavaScript dependency size than our standard `Select` component so should not be used unless specified in designs. These dropdowns are optionally searchable through the `isSearchable` prop. +When used inside a form, provide both a `name` prop and an `aria-label` that matches the `htmlFor` of the associated `FormGroupLabel` or `