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 : '' })} 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: '' } ---
@@ -75,9 +66,8 @@ state: { modal: false, value: '', selection: '' }
Current selection: {state.selection}
{ - 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]; + } +}