Skip to content
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

Make Select components clearable by default #478

Merged
merged 6 commits into from
May 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 20 additions & 30 deletions catalog/input/select.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,9 +14,7 @@ state: { selection: '' }
<div>
<div>Current selection: {state.selection}</div>
<Select
onChange={({ value }) => {
setState({ selection: value });
}}
onChange={selectedOption => setState({ selection: selectedOption ? selectedOption.value : '' })}
options={[
{ value: "washington", label: "Washington" },
{ value: "california", label: "California" },
Expand All @@ -41,9 +34,7 @@ state: { selection: '' }
<div>
<div>Current selection: {state.selection}</div>
<Select
onChange={({ value }) => {
setState({ selection: value });
}}
onChange={selectedOption => setState({ selection: selectedOption ? selectedOption.value : '' })}
isSearchable={false}
options={[
{ value: "washington", label: "Washington" },
Expand All @@ -59,7 +50,7 @@ state: { selection: '' }

```react
showSource: true
state: { modal: false, value: '', selection: '' }
state: { modal: false, selection: '' }
---
<div>
<Button variant="primary" size="medium" onClick={() => setState({ modal: !state.modal })}>Open a modal!</Button>
Expand All @@ -75,9 +66,8 @@ state: { modal: false, value: '', selection: '' }
<DemoDiv>
<div>Current selection: {state.selection}</div>
<Select
onChange={({ value }) => {
setState({ selection: value });
}}
onChange={selectedOption =>
setState({ selection: selectedOption ? selectedOption.value : '' })}
options={[
{ value: "washington", label: "Washington" },
{ value: "california", label: "California" },
Expand All @@ -94,13 +84,13 @@ state: { modal: false, value: '', selection: '' }

```react
showSource: true
state: { tags: [] }
state: { selection: [] }
---
<div>
<div>Current selection: {state.selection}</div>
<div>Current selection: {state.selection.join(', ')}</div>
<Select
onChange={({ value }) => {
setState({ selection: value });
onChange={(selectedOptions) => {
setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
Expand All @@ -117,13 +107,13 @@ state: { tags: [] }

```react
showSource: true
state: { tags: [] }
state: { selection: [] }
---
<div>
<div>Current selection: {state.selection}</div>
<div>Current selection: {state.selection.join(', ')}</div>
<CreatableSelect
onChange={({ value }) => {
setState({ selection: value });
onChange={(selectedOptions) => {
setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
Expand All @@ -143,10 +133,10 @@ showSource: true
state: { selection: [], pendingSelectedValues: [{value: 'Texas', label: 'Texas'}] }
---
<div>
<div>Current selection: {state.selection}</div>
<div>Current selection: {state.selection.join(', ')}</div>
<CreatableSelect
onChange={({ value }) => {
setState({ selection: value });
onChange={(selectedOptions) => {
setState({ selection: selectedOptions ? selectedOptions.map(option => option.value) : [] });
}}
isMulti
options={[
Expand Down
200 changes: 84 additions & 116 deletions components/text-input/select.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<ReactSelect
ref={ref}
classNamePrefix="fl-select"
theme={selectTheme}
components={{ ...defaultComponents, ...components }}
noOptionsMessage={noOptionsMessage}
menuPortalTarget={body}
isDisabled={disabled || isDisabled}
{...props}
onChange={onChange}
styles={selectStyles(props, theme)}
onKeyDown={e => handleKeyDown(e, onConsumerKeyDown)}
/>
);
},
);
export const Select = React.forwardRef((props, ref) => {
const transformedProps = useCommonSelectProps(props, ref);
return <ReactSelect {...transformedProps} />;
});

/** 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 (
<ReactSelectCreatable
ref={ref}
classNamePrefix="fl-select"
theme={selectTheme}
formatCreateLabel={node => <span>New entry: {node}</span>}
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 (
<ReactSelectCreatable
formatCreateLabel={node => <span>New entry: {node}</span>}
{...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 (
<DebouncedSelectAsyncCreatable
ref={ref}
allowCreateWhileLoading={false}
classNamePrefix="fl-select"
theme={selectTheme}
components={{ ...defaultComponents, ...components }}
formatCreateLabel={node => <span>New entry: {node}</span>}
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 (
<DebouncedSelectAsyncCreatable
allowCreateWhileLoading={false}
formatCreateLabel={node => <span>New entry: {node}</span>}
{...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 <DebouncedSelectAsync {...transformedProps} />;
});

return (
<DebouncedSelectAsync
ref={ref}
classNamePrefix="fl-select"
theme={selectTheme}
components={{ ...defaultComponents, ...components }}
noOptionsMessage={noOptionsMessage}
menuPortalTarget={body}
isDisabled={disabled || isDisabled}
{...props}
onChange={onChange}
styles={selectStyles(props, theme)}
onKeyDown={e => 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);
Expand All @@ -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],
);
}
TyMick marked this conversation as resolved.
Show resolved Hide resolved
57 changes: 57 additions & 0 deletions components/text-input/use-clearable-select-value.js
Original file line number Diff line number Diff line change
@@ -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,
}) {
TyMick marked this conversation as resolved.
Show resolved Hide resolved
const [selectValue, trySetSelectValue] = useOptionallyControlledState(
controlledSelectValue,
defaultSelectValue,
);
const [inputValue, trySetInputValue] = useOptionallyControlledState(
TyMick marked this conversation as resolved.
Show resolved Hide resolved
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,
};
Comment on lines +51 to +56
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josephdangerstewart and @RobertBolender, the new return object for useClearableSelectValue. Involves some name changes that make more sense outside the Hook.

}
Loading