Skip to content
Draft
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1781295720164.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
gamut: minor
---

feat(SelectDropdown): add isCreateable prop + remove SearchIcon
6 changes: 6 additions & 0 deletions packages/gamut/agent-tools/skills/gamut-forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
195 changes: 195 additions & 0 deletions packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
Original file line number Diff line number Diff line change
@@ -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('');

<SelectDropdown
isCreatable
onInputChange={(value, { action }) => {
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
<FormGroup htmlFor="country" isSoloField label="Country" error={errors.country}>
<SelectDropdown
name="country"
aria-label="country"
options={options}
value={value}
error={Boolean(errors.country)}
onChange={(option) => setValue(option.value)}
/>
</FormGroup>
```

---

## 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');

<SelectDropdown
name="country"
options={options}
value={value}
onChange={(option) => setValue(option.value)}
/>;
```

### Multi (uncontrolled)

```tsx
<SelectDropdown
multiple
name="tags"
options={options}
onChange={(selected) => console.log(selected)}
/>
```

### Creatable multi (uncontrolled)

```tsx
const [options, setOptions] = useState(['Apple', 'Banana']);

<SelectDropdown
isCreatable
multiple
name="fruits"
options={options}
onCreateOption={(v) => setOptions((prev) => [...prev, v])}
/>;
```

### Creatable multi (controlled)

```tsx
const [options, setOptions] = useState(['Apple', 'Banana']);
const [value, setValue] = useState<string[]>([]);

<SelectDropdown
isCreatable
multiple
name="fruits"
options={options}
value={value}
onChange={(selected, meta) => {
setValue(selected.map((o) => o.value));
if (meta.action === 'create-option' && meta.option) {
setOptions((prev) => [...prev, meta.option.value]);
}
}}
/>;
```
76 changes: 59 additions & 17 deletions packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,6 +36,7 @@ import {
} from './types';
import {
filterValueFromOptions,
getCreatedOptionValue,
isMultipleSelectProps,
isOptionsGrouped,
isSingleSelectProps,
Expand Down Expand Up @@ -109,22 +110,30 @@ export const SelectDropdown: React.FC<SelectDropdownProps> = ({
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}`;

Expand Down Expand Up @@ -180,47 +189,68 @@ export const SelectDropdown: React.FC<SelectDropdownProps> = ({
)
);

// 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,
isOptionsGrouped(selectOptions)
);
if (newMultiValues !== multiValues) setMultiValues(newMultiValues);

//
// We only update this when our passed in options or value changes, not multiValues.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, value]);
}, [options, value, multiple]);

const changeHandler = useCallback(
(optionEvent: OptionStrict | OptionsType<OptionStrict>) => {
(
optionEvent: OptionStrict | OptionsType<OptionStrict>,
actionMeta: ActionMeta<OptionStrict>
) => {
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<OptionStrict> =
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<OptionStrict>, {
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<OptionStrict>,
forwardedMeta
);
}
},
[onChange, multiple]
[onChange, multiple, onCreateOption]
);

const keyPressHandler = (e: KeyboardEvent<HTMLDivElement>) => {
Expand All @@ -242,6 +272,13 @@ export const SelectDropdown: React.FC<SelectDropdownProps> = ({
}
};

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<any, false> => {
return getMemoizedStyles(theme, zIndex);
Expand All @@ -265,18 +302,22 @@ export const SelectDropdown: React.FC<SelectDropdownProps> = ({
}}
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}
Expand All @@ -285,6 +326,7 @@ export const SelectDropdown: React.FC<SelectDropdownProps> = ({
styles={memoizedStyles}
value={multiple ? multiValues : parsedValue}
onChange={changeHandler}
onInputChange={onInputChange}
onKeyDown={multiple ? (e) => keyPressHandler(e) : undefined}
{...rest}
/>
Expand Down
Loading
Loading