Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
183 changes: 183 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,183 @@
---
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).

---

## 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. 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 `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