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 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..325c60d1506 --- /dev/null +++ b/packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md @@ -0,0 +1,195 @@ +--- +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 plain strings or option objects. `value` is always a string and references an option's `value`. + +| Field | Required | Notes | +| -------------- | -------- | -------------------------------------------------------------------- | +| `label` | yes | Display text | +| `value` | yes | Unique string; what `value` / `string[]` reference | +| `disabled` | no | Option cannot be selected | +| `subtitle` | no | Secondary text below the label | +| `rightLabel` | no | Text on the right side of the option | +| `icon` | no | A `@codecademy/gamut-icons` component | +| `abbreviation` | 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. + +--- + +## 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` / `name`. +- Pass `name` on SelectDropdown (required for forms). +- Pass `aria-label` (required for forms); it must match the FormGroupLabel `htmlFor` / `name`. +- 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..3a5ba7ee5db 100644 --- a/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx +++ b/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx @@ -9,7 +9,7 @@ import { useState, } from 'react'; import * as React from 'react'; -import { Options as OptionsType, StylesConfig } from 'react-select'; +import { ActionMeta, Options as OptionsType, StylesConfig } from 'react-select'; import { parseOptions, SelectOptionBase } from '../utils'; import { @@ -36,6 +36,7 @@ import { } from './types'; import { filterValueFromOptions, + getCreatedOptionValue, isMultipleSelectProps, isOptionsGrouped, isSingleSelectProps, @@ -109,22 +110,30 @@ 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}`; @@ -180,8 +189,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, @@ -189,38 +201,56 @@ 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]); + }, [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') { + const createdValue = getCreatedOptionValue( + optionEvent, + actionMeta, + multiple + ); + + if (createdValue) { + onCreateOption?.(createdValue); + } + } + 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) => { @@ -242,6 +272,13 @@ export const SelectDropdown: React.FC = ({ } }; + 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); @@ -265,18 +302,22 @@ 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} + noOptionsMessage={noOptionsMessage} options={selectOptions} placeholder={placeholder} selectRef={selectInputRef} @@ -285,6 +326,7 @@ export const SelectDropdown: React.FC = ({ styles={memoizedStyles} value={multiple ? multiValues : parsedValue} onChange={changeHandler} + onInputChange={onInputChange} onKeyDown={multiple ? (e) => keyPressHandler(e) : 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..a1a244babde 100644 --- a/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx +++ b/packages/gamut/src/Form/SelectDropdown/__tests__/utils.test.tsx @@ -2,12 +2,71 @@ import { SelectOptionBase } from '../../utils'; import { ExtendedOption, SelectDropdownGroup } from '../types'; import { filterValueFromOptions, + getCreatedOptionValue, isOptionGroup, isOptionsGrouped, removeValueFromSelectedOptions, } from '../utils'; 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/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..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/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..a7fc50142fa 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,14 +213,36 @@ 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: isNew + ? state.isDisabled + ? theme.colors['text-disabled'] + : theme.colors.primary + : state.isDisabled + ? theme.colors['text-disabled'] + : theme.colors.text, + 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..37fd704ab52 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,19 @@ 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; + /** Customises the "Add" row label */ + formatCreateLabel?: (inputValue: string) => React.ReactNode; + /** 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/SelectDropdown/utils.tsx b/packages/gamut/src/Form/SelectDropdown/utils.tsx index bcf0fd7ac87..75a15438b5a 100644 --- a/packages/gamut/src/Form/SelectDropdown/utils.tsx +++ b/packages/gamut/src/Form/SelectDropdown/utils.tsx @@ -1,8 +1,11 @@ +import { ActionMeta, Options as OptionsType } from 'react-select'; + import { SelectOptionBase } from '../utils'; import { BaseOnChangeProps, ExtendedOption, MultiSelectDropdownProps, + OptionStrict, SelectDropdownGroup, SelectDropdownOptions, SelectDropdownProps, @@ -17,6 +20,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' && 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/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx b/packages/styleguide/src/lib/Atoms/FormInputs/SelectDropdown/SelectDropdown.mdx index 3b09f0209ad..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,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', }, }; @@ -30,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 `