@codecademy/gamut 72.2.3-alpha.c298d6.0 → 72.2.3-alpha.f0a032.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/agent-tools/skills/gamut-forms/SKILL.md +16 -0
  2. package/agent-tools/skills/gamut-select-dropdown/SKILL.md +236 -0
  3. package/dist/Button/IconButton.js +0 -1
  4. package/dist/Form/SelectDropdown/SelectDropdown.js +48 -100
  5. package/dist/Form/SelectDropdown/core/accessibility.d.ts +3 -0
  6. package/dist/Form/SelectDropdown/core/accessibility.js +12 -0
  7. package/dist/Form/SelectDropdown/core/constants.d.ts +13 -0
  8. package/dist/Form/SelectDropdown/core/constants.js +14 -0
  9. package/dist/Form/SelectDropdown/{styles.js → core/styles.js} +30 -12
  10. package/dist/Form/SelectDropdown/{utils.d.ts → core/utils.d.ts} +12 -2
  11. package/dist/Form/SelectDropdown/{utils.js → core/utils.js} +23 -0
  12. package/dist/Form/SelectDropdown/elements/constants.d.ts +0 -8
  13. package/dist/Form/SelectDropdown/elements/constants.js +1 -9
  14. package/dist/Form/SelectDropdown/elements/containers.d.ts +6 -2
  15. package/dist/Form/SelectDropdown/elements/containers.js +17 -1
  16. package/dist/Form/SelectDropdown/elements/controls.d.ts +1 -15
  17. package/dist/Form/SelectDropdown/elements/controls.js +2 -91
  18. package/dist/Form/SelectDropdown/elements/index.d.ts +2 -1
  19. package/dist/Form/SelectDropdown/elements/index.js +2 -1
  20. package/dist/Form/SelectDropdown/elements/options.d.ts +1 -0
  21. package/dist/Form/SelectDropdown/elements/options.js +5 -2
  22. package/dist/Form/SelectDropdown/hooks/useSelectHandlers.d.ts +22 -0
  23. package/dist/Form/SelectDropdown/hooks/useSelectHandlers.js +62 -0
  24. package/dist/Form/SelectDropdown/hooks/useSelectOptions.d.ts +14 -0
  25. package/dist/Form/SelectDropdown/hooks/useSelectOptions.js +39 -0
  26. package/dist/Form/SelectDropdown/types/component-props.d.ts +54 -6
  27. package/dist/Form/SelectDropdown/types/internal.d.ts +3 -3
  28. package/dist/Form/SelectDropdown/types/styles.d.ts +5 -1
  29. package/dist/Form/styles/index.d.ts +1 -1
  30. package/dist/Form/styles/index.js +1 -1
  31. package/dist/Tip/ToolTip/index.d.ts +2 -7
  32. package/dist/Tip/shared/FloatingTip.js +0 -15
  33. package/dist/Tip/shared/InlineTip.js +0 -17
  34. package/dist/Tip/shared/types.d.ts +0 -1
  35. package/package.json +6 -6
  36. /package/dist/Form/SelectDropdown/{styles.d.ts → core/styles.d.ts} +0 -0
@@ -26,6 +26,22 @@ For typical product forms, prefer `GridForm` (declarative `fields`, `LayoutGrid`
26
26
 
27
27
  ---
28
28
 
29
+ ## SelectDropdown vs Select
30
+
31
+ Use `Select` for standard single-select fields where bundle size matters and no special styling is needed. Use `SelectDropdown` when the design calls for any of:
32
+
33
+ - Styled dropdown menu (react-select appearance)
34
+ - Search / typeahead
35
+ - Multi-select with tags
36
+ - Creatable options
37
+ - Option icons, subtitles, right labels, abbreviations, or grouped options
38
+
39
+ `SelectDropdown` carries a larger JS dependency (react-select); don't reach for it as a default drop-in for `Select`.
40
+
41
+ For full SelectDropdown API detail — controlled vs uncontrolled patterns, creatable options, 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.
42
+
43
+ ---
44
+
29
45
  ## `FormGroup` (baseline)
30
46
 
31
47
  `packages/gamut/src/Form/elements/FormGroup.tsx`
@@ -0,0 +1,236 @@
1
+ ---
2
+ name: gamut-select-dropdown
3
+ 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.
4
+ ---
5
+
6
+ # Gamut SelectDropdown
7
+
8
+ Styled dropdown built on react-select.
9
+
10
+ Source: `@codecademy/gamut` — [SelectDropdown.tsx](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/SelectDropdown/SelectDropdown.tsx)
11
+
12
+ See also: [`gamut-forms`](../gamut-forms/SKILL.md) — FormGroup wiring, error regions, and validation UX.
13
+
14
+ Storybook: [Atoms / FormInputs / SelectDropdown](https://gamut.codecademy.com/?path=/docs-atoms-forminputs-selectdropdown--docs)
15
+
16
+ ---
17
+
18
+ ## When to use SelectDropdown vs Select
19
+
20
+ 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).
21
+
22
+ ---
23
+
24
+ ## Options
25
+
26
+ `options` accepts plain strings or option objects. `value` is always a string and references an option's `value`.
27
+
28
+ | Field | Required | Notes |
29
+ | -------------- | -------- | -------------------------------------------------------------------- |
30
+ | `label` | yes | Display text |
31
+ | `value` | yes | Unique string; what `value` / `string[]` reference |
32
+ | `disabled` | no | Option cannot be selected |
33
+ | `subtitle` | no | Secondary text below the label |
34
+ | `rightLabel` | no | Text on the right side of the option |
35
+ | `icon` | no | A `@codecademy/gamut-icons` component |
36
+ | `abbreviation` | no | Short text shown in the input while the full label shows in the menu |
37
+
38
+ Grouped options: `{ label, options: [...], divider? }` (extends react-select `GroupBase`; `divider` draws a rule above the group).
39
+
40
+ ---
41
+
42
+ ## Controlled vs uncontrolled
43
+
44
+ SelectDropdown does **not** accept `defaultValue`.
45
+
46
+ | Mode | Uncontrolled | Controlled |
47
+ | ---------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
48
+ | Single | Not supported | `value` (string) + update in `onChange` |
49
+ | Multi | Omit `value` or pass non-array (`undefined`, `''`) | `value: string[]` + update in `onChange` |
50
+ | Creatable single | Not supported | Same as single; `onCreateOption` appends to `options` |
51
+ | Creatable multi | Omit `value`; `onCreateOption` for options | `value: string[]`; update in `onChange` on every change including `create-option` |
52
+
53
+ 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`.
54
+
55
+ **Controlled creatable multi pitfall:** Updating `options` alone without syncing `value` in `onChange` clears selection when options re-render.
56
+
57
+ ### When to use uncontrolled (multi only)
58
+
59
+ Uncontrolled multi is appropriate when:
60
+
61
+ - No other part of the UI needs to react to the current selection (no live summary, no dependent field, no enabled/disabled button).
62
+ - You only need the value at form submission — via `FormData`, a submit handler reading the DOM, or react-hook-form's `getValues`.
63
+ - Simplicity is the priority; omitting `value` means one less piece of state to manage.
64
+
65
+ ```tsx
66
+ // Good fit: a "tags" field where only the submitted array matters
67
+ <SelectDropdown
68
+ multiple
69
+ name="tags"
70
+ options={tagOptions}
71
+ onCreateOption={(v) => setTagOptions((prev) => [...prev, v])}
72
+ />
73
+ ```
74
+
75
+ ### When to use controlled
76
+
77
+ Use controlled when:
78
+
79
+ - Another part of the UI must reflect the current selection in real time (summary text, a filtered list, an enable/disable condition).
80
+ - You need to pre-populate from an API response, reset on cancel, or sync with a form library like react-hook-form.
81
+ - You are using single-select (the only supported mode for single).
82
+
83
+ ```tsx
84
+ // Good fit: pre-populate from API, clear on cancel, show live summary
85
+ const [selected, setSelected] = useState<string[]>(initialValues);
86
+
87
+ <SelectDropdown
88
+ multiple
89
+ name="languages"
90
+ options={languageOptions}
91
+ value={selected}
92
+ onChange={(opts) => setSelected(opts.map((o) => o.value))}
93
+ />
94
+ <p>Selected: {selected.join(', ') || 'none'}</p>
95
+ ```
96
+
97
+ ---
98
+
99
+ ## onChange contract
100
+
101
+ `onChange` receives option object(s), not `event.target.value`:
102
+
103
+ ```tsx
104
+ // Single
105
+ onChange={(option) => setValue(option.value)}
106
+
107
+ // Multi
108
+ onChange={(selected) => setValue(selected.map((o) => o.value))}
109
+ ```
110
+
111
+ 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`.
112
+
113
+ ---
114
+
115
+ ## Creatable
116
+
117
+ - `isCreatable` forces `isSearchable: true` (TypeScript enforces this).
118
+ - `onCreateOption(inputValue)` — convenience hook to append to `options`.
119
+ - `onChange(selected, meta)` — use `meta.action === 'create-option'` to sync controlled `value` and `options` together.
120
+ - `isValidNewOption` — return `false` to hide the Add row.
121
+ - `validationMessage` — replaces menu "No options" text; mirror in `FormGroup` `error` for field-level feedback.
122
+
123
+ **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`:
124
+
125
+ ```tsx
126
+ const lastInput = useRef('');
127
+
128
+ <SelectDropdown
129
+ isCreatable
130
+ onInputChange={(value, { action }) => {
131
+ if (action === 'input-change') lastInput.current = value;
132
+ if (action === 'input-blur') validate(lastInput.current);
133
+ }}
134
+ />;
135
+ ```
136
+
137
+ ---
138
+
139
+ ## FormGroup wiring
140
+
141
+ - `FormGroup` `htmlFor` must match control `id` (not `name`). Alternatively, pass `htmlFor` directly on SelectDropdown and it becomes `id` downstream.
142
+ - Pass `name` on SelectDropdown (required for forms).
143
+ - Pass `aria-label` (required for forms); it must match the FormGroupLabel `htmlFor`.
144
+ - Pass `error` boolean when FormGroup has an error.
145
+ - Generic FormGroup live-region behavior: see [`gamut-forms`](../gamut-forms/SKILL.md).
146
+
147
+ ```tsx
148
+ <FormGroup htmlFor="country" isSoloField label="Country" error={errors.country}>
149
+ <SelectDropdown
150
+ id="country"
151
+ name="country"
152
+ aria-label="country"
153
+ options={options}
154
+ value={value}
155
+ error={Boolean(errors.country)}
156
+ onChange={(option) => setValue(option.value)}
157
+ />
158
+ </FormGroup>
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Styling & layout props
164
+
165
+ | Prop | Type | Default | Notes |
166
+ | ------------------- | ------------------------ | -------- | --------------------------------------------------------- |
167
+ | `size` | `'small' \| 'medium'` | `medium` | Control height/density |
168
+ | `shownOptionsLimit` | `1`–`6` | `6` | Visible options before the menu scrolls |
169
+ | `inputWidth` | `string \| number` | — | Width of the input independent of the menu |
170
+ | `dropdownWidth` | `string \| number` | — | Width of the menu independent of the input |
171
+ | `menuAlignment` | `'left' \| 'right'` | `left` | Menu edge alignment |
172
+ | `zIndex` | `number` | auto | Menu z-index |
173
+ | `inputProps` | `{ hidden?, combobox? }` | — | `data-*` / `aria-*` only, forwarded to the input elements |
174
+
175
+ ---
176
+
177
+ ## Examples
178
+
179
+ ### Single (controlled)
180
+
181
+ ```tsx
182
+ const [value, setValue] = useState('us');
183
+
184
+ <SelectDropdown
185
+ name="country"
186
+ options={options}
187
+ value={value}
188
+ onChange={(option) => setValue(option.value)}
189
+ />;
190
+ ```
191
+
192
+ ### Multi (uncontrolled)
193
+
194
+ ```tsx
195
+ <SelectDropdown
196
+ multiple
197
+ name="tags"
198
+ options={options}
199
+ onChange={(selected) => console.log(selected)}
200
+ />
201
+ ```
202
+
203
+ ### Creatable multi (uncontrolled)
204
+
205
+ ```tsx
206
+ const [options, setOptions] = useState(['Apple', 'Banana']);
207
+
208
+ <SelectDropdown
209
+ isCreatable
210
+ multiple
211
+ name="fruits"
212
+ options={options}
213
+ onCreateOption={(v) => setOptions((prev) => [...prev, v])}
214
+ />;
215
+ ```
216
+
217
+ ### Creatable multi (controlled)
218
+
219
+ ```tsx
220
+ const [options, setOptions] = useState(['Apple', 'Banana']);
221
+ const [value, setValue] = useState<string[]>([]);
222
+
223
+ <SelectDropdown
224
+ isCreatable
225
+ multiple
226
+ name="fruits"
227
+ options={options}
228
+ value={value}
229
+ onChange={(selected, meta) => {
230
+ setValue(selected.map((o) => o.value));
231
+ if (meta.action === 'create-option' && meta.option) {
232
+ setOptions((prev) => [...prev, meta.option.value]);
233
+ }
234
+ }}
235
+ />;
236
+ ```
@@ -16,7 +16,6 @@ export const IconButton = /*#__PURE__*/forwardRef(({
16
16
  const iconSize = iconSizeMapping[buttonSize];
17
17
  return /*#__PURE__*/_jsx(ToolTip, {
18
18
  info: tip,
19
- closeOnClick: true,
20
19
  ...tipProps,
21
20
  children: /*#__PURE__*/_jsx(IconButtonBase, {
22
21
  ...props,
@@ -1,28 +1,14 @@
1
1
  import { useTheme } from '@emotion/react';
2
- import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import { useId, useMemo, useRef, useState } from 'react';
3
3
  import * as React from 'react';
4
- import { parseOptions } from '../utils';
5
- import { AbbreviatedSingleValue, CustomContainer, CustomInput, CustomValueContainer, DropdownButton, formatGroupLabel, formatOptionLabel, IconOption, MultiValueRemoveButton, MultiValueWithColorMode, onFocus, RemoveAllButton, SelectDropdownContext, TypedReactSelect } from './elements';
6
- import { getMemoizedStyles } from './styles';
7
- import { filterValueFromOptions, isMultipleSelectProps, isOptionsGrouped, isSingleSelectProps, removeValueFromSelectedOptions } from './utils';
4
+ import { onFocus } from './core/accessibility';
5
+ import { defaultComponents } from './core/constants';
6
+ import { getMemoizedStyles } from './core/styles';
7
+ import { resolveNoOptionsMessage } from './core/utils';
8
+ import { formatGroupLabel, formatOptionLabel, SelectDropdownContext, TypedReactSelect } from './elements';
9
+ import { useSelectHandlers } from './hooks/useSelectHandlers';
10
+ import { useSelectOptions } from './hooks/useSelectOptions';
8
11
  import { jsx as _jsx } from "react/jsx-runtime";
9
- const defaultProps = {
10
- name: undefined,
11
- components: {
12
- DropdownIndicator: DropdownButton,
13
- IndicatorSeparator: () => null,
14
- ClearIndicator: RemoveAllButton,
15
- SelectContainer: CustomContainer,
16
- ValueContainer: CustomValueContainer,
17
- MultiValue: MultiValueWithColorMode,
18
- MultiValueRemove: MultiValueRemoveButton,
19
- Option: IconOption,
20
- SingleValue: AbbreviatedSingleValue,
21
- Input: CustomInput
22
- }
23
- };
24
- const onChangeAction = 'select-option';
25
-
26
12
  /**
27
13
  * A flexible dropdown select component built on top of react-select.
28
14
  *
@@ -73,101 +59,58 @@ export const SelectDropdown = ({
73
59
  disabled,
74
60
  dropdownWidth,
75
61
  error,
62
+ formatCreateLabel = inputValue => `Add "${inputValue}"`,
76
63
  id,
77
64
  inputProps,
78
65
  inputWidth,
79
- isSearchable = false,
66
+ isCreatable = false,
67
+ isSearchable: isSearchableProp = false,
68
+ isValidNewOption,
80
69
  menuAlignment = 'left',
81
70
  multiple,
82
71
  name,
83
72
  onChange,
73
+ onCreateOption,
74
+ onInputChange,
84
75
  options,
85
76
  placeholder = 'Select an option',
86
77
  shownOptionsLimit = 6,
87
78
  size,
79
+ validationMessage,
88
80
  value,
89
81
  zIndex,
90
82
  ...rest
91
83
  }) => {
84
+ // isSearchable is forced true when isCreatable is true (CreatableSelect requires a text input)
85
+ const isSearchable = isCreatable || isSearchableProp;
92
86
  const rawInputId = useId();
93
87
  const inputId = name ?? `${id}-select-dropdown-${rawInputId}`;
94
- const [activated, setActivated] = useState(false);
95
- const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined);
96
-
97
- // these are used to programatically manage the focus state of our multi-select options + 'Remove all' button
98
88
  const removeAllButtonRef = useRef(null);
99
89
  const selectInputRef = useRef(null);
100
- const selectOptions = useMemo(() => {
101
- if (!options || Array.isArray(options) && !options.length || typeof options === 'object' && !Array.isArray(options) && Object.keys(options).length === 0) {
102
- return [];
103
- }
104
- if (isOptionsGrouped(options)) {
105
- return options;
106
- }
107
- return parseOptions({
108
- options,
109
- id,
110
- size
111
- });
112
- }, [options, id, size]);
113
- const parsedValue = useMemo(() => {
114
- if (isOptionsGrouped(selectOptions)) {
115
- for (const group of selectOptions) {
116
- if (group.options) {
117
- const foundOption = group.options.find(option => option.value === value);
118
- if (foundOption) return foundOption;
119
- }
120
- }
121
- return undefined;
122
- }
123
- return selectOptions.find(option => option.value === value);
124
- }, [selectOptions, value]);
125
- const [multiValues, setMultiValues] = useState(multiple &&
126
- // To keep this efficient for non-multiSelect
127
- filterValueFromOptions(selectOptions, value, isOptionsGrouped(selectOptions)));
128
-
129
- // If the caller changes the initial value, let's update our value to match.
130
- useEffect(() => {
131
- const newMultiValues = filterValueFromOptions(selectOptions, value, isOptionsGrouped(selectOptions));
132
- if (newMultiValues !== multiValues) setMultiValues(newMultiValues);
133
-
134
- //
135
- // We only update this when our passed in options or value changes, not multiValues.
136
- // eslint-disable-next-line react-hooks/exhaustive-deps
137
- }, [options, value]);
138
- const changeHandler = useCallback(optionEvent => {
139
- setActivated(true);
140
-
141
- // 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!
142
- // Should probably come with: https://codecademy.atlassian.net/browse/GM-354
143
- const onChangeProps = {
144
- onChange,
145
- multiple
146
- };
147
- if (isSingleSelectProps(onChangeProps)) {
148
- const singleOptionEvent = optionEvent;
149
- onChangeProps.onChange?.(singleOptionEvent, {
150
- action: onChangeAction,
151
- option: singleOptionEvent
152
- });
153
- }
154
- if (isMultipleSelectProps(onChangeProps)) {
155
- setMultiValues(optionEvent);
156
- onChangeProps.onChange?.(optionEvent, {
157
- action: onChangeAction,
158
- 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)
159
- });
160
- }
161
- }, [onChange, multiple]);
162
- const keyPressHandler = e => {
163
- if (multiple && e.key === 'Enter' && currentFocusedValue && multiValues) {
164
- const newMultiValues = removeValueFromSelectedOptions(multiValues, currentFocusedValue);
165
- if (newMultiValues !== multiValues) setMultiValues(newMultiValues);
166
- }
167
- if (removeAllButtonRef.current !== null && e.key === 'ArrowRight' && multiValues && currentFocusedValue === multiValues[multiValues.length - 1].value) {
168
- removeAllButtonRef.current.focus();
169
- }
170
- };
90
+ const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined);
91
+ const {
92
+ selectOptions,
93
+ parsedValue
94
+ } = useSelectOptions({
95
+ options,
96
+ id,
97
+ size,
98
+ value: value
99
+ });
100
+ const {
101
+ activated,
102
+ multiValues,
103
+ changeHandler,
104
+ keyPressHandler
105
+ } = useSelectHandlers({
106
+ onChange,
107
+ multiple,
108
+ onCreateOption,
109
+ selectOptions,
110
+ value,
111
+ currentFocusedValue,
112
+ removeAllButtonRef
113
+ });
171
114
  const theme = useTheme();
172
115
  const memoizedStyles = useMemo(() => {
173
116
  return getMemoizedStyles(theme, zIndex);
@@ -180,14 +123,15 @@ export const SelectDropdown = ({
180
123
  selectInputRef
181
124
  },
182
125
  children: /*#__PURE__*/_jsx(TypedReactSelect, {
183
- ...defaultProps,
184
126
  activated: activated,
185
127
  "aria-live": "assertive",
186
128
  ariaLiveMessages: {
187
129
  onFocus
188
130
  },
131
+ components: defaultComponents,
189
132
  dropdownWidth: dropdownWidth,
190
133
  error: Boolean(error),
134
+ formatCreateLabel: formatCreateLabel,
191
135
  formatGroupLabel: formatGroupLabel,
192
136
  formatOptionLabel: formatOptionLabel,
193
137
  id: id || rest.htmlFor || rawInputId,
@@ -196,12 +140,15 @@ export const SelectDropdown = ({
196
140
  ...inputProps
197
141
  },
198
142
  inputWidth: inputWidth,
143
+ isCreatable: isCreatable,
199
144
  isDisabled: disabled,
200
145
  isMulti: multiple,
201
146
  isOptionDisabled: option => option.disabled,
202
147
  isSearchable: isSearchable,
148
+ isValidNewOption: isValidNewOption,
203
149
  menuAlignment: menuAlignment,
204
150
  name: name,
151
+ noOptionsMessage: resolveNoOptionsMessage(validationMessage),
205
152
  options: selectOptions,
206
153
  placeholder: placeholder,
207
154
  selectRef: selectInputRef,
@@ -210,7 +157,8 @@ export const SelectDropdown = ({
210
157
  styles: memoizedStyles,
211
158
  value: multiple ? multiValues : parsedValue,
212
159
  onChange: changeHandler,
213
- onKeyDown: multiple ? e => keyPressHandler(e) : undefined,
160
+ onInputChange: onInputChange,
161
+ onKeyDown: multiple ? keyPressHandler : undefined,
214
162
  ...rest
215
163
  })
216
164
  });
@@ -0,0 +1,3 @@
1
+ import { AriaOnFocus } from 'react-select';
2
+ import { ExtendedOption } from '../types';
3
+ export declare const onFocus: AriaOnFocus<ExtendedOption>;
@@ -0,0 +1,12 @@
1
+ export const onFocus = ({
2
+ focused: {
3
+ label,
4
+ subtitle,
5
+ rightLabel,
6
+ disabled
7
+ }
8
+ }) => {
9
+ const formattedSubtitle = `, ${subtitle}`;
10
+ const formattedRightLabel = `, ${rightLabel}`;
11
+ return `You are currently focused on option ${label}${subtitle ? formattedSubtitle : ''} ${rightLabel ? formattedRightLabel : ''}${disabled ? ', disabled' : ''}`;
12
+ };
@@ -0,0 +1,13 @@
1
+ export declare const defaultComponents: {
2
+ DropdownIndicator: (props: import("../types").SizedIndicatorProps) => import("react/jsx-runtime").JSX.Element;
3
+ IndicatorSeparator: () => null;
4
+ ClearIndicator: (props: import("../types").SizedIndicatorProps) => import("react/jsx-runtime").JSX.Element;
5
+ SelectContainer: ({ children, ...rest }: import("../types").CustomSelectComponentProps<(<Option_18, IsMulti_18 extends boolean, Group_18 extends import("react-select").GroupBase<Option_18>>(props: import("react-select").ContainerProps<Option_18, IsMulti_18, Group_18>) => import("@emotion/react").jsx.JSX.Element)>) => import("react/jsx-runtime").JSX.Element;
6
+ ValueContainer: ({ ...rest }: import("../types").CustomSelectComponentProps<(<Option_20, IsMulti_20 extends boolean, Group_20 extends import("react-select").GroupBase<Option_20>>(props: import("react-select").ValueContainerProps<Option_20, IsMulti_20, Group_20>) => import("@emotion/react").jsx.JSX.Element)>) => import("react/jsx-runtime").JSX.Element;
7
+ MultiValue: (props: import("react-select").MultiValueProps<import("..").ExtendedOption, true, import("react-select").GroupBase<import("..").ExtendedOption>>) => import("react/jsx-runtime").JSX.Element;
8
+ MultiValueRemove: (props: import("react-select").MultiValueRemoveProps<import("..").ExtendedOption, true, import("react-select").GroupBase<import("..").ExtendedOption>>) => import("react/jsx-runtime").JSX.Element;
9
+ Option: ({ children, ...rest }: import("../types").CustomSelectComponentProps<(<Option_16, IsMulti_16 extends boolean, Group_16 extends import("react-select").GroupBase<Option_16>>(props: import("react-select").OptionProps<Option_16, IsMulti_16, Group_16>) => import("@emotion/react").jsx.JSX.Element)>) => import("react/jsx-runtime").JSX.Element;
10
+ SingleValue: (props: import("react-select").SingleValueProps<import("..").ExtendedOption, false>) => import("react/jsx-runtime").JSX.Element;
11
+ Input: ({ ...rest }: import("../types").CustomSelectComponentProps<(<Option_7, IsMulti_7 extends boolean, Group_7 extends import("react-select").GroupBase<Option_7>>(props: import("react-select").InputProps<Option_7, IsMulti_7, Group_7>) => import("@emotion/react").jsx.JSX.Element)>) => import("react/jsx-runtime").JSX.Element;
12
+ };
13
+ export declare const ON_CHANGE_ACTION: "select-option";
@@ -0,0 +1,14 @@
1
+ import { AbbreviatedSingleValue, CustomContainer, CustomInput, CustomValueContainer, DropdownButton, IconOption, MultiValueRemoveButton, MultiValueWithColorMode, RemoveAllButton } from '../elements';
2
+ export const defaultComponents = {
3
+ DropdownIndicator: DropdownButton,
4
+ IndicatorSeparator: () => null,
5
+ ClearIndicator: RemoveAllButton,
6
+ SelectContainer: CustomContainer,
7
+ ValueContainer: CustomValueContainer,
8
+ MultiValue: MultiValueWithColorMode,
9
+ MultiValueRemove: MultiValueRemoveButton,
10
+ Option: IconOption,
11
+ SingleValue: AbbreviatedSingleValue,
12
+ Input: CustomInput
13
+ };
14
+ export const ON_CHANGE_ACTION = 'select-option';
@@ -1,6 +1,6 @@
1
1
  import { css, states, variant } from '@codecademy/gamut-styles';
2
- import { dismissSharedStyles, tagBaseStyles, tagLabelFontSize, tagLabelPadding } from '../../Tag/styles';
3
- import { formBaseComponentStyles, formBaseFieldStylesObject, formFieldDisabledStyles, formFieldPaddingStyles, InputSelectors } from '../styles';
2
+ import { dismissSharedStyles, tagBaseStyles, tagLabelFontSize, tagLabelPadding } from '../../../Tag/styles';
3
+ import { formBaseComponentStyles, formBaseFieldStylesObject, formFieldDisabledStyles, formFieldPaddingStyles, InputSelectors } from '../../styles';
4
4
  const selectDropdownStyles = css({
5
5
  ...formBaseFieldStylesObject,
6
6
  display: 'flex',
@@ -62,7 +62,7 @@ const textColor = css({
62
62
  color: 'text'
63
63
  });
64
64
  const placeholderColor = css({
65
- color: 'text-disabled'
65
+ color: 'text-secondary'
66
66
  });
67
67
  export const getMemoizedStyles = (theme, zIndex) => {
68
68
  return {
@@ -137,6 +137,8 @@ export const getMemoizedStyles = (theme, zIndex) => {
137
137
  error: state.selectProps.error,
138
138
  theme
139
139
  }),
140
+ // Drop react-select's default menu drop shadow; the border above defines the edge.
141
+ boxShadow: 'none',
140
142
  ...(dropdownWidth ? {
141
143
  minWidth: dropdownWidth,
142
144
  width: dropdownWidth
@@ -194,16 +196,32 @@ export const getMemoizedStyles = (theme, zIndex) => {
194
196
  backgroundColor: theme.colors['secondary-hover']
195
197
  }
196
198
  }),
197
- option: (provided, state) => ({
198
- ...getOptionBackground(state.isSelected, state.isFocused)({
199
- theme
200
- }),
201
- alignItems: 'center',
202
- color: state.isDisabled ? 'text-disabled' : 'default',
203
- cursor: state.isDisabled ? 'not-allowed' : 'pointer',
204
- display: 'flex',
205
- padding: state.selectProps.size === 'small' ? '3px 14px' : '11px 14px'
199
+ noOptionsMessage: provided => ({
200
+ ...provided,
201
+ color: theme.colors['text-secondary']
206
202
  }),
203
+ option: (provided, state) => {
204
+ const isNew = state.data?.__isNew__;
205
+ const isSmall = state.selectProps.size === 'small';
206
+ return {
207
+ ...getOptionBackground(state.isSelected, state.isFocused)({
208
+ theme
209
+ }),
210
+ alignItems: 'center',
211
+ color: state.isDisabled ? theme.colors['text-disabled'] : isNew ? theme.colors.primary : theme.colors.text,
212
+ cursor: state.isDisabled ? 'not-allowed' : 'pointer',
213
+ display: 'flex',
214
+ padding: isSmall ? '3px 14px' : '11px 14px',
215
+ ...(isNew && {
216
+ // Gradient creates the 1px divider line at the top edge of the option background
217
+ backgroundImage: `linear-gradient(${theme.colors['text-disabled']} 1px, transparent 1px)`,
218
+ backgroundPosition: '0 0',
219
+ backgroundRepeat: 'no-repeat',
220
+ backgroundSize: '100% 1px',
221
+ paddingTop: isSmall ? '11px' : '19px'
222
+ })
223
+ };
224
+ },
207
225
  placeholder: provided => ({
208
226
  ...provided,
209
227
  ...placeholderColor({
@@ -1,7 +1,14 @@
1
- import { SelectOptionBase } from '../utils';
2
- import { BaseOnChangeProps, ExtendedOption, MultiSelectDropdownProps, SelectDropdownGroup, SelectDropdownOptions, SelectDropdownProps, SingleSelectDropdownProps } from './types';
1
+ import * as React from 'react';
2
+ import { ActionMeta, Options as OptionsType } from 'react-select';
3
+ import { SelectOptionBase } from '../../utils';
4
+ import { BaseOnChangeProps, ExtendedOption, MultiSelectDropdownProps, OptionStrict, SelectDropdownGroup, SelectDropdownOptions, SelectDropdownProps, SingleSelectDropdownProps } from '../types';
3
5
  export declare const isMultipleSelectProps: (props: BaseOnChangeProps) => props is MultiSelectDropdownProps;
4
6
  export declare const isSingleSelectProps: (props: BaseOnChangeProps) => props is SingleSelectDropdownProps;
7
+ /**
8
+ * Resolves the value for a newly created option from react-select action metadata
9
+ * or the onChange option payload. Returns undefined when no reliable value exists.
10
+ */
11
+ export declare const getCreatedOptionValue: (optionEvent: OptionStrict | OptionsType<OptionStrict>, actionMeta: ActionMeta<OptionStrict>, multiple?: boolean) => string | undefined;
5
12
  export declare const isOptionGroup: (obj: unknown) => obj is SelectDropdownGroup;
6
13
  export declare const isOptionsGrouped: (options: SelectDropdownOptions) => options is SelectDropdownGroup[];
7
14
  /**
@@ -23,4 +30,7 @@ export declare const filterValueFromOptions: (options: SelectOptionBase[] | Sele
23
30
  * @param value - The value or values to remove
24
31
  * @returns New array with the specified values removed
25
32
  */
33
+ export declare const resolveNoOptionsMessage: (validationMessage: SelectDropdownProps["validationMessage"]) => ((obj: {
34
+ inputValue: string;
35
+ }) => React.ReactNode) | undefined;
26
36
  export declare const removeValueFromSelectedOptions: (selectedOptions: ExtendedOption[] | SelectOptionBase[], value: SelectDropdownProps["value"]) => SelectOptionBase[];
@@ -1,5 +1,21 @@
1
1
  export const isMultipleSelectProps = props => !!props.multiple;
2
2
  export const isSingleSelectProps = props => !props.multiple;
3
+ /**
4
+ * Resolves the value for a newly created option from react-select action metadata
5
+ * or the onChange option payload. Returns undefined when no reliable value exists.
6
+ */
7
+ export const getCreatedOptionValue = (optionEvent, actionMeta, multiple) => {
8
+ const metaValue = actionMeta.option?.value;
9
+ if (metaValue) return metaValue;
10
+ if (!multiple) {
11
+ const {
12
+ value
13
+ } = optionEvent;
14
+ return value || undefined;
15
+ }
16
+ const newOption = optionEvent.find(option => option.__isNew__);
17
+ return newOption?.value || undefined;
18
+ };
3
19
  export const isOptionGroup = obj => obj != null && typeof obj === 'object' && 'options' in obj && obj.options !== undefined;
4
20
  export const isOptionsGrouped = options => Array.isArray(options) && options.some(option => isOptionGroup(option));
5
21
 
@@ -28,6 +44,13 @@ export const filterValueFromOptions = (options, value, optionsAreGrouped) => {
28
44
  * @param value - The value or values to remove
29
45
  * @returns New array with the specified values removed
30
46
  */
47
+ export const resolveNoOptionsMessage = validationMessage => {
48
+ if (validationMessage === undefined) return undefined;
49
+ if (typeof validationMessage === 'function') {
50
+ return validationMessage;
51
+ }
52
+ return () => validationMessage;
53
+ };
31
54
  export const removeValueFromSelectedOptions = (selectedOptions, value) => {
32
55
  return selectedOptions.filter(option => {
33
56
  if (Array.isArray(value)) {