@codecademy/gamut
72.2.272.2.3-alpha.f0a032.0
−
Removed (4 files)
+
Added (13 files)
~
Modified (18 files)
Index: package/dist/Form/SelectDropdown/elements/constants.js
===================================================================
--- package/dist/Form/SelectDropdown/elements/constants.js
+++ package/dist/Form/SelectDropdown/elements/constants.js
@@ -1,5 +1,5 @@
-import { ArrowChevronDownIcon, CloseIcon, MiniChevronDownIcon, MiniDeleteIcon, SearchIcon } from '@codecademy/gamut-icons';
+import { ArrowChevronDownIcon, CloseIcon, MiniChevronDownIcon, MiniDeleteIcon } from '@codecademy/gamut-icons';
export const iconSize = {
small: 12,
medium: 16
};
@@ -15,16 +15,8 @@
mediumChevron: {
size: iconSize.medium,
icon: ArrowChevronDownIcon
},
- smallSearchable: {
- size: iconSize.small,
- icon: SearchIcon
- },
- mediumSearchable: {
- size: iconSize.medium,
- icon: SearchIcon
- },
smallRemove: {
size: iconSize.small,
icon: MiniDeleteIcon
}, Index: package/dist/Form/SelectDropdown/elements/containers.js
===================================================================
--- package/dist/Form/SelectDropdown/elements/containers.js
+++ package/dist/Form/SelectDropdown/elements/containers.js
@@ -1,6 +1,7 @@
import { createContext, useLayoutEffect } from 'react';
import ReactSelect, { components as SelectDropdownElements } from 'react-select';
+import CreatableSelect from 'react-select/creatable';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/**
* React context for sharing state between SelectDropdown components.
* Provides access to focus state and refs for keyboard navigation.
@@ -115,14 +116,29 @@
};
/**
* 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({
selectRef,
+ isCreatable,
+ formatCreateLabel,
+ isValidNewOption,
...props
}) {
+ if (isCreatable) {
+ return /*#__PURE__*/_jsx(CreatableSelect, {
+ ...props,
+ formatCreateLabel: formatCreateLabel,
+ isValidNewOption: isValidNewOption,
+ ref: selectRef
+ });
+ }
return /*#__PURE__*/_jsx(ReactSelect, {
...props,
ref: selectRef
}); Index: package/dist/Form/SelectDropdown/elements/controls.js
===================================================================
--- package/dist/Form/SelectDropdown/elements/controls.js
+++ package/dist/Form/SelectDropdown/elements/controls.js
@@ -1,51 +1,24 @@
-import _styled from "@emotion/styled/base";
-import { css, theme } from '@codecademy/gamut-styles';
-import { useContext } from 'react';
import { components as SelectDropdownElements } from 'react-select';
import { indicatorIcons } from './constants';
-import { SelectDropdownContext } from './containers';
import { jsx as _jsx } from "react/jsx-runtime";
const {
DropdownIndicator
} = SelectDropdownElements;
/**
- * Generates accessible focus messages for screen readers.
- * Provides detailed information about the currently focused option.
- *
- * @param params - Object containing the focused option details
- * @returns Formatted accessibility message
- */
-export const onFocus = ({
- focused: {
- label,
- subtitle,
- rightLabel,
- disabled
- }
-}) => {
- const formattedSubtitle = `, ${subtitle}`;
- const formattedRightLabel = `, ${rightLabel}`;
- const msg = `You are currently focused on option ${label}${subtitle ? formattedSubtitle : ''} ${rightLabel ? formattedRightLabel : ''}${disabled ? ', disabled' : ''}`;
- return msg;
-};
-
-/**
* Custom dropdown indicator that shows either a chevron or search icon.
* The icon type depends on whether the select is searchable or not.
*/
export const DropdownButton = props => {
const {
- size,
- isSearchable
+ size
} = props.selectProps;
const color = props.isDisabled ? 'text-disabled' : 'text';
const iconSize = size ?? 'medium';
- const iconType = isSearchable ? 'Searchable' : 'Chevron';
const {
...iconProps
- } = indicatorIcons[`${iconSize}${iconType}`];
+ } = indicatorIcons[`${iconSize}Chevron`];
const {
icon: IndicatorIcon
} = iconProps;
return /*#__PURE__*/_jsx(DropdownIndicator, {
@@ -54,67 +27,5 @@
...iconProps,
color: color
})
});
-};
-const CustomStyledRemoveAllDiv = /*#__PURE__*/_styled('div', {
- target: "e1xkmr70",
- label: "CustomStyledRemoveAllDiv"
-})(css({
- '&:focus': {
- outline: `2px solid ${theme.colors.primary}`
- },
- '&:focus-visible': {
- outline: `2px solid ${theme.colors.primary}`
- }
-}), process.env.NODE_ENV === "production" ? "" : "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NyYy9Gb3JtL1NlbGVjdERyb3Bkb3duL2VsZW1lbnRzL2NvbnRyb2xzLnRzeCJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFxRGlDIiwiZmlsZSI6Ii4uLy4uLy4uLy4uL3NyYy9Gb3JtL1NlbGVjdERyb3Bkb3duL2VsZW1lbnRzL2NvbnRyb2xzLnRzeCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGNzcywgdGhlbWUgfSBmcm9tICdAY29kZWNhZGVteS9nYW11dC1zdHlsZXMnO1xuaW1wb3J0IHN0eWxlZCBmcm9tICdAZW1vdGlvbi9zdHlsZWQnO1xuaW1wb3J0IHsgS2V5Ym9hcmRFdmVudCwgdXNlQ29udGV4dCB9IGZyb20gJ3JlYWN0JztcbmltcG9ydCB7XG4gIEFyaWFPbkZvY3VzLFxuICBjb21wb25lbnRzIGFzIFNlbGVjdERyb3Bkb3duRWxlbWVudHMsXG59IGZyb20gJ3JlYWN0LXNlbGVjdCc7XG5cbmltcG9ydCB7IEV4dGVuZGVkT3B0aW9uLCBTaXplZEluZGljYXRvclByb3BzIH0gZnJvbSAnLi4vdHlwZXMnO1xuaW1wb3J0IHsgaW5kaWNhdG9ySWNvbnMgfSBmcm9tICcuL2NvbnN0YW50cyc7XG5pbXBvcnQgeyBTZWxlY3REcm9wZG93bkNvbnRleHQgfSBmcm9tICcuL2NvbnRhaW5lcnMnO1xuXG5jb25zdCB7IERyb3Bkb3duSW5kaWNhdG9yIH0gPSBTZWxlY3REcm9wZG93bkVsZW1lbnRzO1xuXG4vKipcbiAqIEdlbmVyYXRlcyBhY2Nlc3NpYmxlIGZvY3VzIG1lc3NhZ2VzIGZvciBzY3JlZW4gcmVhZGVycy5cbiAqIFByb3ZpZGVzIGRldGFpbGVkIGluZm9ybWF0aW9uIGFib3V0IHRoZSBjdXJyZW50bHkgZm9jdXNlZCBvcHRpb24uXG4gKlxuICogQHBhcmFtIHBhcmFtcyAtIE9iamVjdCBjb250YWluaW5nIHRoZSBmb2N1c2VkIG9wdGlvbiBkZXRhaWxzXG4gKiBAcmV0dXJucyBGb3JtYXR0ZWQgYWNjZXNzaWJpbGl0eSBtZXNzYWdlXG4gKi9cbmV4cG9ydCBjb25zdCBvbkZvY3VzOiBBcmlhT25Gb2N1czxFeHRlbmRlZE9wdGlvbj4gPSAoe1xuICBmb2N1c2VkOiB7IGxhYmVsLCBzdWJ0aXRsZSwgcmlnaHRMYWJlbCwgZGlzYWJsZWQgfSxcbn0pID0+IHtcbiAgY29uc3QgZm9ybWF0dGVkU3VidGl0bGUgPSBgLCAke3N1YnRpdGxlfWA7XG4gIGNvbnN0IGZvcm1hdHRlZFJpZ2h0TGFiZWwgPSBgLCAke3JpZ2h0TGFiZWx9YDtcblxuICBjb25zdCBtc2cgPSBgWW91IGFyZSBjdXJyZW50bHkgZm9jdXNlZCBvbiBvcHRpb24gJHtsYWJlbH0ke1xuICAgIHN1YnRpdGxlID8gZm9ybWF0dGVkU3VidGl0bGUgOiAnJ1xuICB9ICR7cmlnaHRMYWJlbCA/IGZvcm1hdHRlZFJpZ2h0TGFiZWwgOiAnJ30ke2Rpc2FibGVkID8gJywgZGlzYWJsZWQnIDogJyd9YDtcblxuICByZXR1cm4gbXNnO1xufTtcblxuLyoqXG4gKiBDdXN0b20gZHJvcGRvd24gaW5kaWNhdG9yIHRoYXQgc2hvd3MgZWl0aGVyIGEgY2hldnJvbiBvciBzZWFyY2ggaWNvbi5cbiAqIFRoZSBpY29uIHR5cGUgZGVwZW5kcyBvbiB3aGV0aGVyIHRoZSBzZWxlY3QgaXMgc2VhcmNoYWJsZSBvciBub3QuXG4gKi9cbmV4cG9ydCBjb25zdCBEcm9wZG93bkJ1dHRvbiA9IChwcm9wczogU2l6ZWRJbmRpY2F0b3JQcm9wcykgPT4ge1xuICBjb25zdCB7IHNpemUsIGlzU2VhcmNoYWJsZSB9ID0gcHJvcHMuc2VsZWN0UHJvcHM7XG4gIGNvbnN0IGNvbG9yID0gcHJvcHMuaXNEaXNhYmxlZCA/ICd0ZXh0LWRpc2FibGVkJyA6ICd0ZXh0JztcbiAgY29uc3QgaWNvblNpemUgPSBzaXplID8/ICdtZWRpdW0nO1xuICBjb25zdCBpY29uVHlwZSA9IGlzU2VhcmNoYWJsZSA/ICdTZWFyY2hhYmxlJyA6ICdDaGV2cm9uJztcbiAgY29uc3QgeyAuLi5pY29uUHJvcHMgfSA9IGluZGljYXRvckljb25zW2Ake2ljb25TaXplfSR7aWNvblR5cGV9YF07XG4gIGNvbnN0IHsgaWNvbjogSW5kaWNhdG9ySWNvbiB9ID0gaWNvblByb3BzO1xuXG4gIHJldHVybiAoXG4gICAgPERyb3Bkb3duSW5kaWNhdG9yIHsuLi5wcm9wc30+XG4gICAgICA8SW5kaWNhdG9ySWNvbiB7Li4uaWNvblByb3BzfSBjb2xvcj17Y29sb3J9IC8+XG4gICAgPC9Ecm9wZG93bkluZGljYXRvcj5cbiAgKTtcbn07XG5cbmNvbnN0IEN1c3RvbVN0eWxlZFJlbW92ZUFsbERpdiA9IHN0eWxlZCgnZGl2JykoXG4gIGNzcyh7XG4gICAgJyY6Zm9jdXMnOiB7XG4gICAgICBvdXRsaW5lOiBgMnB4IHNvbGlkICR7dGhlbWUuY29sb3JzLnByaW1hcnl9YCxcbiAgICB9LFxuICAgICcmOmZvY3VzLXZpc2libGUnOiB7XG4gICAgICBvdXRsaW5lOiBgMnB4IHNvbGlkICR7dGhlbWUuY29sb3JzLnByaW1hcnl9YCxcbiAgICB9LFxuICB9KVxuKTtcblxuLyoqXG4gKiBDdXN0b20gcmVtb3ZlIGFsbCBidXR0b24gZm9yIG11bHRpLXNlbGVjdCBtb2RlLlxuICogUHJvdmlkZXMga2V5Ym9hcmQgbmF2aWdhdGlvbiBhbmQgYWNjZXNzaWJsZSByZW1vdmFsIG9mIGFsbCBzZWxlY3RlZCB2YWx1ZXMuXG4gKi9cbmV4cG9ydCBjb25zdCBSZW1vdmVBbGxCdXR0b24gPSAocHJvcHM6IFNpemVkSW5kaWNhdG9yUHJvcHMpID0+IHtcbiAgY29uc3Qge1xuICAgIGdldFN0eWxlcyxcbiAgICBpbm5lclByb3BzOiB7IC4uLnJlc3RJbm5lclByb3BzIH0sXG4gICAgc2VsZWN0UHJvcHM6IHsgc2l6ZSB9LFxuICB9ID0gcHJvcHM7XG5cbiAgY29uc3QgeyByZW1vdmVBbGxCdXR0b25SZWYsIHNlbGVjdElucHV0UmVmIH0gPSB1c2VDb250ZXh0KFxuICAgIFNlbGVjdERyb3Bkb3duQ29udGV4dFxuICApO1xuXG4gIGNvbnN0IGljb25TaXplID0gc2l6ZSA/PyAnbWVkaXVtJztcbiAgY29uc3QgeyAuLi5pY29uUHJvcHMgfSA9IGluZGljYXRvckljb25zW2Ake2ljb25TaXplfVJlbW92ZWBdO1xuICBjb25zdCB7IGljb246IEluZGljYXRvckljb24gfSA9IGljb25Qcm9wcztcblxuICBjb25zdCBvbktleVByZXNzID0gKGU6IEtleWJvYXJkRXZlbnQ8SFRNTERpdkVsZW1lbnQ+KSA9PiB7XG4gICAgaWYgKGUua2V5ID09PSAnRW50ZXInICYmIHJlc3RJbm5lclByb3BzLm9uTW91c2VEb3duKSB7XG4gICAgICByZXN0SW5uZXJQcm9wcy5vbk1vdXNlRG93bihlIGFzIGFueSk7XG4gICAgfVxuXG4gICAgaWYgKFxuICAgICAgc2VsZWN0SW5wdXRSZWY/LmN1cnJlbnQgJiZcbiAgICAgIChlLmtleSA9PT0gJ0Fycm93UmlnaHQnIHx8IGUua2V5ID09PSAnQXJyb3dMZWZ0JyB8fCBlLmtleSA9PT0gJ0Fycm93RG93bicpXG4gICAgKSB7XG4gICAgICBzZWxlY3RJbnB1dFJlZj8uY3VycmVudC5mb2N1cygpO1xuICAgIH1cbiAgfTtcblxuICBjb25zdCBzdHlsZSA9IGdldFN0eWxlcygnY2xlYXJJbmRpY2F0b3InLCBwcm9wcykgYXMgUmVhY3QuQ1NTUHJvcGVydGllcztcblxuICByZXR1cm4gKFxuICAgIDxDdXN0b21TdHlsZWRSZW1vdmVBbGxEaXZcbiAgICAgIGFyaWEtbGFiZWw9XCJSZW1vdmUgYWxsIHNlbGVjdGVkXCJcbiAgICAgIHJvbGU9XCJidXR0b25cIlxuICAgICAgdGFiSW5kZXg9ezB9XG4gICAgICB7Li4ucmVzdElubmVyUHJvcHN9XG4gICAgICByZWY9e3JlbW92ZUFsbEJ1dHRvblJlZn1cbiAgICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBnYW11dC9uby1pbmxpbmUtc3R5bGVcbiAgICAgIHN0eWxlPXtzdHlsZX1cbiAgICAgIG9uS2V5RG93bj17b25LZXlQcmVzc31cbiAgICA+XG4gICAgICA8SW5kaWNhdG9ySWNvbiB7Li4uaWNvblByb3BzfSBjb2xvcj1cInRleHRcIiAvPlxuICAgIDwvQ3VzdG9tU3R5bGVkUmVtb3ZlQWxsRGl2PlxuICApO1xufTtcbiJdfQ== */");
-
-/**
- * Custom remove all button for multi-select mode.
- * Provides keyboard navigation and accessible removal of all selected values.
- */
-export const RemoveAllButton = props => {
- const {
- getStyles,
- innerProps: {
- ...restInnerProps
- },
- selectProps: {
- size
- }
- } = props;
- const {
- removeAllButtonRef,
- selectInputRef
- } = useContext(SelectDropdownContext);
- const iconSize = size ?? 'medium';
- const {
- ...iconProps
- } = indicatorIcons[`${iconSize}Remove`];
- const {
- icon: IndicatorIcon
- } = iconProps;
- const onKeyPress = e => {
- if (e.key === 'Enter' && restInnerProps.onMouseDown) {
- restInnerProps.onMouseDown(e);
- }
- if (selectInputRef?.current && (e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'ArrowDown')) {
- selectInputRef?.current.focus();
- }
- };
- const style = getStyles('clearIndicator', props);
- return /*#__PURE__*/_jsx(CustomStyledRemoveAllDiv, {
- "aria-label": "Remove all selected",
- role: "button",
- tabIndex: 0,
- ...restInnerProps,
- ref: removeAllButtonRef
- // eslint-disable-next-line gamut/no-inline-style
- ,
- style: style,
- onKeyDown: onKeyPress,
- children: /*#__PURE__*/_jsx(IndicatorIcon, {
- ...iconProps,
- color: "text"
- })
- });
};
\ No newline at end of file Index: package/dist/Form/SelectDropdown/elements/index.js
===================================================================
--- package/dist/Form/SelectDropdown/elements/index.js
+++ package/dist/Form/SelectDropdown/elements/index.js
@@ -1,5 +1,6 @@
export { iconSize, selectedIconSize, indicatorIcons } from './constants';
export { MultiValueWithColorMode, MultiValueRemoveButton, RemoveAllButton } from './multi-value';
-export { DropdownButton, onFocus } from './controls';
+export { DropdownButton } from './controls';
+export { onFocus } from '../core/accessibility';
export { SelectDropdownContext, CustomContainer, CustomInput, CustomValueContainer, TypedReactSelect } from './containers';
export { IconOption, AbbreviatedSingleValue, formatOptionLabel, formatGroupLabel } from './options';
\ No newline at end of file Index: package/dist/Form/styles/index.js
===================================================================
--- package/dist/Form/styles/index.js
+++ package/dist/Form/styles/index.js
@@ -1,4 +1,4 @@
export * from './shared-system-props';
export * from './Checkbox-styles';
export * from './Radio-styles';
-export * from '../SelectDropdown/styles';
\ No newline at end of file
+export * from '../SelectDropdown/core/styles';
\ No newline at end of file Index: package/dist/Form/SelectDropdown/elements/options.js
===================================================================
--- package/dist/Form/SelectDropdown/elements/options.js
+++ package/dist/Form/SelectDropdown/elements/options.js
@@ -43,8 +43,9 @@
/**
* Custom option component that displays a check icon for selected items.
* Also manages ARIA attributes for accessibility.
+ * Skips the check icon for react-select/creatable's "Add" row (__isNew__).
*/
export const IconOption = ({
children,
...rest
@@ -53,17 +54,19 @@
size
} = rest.selectProps;
const {
isFocused,
- innerProps
+ innerProps,
+ data
} = rest;
+ const isNew = data?.__isNew__;
return /*#__PURE__*/_jsxs(SelectDropdownElements.Option, {
...rest,
innerProps: {
...innerProps,
'aria-selected': isFocused
},
- children: [children, rest?.isSelected && /*#__PURE__*/_jsx(CheckIcon, {
+ children: [children, !isNew && rest?.isSelected && /*#__PURE__*/_jsx(CheckIcon, {
size: selectedIconSize[size ?? 'medium']
})]
});
}; Index: package/dist/Form/SelectDropdown/SelectDropdown.js
===================================================================
--- package/dist/Form/SelectDropdown/SelectDropdown.js
+++ package/dist/Form/SelectDropdown/SelectDropdown.js
@@ -1,29 +1,15 @@
import { useTheme } from '@emotion/react';
-import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { useId, useMemo, useRef, useState } from 'react';
import * as React from 'react';
-import { parseOptions } from '../utils';
-import { AbbreviatedSingleValue, CustomContainer, CustomInput, CustomValueContainer, DropdownButton, formatGroupLabel, formatOptionLabel, IconOption, MultiValueRemoveButton, MultiValueWithColorMode, onFocus, RemoveAllButton, SelectDropdownContext, TypedReactSelect } from './elements';
-import { getMemoizedStyles } from './styles';
-import { filterValueFromOptions, isMultipleSelectProps, isOptionsGrouped, isSingleSelectProps, removeValueFromSelectedOptions } from './utils';
+import { onFocus } from './core/accessibility';
+import { defaultComponents } from './core/constants';
+import { getMemoizedStyles } from './core/styles';
+import { resolveNoOptionsMessage } from './core/utils';
+import { formatGroupLabel, formatOptionLabel, SelectDropdownContext, TypedReactSelect } from './elements';
+import { useSelectHandlers } from './hooks/useSelectHandlers';
+import { useSelectOptions } from './hooks/useSelectOptions';
import { jsx as _jsx } from "react/jsx-runtime";
-const defaultProps = {
- name: undefined,
- components: {
- DropdownIndicator: DropdownButton,
- IndicatorSeparator: () => null,
- ClearIndicator: RemoveAllButton,
- SelectContainer: CustomContainer,
- ValueContainer: CustomValueContainer,
- MultiValue: MultiValueWithColorMode,
- MultiValueRemove: MultiValueRemoveButton,
- Option: IconOption,
- SingleValue: AbbreviatedSingleValue,
- Input: CustomInput
- }
-};
-const onChangeAction = 'select-option';
-
/**
* A flexible dropdown select component built on top of react-select.
*
* Supports both single and multi-select modes with customizable options including
@@ -72,103 +58,60 @@
export const SelectDropdown = ({
disabled,
dropdownWidth,
error,
+ formatCreateLabel = inputValue => `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}`;
- const [activated, setActivated] = useState(false);
- const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined);
-
- // these are used to programatically manage the focus state of our multi-select options + 'Remove all' button
const removeAllButtonRef = useRef(null);
const selectInputRef = useRef(null);
- const selectOptions = useMemo(() => {
- if (!options || Array.isArray(options) && !options.length || typeof options === 'object' && !Array.isArray(options) && Object.keys(options).length === 0) {
- return [];
- }
- if (isOptionsGrouped(options)) {
- return options;
- }
- return parseOptions({
- options,
- id,
- size
- });
- }, [options, id, size]);
- const parsedValue = useMemo(() => {
- if (isOptionsGrouped(selectOptions)) {
- for (const group of selectOptions) {
- if (group.options) {
- const foundOption = group.options.find(option => option.value === value);
- if (foundOption) return foundOption;
- }
- }
- return undefined;
- }
- return selectOptions.find(option => option.value === value);
- }, [selectOptions, value]);
- const [multiValues, setMultiValues] = useState(multiple &&
- // To keep this efficient for non-multiSelect
- filterValueFromOptions(selectOptions, value, isOptionsGrouped(selectOptions)));
-
- // If the caller changes the initial value, let's update our value to match.
- useEffect(() => {
- 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]);
- const changeHandler = useCallback(optionEvent => {
- 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
- const onChangeProps = {
- onChange,
- multiple
- };
- if (isSingleSelectProps(onChangeProps)) {
- const singleOptionEvent = optionEvent;
- onChangeProps.onChange?.(singleOptionEvent, {
- action: onChangeAction,
- option: singleOptionEvent
- });
- }
- if (isMultipleSelectProps(onChangeProps)) {
- setMultiValues(optionEvent);
- onChangeProps.onChange?.(optionEvent, {
- 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)
- });
- }
- }, [onChange, multiple]);
- const keyPressHandler = e => {
- if (multiple && e.key === 'Enter' && currentFocusedValue && multiValues) {
- const newMultiValues = removeValueFromSelectedOptions(multiValues, currentFocusedValue);
- if (newMultiValues !== multiValues) setMultiValues(newMultiValues);
- }
- if (removeAllButtonRef.current !== null && e.key === 'ArrowRight' && multiValues && currentFocusedValue === multiValues[multiValues.length - 1].value) {
- removeAllButtonRef.current.focus();
- }
- };
+ const [currentFocusedValue, setCurrentFocusedValue] = useState(undefined);
+ const {
+ selectOptions,
+ parsedValue
+ } = useSelectOptions({
+ options,
+ id,
+ size,
+ value: value
+ });
+ const {
+ activated,
+ multiValues,
+ changeHandler,
+ keyPressHandler
+ } = useSelectHandlers({
+ onChange,
+ multiple,
+ onCreateOption,
+ selectOptions,
+ value,
+ currentFocusedValue,
+ removeAllButtonRef
+ });
const theme = useTheme();
const memoizedStyles = useMemo(() => {
return getMemoizedStyles(theme, zIndex);
}, [theme, zIndex]);
@@ -179,39 +122,44 @@
removeAllButtonRef,
selectInputRef
},
children: /*#__PURE__*/_jsx(TypedReactSelect, {
- ...defaultProps,
activated: activated,
"aria-live": "assertive",
ariaLiveMessages: {
onFocus
},
+ components: defaultComponents,
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: resolveNoOptionsMessage(validationMessage),
options: selectOptions,
placeholder: placeholder,
selectRef: selectInputRef,
shownOptionsLimit: shownOptionsLimit,
size: size,
styles: memoizedStyles,
value: multiple ? multiValues : parsedValue,
onChange: changeHandler,
- onKeyDown: multiple ? e => keyPressHandler(e) : undefined,
+ onInputChange: onInputChange,
+ onKeyDown: multiple ? keyPressHandler : undefined,
...rest
})
});
};
\ No newline at end of file Index: package/package.json
===================================================================
--- package/package.json
+++ package/package.json
@@ -1,16 +1,16 @@
{
"name": "@codecademy/gamut",
"description": "Styleguide & Component library for Codecademy",
- "version": "72.2.2",
+ "version": "72.2.3-alpha.f0a032.0",
"author": "Codecademy Engineering <[email protected]>",
"bin": "./bin/gamut.mjs",
"dependencies": {
- "@codecademy/gamut-icons": "9.57.9",
- "@codecademy/gamut-illustrations": "0.58.15",
- "@codecademy/gamut-patterns": "0.10.34",
- "@codecademy/gamut-styles": "20.0.2",
- "@codecademy/variance": "0.26.1",
+ "@codecademy/gamut-icons": "9.57.10-alpha.f0a032.0",
+ "@codecademy/gamut-illustrations": "0.58.16-alpha.f0a032.0",
+ "@codecademy/gamut-patterns": "0.10.35-alpha.f0a032.0",
+ "@codecademy/gamut-styles": "20.0.3-alpha.f0a032.0",
+ "@codecademy/variance": "0.26.2-alpha.f0a032.0",
"@formatjs/intl-locale": "5.3.1",
"@react-aria/interactions": "3.25.0",
"@types/marked": "^4.0.8",
"@vidstack/react": "^1.12.12", Index: package/agent-tools/skills/gamut-forms/SKILL.md
===================================================================
--- package/agent-tools/skills/gamut-forms/SKILL.md
+++ package/agent-tools/skills/gamut-forms/SKILL.md
@@ -25,8 +25,24 @@
- Checkbox, Radio, Select: same pairing; checkbox/radio use the visually hidden input pattern from `@codecademy/gamut-styles` where applicable.
---
+## SelectDropdown vs Select
+
+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:
+
+- Styled dropdown menu (react-select appearance)
+- Search / typeahead
+- Multi-select with tags
+- Creatable options
+- Option icons, subtitles, right labels, abbreviations, or grouped options
+
+`SelectDropdown` carries a larger JS dependency (react-select); don't reach for it as a default drop-in for `Select`.
+
+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.
+
+---
+
## `FormGroup` (baseline)
`packages/gamut/src/Form/elements/FormGroup.tsx` Index: package/dist/Form/SelectDropdown/types/component-props.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/types/component-props.d.ts
+++ package/dist/Form/SelectDropdown/types/component-props.d.ts
@@ -1,6 +1,6 @@
import { Ref, SelectHTMLAttributes } from 'react';
-import { Props as NamedProps } from 'react-select';
+import { Options as OptionsType, Props as NamedProps } from 'react-select';
import { SelectComponentProps } from '../../inputs/Select';
import { OptionStrict, SelectDropdownGroup, SelectDropdownOptions } from './options';
import { ReactSelectAdditionalProps, SelectDropdownSizes, SharedProps } from './styles';
/**
@@ -27,9 +27,9 @@
/**
* Core props interface that defines the essential properties for SelectDropdown.
* This interface combines base props with react-select props and HTML select attributes.
*/
-export interface SelectDropdownCoreProps extends SelectDropdownBaseProps, Omit<NamedProps<OptionStrict, boolean>, 'formatOptionLabel' | 'isDisabled' | 'value' | 'options' | 'components' | 'styles' | 'theme' | 'onChange' | 'multiple'>, Pick<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'disabled' | 'onClick'>, SharedProps {
+export interface SelectDropdownCoreProps extends SelectDropdownBaseProps, Omit<NamedProps<OptionStrict, boolean>, 'formatOptionLabel' | 'isDisabled' | 'value' | 'options' | 'components' | 'styles' | 'theme' | 'onChange' | 'multiple' | 'isSearchable'>, Pick<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'disabled' | 'onClick'>, SharedProps {
/** Required name attribute for the select input */
name: string;
/** Placeholder text shown when no option is selected.
* Placeholder text is not recommended for accessibility. If you need to use placeholder text,
@@ -37,8 +37,41 @@
* I.e - if the placeholder text describes an action you'd like the user to take, please use a label instead. */
placeholder?: string;
/** Array of options or option groups to display in the dropdown */
options?: SelectDropdownOptions | SelectDropdownGroup[];
+ /**
+ * Allows users to create new options by typing a value not in the options list.
+ * When true, isSearchable is automatically set to true.
+ * Pair with onCreateOption to persist new options.
+ */
+ isCreatable?: boolean;
+ /**
+ * Called when the user confirms a new option via the "Add" row.
+ * Convenience callback for persisting the new value to your `options` list.
+ * Selection updates are delivered through `onChange` with `action: 'create-option'`.
+ */
+ onCreateOption?: (inputValue: string) => void;
+ /**
+ * Customises the label shown in the "Add" row.
+ * Defaults to: (inputValue) => `Add "${inputValue}"`.
+ */
+ formatCreateLabel?: (inputValue: string) => React.ReactNode;
+ /**
+ * Controls when the "Add" row is visible.
+ * Receives the current input, selected values, and all options.
+ * Defaults to react-select's built-in logic (hidden when input matches an existing option label).
+ * Use cases: minimum-length gating, pattern validation, case-insensitive dedup, max-items cap.
+ */
+ isValidNewOption?: (inputValue: string, value: OptionsType<OptionStrict>, options: OptionsType<OptionStrict>) => boolean;
+ /**
+ * Customizes the message shown inside the dropdown menu when no option matches
+ * the current input (react-select's "No options" state). Useful for surfacing
+ * validation/error text directly in the dropdown. Accepts a node, or a function
+ * receiving the current input value.
+ */
+ validationMessage?: React.ReactNode | ((obj: {
+ inputValue: string;
+ }) => React.ReactNode);
}
/**
* Props for single-select mode.
* When multiple is false or undefined, only one option can be selected.
@@ -59,12 +92,24 @@
/** Callback fired when the selected values change */
onChange?: NamedProps<OptionStrict, true>['onChange'];
}
/**
+ * Enforces that isSearchable cannot be false when isCreatable is true.
+ * Creatable mode requires the search input so users can type new option values.
+ */
+type CreatableConstraint = {
+ isCreatable?: false | undefined;
+ isSearchable?: boolean;
+} | {
+ isCreatable: true;
+ isSearchable?: true;
+};
+/**
* Union type for all SelectDropdown prop variants.
- * Supports both single and multi-select modes through discriminated union.
+ * Supports both single and multi-select modes through discriminated union,
+ * intersected with CreatableConstraint to enforce isSearchable compatibility.
*/
-export type SelectDropdownProps = SingleSelectDropdownProps | MultiSelectDropdownProps;
+export type SelectDropdownProps = (SingleSelectDropdownProps | MultiSelectDropdownProps) & CreatableConstraint;
/**
* Base interface for onChange-related props.
* Used internally for type checking and prop validation.
*/
@@ -75,10 +120,13 @@
onChange?: SingleSelectDropdownProps['onChange'] | MultiSelectDropdownProps['onChange'];
}
/**
* Props for the typed React Select component wrapper.
- * Extends ReactSelectAdditionalProps with an optional ref.
+ * Extends ReactSelectAdditionalProps with an optional ref and creatable flag.
*/
-export interface TypedReactSelectProps extends ReactSelectAdditionalProps {
+export interface TypedReactSelectProps extends ReactSelectAdditionalProps, Pick<SelectDropdownCoreProps, 'formatCreateLabel' | 'isValidNewOption'> {
/** Optional ref to the underlying react-select component */
selectRef?: Ref<any>;
+ /** When true, renders CreatableSelect instead of ReactSelect */
+ isCreatable?: boolean;
}
+export {}; Index: package/dist/Form/SelectDropdown/elements/constants.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/elements/constants.d.ts
+++ package/dist/Form/SelectDropdown/elements/constants.d.ts
@@ -14,16 +14,8 @@
mediumChevron: {
size: number;
icon: import("react").ForwardRefExoticComponent<import("@codecademy/gamut-icons").GamutIconProps & import("react").RefAttributes<SVGSVGElement>>;
};
- smallSearchable: {
- size: number;
- icon: import("react").ForwardRefExoticComponent<import("@codecademy/gamut-icons").GamutIconProps & import("react").RefAttributes<SVGSVGElement>>;
- };
- mediumSearchable: {
- size: number;
- icon: import("react").ForwardRefExoticComponent<import("@codecademy/gamut-icons").GamutIconProps & import("react").RefAttributes<SVGSVGElement>>;
- };
smallRemove: {
size: number;
icon: import("react").ForwardRefExoticComponent<import("@codecademy/gamut-icons").GamutIconProps & import("react").RefAttributes<SVGSVGElement>>;
}; Index: package/dist/Form/SelectDropdown/elements/containers.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/elements/containers.d.ts
+++ package/dist/Form/SelectDropdown/elements/containers.d.ts
@@ -23,7 +23,11 @@
*/
export declare const CustomInput: ({ ...rest }: CustomSelectComponentProps<typeof SelectDropdownElements.Input>) => import("react/jsx-runtime").JSX.Element;
/**
* 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 declare function TypedReactSelect<OptionType, IsMulti extends boolean = false, GroupType extends GroupBase<OptionType> = GroupBase<OptionType>>({ selectRef, ...props }: Props<OptionType, IsMulti, GroupType> & TypedReactSelectProps): import("react/jsx-runtime").JSX.Element;
+export declare function TypedReactSelect<OptionType, IsMulti extends boolean = false, GroupType extends GroupBase<OptionType> = GroupBase<OptionType>>({ selectRef, isCreatable, formatCreateLabel, isValidNewOption, ...props }: Props<OptionType, IsMulti, GroupType> & TypedReactSelectProps): import("react/jsx-runtime").JSX.Element; Index: package/dist/Form/SelectDropdown/elements/controls.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/elements/controls.d.ts
+++ package/dist/Form/SelectDropdown/elements/controls.d.ts
@@ -1,20 +1,6 @@
-import { AriaOnFocus } from 'react-select';
-import { ExtendedOption, SizedIndicatorProps } from '../types';
+import { SizedIndicatorProps } from '../types';
/**
- * Generates accessible focus messages for screen readers.
- * Provides detailed information about the currently focused option.
- *
- * @param params - Object containing the focused option details
- * @returns Formatted accessibility message
- */
-export declare const onFocus: AriaOnFocus<ExtendedOption>;
-/**
* Custom dropdown indicator that shows either a chevron or search icon.
* The icon type depends on whether the select is searchable or not.
*/
export declare const DropdownButton: (props: SizedIndicatorProps) => import("react/jsx-runtime").JSX.Element;
-/**
- * Custom remove all button for multi-select mode.
- * Provides keyboard navigation and accessible removal of all selected values.
- */
-export declare const RemoveAllButton: (props: SizedIndicatorProps) => import("react/jsx-runtime").JSX.Element; Index: package/dist/Form/SelectDropdown/elements/index.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/elements/index.d.ts
+++ package/dist/Form/SelectDropdown/elements/index.d.ts
@@ -1,5 +1,6 @@
export { iconSize, selectedIconSize, indicatorIcons } from './constants';
export { MultiValueWithColorMode, MultiValueRemoveButton, RemoveAllButton, } from './multi-value';
-export { DropdownButton, onFocus } from './controls';
+export { DropdownButton } from './controls';
+export { onFocus } from '../core/accessibility';
export { SelectDropdownContext, CustomContainer, CustomInput, CustomValueContainer, TypedReactSelect, } from './containers';
export { IconOption, AbbreviatedSingleValue, formatOptionLabel, formatGroupLabel, } from './options'; Index: package/dist/Form/styles/index.d.ts
===================================================================
--- package/dist/Form/styles/index.d.ts
+++ package/dist/Form/styles/index.d.ts
@@ -1,4 +1,4 @@
export * from './shared-system-props';
export * from './Checkbox-styles';
export * from './Radio-styles';
-export * from '../SelectDropdown/styles';
+export * from '../SelectDropdown/core/styles'; Index: package/dist/Form/SelectDropdown/types/internal.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/types/internal.d.ts
+++ package/dist/Form/SelectDropdown/types/internal.d.ts
@@ -13,9 +13,9 @@
/**
* Ref type for programmatic focus management.
* Used for managing focus on select input and remove all button.
*/
-export type ProgramaticFocusRef = React.MutableRefObject<HTMLDivElement> | React.MutableRefObject<null>;
+export type ProgrammaticFocusRef = React.MutableRefObject<HTMLDivElement> | React.MutableRefObject<null>;
/**
* Context value for SelectDropdown internal state management.
* Provides access to focus state and refs for keyboard navigation.
*/
@@ -24,11 +24,11 @@
currentFocusedValue?: SelectOptionBase['value'];
/** Function to update the currently focused value */
setCurrentFocusedValue?: React.Dispatch<React.SetStateAction<unknown>>;
/** Ref to the select input for programmatic focus */
- selectInputRef?: ProgramaticFocusRef;
+ selectInputRef?: ProgrammaticFocusRef;
/** Ref to the remove all button for programmatic focus */
- removeAllButtonRef?: ProgramaticFocusRef;
+ removeAllButtonRef?: ProgrammaticFocusRef;
}
/**
* Props for sized indicator components (dropdown arrow, search icon, etc.).
* Combines react-select's DropdownIndicatorProps with internal select props. Index: package/dist/Form/SelectDropdown/elements/options.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/elements/options.d.ts
+++ package/dist/Form/SelectDropdown/elements/options.d.ts
@@ -2,8 +2,9 @@
import { CustomSelectComponentProps, ExtendedOption, SelectDropdownGroup } from '../types';
/**
* Custom option component that displays a check icon for selected items.
* Also manages ARIA attributes for accessibility.
+ * Skips the check icon for react-select/creatable's "Add" row (__isNew__).
*/
export declare const IconOption: ({ children, ...rest }: CustomSelectComponentProps<typeof SelectDropdownElements.Option>) => import("react/jsx-runtime").JSX.Element;
/**
* Custom single value component that displays abbreviated text when available. Index: package/dist/Form/SelectDropdown/types/styles.d.ts
===================================================================
--- package/dist/Form/SelectDropdown/types/styles.d.ts
+++ package/dist/Form/SelectDropdown/types/styles.d.ts
@@ -1,6 +1,6 @@
import { StyleProps } from '@codecademy/variance';
-import { conditionalBorderStates } from '../styles';
+import { conditionalBorderStates } from '../core/styles';
import { InternalInputsProps } from './component-props';
/**
* Size variants for the SelectDropdown component.
*/
@@ -68,6 +68,10 @@
*/
export type OptionState = BaseSelectComponentProps & InteractionStates & {
/** Whether the option is selected */
isSelected: boolean;
+ /** Option data — includes __isNew__ for react-select/creatable's "Add" row */
+ data?: {
+ __isNew__?: boolean;
+ };
};
export {};