-
Notifications
You must be signed in to change notification settings - Fork 31
feat(SelectDropdown): isCreateable support, removes SearchIcon, styling fixes
#3371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
c8563bf
feat(SelectDropdown):isCreatable
dreamwasp 8d86bc8
tweaks
dreamwasp cabad9d
refactor prop table
dreamwasp 2ab39ce
write SelectDropdown skill
dreamwasp cd2dc5a
Merge branch 'main' into cass-gmt-292
dreamwasp 878a06f
tweak props + co-pilot
dreamwasp d99285b
Merge branch 'main' into cass-gmt-292
dreamwasp f2e6db1
as
dreamwasp 7064061
tweak
dreamwasp a4b27b8
SelectDropdown skill
dreamwasp 9000ba8
Merge branch 'main' into cass-gmt-292
dreamwasp c8f2d09
update skills
dreamwasp 5252f6a
refactor skills + stories
dreamwasp 5d1cbda
Merge branch 'main' into cass-gmt-292
dreamwasp cf1756a
Merge branch 'main' into cass-gmt-292
dreamwasp 82628dd
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp 20f8b22
amy edits
dreamwasp 53e75eb
Merge branch 'main' into cass-gmt-292
dreamwasp fc9bead
change placeholde text color
dreamwasp 721362e
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp 7c6722a
Update packages/gamut/src/Form/SelectDropdown/styles.ts
dreamwasp e7cc7ac
merge main
dreamwasp d0c8703
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp f010705
fix syntax
dreamwasp bc91ff5
fix linting errors
dreamwasp 331c8ac
reorgnize
dreamwasp 8e8a130
fix errors
dreamwasp 53f4001
Merge branch 'main' into cass-gmt-292
dreamwasp e7a0f1a
amy fix
dreamwasp f781d4d
Update packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
dreamwasp 7875d9d
Update packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
dreamwasp bcfbe88
start kenny edits
dreamwasp ae462ba
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp 86275d8
Merge branch 'main' into cass-gmt-292
dreamwasp b3502b7
validation message, still tweaking
dreamwasp c07b2a4
Merge branch 'main' into cass-gmt-292
dreamwasp 03b5d3f
style: use classic /* */ block comments in useNoOptionsAnnouncement
dreamwasp cb6bddc
docs: fix SelectDropdown FormGroup wiring guidance, document no-optio…
dreamwasp 8cae749
better validation props
dreamwasp 69c183d
Merge branch 'main' into cass-gmt-292
dreamwasp dab8a9f
copilot suggestions
dreamwasp bd729a4
Merge branch 'cass-gmt-292' of github.com:Codecademy/gamut into cass-…
dreamwasp f4df825
create option position
dreamwasp dba919f
use no options announcement
dreamwasp c0e9aa9
id
dreamwasp 6fc94d5
Merge branch 'main' into cass-gmt-292
dreamwasp 1cea05c
tweak skill
dreamwasp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| gamut: minor | ||
| --- | ||
|
|
||
| feat(SelectDropdown): add isCreatable prop + remove SearchIcon |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
240 changes: 240 additions & 0 deletions
240
packages/gamut/agent-tools/skills/gamut-select-dropdown/SKILL.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| --- | ||
| 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 an array of plain strings or option objects. `value` is always a string and references an option's `value`. | ||
|
|
||
| The table below shows the keys and value types for an option object: | ||
|
|
||
| | Field | Type | Required | Notes | | ||
| | -------------- | --------------------------------- | -------- | -------------------------------------------------------------------- | | ||
| | `label` | `string` | yes | Display text | | ||
| | `value` | `string` | yes | Unique string; what `value` / `string[]` reference | | ||
| | `disabled` | `boolean` | no | Option cannot be selected | | ||
| | `subtitle` | `string` | no | Secondary text below the label | | ||
| | `rightLabel` | `string` | no | Text on the right side of the option | | ||
| | `icon` | icon component from `gamut-icons` | no | A `@codecademy/gamut-icons` component | | ||
| | `abbreviation` | `string` | 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. | ||
|
|
||
| ### When to use uncontrolled (multi only) | ||
|
|
||
| Uncontrolled multi is appropriate when: | ||
|
|
||
| - No other part of the UI needs to react to the current selection (no live summary, no dependent field, no enabled/disabled button). | ||
| - You only need the value at form submission — via `FormData`, a submit handler reading the DOM, or react-hook-form's `getValues`. | ||
| - Simplicity is the priority; omitting `value` means one less piece of state to manage. | ||
|
|
||
| ```tsx | ||
| // Good fit: a "tags" field where only the submitted array matters | ||
| <SelectDropdown | ||
| multiple | ||
| name="tags" | ||
| options={tagOptions} | ||
| onCreateOption={(v) => setTagOptions((prev) => [...prev, v])} | ||
| /> | ||
| ``` | ||
|
|
||
| ### When to use controlled | ||
|
|
||
| Use controlled when: | ||
|
|
||
| - Another part of the UI must reflect the current selection in real time (summary text, a filtered list, an enable/disable condition). | ||
| - You need to pre-populate from an API response, reset on cancel, or sync with a form library like react-hook-form. | ||
| - You are using single-select (the only supported mode for single). | ||
|
|
||
| ```tsx | ||
| // Good fit: pre-populate from API, clear on cancel, show live summary | ||
| const [selected, setSelected] = useState<string[]>(initialValues); | ||
|
|
||
| <SelectDropdown | ||
| multiple | ||
| name="languages" | ||
| options={languageOptions} | ||
| value={selected} | ||
| onChange={(opts) => setSelected(opts.map((o) => o.value))} | ||
| /> | ||
| <p>Selected: {selected.join(', ') || 'none'}</p> | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 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`. | ||
|
|
||
| **Action normalization gotcha:** for every action other than `create-option`, `changeHandler` overwrites the real react-select action with a hardcoded `'select-option'` before calling consumer `onChange`. `meta.action` never distinguishes `remove-value`, `clear`, `pop-value`, or `deselect-option` — only `'select-option'` or `'create-option'` ever reach you. Derive "cleared" / "removed one tag" from a diff of the `selected` array instead of `meta.action`. | ||
|
|
||
| --- | ||
|
|
||
| ## 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. | ||
| - `createOptionPosition` — `'first' | 'last'` (default `'last'`). Renders the Add row above or below the rest of the options list. | ||
| - `validationMessage` — content shown _inside the dropdown menu itself_ in place of the default "No options" text, whenever no option matches the current input (empty `options`, or every option filtered out by a search). Not tied to `isCreatable` — any searchable SelectDropdown can use it. Accepts a `ReactNode` or a function receiving `{ inputValue }`, so you can surface live validation/error copy (e.g. "No results for '{inputValue}'") right where the user is looking. Mirror the same text in `FormGroup`'s `error` prop for field-level feedback below the control. | ||
| - SelectDropdown already announces its "No options" text (default or `validationMessage`) to screen readers via a debounced (`400ms`), standalone `aria-live="polite" role="status"` live region — react-select's own live region only fires when its `options` prop is non-empty, so it stays silent for the empty-options case; this fills that gap, including mid-fetch states. Don't build a separate announcement for this; just set `validationMessage`. | ||
|
|
||
| **Validation after blur:** react-select's `onInputChange` reports an empty string on the `'input-blur'` action — the typed value has already been cleared internally by the time that fires. Store the last typed value in a ref during `'input-change'` 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 SelectDropdown's `name` — **not** `id`. Unlike plain `Select`, SelectDropdown's `id`/`htmlFor` prop only sets the id of react-select's outer wrapper `<div>` (not labelable); the actual focusable combobox input always takes its id from `inputId`, which is `name`. A label pointed at `id` instead of `name` silently breaks the label/input association whenever the two differ. | ||
| - Pass `name` on SelectDropdown (required for forms, and it doubles as the accessible id target). | ||
| - Rely on FormGroupLabel's `htmlFor`/`id` connection for the accessible label — this is the standard HTML pattern and preferred method. Do not use `aria-label` when a FormGroupLabel is present, as it overrides the visible label. | ||
| - 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" | ||
| 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` | `2` | Menu z-index (control container defaults to `3`) | | ||
| | `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]); | ||
| } | ||
| }} | ||
| />; | ||
| ``` | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.namedoesnt match them up. itsidon the control matcheshtmlForon the label, or you can passhtmlForon the control and it becomesiddownstream (kinda weird imo)