diff --git a/catalog/input/select.md b/catalog/input/select.md
index a793d7081..1f5527029 100644
--- a/catalog/input/select.md
+++ b/catalog/input/select.md
@@ -1,14 +1,9 @@
-A typeahead control with keyboard navigation based on react-select.
+A typeahead control with keyboard navigation based on [React Select](https://react-select.com).
-[Component documentation](https://github.com/JedWatson/react-select)
+### Upgrading from v5 to v6
-### Migration guide from v5 to v6:
-
-- Change the import to `text-input` (see below)
-
-```
-import { Select } from '@faithlife/styled-ui/text-input';
-```
+1. Import from `'@faithlife/styled-ui/text-input'` instead of `'@faithlife/styled-ui/dist/text-input-v2'`.
+2. `onChange` behaves a bit differently now when `isMulti` is `true`. If one or more options are selected and then later all selected options are removed, upon the removal of the last option the value passed to `onChange` will be `null`. In v5, the value passed to `onChange` in this situation would have been `[]`. See the [React Select v3 upgrade guide](https://github.com/JedWatson/react-select/issues/3585) for more details.
### Single select
@@ -19,9 +14,7 @@ state: { selection: '' }
Current selection: {state.selection}
{
- setState({ selection: value });
- }}
+ onChange={selectedOption => setState({ selection: selectedOption ? selectedOption.value : '' })}
options={[
{ value: "washington", label: "Washington" },
{ value: "california", label: "California" },
@@ -41,9 +34,7 @@ state: { selection: '' }
Current selection: {state.selection}
{
- setState({ selection: value });
- }}
+ onChange={selectedOption => setState({ selection: selectedOption ? selectedOption.value : '' })}
isSearchable={false}
options={[
{ value: "washington", label: "Washington" },
@@ -59,7 +50,7 @@ state: { selection: '' }
```react
showSource: true
-state: { modal: false, value: '', selection: '' }
+state: { modal: false, selection: '' }
---
setState({ modal: !state.modal })}>Open a modal!
@@ -75,9 +66,8 @@ state: { modal: false, value: '', selection: '' }
Current selection: {state.selection}
{
- setState({ selection: value });
- }}
+ onChange={selectedOption =>
+ setState({ selection: selectedOption ? selectedOption.value : '' })}
options={[
{ value: "washington", label: "Washington" },
{ value: "california", label: "California" },
@@ -94,13 +84,13 @@ state: { modal: false, value: '', selection: '' }
```react
showSource: true
-state: { tags: [] }
+state: { selection: [] }
---
-
Current selection: {state.selection}
+
Current selection: {state.selection.join(', ')}
{
- setState({ selection: value });
+ onChange={(selectedOptions) => {
+ setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
@@ -117,13 +107,13 @@ state: { tags: [] }
```react
showSource: true
-state: { tags: [] }
+state: { selection: [] }
---
-
Current selection: {state.selection}
+
Current selection: {state.selection.join(', ')}
{
- setState({ selection: value });
+ onChange={(selectedOptions) => {
+ setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
@@ -143,10 +133,10 @@ showSource: true
state: { selection: [], pendingSelectedValues: [{value: 'Texas', label: 'Texas'}] }
---
-
Current selection: {state.selection}
+
Current selection: {state.selection.join(', ')}
{
- setState({ selection: value });
+ onChange={(selectedOptions) => {
+ setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
diff --git a/components/text-input/select.jsx b/components/text-input/select.jsx
index 640883c5e..00c884594 100644
--- a/components/text-input/select.jsx
+++ b/components/text-input/select.jsx
@@ -1,10 +1,11 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect } from 'react';
import ReactSelect, { components as reactSelectComponents } from 'react-select';
import ReactSelectCreatable from 'react-select/creatable';
import { colors } from '../shared-styles';
import { ChevronDown } from '../icons/12px';
import { Checkbox } from '../../components/check-box';
import { DebouncedSelectAsync, DebouncedSelectAsyncCreatable } from './debounced-async';
+import { useClearableSelectValue } from './use-clearable-select-value';
import * as Styled from './styled';
import { ThemedBox } from '../ThemedBox';
import { useTheme } from '../../theme';
@@ -242,115 +243,95 @@ function handleKeyDown(e, onConsumerKeyDown) {
export { reactSelectComponents };
/** Autocomplete control based on react-select */
-export const Select = React.forwardRef(
- ({ components = {}, disabled, isDisabled, ...props }, ref) => {
- const body = useBody();
- const onConsumerKeyDown = props.onKeyDown;
-
- const onChange = useLegacyChangeHandler(props.onChange, props.isMulti);
- const theme = useTheme();
-
- return (
- handleKeyDown(e, onConsumerKeyDown)}
- />
- );
- },
-);
+export const Select = React.forwardRef((props, ref) => {
+ const transformedProps = useCommonSelectProps(props, ref);
+ return ;
+});
/** The same as `Select`, but allows new entries. */
-export const CreatableSelect = React.forwardRef(
- ({ components = {}, disabled, isDisabled, ...props }, ref) => {
- const body = useBody();
- const onConsumerKeyDown = props.onKeyDown;
-
- const onChange = useLegacyChangeHandler(props.onChange, props.isMulti);
- const theme = useTheme();
-
- return (
- New entry: {node} }
- components={{ ...defaultComponents, ...components }}
- noOptionsMessage={noOptionsMessage}
- menuPortalTarget={body}
- isDisabled={disabled || isDisabled}
- {...props}
- onChange={onChange}
- styles={selectStyles(props, theme)}
- onKeyDown={e => handleKeyDown(e, onConsumerKeyDown)}
- />
- );
- },
-);
+export const CreatableSelect = React.forwardRef((props, ref) => {
+ const transformedProps = useCommonSelectProps(props, ref);
+ return (
+ New entry: {node} }
+ {...transformedProps}
+ />
+ );
+});
/** The same as `Select`, but allows new entries and fetches data asynchronously. */
-export const AsyncCreatableSelect = React.forwardRef(
- ({ components = {}, disabled, isDisabled, ...props }, ref) => {
- const body = useBody();
- const onConsumerKeyDown = props.onKeyDown;
-
- const onChange = useLegacyChangeHandler(props.onChange, props.isMulti);
- const theme = useTheme();
-
- return (
- New entry: {node} }
- noOptionsMessage={noOptionsMessage}
- menuPortalTarget={body}
- isDisabled={disabled || isDisabled}
- {...props}
- onChange={onChange}
- styles={selectStyles(props, theme)}
- onKeyDown={e => handleKeyDown(e, onConsumerKeyDown)}
- />
- );
- },
-);
+export const AsyncCreatableSelect = React.forwardRef((props, ref) => {
+ const transformedProps = useCommonSelectProps(props, ref);
+ return (
+ New entry: {node} }
+ {...transformedProps}
+ />
+ );
+});
/** The same as `Select`, but fetches options asynchronously. */
-export const AsyncSelect = React.forwardRef(
- ({ components = {}, disabled, isDisabled, ...props }, ref) => {
- const body = useBody();
- const onConsumerKeyDown = props.onKeyDown;
-
- const onChange = useLegacyChangeHandler(props.onChange, props.isMulti);
- const theme = useTheme();
+export const AsyncSelect = React.forwardRef((props, ref) => {
+ const transformedProps = useCommonSelectProps(props, ref);
+ return ;
+});
- return (
- handleKeyDown(e, onConsumerKeyDown)}
- />
- );
- },
-);
+export function useCommonSelectProps(props, ref) {
+ const {
+ components,
+ disabled,
+ isDisabled,
+ styles,
+ onKeyDown,
+ value,
+ defaultValue,
+ onChange,
+ inputValue,
+ defaultInputValue,
+ onInputChange,
+ isClearable = true,
+ isMulti,
+ ...otherProps
+ } = props;
+
+ const body = useBody();
+ const theme = useTheme();
+ const {
+ innerValue,
+ innerOnChange,
+ innerInputValue,
+ innerOnInputChange,
+ } = useClearableSelectValue({
+ value,
+ defaultValue,
+ onChange,
+ inputValue,
+ defaultInputValue,
+ onInputChange,
+ isClearable,
+ isMulti,
+ });
+
+ return {
+ ref: ref,
+ classNamePrefix: 'fl-select',
+ theme: selectTheme,
+ components: { ...defaultComponents, ...components },
+ noOptionsMessage: noOptionsMessage,
+ menuPortalTarget: body,
+ isDisabled: disabled || isDisabled,
+ styles: selectStyles(props, theme),
+ onKeyDown: e => handleKeyDown(e, onKeyDown),
+ value: innerValue,
+ onChange: innerOnChange,
+ inputValue: innerInputValue,
+ onInputChange: innerOnInputChange,
+ isClearable: isClearable,
+ isMulti: isMulti,
+ ...otherProps,
+ };
+}
function useBody() {
const [body, setBody] = useState(null);
@@ -360,16 +341,3 @@ function useBody() {
return body;
}
-
-// TODO: Remove in version 6.0
-// This undoes the change type normalization introduced in react-select 3
-function useLegacyChangeHandler(onChange, isMulti) {
- return useCallback(
- (values, change) => {
- if (onChange) {
- return onChange(isMulti ? values || [] : values, change);
- }
- },
- [isMulti, onChange],
- );
-}
diff --git a/components/text-input/use-clearable-select-value.js b/components/text-input/use-clearable-select-value.js
new file mode 100644
index 000000000..e5fc196c6
--- /dev/null
+++ b/components/text-input/use-clearable-select-value.js
@@ -0,0 +1,57 @@
+import { useCallback } from 'react';
+import { useOptionallyControlledState } from '../utils/use-optionally-controlled-state';
+
+/**
+ * A React Select workaround to clear the `Select` value when the user is typing into the `Select`'s
+ * input (as long as the `Select` is only for a single value and is clearable).
+ */
+export function useClearableSelectValue({
+ value: controlledSelectValue,
+ defaultValue: defaultSelectValue,
+ onChange: onSelectChange,
+ inputValue: controlledInputValue,
+ defaultInputValue,
+ onInputChange,
+ isClearable,
+ isMulti,
+}) {
+ const [selectValue, trySetSelectValue] = useOptionallyControlledState(
+ controlledSelectValue,
+ defaultSelectValue,
+ );
+ const [inputValue, trySetInputValue] = useOptionallyControlledState(
+ controlledInputValue,
+ defaultInputValue,
+ );
+
+ const innerOnSelectChange = useCallback(
+ (newSelectValue, meta) => {
+ trySetSelectValue(newSelectValue);
+ if (onSelectChange) {
+ onSelectChange(newSelectValue, meta);
+ }
+ },
+ [onSelectChange, trySetSelectValue],
+ );
+ const innerOnInputChange = useCallback(
+ (newInputValue, meta) => {
+ const userIsTyping = meta.action === 'input-change';
+ if (isClearable && !isMulti && userIsTyping) {
+ innerOnSelectChange(null, { action: 'set-value' });
+ }
+
+ trySetInputValue(newInputValue);
+ if (onInputChange) {
+ onInputChange(newInputValue, meta);
+ }
+ },
+ [innerOnSelectChange, isClearable, isMulti, onInputChange, trySetInputValue],
+ );
+
+ return {
+ innerValue: selectValue,
+ innerOnChange: innerOnSelectChange,
+ innerInputValue: inputValue,
+ innerOnInputChange,
+ };
+}
diff --git a/components/utils/use-optionally-controlled-state.js b/components/utils/use-optionally-controlled-state.js
new file mode 100644
index 000000000..e1517a30a
--- /dev/null
+++ b/components/utils/use-optionally-controlled-state.js
@@ -0,0 +1,22 @@
+import { useState } from 'react';
+
+const EMPTY_FUNCTION = () => {};
+
+/**
+ * For situations when a value can be controlled externally via props, but if it isn't controlled
+ * you'd like to control it internally via state.
+ * @param {any} controlledValue - The prop that can externally control the value when defined.
+ * @param {any} defaultValue - An optional default value to initialize the state with when the value
+ * is not externally controlled.
+ * @returns {[value: any, trySetValue: (newValue?: any) => void]} An array of the state value and
+ * either a setter function or (if the value is externally controlled) an empty function.
+ */
+export function useOptionallyControlledState(controlledValue, defaultValue) {
+ const [value, setValue] = useState(defaultValue);
+
+ if (controlledValue !== undefined) {
+ return [controlledValue, EMPTY_FUNCTION];
+ } else {
+ return [value, setValue];
+ }
+}