Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c8563bf
feat(SelectDropdown):isCreatable
dreamwasp Jun 5, 2026
8d86bc8
tweaks
dreamwasp Jun 11, 2026
cabad9d
refactor prop table
dreamwasp Jun 12, 2026
2ab39ce
write SelectDropdown skill
dreamwasp Jun 12, 2026
cd2dc5a
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 12, 2026
878a06f
tweak props + co-pilot
dreamwasp Jun 12, 2026
d99285b
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 16, 2026
f2e6db1
as
dreamwasp Jun 16, 2026
7064061
tweak
dreamwasp Jun 16, 2026
a4b27b8
SelectDropdown skill
dreamwasp Jun 16, 2026
9000ba8
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 16, 2026
c8f2d09
update skills
dreamwasp Jun 18, 2026
5252f6a
refactor skills + stories
dreamwasp Jun 22, 2026
5d1cbda
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 22, 2026
cf1756a
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 22, 2026
82628dd
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp Jun 22, 2026
20f8b22
amy edits
dreamwasp Jun 24, 2026
53e75eb
Merge branch 'main' into cass-gmt-292
dreamwasp Jun 24, 2026
fc9bead
change placeholde text color
dreamwasp Jun 25, 2026
721362e
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp Jun 25, 2026
7c6722a
Update packages/gamut/src/Form/SelectDropdown/styles.ts
dreamwasp Jun 29, 2026
e7cc7ac
merge main
dreamwasp Jun 29, 2026
d0c8703
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp Jun 29, 2026
f010705
fix syntax
dreamwasp Jun 29, 2026
bc91ff5
fix linting errors
dreamwasp Jun 29, 2026
331c8ac
reorgnize
dreamwasp Jun 29, 2026
8e8a130
fix errors
dreamwasp Jun 29, 2026
53f4001
Merge branch 'main' into cass-gmt-292
dreamwasp Jul 7, 2026
e7a0f1a
amy fix
dreamwasp Jul 7, 2026
f781d4d
Update packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
dreamwasp Jul 9, 2026
7875d9d
Update packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
dreamwasp Jul 9, 2026
bcfbe88
start kenny edits
dreamwasp Jul 9, 2026
ae462ba
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp Jul 9, 2026
86275d8
Merge branch 'main' into cass-gmt-292
dreamwasp Jul 17, 2026
b3502b7
validation message, still tweaking
dreamwasp Jul 21, 2026
c07b2a4
Merge branch 'main' into cass-gmt-292
dreamwasp Jul 22, 2026
03b5d3f
style: use classic /* */ block comments in useNoOptionsAnnouncement
dreamwasp Jul 22, 2026
cb6bddc
docs: fix SelectDropdown FormGroup wiring guidance, document no-optio…
dreamwasp Jul 22, 2026
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
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
155 changes: 155 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,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).
Comment thread
dreamwasp marked this conversation as resolved.
Outdated

---

## Controlled vs uncontrolled

SelectDropdown does **not** accept `defaultValue`.
Comment thread
dreamwasp marked this conversation as resolved.
Outdated

| 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
<FormGroup htmlFor="country" isSoloField label="Country" error={errors.country}>
<SelectDropdown
name="country"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be id. name doesnt match them up. its id on the control matches htmlFor on the label, or you can pass htmlFor on the control and it becomes id downstream (kinda weird imo)

options={options}
value={value}
error={Boolean(errors.country)}
onChange={(option) => setValue(option.value)}
/>
</FormGroup>
```

---

## 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]);
}
}}
/>;
```

---

## 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.
67 changes: 50 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 Down Expand Up @@ -109,22 +109,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}`;

Comment on lines 100 to 102
Expand Down Expand Up @@ -180,47 +188,60 @@ 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') {
onCreateOption?.(actionMeta.option?.value ?? '');
}

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 +263,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 +293,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 +317,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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
CloseIcon,
MiniChevronDownIcon,
MiniDeleteIcon,
SearchIcon,
} from '@codecademy/gamut-icons';

export const iconSize = { small: 12, medium: 16 };
Expand All @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion packages/gamut/src/Form/SelectDropdown/elements/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ReactSelect, {
GroupBase,
Props,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';

import {
CustomSelectComponentProps,
Expand Down Expand Up @@ -120,15 +121,32 @@ 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,
IsMulti extends boolean = false,
GroupType extends GroupBase<OptionType> = GroupBase<OptionType>
>({
selectRef,
isCreatable,
formatCreateLabel,
isValidNewOption,
...props
}: Props<OptionType, IsMulti, GroupType> & TypedReactSelectProps) {
if (isCreatable) {
return (
<CreatableSelect
{...(props as any)}
formatCreateLabel={formatCreateLabel}
isValidNewOption={isValidNewOption}
ref={selectRef}
/>
);
}
return <ReactSelect {...props} ref={selectRef} />;
}
Loading
Loading