From c8563bfd924ff46d87a9a4a2c04b8e249f92c832 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 5 Jun 2026 15:39:42 -0400 Subject: [PATCH 1/8] feat(SelectDropdown):isCreatable --- .../Form/SelectDropdown/SelectDropdown.tsx | 12 ++- .../SelectDropdown/elements/containers.tsx | 20 +++- .../Form/SelectDropdown/elements/options.tsx | 6 +- .../gamut/src/Form/SelectDropdown/styles.ts | 34 +++++-- .../SelectDropdown/types/component-props.ts | 61 ++++++++++++- .../src/Form/SelectDropdown/types/styles.ts | 2 + .../Form/__tests__/SelectDropdown.test.tsx | 63 +++++++++++++ .../SelectDropdown/SelectDropdown.mdx | 17 ++++ .../SelectDropdown/SelectDropdown.stories.tsx | 91 +++++++++++++++++++ 9 files changed, 289 insertions(+), 17 deletions(-) diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index f31839eaa46..67340c16963 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -109,14 +109,18 @@ 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, options, placeholder = 'Select an option', shownOptionsLimit = 6, @@ -125,6 +129,8 @@ export const SelectDropdown: React.FC = ({ 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}`; @@ -265,16 +271,19 @@ export const SelectDropdown: React.FC = ({ }} dropdownWidth={dropdownWidth} error={Boolean(error)} + formatCreateLabel={formatCreateLabel} formatGroupLabel={formatGroupLabel} formatOptionLabel={formatOptionLabel} id={id || rest.htmlFor || rawInputId} inputId={inputId} inputProps={{ ...inputProps }} inputWidth={inputWidth} + isCreatable={isCreatable} isDisabled={disabled} isMulti={multiple} isOptionDisabled={(option) => option.disabled} isSearchable={isSearchable} + isValidNewOption={isValidNewOption} menuAlignment={menuAlignment} name={name} options={selectOptions} @@ -285,6 +294,7 @@ export const SelectDropdown: React.FC = ({ styles={memoizedStyles} value={multiple ? multiValues : parsedValue} onChange={changeHandler} + onCreateOption={onCreateOption} onKeyDown={multiple ? (e) => keyPressHandler(e) : undefined} {...rest} /> diff --git a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx b/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx index 47dd615a032..7c338eca12a 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,9 @@ 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, onCreateOption, isValidNewOption) + * are stripped from the non-creatable path so they don't reach ReactSelect. */ export function TypedReactSelect< OptionType, @@ -128,7 +131,22 @@ export function TypedReactSelect< GroupType extends GroupBase = GroupBase >({ selectRef, + isCreatable, + formatCreateLabel, + isValidNewOption, + onCreateOption, ...props }: Props & TypedReactSelectProps) { + if (isCreatable) { + return ( + + ); + } return ; } 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/styles.ts b/packages/gamut/src/Form/SelectDropdown/styles.ts index ba9178c113c..2f0c36f1bd5 100644 --- a/packages/gamut/src/Form/SelectDropdown/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/styles.ts @@ -211,14 +211,32 @@ 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', - }), + 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: isNew + ? state.isDisabled + ? theme.colors['text-disabled'] + : theme.colors.primary + : state.isDisabled + ? 'text-disabled' + : 'default', + cursor: state.isDisabled ? 'not-allowed' : 'pointer', + display: 'flex', + padding: isSmall ? '3px 14px' : '11px 14px', + ...(isNew && { + // Gradient creates the 1px divider line centred in the 16px spacer above the option text + backgroundImage: `linear-gradient(${theme.colors['text-disabled']} 1px, transparent 1px)`, + backgroundPosition: '0 8px', + backgroundRepeat: 'no-repeat', + backgroundSize: '100% 1px', + paddingTop: isSmall ? '19px' : '27px', + }), + }; + }, placeholder: (provided) => ({ ...provided, ...placeholderColor({ theme }), diff --git a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts index d5c89453083..d9ea162149b 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,33 @@ 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. + * The consumer is responsible for appending the new option to the options array. + */ + 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; } /** @@ -97,13 +125,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 +159,21 @@ 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 { /** Optional ref to the underlying react-select component */ selectRef?: Ref; + /** When true, renders CreatableSelect instead of ReactSelect */ + isCreatable?: boolean; + /** Forwarded to CreatableSelect; customises the "Add" row label */ + formatCreateLabel?: (inputValue: string) => React.ReactNode; + /** Forwarded to CreatableSelect; called on new option confirmation */ + onCreateOption?: (inputValue: string) => void; + /** Forwarded to CreatableSelect; controls visibility of the "Add" row */ + isValidNewOption?: ( + inputValue: string, + value: OptionsType, + options: OptionsType + ) => boolean; } diff --git a/packages/gamut/src/Form/SelectDropdown/types/styles.ts b/packages/gamut/src/Form/SelectDropdown/types/styles.ts index 3be7acbd824..23e7f2334ec 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/styles.ts @@ -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..390b2d109cb 100644 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx @@ -405,4 +405,67 @@ describe('SelectDropdown', () => { expect(comboboxInput).toHaveAttribute('data-zero', '0'); }); }); + + 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('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'); + }); + + expect(view.queryByText('Add "red"')).not.toBeInTheDocument(); + }); + + 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('respects a custom formatCreateLabel', async () => { + const { view } = renderView({ + isCreatable: true, + formatCreateLabel: (v: string) => `Create tag: "${v}"`, + }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'purple'); + }); + + expect(view.getByText('Create tag: "purple"')).toBeInTheDocument(); + }); + + it('hides the "Add" row when isValidNewOption returns false', async () => { + const { view } = renderView({ + isCreatable: true, + isValidNewOption: () => false, + }); + + await act(async () => { + await userEvent.type(view.getByRole('combobox'), 'anything'); + }); + + expect(view.queryByText('Add "anything"')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 3b09f0209ad..688d9ba0584 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx @@ -206,6 +206,23 @@ In the example below, the z-index menu is configured to the default value (`auto +## Creatable + +Use the `isCreatable` prop to allow users to add options that aren't in the list. When the user types a value that doesn't match any existing option, an **Add "value"** row appears at the bottom of the dropdown. Selecting it fires `onCreateOption` with the typed string — the consumer is responsible for appending the new option to the `options` array. + +`isSearchable` is automatically set to `true` when `isCreatable` is `true` (passing `isSearchable={false}` alongside `isCreatable` is a TypeScript error). + + + +### Props + +| Prop | Type | Default | Description | +| ------------------- | ----------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | +| `isCreatable` | `boolean` | `false` | Enables option creation. Automatically forces `isSearchable` to `true`. | +| `onCreateOption` | `(inputValue: string) => void` | — | Called when the user confirms a new option. Append the value to `options` to persist it. | +| `formatCreateLabel` | `(inputValue: string) => ReactNode` | `Add "inputValue"` | Customises the label shown in the "Add" row. | +| `isValidNewOption` | `(inputValue, value, options) => boolean` | react-select default | Controls when the "Add" row is visible. Use for min-length gating, pattern validation, or max-items caps. | + ## Multiple select diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx index 8576c849958..4613bfa74d3 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx @@ -8,6 +8,7 @@ import { } from '@codecademy/gamut'; import { RadarIcon, ResponsiveIcon, RocketIcon } from '@codecademy/gamut-icons'; import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; const fruitOptions = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Eggplant']; @@ -466,6 +467,96 @@ export const CustomInputProps: Story = { ), }; +export const Creatable: Story = { + args: { + name: 'creatable-dropdown', + isCreatable: true, + placeholder: 'Select or type to add…', + }, + render: (args) => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + return ( + + + + setOptions((prev) => [...prev, inputValue]) + } + /> + + + ); + }, +}; + +export const CreatableMulti: Story = { + render: () => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + return ( + + + + setOptions((prev) => [...prev, inputValue]) + } + /> + + + ); + }, +}; + +export const CreatableWithValidation: Story = { + render: () => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + return ( + + + { + if (inputValue.trim().length < 3) return false; + return !currentOptions.some( + (opt) => + opt.label.toLowerCase() === inputValue.trim().toLowerCase() + ); + }} + onCreateOption={(inputValue) => + setOptions((prev) => [...prev, inputValue.trim()]) + } + /> + + + ); + }, +}; + export const MultipleSelect: Story = { args: { name: 'multi-dropdown', From 8d86bc8f645c0581df4d5b4466832efb8e0d9ccd Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Thu, 11 Jun 2026 15:40:45 -0400 Subject: [PATCH 2/8] tweaks --- .../Form/SelectDropdown/SelectDropdown.tsx | 43 +- .../Form/SelectDropdown/elements/constants.ts | 9 - .../Form/SelectDropdown/elements/controls.tsx | 5 +- .../gamut/src/Form/SelectDropdown/styles.ts | 6 + .../SelectDropdown/types/component-props.ts | 9 + .../Form/__tests__/SelectDropdown.test.tsx | 682 ++++++++++-------- .../SelectDropdown/SelectDropdown.mdx | 21 +- .../SelectDropdown/SelectDropdown.stories.tsx | 36 +- 8 files changed, 483 insertions(+), 328 deletions(-) diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index 67340c16963..4c9ec54557d 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -9,7 +9,11 @@ import { useState, } from 'react'; import * as React from 'react'; -import { Options as OptionsType, StylesConfig } from 'react-select'; +import { + InputActionMeta, + Options as OptionsType, + StylesConfig, +} from 'react-select'; import { parseOptions, SelectOptionBase } from '../utils'; import { @@ -121,10 +125,12 @@ export const SelectDropdown: React.FC = ({ name, onChange, onCreateOption, + onInputChange, options, placeholder = 'Select an option', shownOptionsLimit = 6, size, + validationMessage, value, zIndex, ...rest @@ -136,6 +142,9 @@ export const SelectDropdown: React.FC = ({ const [activated, setActivated] = useState(false); const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined); + // Controlled input value for creatable mode so typed text persists across + // blur / menu-close (react-select clears it by default). + const [inputValue, setInputValue] = useState(''); // these are used to programatically manage the focus state of our multi-select options + 'Remove all' button const removeAllButtonRef = useRef(null); @@ -195,7 +204,6 @@ export const SelectDropdown: React.FC = ({ ); 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]); @@ -248,6 +256,34 @@ export const SelectDropdown: React.FC = ({ } }; + const handleInputChange = useCallback( + (newValue: string, actionMeta: InputActionMeta) => { + if (isCreatable) { + /* Keep typed text instead of letting react-select clear it on blur / + menu-close. Since the value didn't actually change, we also skip + forwarding these to the consumer so derived state (e.g. validation + errors) isn't reset against an empty value. 'set-value' (after + selecting/creating) still clears and forwards as normal. */ + if ( + actionMeta.action === 'input-blur' || + actionMeta.action === 'menu-close' + ) { + return; + } + setInputValue(newValue); + } + onInputChange?.(newValue, actionMeta); + }, + [isCreatable, onInputChange] + ); + + const noOptionsMessage = + validationMessage === undefined + ? undefined // fall back to react-select default ("No options") + : typeof validationMessage === 'function' + ? (validationMessage as (obj: { inputValue: string }) => React.ReactNode) + : () => validationMessage; + const theme = useTheme(); const memoizedStyles = useMemo((): StylesConfig => { return getMemoizedStyles(theme, zIndex); @@ -277,6 +313,7 @@ export const SelectDropdown: React.FC = ({ id={id || rest.htmlFor || rawInputId} inputId={inputId} inputProps={{ ...inputProps }} + inputValue={isCreatable ? inputValue : undefined} inputWidth={inputWidth} isCreatable={isCreatable} isDisabled={disabled} @@ -286,6 +323,7 @@ export const SelectDropdown: React.FC = ({ isValidNewOption={isValidNewOption} menuAlignment={menuAlignment} name={name} + noOptionsMessage={noOptionsMessage} options={selectOptions} placeholder={placeholder} selectRef={selectInputRef} @@ -295,6 +333,7 @@ export const SelectDropdown: React.FC = ({ value={multiple ? multiValues : parsedValue} onChange={changeHandler} onCreateOption={onCreateOption} + onInputChange={handleInputChange} onKeyDown={multiple ? (e) => keyPressHandler(e) : undefined} {...rest} /> 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/controls.tsx b/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx index 3152ed97e2c..0effe010d08 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/controls.tsx @@ -37,11 +37,10 @@ export const onFocus: AriaOnFocus = ({ * 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 ( diff --git a/packages/gamut/src/Form/SelectDropdown/styles.ts b/packages/gamut/src/Form/SelectDropdown/styles.ts index 2f0c36f1bd5..64b6ef48adb 100644 --- a/packages/gamut/src/Form/SelectDropdown/styles.ts +++ b/packages/gamut/src/Form/SelectDropdown/styles.ts @@ -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,6 +213,10 @@ export const getMemoizedStyles = ( backgroundColor: theme.colors['secondary-hover'], }, }), + noOptionsMessage: (provided) => ({ + ...provided, + color: theme.colors['text-secondary'], + }), option: (provided, state: OptionState) => { const isNew = state.data?.__isNew__; const isSmall = state.selectProps.size === 'small'; diff --git a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts index d9ea162149b..13e04c25b26 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts @@ -101,6 +101,15 @@ export interface SelectDropdownCoreProps 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); } /** diff --git a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx index 390b2d109cb..38651c2b773 100644 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx @@ -1,4 +1,5 @@ import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { act } from 'react'; @@ -50,359 +51,380 @@ 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'); - }); - - it.each([ - ['array', selectOptions], - ['object', selectOptionsObject], - ])('renders options when options is an %s', async (_, options) => { - const { view } = renderView({ options }); - - await openDropdown(view); - - view.getByText('green'); - }); - - it('renders a small dropdown when size is "small"', () => { - const { view } = renderView({ size: 'small' }); - view.getByTitle('Mini Chevron Down Icon'); - }); + expect(view.getByRole('combobox')).toHaveAttribute('id', 'colors'); + }); - it('renders a medium dropdown when size is "medium"', () => { - const { view } = renderView({ size: 'medium' }); - view.getByTitle('Arrow Chevron Down Icon'); - }); + it.each([ + ['array', selectOptions], + ['object', selectOptionsObject], + ])('renders options when options is an %s', async (_, options) => { + const { view } = renderView({ options }); - it('renders a medium dropdown by default', () => { - const { view } = renderView(); - view.getByTitle('Arrow Chevron Down Icon'); - }); + await openDropdown(view); - it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified', async () => { - const { view } = renderView({ shownOptionsLimit: 4 }); + view.getByText('green'); + }); - await openDropdown(view); + it('renders a small dropdown when size is "small"', () => { + const { view } = renderView({ size: 'small' }); + view.getByTitle('Mini Chevron Down Icon'); + }); - 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, + it('renders a medium dropdown when size is "medium"', () => { + const { view } = renderView({ size: 'medium' }); + view.getByTitle('Arrow Chevron Down Icon'); }); - await openDropdown(view); + it('renders a medium dropdown by default', () => { + const { view } = renderView(); + view.getByTitle('Arrow Chevron Down Icon'); + }); - expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '8rem' }); - }); + it('renders a dropdown with the correct maxHeight when shownOptionsLimit is specified', async () => { + const { view } = renderView({ shownOptionsLimit: 4 }); - it('renders a dropdown with icons', async () => { - const { view } = renderView({ options: optionsIconsArray }); + 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, + }); - 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', + expect(view.getByRole('listbox')).toHaveStyle({ maxHeight: '8rem' }); }); - expect(view.getByTitle('Data Transfer Vertical Icon')).toBeInTheDocument(); - const selectedValueContainer = view.getByRole('combobox').closest('div'); - expect(selectedValueContainer).toHaveTextContent( - 'Data Transfer Vertical Icon' - ); - }); - - it('function passed to onInputChanges is called on input change', async () => { - const onInputChange = jest.fn(); - const { view } = renderView({ onInputChange }); + it('renders a dropdown with icons', async () => { + const { view } = renderView({ options: optionsIconsArray }); - await openDropdown(view); + await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); + optionsIconsArray.forEach((icon) => expect(view.getByTitle(icon.label))); }); - expect(onInputChange).toHaveBeenCalled(); - }); - - it('works with multiple selection', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - onChange, - }); + it('displays icon in selected value when option has icon', async () => { + const { view } = renderView({ + options: optionsIconsArray, + value: 'one', + }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('red')); + 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); - await act(async () => { - await userEvent.click(view.getByText('green')); - }); + it('function passed to onInputChanges is called on input change', async () => { + const onInputChange = jest.fn(); + const { view } = renderView({ onInputChange }); - view.getByText('red'); - view.getByText('green'); + await openDropdown(view); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenNthCalledWith( - 1, - [ - { - label: 'red', - value: 'red', - }, - ], - { - action: 'select-option', - } - ); - expect(onChange).toHaveBeenNthCalledWith( - 2, - [ - { - label: 'red', - value: 'red', - }, - { - label: 'green', - value: 'green', - }, - ], - { - action: 'select-option', - } - ); - }); + await act(async () => { + await userEvent.click(view.getByText('red')); + }); - it('displays abbreviations in multiselect mode', async () => { - const onChange = jest.fn(); - const { view } = renderView({ - multiple: true, - options: optionsWithAbbreviations, - onChange, + expect(onInputChange).toHaveBeenCalled(); }); - await openDropdown(view); - await act(async () => { - await userEvent.click(view.getByText('United States of America')); - }); + 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('United Kingdom')); - }); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('red')); + }); - view.getByText('USA'); - view.getByText('UK'); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('green')); + }); - 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, - [ - { - label: 'United States of America', - value: 'usa', - abbreviation: 'USA', - key: 'usa', - size: undefined, - }, - { - label: 'United Kingdom', - value: 'uk', - abbreviation: 'UK', - key: 'uk', - size: undefined, - }, - ], - { - action: 'select-option', - option: undefined, - } - ); - }); + view.getByText('red'); + view.getByText('green'); - 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', + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenNthCalledWith( + 1, + [ + { + label: 'red', + value: 'red', }, - }, - }); - - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'non-searchable-combobox' + ], + { + action: 'select-option', + } ); - expect(comboboxInput).toHaveAttribute('data-custom-attr', 'custom-value'); - - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="test-field"]' + expect(onChange).toHaveBeenNthCalledWith( + 2, + [ + { + label: 'red', + value: 'red', + }, + { + label: 'green', + value: 'green', + }, + ], + { + action: 'select-option', + } ); - 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', () => { + it('displays abbreviations in multiselect mode', async () => { + const onChange = jest.fn(); 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', - }, - }, + multiple: true, + options: optionsWithAbbreviations, + onChange, }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute( - 'data-testid', - 'searchable-combobox' - ); - expect(comboboxInput).toHaveAttribute( - 'data-custom-attr', - 'searchable-custom-value' - ); + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('United States of America')); + }); - const hiddenInput = view.container.querySelector( - 'input[type="hidden"][data-form-field="searchable-field"]' - ); - expect(hiddenInput).toHaveAttribute( - 'data-form-field', - 'searchable-field' + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('United Kingdom')); + }); + + 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, + }, + ], + { + action: 'select-option', + option: undefined, + } ); - expect(hiddenInput).toHaveAttribute( - 'data-hidden-attr', - 'searchable-hidden-value' + 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, + }, + ], + { + action: 'select-option', + option: undefined, + } ); }); - 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', + 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', + }, }, - }, + }); + + 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'); }); - const comboboxInput = view.getByRole('combobox'); - expect(comboboxInput).toHaveAttribute('data-testid', 'combobox-only'); - expect(comboboxInput).toHaveAttribute( - 'data-aria-label', - 'Custom combobox label' - ); - }); - - 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', + 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'); - // 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 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' + ); + }); - 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', + 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'); }); - 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 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', + }, + }, + }); + + 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, + 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'); }); + }); + + 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 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'); + await openDropdown(view); + + expect(view.getByText('No fruits available')).toBeInTheDocument(); + expect(view.queryByText('No options')).not.toBeInTheDocument(); + }); }); }); @@ -467,5 +489,75 @@ describe('SelectDropdown', () => { expect(view.queryByText('Add "anything"')).not.toBeInTheDocument(); }); + + it('keeps the typed text after 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.blur(combobox); + }); + + expect(combobox).toHaveValue('pur'); + }); + + it('does not forward 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.blur(combobox); + }); + + // Text persists, so consumers should not be told the value went empty. + expect(onInputChange).not.toHaveBeenCalled(); + }); + + 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(''); + }); + + 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/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 688d9ba0584..9fc7b6349bf 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx @@ -212,16 +212,25 @@ Use the `isCreatable` prop to allow users to add options that aren't in the list `isSearchable` is automatically set to `true` when `isCreatable` is `true` (passing `isSearchable={false}` alongside `isCreatable` is a TypeScript error). +In creatable mode the typed text is retained when the input blurs or the menu closes, so a user who clicks away mid-entry can return to it. The text only clears once an option is selected or created. + ### Props -| Prop | Type | Default | Description | -| ------------------- | ----------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | -| `isCreatable` | `boolean` | `false` | Enables option creation. Automatically forces `isSearchable` to `true`. | -| `onCreateOption` | `(inputValue: string) => void` | — | Called when the user confirms a new option. Append the value to `options` to persist it. | -| `formatCreateLabel` | `(inputValue: string) => ReactNode` | `Add "inputValue"` | Customises the label shown in the "Add" row. | -| `isValidNewOption` | `(inputValue, value, options) => boolean` | react-select default | Controls when the "Add" row is visible. Use for min-length gating, pattern validation, or max-items caps. | +| Prop | Type | Default | Description | +| ------------------- | ---------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `isCreatable` | `boolean` | `false` | Enables option creation. Automatically forces `isSearchable` to `true`. | +| `onCreateOption` | `(inputValue: string) => void` | — | Called when the user confirms a new option. Append the value to `options` to persist it. | +| `formatCreateLabel` | `(inputValue: string) => ReactNode` | `Add "inputValue"` | Customises the label shown in the "Add" row. | +| `isValidNewOption` | `(inputValue, value, options) => boolean` | react-select default | Controls when the "Add" row is visible. Use for min-length gating, pattern validation, or max-items caps. | +| `validationMessage` | `ReactNode \| (({ inputValue }) => ReactNode)` | `"No options"` | Replaces the in-menu "No options" text. Useful for surfacing validation/error messages directly inside the dropdown. | + +### Surfacing validation in the dropdown + +`validationMessage` customises the text shown inside the menu when no option matches the current input. Pair it with `isValidNewOption` (to gate the "Add" row) and a `FormGroup` `error` to communicate the same issue both inside the dropdown and below the field. + + ## Multiple select diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx index 4613bfa74d3..e456fc8940d 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx @@ -506,6 +506,7 @@ export const CreatableMulti: Story = { label="Pick fruits or add your own" > { const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + const [error, setError] = useState(); + + const validate = (inputValue: string) => { + const trimmed = inputValue.trim(); + if (trimmed.length < 3) return 'Enter at least 3 characters.'; + return undefined; + }; + return ( + !validate(inputValue) && inputValue.trim().length > 0 + } name="creatable-validated-dropdown" options={options} placeholder="Type at least 3 characters to add…" - isValidNewOption={( - inputValue: string, - _value, - currentOptions: { label: string }[] - ) => { - if (inputValue.trim().length < 3) return false; - return !currentOptions.some( - (opt) => - opt.label.toLowerCase() === inputValue.trim().toLowerCase() - ); + validationMessage={({ inputValue }) => + validate(inputValue) ?? 'No matching fruit' + } + onCreateOption={(inputValue) => { + setOptions((prev) => [...prev, inputValue.trim()]); + setError(undefined); }} - onCreateOption={(inputValue) => - setOptions((prev) => [...prev, inputValue.trim()]) + onInputChange={(inputValue: string) => + setError(validate(inputValue)) } /> From cabad9d3f0a8390aef33151914a2866ef89911d7 Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 12 Jun 2026 12:58:31 -0400 Subject: [PATCH 3/8] refactor prop table --- .../SelectDropdown/SelectDropdown.mdx | 54 +- .../SelectDropdown/SelectDropdown.stories.tsx | 619 +++++++----------- .../SelectDropdown/creatablePropsTable.tsx | 88 +++ 3 files changed, 361 insertions(+), 400 deletions(-) create mode 100644 packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/creatablePropsTable.tsx diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 9fc7b6349bf..171301c537f 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx @@ -2,6 +2,7 @@ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; import { Callout, ComponentHeader, LinkTo } from '~styleguide/blocks'; +import { CreatablePropsTable } from './creatablePropsTable'; import * as SelectDropdownStories from './SelectDropdown.stories'; export const parameters = { @@ -129,6 +130,21 @@ To add some pizzazz to your dropdown, you can add any of our +### Basic usage -### Props +A single-select creatable dropdown. Type a value that isn't listed, choose the **Add** row, and `onCreateOption` appends it to the options. -| Prop | Type | Default | Description | -| ------------------- | ---------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `isCreatable` | `boolean` | `false` | Enables option creation. Automatically forces `isSearchable` to `true`. | -| `onCreateOption` | `(inputValue: string) => void` | — | Called when the user confirms a new option. Append the value to `options` to persist it. | -| `formatCreateLabel` | `(inputValue: string) => ReactNode` | `Add "inputValue"` | Customises the label shown in the "Add" row. | -| `isValidNewOption` | `(inputValue, value, options) => boolean` | react-select default | Controls when the "Add" row is visible. Use for min-length gating, pattern validation, or max-items caps. | -| `validationMessage` | `ReactNode \| (({ inputValue }) => ReactNode)` | `"No options"` | Replaces the in-menu "No options" text. Useful for surfacing validation/error messages directly inside the dropdown. | + -### Surfacing validation in the dropdown +### Multiple selection -`validationMessage` customises the text shown inside the menu when no option matches the current input. Pair it with `isValidNewOption` (to gate the "Add" row) and a `FormGroup` `error` to communicate the same issue both inside the dropdown and below the field. +Combine `isCreatable` with `multiple` to let users select several existing options and add their own. Each created value is appended to the options list and included in the current selection. - + -## Multiple select - - +### Validation and inline messages -## Accessibility +Gate which inputs can be added with `isValidNewOption` — return `false` to hide the **Add** row (e.g. minimum length, pattern matching, or de-duplication). Use `validationMessage` to replace the menu's default "No options" text with contextual feedback, and mirror the same message in a `FormGroup` `error` so it's also surfaced below the field. -### FormGroup + SelectDropdown + -`SelectDropdown` formats nicely in FormGroups and reflects error states. Make sure you provide an htmlFor to the `FormGroup` and a `name` to the `SelectDropdown` for maximum accessibility. See FormGroup story for the customizations available. +### Props - - + ## Playground diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx index e456fc8940d..97a3f5d9b18 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.stories.tsx @@ -32,15 +32,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - args: {}, - render: (args) => ( - - - - ), -}; - export const Base: Story = { args: { name: 'base-dropdown', @@ -446,127 +437,6 @@ export const Icons: Story = { ), }; -export const CustomInputProps: Story = { - args: { - options: ['inspect me to see my inputProps', 'yes I am!', ':)'], - inputProps: { - hidden: { 'data-form-field': 'what' }, - combobox: { - 'data-testid': 'custom-select', - 'data-cy': 'custom-dropdown', - }, - }, - name: 'what', - }, - render: (args) => ( - - - - - - ), -}; - -export const Creatable: Story = { - args: { - name: 'creatable-dropdown', - isCreatable: true, - placeholder: 'Select or type to add…', - }, - render: (args) => { - const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); - return ( - - - - setOptions((prev) => [...prev, inputValue]) - } - /> - - - ); - }, -}; - -export const CreatableMulti: Story = { - render: () => { - const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); - return ( - - - - setOptions((prev) => [...prev, inputValue]) - } - /> - - - ); - }, -}; - -export const CreatableWithValidation: Story = { - render: () => { - const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); - const [error, setError] = useState(); - - const validate = (inputValue: string) => { - const trimmed = inputValue.trim(); - if (trimmed.length < 3) return 'Enter at least 3 characters.'; - return undefined; - }; - - return ( - - - - !validate(inputValue) && inputValue.trim().length > 0 - } - name="creatable-validated-dropdown" - options={options} - placeholder="Type at least 3 characters to add…" - validationMessage={({ inputValue }) => - validate(inputValue) ?? 'No matching fruit' - } - onCreateOption={(inputValue) => { - setOptions((prev) => [...prev, inputValue.trim()]); - setError(undefined); - }} - onInputChange={(inputValue: string) => - setError(validate(inputValue)) - } - /> - - - ); - }, -}; - export const MultipleSelect: Story = { args: { name: 'multi-dropdown', @@ -599,46 +469,27 @@ export const MultipleSelect: Story = { ), }; -export const FormGroupSelectDropdown: Story = { +export const CustomInputProps: Story = { args: { - options: ['hello', 'hi', 'howdy'], - value: 'oh no', - name: 'big-label', + options: ['inspect me to see my inputProps', 'yes I am!', ':)'], + inputProps: { + hidden: { 'data-form-field': 'what' }, + combobox: { + 'data-testid': 'custom-select', + 'data-cy': 'custom-dropdown', + }, + }, + name: 'what', }, render: (args) => ( - + ), }; -export const FormGroupError: Story = { - args: { - options: ['Error', 'oh no', ':('], - name: 'error-example-unique', - placeholder: 'cry cry cry', - }, - render: (args) => ( - - - - - - ), -}; - export const AbbreviatedInput: Story = { args: { name: 'abbreviated-dropdown', @@ -687,59 +538,9 @@ export const AbbreviatedInput: Story = { ), }; -export const IndependentWidths: Story = { - args: { - name: 'width-dropdown', - options: [ - { - label: 'Machine Learning Engineer', - abbreviation: 'ML Eng', - value: 'ml-engineer', - subtitle: 'Build AI/ML systems', - }, - { - label: 'Frontend Developer', - abbreviation: 'FE Dev', - value: 'frontend-dev', - subtitle: 'React, Vue, Angular', - }, - { - label: 'Backend Developer', - abbreviation: 'BE Dev', - value: 'backend-dev', - subtitle: 'Node.js, Python, Java', - }, - { - label: 'Full Stack Developer', - abbreviation: 'FS Dev', - value: 'fullstack-dev', - subtitle: 'End-to-end development', - }, - ], - inputWidth: '150px', - dropdownWidth: '350px', - - placeholder: 'Select a role', - }, - render: (args) => ( - - - - - - Input is 150px wide, dropdown is 350px wide - - - ), -}; - -export const SmallWithAbbreviations: Story = { +export const AbbreviatedSmallSize: Story = { args: { - name: 'small-abbreviated-dropdown', + name: 'abbreviated-small', options: [ { label: 'JavaScript', @@ -761,37 +562,27 @@ export const SmallWithAbbreviations: Story = { abbreviation: 'Java', value: 'java', }, - { - label: 'C++', - abbreviation: 'C++', - value: 'cpp', - }, ], size: 'small', inputWidth: '80px', dropdownWidth: '200px', - placeholder: 'Select JScript', }, render: (args) => ( - - Small size, input shows "JS" but dropdown shows - "JavaScript" - ), }; -export const ComplexAbbreviatedOptions: Story = { +export const AbbreviatedWithSubtitleAndRightLabel: Story = { args: { - name: 'complex-abbreviated-dropdown', + name: 'abbreviated-detailed', options: [ { label: 'Senior Software Engineer', @@ -814,37 +605,24 @@ export const ComplexAbbreviatedOptions: Story = { subtitle: '8+ years experience', rightLabel: 'Staff', }, - { - label: 'Distinguished Engineer', - abbreviation: 'Distinguished Eng', - value: 'distinguished-eng', - subtitle: '15+ years experience', - rightLabel: 'Distinguished', - }, ], - inputWidth: '80px', - dropdownWidth: '400px', - placeholder: 'Select seniority level', }, render: (args) => ( - - Shows abbreviated text in input, full details in dropdown - ), }; -export const AbbreviatedWithSubtitleAndRightLabel: Story = { +export const ComplexAbbreviatedOptions: Story = { args: { - name: 'abbreviated-detailed', + name: 'complex-abbreviated-dropdown', options: [ { label: 'Senior Software Engineer', @@ -867,66 +645,44 @@ export const AbbreviatedWithSubtitleAndRightLabel: Story = { subtitle: '8+ years experience', rightLabel: 'Staff', }, + { + label: 'Distinguished Engineer', + abbreviation: 'Distinguished Eng', + value: 'distinguished-eng', + subtitle: '15+ years experience', + rightLabel: 'Distinguished', + }, ], + inputWidth: '80px', + dropdownWidth: '400px', + placeholder: 'Select seniority level', }, render: (args) => ( + + Shows abbreviated text in input, full details in dropdown + ), }; -export const AbbreviatedSmallSize: Story = { +export const IndependentWidths: Story = { args: { - name: 'abbreviated-small', + name: 'width-dropdown', options: [ { - label: 'JavaScript', - abbreviation: 'JS', - value: 'javascript', - }, - { - label: 'TypeScript', - abbreviation: 'TS', - value: 'typescript', - }, - { - label: 'Python', - abbreviation: 'PY', - value: 'python', - }, - { - label: 'Java', - abbreviation: 'Java', - value: 'java', + label: 'Machine Learning Engineer', + abbreviation: 'ML Eng', + value: 'ml-engineer', + subtitle: 'Build AI/ML systems', }, - ], - size: 'small', - inputWidth: '80px', - dropdownWidth: '200px', - }, - render: (args) => ( - - - - - - ), -}; -export const MenuAlignmentRight: Story = { - args: { - name: 'menu-alignment-right', - options: [ { label: 'Frontend Developer', abbreviation: 'FE Dev', @@ -947,37 +703,29 @@ export const MenuAlignmentRight: Story = { }, ], inputWidth: '150px', - dropdownWidth: '300px', - menuAlignment: 'right', + dropdownWidth: '350px', + placeholder: 'Select a role', }, render: (args) => ( - + - Dropdown aligns to the right edge of the input + Input is 150px wide, dropdown is 350px wide - + ), }; -// These are for testing, I will delete before shipping - -export const DisabledMultiValue: Story = { +export const SmallWithAbbreviations: Story = { args: { - name: 'disabled-small-multi', + name: 'small-abbreviated-dropdown', options: [ { label: 'JavaScript', @@ -999,117 +747,86 @@ export const DisabledMultiValue: Story = { abbreviation: 'Java', value: 'java', }, + { + label: 'C++', + abbreviation: 'C++', + value: 'cpp', + }, ], - placeholder: 'Long truncated placeholder', size: 'small', - inputWidth: '100px', + inputWidth: '80px', dropdownWidth: '200px', - multiple: true, - value: ['python', 'java'], - disabled: true, + placeholder: 'Select JScript', }, render: (args) => ( + + Small size, input shows "JS" but dropdown shows + "JavaScript" + ), }; -export const LongPlaceholder: Story = { - args: { - name: 'long-placeholder', - options: [ - { - label: 'JavaScript', - abbreviation: 'JS', - value: 'javascript', - }, - { - label: 'TypeScript', - abbreviation: 'TS', - value: 'typescript', - }, - { - label: 'Python', - abbreviation: 'PY', - value: 'python', - }, - { - label: 'Java', - abbreviation: 'Java', - value: 'java', - }, - ], - placeholder: 'Long truncated placeholder', - size: 'small', - inputWidth: '300px', - dropdownWidth: '400px', - multiple: true, - menuAlignment: 'right', - }, - render: (args) => ( - - - - - - ), -}; -export const LongPlaceholderAgain: Story = { +export const MenuAlignmentRight: Story = { args: { - name: 'long-placeholder-again', + name: 'menu-alignment-right', options: [ { - label: 'JavaScript', - abbreviation: 'JS', - value: 'javascript', - }, - { - label: 'TypeScript', - abbreviation: 'TS', - value: 'typescript', + label: 'Frontend Developer', + abbreviation: 'FE Dev', + value: 'frontend-dev', + subtitle: 'React, Vue, Angular', }, { - label: 'Python', - abbreviation: 'PY', - value: 'python', + label: 'Backend Developer', + abbreviation: 'BE Dev', + value: 'backend-dev', + subtitle: 'Node.js, Python, Java', }, { - label: 'Java', - abbreviation: 'Java', - value: 'java', + label: 'Full Stack Developer', + abbreviation: 'FS Dev', + value: 'fullstack-dev', + subtitle: 'End-to-end development', }, ], - placeholder: 'Long truncated placeholder', - size: 'small', - inputWidth: '400px', - dropdownWidth: '200px', - multiple: true, + inputWidth: '150px', + dropdownWidth: '300px', menuAlignment: 'right', + placeholder: 'Select a role', }, render: (args) => ( - + - + + Dropdown aligns to the right edge of the input + + ), }; +// These are for testing, I will delete before shipping + export const zIndexOnMenu: Story = { render: (args) => ( @@ -1170,3 +887,151 @@ export const zIndexOnMenu: Story = { ), }; + +export const Creatable: Story = { + args: { + name: 'creatable-dropdown', + isCreatable: true, + placeholder: 'Select or type to add…', + }, + render: (args) => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + return ( + + + + setOptions((prev) => [...prev, inputValue]) + } + /> + + + ); + }, +}; + +export const CreatableMulti: Story = { + render: () => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + return ( + + + + setOptions((prev) => [...prev, inputValue]) + } + /> + + + ); + }, +}; + +export const CreatableWithValidation: Story = { + render: () => { + const [options, setOptions] = useState(['Apple', 'Banana', 'Cherry']); + const [error, setError] = useState(); + + const validate = (inputValue: string) => { + const trimmed = inputValue.trim(); + if (trimmed.length < 3) return 'Enter at least 3 characters.'; + return undefined; + }; + + return ( + + + + !validate(inputValue) && inputValue.trim().length > 0 + } + name="creatable-validated-dropdown" + options={options} + placeholder="Type at least 3 characters to add…" + validationMessage={({ inputValue }) => + validate(inputValue) ?? 'No matching fruit' + } + onCreateOption={(inputValue) => { + setOptions((prev) => [...prev, inputValue.trim()]); + setError(undefined); + }} + onInputChange={(inputValue: string) => + setError(validate(inputValue)) + } + /> + + + ); + }, +}; + +export const FormGroupSelectDropdown: Story = { + args: { + options: ['hello', 'hi', 'howdy'], + value: 'oh no', + name: 'big-label', + }, + render: (args) => ( + + + + + + ), +}; + +export const FormGroupError: Story = { + args: { + options: ['Error', 'oh no', ':('], + name: 'error-example-unique', + placeholder: 'cry cry cry', + }, + render: (args) => ( + + + + + + ), +}; + +export const Default: Story = { + args: {}, + render: (args) => ( + + + + ), +}; diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/creatablePropsTable.tsx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/creatablePropsTable.tsx new file mode 100644 index 00000000000..fcaa243dffd --- /dev/null +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/creatablePropsTable.tsx @@ -0,0 +1,88 @@ +import { Code, TokenTable } from '~styleguide/blocks'; + +const creatablePropColumns = [ + { + key: 'prop', + name: 'Prop', + size: 'md' as const, + render: ({ prop }: { prop: string }) => {prop}, + }, + { + key: 'type', + name: 'Type', + size: 'lg' as const, + render: ({ type }: { type: string }) => {type}, + }, + { + key: 'defaultValue', + name: 'Default', + size: 'md' as const, + render: ({ defaultValue }: { defaultValue: string | null }) => + defaultValue ? {defaultValue} : '—', + }, + { + key: 'description', + name: 'Description', + size: 'fill' as const, + render: ({ description }: { description: React.ReactNode }) => description, + }, +]; + +const creatableProps = [ + { + id: 'isCreatable', + prop: 'isCreatable', + type: 'boolean', + defaultValue: 'false', + description: ( + <> + Enables option creation. Automatically forces isSearchable{' '} + to true. + + ), + }, + { + id: 'onCreateOption', + prop: 'onCreateOption', + type: '(inputValue: string) => void', + defaultValue: null, + description: ( + <> + Called when the user confirms a new option. Append the value to{' '} + options to persist it. + + ), + }, + { + id: 'formatCreateLabel', + prop: 'formatCreateLabel', + type: '(inputValue: string) => ReactNode', + defaultValue: 'Add "inputValue"', + description: 'Customises the label shown in the "Add" row.', + }, + { + id: 'isValidNewOption', + prop: 'isValidNewOption', + type: '(inputValue, value, options) => boolean', + defaultValue: 'react-select default', + description: + 'Controls when the "Add" row is visible. Use for min-length gating, pattern validation, or max-items caps.', + }, + { + id: 'validationMessage', + prop: 'validationMessage', + type: 'ReactNode | (({ inputValue }) => ReactNode)', + defaultValue: '"No options"', + description: + 'Replaces the in-menu "No options" text. Useful for surfacing validation/error messages directly inside the dropdown.', + }, +]; + +export const CreatablePropsTable = () => ( + +); From 2ab39cec79d664cc79641921291c30b3c03a515b Mon Sep 17 00:00:00 2001 From: dreamwasp Date: Fri, 12 Jun 2026 15:33:16 -0400 Subject: [PATCH 4/8] write SelectDropdown skill --- .../agent-tools/skills/gamut-forms/SKILL.md | 6 + .../skills/gamut-select-dropdown/SKILL.md | 155 ++++++++++++++++ .../Form/SelectDropdown/SelectDropdown.tsx | 76 ++++---- .../SelectDropdown/elements/containers.tsx | 8 +- .../SelectDropdown/types/component-props.ts | 9 +- .../Form/__tests__/SelectDropdown.test.tsx | 163 ++++++++++++++++- .../SelectDropdown/SelectDropdown.mdx | 154 +++++++++------- .../SelectDropdown/SelectDropdown.stories.tsx | 166 +++++++++++++----- .../SelectDropdown/controlledModeTable.tsx | 92 ++++++++++ .../SelectDropdown/creatablePropsTable.tsx | 5 +- 10 files changed, 660 insertions(+), 174 deletions(-) create mode 100644 packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md create mode 100644 packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/controlledModeTable.tsx diff --git a/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md b/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md index ea61d364efa..758001eb5fd 100644 --- a/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md +++ b/packages/gamut/agent-tools/skills/gamut-forms/SKILL.md @@ -26,6 +26,12 @@ For typical product forms, prefer `GridForm` (declarative `fields`, `LayoutGrid` --- +## SelectDropdown + +For `SelectDropdown` — single vs multi value, controlled vs uncontrolled patterns, creatable options, and 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; SelectDropdown-specific state contracts live in that skill. + +--- + ## `FormGroup` (baseline) [`FormGroup.tsx`](https://github.com/Codecademy/gamut/blob/main/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..00939e1e8bb --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md @@ -0,0 +1,155 @@ +--- +name: gamut-select-dropdown +description: Use when implementing or auditing SelectDropdown — single/multi modes, controlled vs uncontrolled value, creatable options, FormGroup wiring, and react-select action meta. Pair with gamut-forms for FormGroup/validation patterns. +--- + +# Gamut SelectDropdown + +Styled dropdown built on react-select. Supports single and multi-select, searchable menus, creatable options, icons, groups, and abbreviations. + +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). + +--- + +## 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. + +--- + +## 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. Handle `onInputChange`: validate on `input-change`, re-validate from last typed value on `input-blur` so FormGroup error persists. + +--- + +## FormGroup wiring + +- `FormGroup` `htmlFor` must match control `id` / `name`. +- Pass `name` on SelectDropdown (required for forms). +- Pass `error` boolean when FormGroup has an error. +- Generic FormGroup live-region behavior: see [`gamut-forms`](../gamut-forms/SKILL.md). + +```tsx + + setValue(option.value)} + /> + +``` + +--- + +## 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]); + } + }} +/>; +``` + +--- + +## Storybook note + +Default story args include `value: ''`. Spreading `{...args}` in custom renders behaves as controlled empty single. Omit `value` when demonstrating uncontrolled multi or creatable multi. diff --git a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx index 4c9ec54557d..f164ef0f068 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -9,11 +9,7 @@ import { useState, } from 'react'; import * as React from 'react'; -import { - InputActionMeta, - Options as OptionsType, - StylesConfig, -} from 'react-select'; +import { ActionMeta, Options as OptionsType, StylesConfig } from 'react-select'; import { parseOptions, SelectOptionBase } from '../utils'; import { @@ -142,9 +138,6 @@ export const SelectDropdown: React.FC = ({ const [activated, setActivated] = useState(false); const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined); - // Controlled input value for creatable mode so typed text persists across - // blur / menu-close (react-select clears it by default). - const [inputValue, setInputValue] = useState(''); // these are used to programatically manage the focus state of our multi-select options + 'Remove all' button const removeAllButtonRef = useRef(null); @@ -195,8 +188,11 @@ export const SelectDropdown: React.FC = ({ ) ); - // If the caller changes the initial value, let's update our value to match. + // 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, @@ -206,35 +202,46 @@ export const SelectDropdown: React.FC = ({ // We only update this when our passed in options or value changes, not multiValues. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, value]); + }, [options, value, multiple]); const changeHandler = useCallback( - (optionEvent: OptionStrict | OptionsType) => { + ( + optionEvent: OptionStrict | OptionsType, + actionMeta: ActionMeta + ) => { 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 + if (actionMeta.action === 'create-option') { + onCreateOption?.(actionMeta.option?.value ?? ''); + } + const onChangeProps = { onChange, multiple }; + const forwardedMeta: ActionMeta = + actionMeta.action === 'create-option' + ? actionMeta + : { + action: onChangeAction, + option: isMultipleSelectProps(onChangeProps) + ? undefined + : (optionEvent as OptionStrict), + }; if (isSingleSelectProps(onChangeProps)) { const singleOptionEvent = optionEvent as OptionStrict; - onChangeProps.onChange?.(singleOptionEvent, { - action: onChangeAction, - option: singleOptionEvent, - }); + onChangeProps.onChange?.(singleOptionEvent, forwardedMeta); } 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) - }); + onChangeProps.onChange?.( + optionEvent as OptionsType, + forwardedMeta + ); } }, - [onChange, multiple] + [onChange, multiple, onCreateOption] ); const keyPressHandler = (e: KeyboardEvent) => { @@ -256,27 +263,6 @@ export const SelectDropdown: React.FC = ({ } }; - const handleInputChange = useCallback( - (newValue: string, actionMeta: InputActionMeta) => { - if (isCreatable) { - /* Keep typed text instead of letting react-select clear it on blur / - menu-close. Since the value didn't actually change, we also skip - forwarding these to the consumer so derived state (e.g. validation - errors) isn't reset against an empty value. 'set-value' (after - selecting/creating) still clears and forwards as normal. */ - if ( - actionMeta.action === 'input-blur' || - actionMeta.action === 'menu-close' - ) { - return; - } - setInputValue(newValue); - } - onInputChange?.(newValue, actionMeta); - }, - [isCreatable, onInputChange] - ); - const noOptionsMessage = validationMessage === undefined ? undefined // fall back to react-select default ("No options") @@ -313,7 +299,6 @@ export const SelectDropdown: React.FC = ({ id={id || rest.htmlFor || rawInputId} inputId={inputId} inputProps={{ ...inputProps }} - inputValue={isCreatable ? inputValue : undefined} inputWidth={inputWidth} isCreatable={isCreatable} isDisabled={disabled} @@ -332,8 +317,7 @@ export const SelectDropdown: React.FC = ({ styles={memoizedStyles} value={multiple ? multiValues : parsedValue} onChange={changeHandler} - onCreateOption={onCreateOption} - onInputChange={handleInputChange} + onInputChange={onInputChange} onKeyDown={multiple ? (e) => keyPressHandler(e) : undefined} {...rest} /> diff --git a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx b/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx index 7c338eca12a..cc3f12cdc64 100644 --- a/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx +++ b/packages/gamut/src/Form/SelectDropdown/elements/containers.tsx @@ -122,8 +122,10 @@ export const CustomInput = ({ /** * Typed wrapper around react-select component. * Renders CreatableSelect when isCreatable is true, ReactSelect otherwise. - * Creatable-only props (formatCreateLabel, onCreateOption, isValidNewOption) - * are stripped from the non-creatable path so they don't reach ReactSelect. + * 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, @@ -134,7 +136,6 @@ export function TypedReactSelect< isCreatable, formatCreateLabel, isValidNewOption, - onCreateOption, ...props }: Props & TypedReactSelectProps) { if (isCreatable) { @@ -143,7 +144,6 @@ export function TypedReactSelect< {...(props as any)} formatCreateLabel={formatCreateLabel} isValidNewOption={isValidNewOption} - onCreateOption={onCreateOption} ref={selectRef} /> ); diff --git a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts index 13e04c25b26..37fd704ab52 100644 --- a/packages/gamut/src/Form/SelectDropdown/types/component-props.ts +++ b/packages/gamut/src/Form/SelectDropdown/types/component-props.ts @@ -82,7 +82,8 @@ export interface SelectDropdownCoreProps isCreatable?: boolean; /** * Called when the user confirms a new option via the "Add" row. - * The consumer is responsible for appending the new option to the options array. + * 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; /** @@ -175,11 +176,9 @@ export interface TypedReactSelectProps extends ReactSelectAdditionalProps { selectRef?: Ref; /** When true, renders CreatableSelect instead of ReactSelect */ isCreatable?: boolean; - /** Forwarded to CreatableSelect; customises the "Add" row label */ + /** Customises the "Add" row label */ formatCreateLabel?: (inputValue: string) => React.ReactNode; - /** Forwarded to CreatableSelect; called on new option confirmation */ - onCreateOption?: (inputValue: string) => void; - /** Forwarded to CreatableSelect; controls visibility of the "Add" row */ + /** Controls visibility of the "Add" row */ isValidNewOption?: ( inputValue: string, value: OptionsType, diff --git a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx index 38651c2b773..0735805854f 100644 --- a/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx +++ b/packages/gamut/src/Form/__tests__/SelectDropdown.test.tsx @@ -1,7 +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, @@ -12,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 */ @@ -464,6 +517,41 @@ describe('SelectDropdown', () => { 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({ + isCreatable: true, + multiple: true, + onChange, + }); + + await openDropdown(view); + await act(async () => { + await userEvent.click(view.getByText('red')); + }); + + const combobox = view.getByRole('combobox'); + + await act(async () => { + await userEvent.type(combobox, 'purple'); + }); + + 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' }), + }) + ); + }); + it('respects a custom formatCreateLabel', async () => { const { view } = renderView({ isCreatable: true, @@ -490,7 +578,7 @@ describe('SelectDropdown', () => { expect(view.queryByText('Add "anything"')).not.toBeInTheDocument(); }); - it('keeps the typed text after the input blurs', async () => { + it('clears the typed text when the input blurs', async () => { const { view } = renderView({ isCreatable: true }); const combobox = view.getByRole('combobox'); @@ -503,10 +591,10 @@ describe('SelectDropdown', () => { fireEvent.blur(combobox); }); - expect(combobox).toHaveValue('pur'); + expect(combobox).toHaveValue(''); }); - it('does not forward onInputChange to the consumer when the input blurs', async () => { + it('forwards onInputChange to the consumer when the input blurs', async () => { const onInputChange = jest.fn(); const { view } = renderView({ isCreatable: true, onInputChange }); @@ -522,8 +610,10 @@ describe('SelectDropdown', () => { fireEvent.blur(combobox); }); - // Text persists, so consumers should not be told the value went empty. - expect(onInputChange).not.toHaveBeenCalled(); + expect(onInputChange).toHaveBeenCalledWith('', { + action: 'input-blur', + prevInputValue: 'pur', + }); }); it('clears the typed text after an option is created', async () => { @@ -542,6 +632,67 @@ describe('SelectDropdown', () => { 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({ diff --git a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 171301c537f..ed33506a98e 100644 --- a/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx +++ b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx @@ -2,6 +2,7 @@ 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'; @@ -16,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', }, }; @@ -31,44 +32,7 @@ Use SelectDropdown to pick from exclusive options within a styled dropdown. If you are using this in a standard form, `SelectDropdown` must be provided an `aria-label` and a `name`. The `aria-label` must match the `htmlFor` of the FormGroupLabel or `