diff --git a/.changeset/purple-moons-promise.md b/.changeset/purple-moons-promise.md new file mode 100644 index 0000000000..1b66c431ac --- /dev/null +++ b/.changeset/purple-moons-promise.md @@ -0,0 +1,5 @@ +--- +"react-select": major +--- + +Removed usages of UNSAFE React methods diff --git a/packages/react-select/src/Async.js b/packages/react-select/src/Async.js index 26952e2336..d40d0aafef 100644 --- a/packages/react-select/src/Async.js +++ b/packages/react-select/src/Async.js @@ -50,6 +50,9 @@ type State = { loadedInputValue?: string, loadedOptions: OptionsType, passEmptyOptions: boolean, + optionsCache: { [string]: OptionsType }, + prevDefaultOptions: OptionsType | boolean | void, + prevCacheOptions: any | void, }; export const makeAsyncSelect = ( @@ -60,7 +63,6 @@ export const makeAsyncSelect = ( select: ElementRef<*>; lastRequest: {}; mounted: boolean = false; - optionsCache: { [string]: OptionsType } = {}; constructor(props: C & AsyncProps) { super(); this.state = { @@ -72,6 +74,31 @@ export const makeAsyncSelect = ( isLoading: props.defaultOptions === true, loadedOptions: [], passEmptyOptions: false, + optionsCache: {}, + prevDefaultOptions: undefined, + prevCacheOptions: undefined, + }; + } + static getDerivedStateFromProps(props: C & AsyncProps, state: State) { + const newCacheOptionsState = + props.cacheOptions !== state.prevCacheOptions + ? { + prevCacheOptions: props.cacheOptions, + optionsCache: {}, + } + : {}; + const newDefaultOptionsState = + props.defaultOptions !== state.prevDefaultOptions + ? { + prevDefaultOptions: props.defaultOptions, + defaultOptions: Array.isArray(props.defaultOptions) + ? props.defaultOptions + : undefined, + } + : {}; + return { + ...newCacheOptionsState, + ...newDefaultOptionsState, }; } componentDidMount() { @@ -86,19 +113,6 @@ export const makeAsyncSelect = ( }); } } - UNSAFE_componentWillReceiveProps(nextProps: C & AsyncProps) { - // if the cacheOptions prop changes, clear the cache - if (nextProps.cacheOptions !== this.props.cacheOptions) { - this.optionsCache = {}; - } - if (nextProps.defaultOptions !== this.props.defaultOptions) { - this.setState({ - defaultOptions: Array.isArray(nextProps.defaultOptions) - ? nextProps.defaultOptions - : undefined, - }); - } - } componentWillUnmount() { this.mounted = false; } @@ -131,11 +145,11 @@ export const makeAsyncSelect = ( }); return; } - if (cacheOptions && this.optionsCache[inputValue]) { + if (cacheOptions && this.state.optionsCache[inputValue]) { this.setState({ inputValue, loadedInputValue: inputValue, - loadedOptions: this.optionsCache[inputValue], + loadedOptions: this.state.optionsCache[inputValue], isLoading: false, passEmptyOptions: false, }); @@ -150,17 +164,17 @@ export const makeAsyncSelect = ( () => { this.loadOptions(inputValue, options => { if (!this.mounted) return; - if (options) { - this.optionsCache[inputValue] = options; - } if (request !== this.lastRequest) return; delete this.lastRequest; - this.setState({ + this.setState(state => ({ isLoading: false, loadedInputValue: inputValue, loadedOptions: options || [], passEmptyOptions: false, - }); + optionsCache: options + ? { ...state.optionsCache, [inputValue]: options } + : state.optionsCache, + })); }); } ); diff --git a/packages/react-select/src/Creatable.js b/packages/react-select/src/Creatable.js index 057653045d..a30069b30d 100644 --- a/packages/react-select/src/Creatable.js +++ b/packages/react-select/src/Creatable.js @@ -99,7 +99,7 @@ export const makeCreatableSelect = ( options: options, }; } - UNSAFE_componentWillReceiveProps(nextProps: CreatableProps & C) { + static getDerivedStateFromProps(props: CreatableProps & C, state: State) { const { allowCreateWhileLoading, createOptionPosition, @@ -109,15 +109,15 @@ export const makeCreatableSelect = ( isLoading, isValidNewOption, value, - } = nextProps; - const options = nextProps.options || []; - let { newOption } = this.state; + } = props; + const options = props.options || []; + let { newOption } = state; if (isValidNewOption(inputValue, cleanValue(value), options)) { newOption = getNewOptionData(inputValue, formatCreateLabel(inputValue)); } else { newOption = undefined; } - this.setState({ + return { newOption: newOption, options: (allowCreateWhileLoading || !isLoading) && newOption @@ -125,7 +125,7 @@ export const makeCreatableSelect = ( ? [newOption, ...options] : [...options, newOption] : options, - }); + }; } onChange = (newValue: ValueType, actionMeta: ActionMeta) => { const { diff --git a/packages/react-select/src/Select.js b/packages/react-select/src/Select.js index d198ac1c2f..411549bc75 100644 --- a/packages/react-select/src/Select.js +++ b/packages/react-select/src/Select.js @@ -3,7 +3,6 @@ import React, { Component, type ElementRef, type Node } from 'react'; import memoizeOne from 'memoize-one'; import { MenuPlacer } from './components/Menu'; -import isEqual from './internal/react-fast-compare'; import { createFilter } from './filters'; import { @@ -33,16 +32,15 @@ import { } from './utils'; import { - formatGroupLabel, - getOptionLabel, - getOptionValue, - isOptionDisabled, + formatGroupLabel as formatGroupLabelBuiltin, + getOptionLabel as getOptionLabelBuiltin, + getOptionValue as getOptionValueBuiltin, + isOptionDisabled as isOptionDisabledBuiltin, } from './builtins'; import { defaultComponents, type PlaceholderOrValue, - type SelectComponents, type SelectComponentsConfig, } from './components/index'; @@ -135,13 +133,13 @@ export type Props = { An example can be found in the [Replacing builtins](/advanced#replacing-builtins) documentation. */ - formatGroupLabel: typeof formatGroupLabel, + formatGroupLabel: typeof formatGroupLabelBuiltin, /* Formats option labels in the menu and control as React components */ formatOptionLabel?: (OptionType, FormatOptionLabelMeta) => Node, /* Resolves option data to a string to be displayed as the label by components */ - getOptionLabel: typeof getOptionLabel, + getOptionLabel: typeof getOptionLabelBuiltin, /* Resolves option data to a string to compare options and specify value attributes */ - getOptionValue: typeof getOptionValue, + getOptionValue: typeof getOptionValueBuiltin, /* Hide the selected option from the menu */ hideSelectedOptions?: boolean, /* The id to set on the SelectContainer component. */ @@ -257,15 +255,15 @@ export const defaultProps = { controlShouldRenderValue: true, escapeClearsValue: false, filterOption: createFilter(), - formatGroupLabel: formatGroupLabel, - getOptionLabel: getOptionLabel, - getOptionValue: getOptionValue, + formatGroupLabel: formatGroupLabelBuiltin, + getOptionLabel: getOptionLabelBuiltin, + getOptionValue: getOptionValueBuiltin, isDisabled: false, isLoading: false, isMulti: false, isRtl: false, isSearchable: true, - isOptionDisabled: isOptionDisabled, + isOptionDisabled: isOptionDisabledBuiltin, loadingMessage: () => 'Loading...', maxMenuHeight: 300, minMenuHeight: 140, @@ -287,11 +285,6 @@ export const defaultProps = { tabSelectsValue: true, }; -type MenuOptions = { - render: Array, - focusable: Array, -}; - type State = { ariaLiveSelection: string, ariaLiveContext: string, @@ -299,12 +292,184 @@ type State = { isFocused: boolean, focusedOption: OptionType | null, focusedValue: OptionType | null, - menuOptions: MenuOptions, selectValue: OptionsType, + clearFocusValueOnUpdate: boolean, + inputIsHiddenAfterUpdate: ?boolean, + prevProps: Props | void, }; type ElRef = ElementRef<*>; +type CategorizedOption = { + type: 'option', + data: OptionType, + isDisabled: boolean, + isSelected: boolean, + label: string, + value: string, + index: number, +}; + +type CategorizedGroup = { + type: 'group', + data: GroupType, + options: OptionsType, + index: number, +}; + +type CategorizedGroupOrOption = CategorizedGroup | CategorizedOption; + +function toCategorizedOption( + props: Props, + option: OptionType, + selectValue: OptionsType, + index: number +) { + const isDisabled = isOptionDisabled(props, option, selectValue); + const isSelected = isOptionSelected(props, option, selectValue); + const label = getOptionLabel(props, option); + const value = getOptionValue(props, option); + + return { + type: 'option', + data: option, + isDisabled, + isSelected, + label, + value, + index, + }; +} + +function buildCategorizedOptions( + props: Props, + selectValue: OptionsType +): CategorizedGroupOrOption[] { + return (props.options + .map((groupOrOption, groupOrOptionIndex) => { + if (groupOrOption.options) { + const categorizedOptions = groupOrOption.options + .map(option => + toCategorizedOption(props, option, selectValue, option) + ) + .filter(categorizedOption => isFocusable(props, categorizedOption)); + return categorizedOptions.length > 0 + ? { + type: 'group', + data: groupOrOption, + options: categorizedOptions, + index: groupOrOptionIndex, + } + : undefined; + } + const categorizedOption = toCategorizedOption( + props, + groupOrOption, + selectValue, + groupOrOptionIndex + ); + return isFocusable(props, categorizedOption) + ? categorizedOption + : undefined; + }) + // Flow limitation (see https://github.com/facebook/flow/issues/1414) + .filter(categorizedOption => !!categorizedOption): any[]); +} + +function buildFocusableOptionsFromCategorizedOptions( + categorizedOptions: CategorizedGroupOrOption[] +) { + return categorizedOptions.reduce((optionsAccumulator, categorizedOption) => { + if (categorizedOption.type === 'group') { + optionsAccumulator.push(...categorizedOption.options); + } else { + optionsAccumulator.push(categorizedOption.data); + } + return optionsAccumulator; + }, []); +} + +function buildFocusableOptions(props: Props, selectValue: OptionsType) { + return buildFocusableOptionsFromCategorizedOptions( + buildCategorizedOptions(props, selectValue) + ); +} + +function isFocusable(props: Props, categorizedOption: CategorizedOption) { + const { inputValue = '' } = props; + const { data, isSelected, label, value } = categorizedOption; + + return ( + (!shouldHideSelectedOptions(props) || !isSelected) && + filterOption(props, { label, value, data }, inputValue) + ); +} + +function getNextFocusedValue(state: State, nextSelectValue: OptionsType) { + const { focusedValue, selectValue: lastSelectValue } = state; + const lastFocusedIndex = lastSelectValue.indexOf(focusedValue); + if (lastFocusedIndex > -1) { + const nextFocusedIndex = nextSelectValue.indexOf(focusedValue); + if (nextFocusedIndex > -1) { + // the focused value is still in the selectValue, return it + return focusedValue; + } else if (lastFocusedIndex < nextSelectValue.length) { + // the focusedValue is not present in the next selectValue array by + // reference, so return the new value at the same index + return nextSelectValue[lastFocusedIndex]; + } + } + return null; +} + +function getNextFocusedOption(state: State, options: OptionsType) { + const { focusedOption: lastFocusedOption } = state; + return lastFocusedOption && options.indexOf(lastFocusedOption) > -1 + ? lastFocusedOption + : options[0]; +} +const getOptionLabel = (props: Props, data: OptionType): string => { + return props.getOptionLabel(data); +}; +const getOptionValue = (props: Props, data: OptionType): string => { + return props.getOptionValue(data); +}; + +function isOptionDisabled( + props: Props, + option: OptionType, + selectValue: OptionsType +): boolean { + return typeof props.isOptionDisabled === 'function' + ? props.isOptionDisabled(option, selectValue) + : false; +} +function isOptionSelected( + props: Props, + option: OptionType, + selectValue: OptionsType +): boolean { + if (selectValue.indexOf(option) > -1) return true; + if (typeof props.isOptionSelected === 'function') { + return props.isOptionSelected(option, selectValue); + } + const candidate = getOptionValue(props, option); + return selectValue.some(i => getOptionValue(props, i) === candidate); +} +function filterOption( + props: Props, + option: { label: string, value: string, data: OptionType }, + inputValue: string +) { + return props.filterOption ? props.filterOption(option, inputValue) : true; +} + +const shouldHideSelectedOptions = (props: Props) => { + const { hideSelectedOptions, isMulti } = props; + if (hideSelectedOptions === undefined) return isMulti; + return hideSelectedOptions; +}; + let instanceId = 1; export default class Select extends Component { @@ -316,8 +481,10 @@ export default class Select extends Component { focusedValue: null, inputIsHidden: false, isFocused: false, - menuOptions: { render: [], focusable: [] }, selectValue: [], + clearFocusValueOnUpdate: false, + inputIsHiddenAfterUpdate: undefined, + prevProps: undefined, }; // Misc. Instance Properties @@ -325,13 +492,9 @@ export default class Select extends Component { blockOptionHover: boolean = false; isComposing: boolean = false; - clearFocusValueOnUpdate: boolean = false; commonProps: any; // TODO - components: SelectComponents; - hasGroups: boolean = false; initialTouchX: number = 0; initialTouchY: number = 0; - inputIsHiddenAfterUpdate: ?boolean; instancePrefix: string = ''; openAfterFocus: boolean = false; scrollToFocusedOptionOnUpdate: boolean = false; @@ -362,33 +525,53 @@ export default class Select extends Component { constructor(props: Props) { super(props); - const { value } = props; - this.cacheComponents = memoizeOne(this.cacheComponents, isEqual).bind(this); - this.cacheComponents(props.components); this.instancePrefix = 'react-select-' + (this.props.instanceId || ++instanceId); - - const selectValue = cleanValue(value); - - this.buildMenuOptions = memoizeOne( - this.buildMenuOptions, - (newArgs: any, lastArgs: any) => { - const [newProps, newSelectValue] = (newArgs: [Props, OptionsType]); - const [lastProps, lastSelectValue] = (lastArgs: [Props, OptionsType]); - - return ( - newSelectValue === lastSelectValue && - newProps.inputValue === lastProps.inputValue && - newProps.options === lastProps.options - ); - } - ).bind(this); - const menuOptions = props.menuIsOpen - ? this.buildMenuOptions(props, selectValue) - : { render: [], focusable: [] }; - - this.state.menuOptions = menuOptions; - this.state.selectValue = selectValue; + this.state.selectValue = cleanValue(props.value); + } + static getDerivedStateFromProps(props: Props, state: State) { + const { + prevProps, + clearFocusValueOnUpdate, + inputIsHiddenAfterUpdate, + } = state; + const { options, value, menuIsOpen, inputValue } = props; + let newMenuOptionsState = {}; + if ( + prevProps && + (value !== prevProps.value || + options !== prevProps.options || + menuIsOpen !== prevProps.menuIsOpen || + inputValue !== prevProps.inputValue) + ) { + const selectValue = cleanValue(value); + const focusableOptions = menuIsOpen + ? buildFocusableOptions(props, selectValue) + : []; + const focusedValue = clearFocusValueOnUpdate + ? getNextFocusedValue(state, selectValue) + : null; + const focusedOption = getNextFocusedOption(state, focusableOptions); + newMenuOptionsState = { + selectValue, + focusedOption, + focusedValue, + clearFocusValueOnUpdate: false, + }; + } + // some updates should toggle the state of the input visibility + const newInputIsHiddenState = + inputIsHiddenAfterUpdate != null && props !== prevProps + ? { + inputIsHidden: inputIsHiddenAfterUpdate, + inputIsHiddenAfterUpdate: undefined, + } + : {}; + return { + ...newMenuOptionsState, + ...newInputIsHiddenState, + prevProps: props, + }; } componentDidMount() { this.startListeningComposition(); @@ -403,33 +586,6 @@ export default class Select extends Component { this.focusInput(); } } - UNSAFE_componentWillReceiveProps(nextProps: Props) { - const { options, value, menuIsOpen, inputValue } = this.props; - // re-cache custom components - this.cacheComponents(nextProps.components); - // rebuild the menu options - if ( - nextProps.value !== value || - nextProps.options !== options || - nextProps.menuIsOpen !== menuIsOpen || - nextProps.inputValue !== inputValue - ) { - const selectValue = cleanValue(nextProps.value); - const menuOptions = nextProps.menuIsOpen - ? this.buildMenuOptions(nextProps, selectValue) - : { render: [], focusable: [] }; - const focusedValue = this.getNextFocusedValue(selectValue); - const focusedOption = this.getNextFocusedOption(menuOptions.focusable); - this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); - } - // some updates should toggle the state of the input visibility - if (this.inputIsHiddenAfterUpdate != null) { - this.setState({ - inputIsHidden: this.inputIsHiddenAfterUpdate, - }); - delete this.inputIsHiddenAfterUpdate; - } - } componentDidUpdate(prevProps: Props) { const { isDisabled, menuIsOpen } = this.props; const { isFocused } = this.state; @@ -463,9 +619,7 @@ export default class Select extends Component { this.stopListeningToTouch(); document.removeEventListener('scroll', this.onScroll, true); } - cacheComponents = (components: SelectComponents) => { - this.components = defaultComponents({ components }); - }; + // ============================== // Consumer Handlers // ============================== @@ -505,13 +659,12 @@ export default class Select extends Component { openMenu(focusOption: 'first' | 'last') { const { selectValue, isFocused } = this.state; - const menuOptions = this.buildMenuOptions(this.props, selectValue); + const focusableOptions = this.buildFocusableOptions(); const { isMulti, tabSelectsValue } = this.props; - let openAtIndex = - focusOption === 'first' ? 0 : menuOptions.focusable.length - 1; + let openAtIndex = focusOption === 'first' ? 0 : focusableOptions.length - 1; if (!isMulti) { - const selectedIndex = menuOptions.focusable.indexOf(selectValue[0]); + const selectedIndex = focusableOptions.indexOf(selectValue[0]); if (selectedIndex > -1) { openAtIndex = selectedIndex; } @@ -519,13 +672,12 @@ export default class Select extends Component { // only scroll if the menu isn't already open this.scrollToFocusedOptionOnUpdate = !(isFocused && this.menuListRef); - this.inputIsHiddenAfterUpdate = false; this.setState( { - menuOptions, + inputIsHiddenAfterUpdate: false, focusedValue: null, - focusedOption: menuOptions.focusable[openAtIndex], + focusedOption: focusableOptions[openAtIndex], }, () => { this.onMenuOpen(); @@ -591,8 +743,8 @@ export default class Select extends Component { focusOption(direction: FocusDirection = 'first') { const { pageSize, tabSelectsValue } = this.props; - const { focusedOption, menuOptions } = this.state; - const options = menuOptions.focusable; + const { focusedOption, selectValue } = this.state; + const options = this.getFocusableOptions(); if (!options.length) return; let nextFocus = 0; // handles 'first' @@ -626,7 +778,7 @@ export default class Select extends Component { this.announceAriaLiveContext({ event: 'menu', context: { - isDisabled: isOptionDisabled(options[nextFocus]), + isDisabled: this.isOptionDisabled(options[nextFocus], selectValue), tabSelectsValue, }, }); @@ -643,11 +795,11 @@ export default class Select extends Component { const { closeMenuOnSelect, isMulti } = this.props; this.onInputChange('', { action: 'set-value' }); if (closeMenuOnSelect) { - this.inputIsHiddenAfterUpdate = !isMulti; + this.setState({ inputIsHiddenAfterUpdate: !isMulti }); this.onMenuClose(); } // when the select value should change, we should reset focusedValue - this.clearFocusValueOnUpdate = true; + this.setState({ clearFocusValueOnUpdate: true }); this.onChange(newValue, { action, option }); }; selectOption = (newValue: OptionType) => { @@ -806,38 +958,11 @@ export default class Select extends Component { }; } - getNextFocusedValue(nextSelectValue: OptionsType) { - if (this.clearFocusValueOnUpdate) { - this.clearFocusValueOnUpdate = false; - return null; - } - const { focusedValue, selectValue: lastSelectValue } = this.state; - const lastFocusedIndex = lastSelectValue.indexOf(focusedValue); - if (lastFocusedIndex > -1) { - const nextFocusedIndex = nextSelectValue.indexOf(focusedValue); - if (nextFocusedIndex > -1) { - // the focused value is still in the selectValue, return it - return focusedValue; - } else if (lastFocusedIndex < nextSelectValue.length) { - // the focusedValue is not present in the next selectValue array by - // reference, so return the new value at the same index - return nextSelectValue[lastFocusedIndex]; - } - } - return null; - } - - getNextFocusedOption(options: OptionsType) { - const { focusedOption: lastFocusedOption } = this.state; - return lastFocusedOption && options.indexOf(lastFocusedOption) > -1 - ? lastFocusedOption - : options[0]; - } getOptionLabel = (data: OptionType): string => { - return this.props.getOptionLabel(data); + return getOptionLabel(this.props, data); }; getOptionValue = (data: OptionType): string => { - return this.props.getOptionValue(data); + return getOptionValue(this.props, data); }; getStyles = (key: string, props: {}): {} => { const base = defaultStyles[key](props); @@ -848,17 +973,40 @@ export default class Select extends Component { getElementId = (element: 'group' | 'input' | 'listbox' | 'option') => { return `${this.instancePrefix}-${element}`; }; - getActiveDescendentId = () => { - const { menuIsOpen } = this.props; - const { menuOptions, focusedOption } = this.state; - if (!focusedOption || !menuIsOpen) return undefined; + getComponents = () => { + return defaultComponents(this.props); + }; - const index = menuOptions.focusable.indexOf(focusedOption); - const option = menuOptions.render[index]; + buildCategorizedOptionsFromPropsAndSelectValue = memoizeOne( + buildCategorizedOptions, + (newArgs: any, lastArgs: any) => { + const [newProps, newSelectValue] = (newArgs: [Props, OptionsType]); + const [lastProps, lastSelectValue] = (lastArgs: [Props, OptionsType]); - return option && option.key; - }; + return ( + newSelectValue === lastSelectValue && + newProps.inputValue === lastProps.inputValue && + newProps.options === lastProps.options + ); + } + ); + buildCategorizedOptions = () => + this.buildCategorizedOptionsFromPropsAndSelectValue( + this.props, + this.state.selectValue + ); + getCategorizedOptions = () => + this.props.menuIsOpen ? this.buildCategorizedOptions() : []; + buildFocusableOptionsFromCategorizedOptions = memoizeOne( + buildFocusableOptionsFromCategorizedOptions + ); + buildFocusableOptions = () => + this.buildFocusableOptionsFromCategorizedOptions( + this.buildCategorizedOptions() + ); + getFocusableOptions = () => + this.props.menuIsOpen ? this.buildFocusableOptions() : []; // ============================== // Helpers @@ -894,10 +1042,10 @@ export default class Select extends Component { return selectValue.length > 0; } hasOptions() { - return !!this.state.menuOptions.render.length; + return !!this.getFocusableOptions().length; } countOptions() { - return this.state.menuOptions.focusable.length; + return this.getFocusableOptions().length; } isClearable(): boolean { const { isClearable, isMulti } = this.props; @@ -909,25 +1057,16 @@ export default class Select extends Component { return isClearable; } isOptionDisabled(option: OptionType, selectValue: OptionsType): boolean { - return typeof this.props.isOptionDisabled === 'function' - ? this.props.isOptionDisabled(option, selectValue) - : false; + return isOptionDisabled(this.props, option, selectValue); } isOptionSelected(option: OptionType, selectValue: OptionsType): boolean { - if (selectValue.indexOf(option) > -1) return true; - if (typeof this.props.isOptionSelected === 'function') { - return this.props.isOptionSelected(option, selectValue); - } - const candidate = this.getOptionValue(option); - return selectValue.some(i => this.getOptionValue(i) === candidate); + return isOptionSelected(this.props, option, selectValue); } filterOption( option: { label: string, value: string, data: OptionType }, inputValue: string ) { - return this.props.filterOption - ? this.props.filterOption(option, inputValue) - : true; + return filterOption(this.props, option, inputValue); } formatOptionLabel(data: OptionType, context: FormatOptionLabelContext): Node { if (typeof this.props.formatOptionLabel === 'function') { @@ -998,7 +1137,7 @@ export default class Select extends Component { const { isMulti, menuIsOpen } = this.props; this.focusInput(); if (menuIsOpen) { - this.inputIsHiddenAfterUpdate = !isMulti; + this.setState({ inputIsHiddenAfterUpdate: !isMulti }); this.onMenuClose(); } else { this.openMenu('first'); @@ -1142,7 +1281,7 @@ export default class Select extends Component { handleInputChange = (event: SyntheticKeyboardEvent) => { const inputValue = event.currentTarget.value; - this.inputIsHiddenAfterUpdate = false; + this.setState({ inputIsHiddenAfterUpdate: false }); this.onInputChange(inputValue, { action: 'input-change' }); if (!this.props.menuIsOpen) { this.onMenuOpen(); @@ -1153,7 +1292,7 @@ export default class Select extends Component { if (this.props.onFocus) { this.props.onFocus(event); } - this.inputIsHiddenAfterUpdate = false; + this.setState({ inputIsHiddenAfterUpdate: false }); this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }, @@ -1188,9 +1327,7 @@ export default class Select extends Component { this.setState({ focusedOption }); }; shouldHideSelectedOptions = () => { - const { hideSelectedOptions, isMulti } = this.props; - if (hideSelectedOptions === undefined) return isMulti; - return hideSelectedOptions; + return shouldHideSelectedOptions(this.props); }; // ============================== @@ -1277,7 +1414,7 @@ export default class Select extends Component { return; case 'Escape': if (menuIsOpen) { - this.inputIsHiddenAfterUpdate = false; + this.setState({ inputIsHiddenAfterUpdate: false }); this.onInputChange('', { action: 'menu-close' }); this.onMenuClose(); } else if (isClearable && escapeClearsValue) { @@ -1331,84 +1468,6 @@ export default class Select extends Component { event.preventDefault(); }; - // ============================== - // Menu Options - // ============================== - - buildMenuOptions = (props: Props, selectValue: OptionsType): MenuOptions => { - const { inputValue = '', options } = props; - - const toOption = (option, id) => { - const isDisabled = this.isOptionDisabled(option, selectValue); - const isSelected = this.isOptionSelected(option, selectValue); - const label = this.getOptionLabel(option); - const value = this.getOptionValue(option); - - if ( - (this.shouldHideSelectedOptions() && isSelected) || - !this.filterOption({ label, value, data: option }, inputValue) - ) { - return; - } - - const onHover = isDisabled ? undefined : () => this.onOptionHover(option); - const onSelect = isDisabled ? undefined : () => this.selectOption(option); - const optionId = `${this.getElementId('option')}-${id}`; - - return { - innerProps: { - id: optionId, - onClick: onSelect, - onMouseMove: onHover, - onMouseOver: onHover, - tabIndex: -1, - }, - data: option, - isDisabled, - isSelected, - key: optionId, - label, - type: 'option', - value, - }; - }; - - return options.reduce( - (acc, item, itemIndex) => { - if (item.options) { - // TODO needs a tidier implementation - if (!this.hasGroups) this.hasGroups = true; - - const { options: items } = item; - const children = items - .map((child, i) => { - const option = toOption(child, `${itemIndex}-${i}`); - if (option) acc.focusable.push(child); - return option; - }) - .filter(Boolean); - if (children.length) { - const groupId = `${this.getElementId('group')}-${itemIndex}`; - acc.render.push({ - type: 'group', - key: groupId, - data: item, - options: children, - }); - } - } else { - const option = toOption(item, `${itemIndex}`); - if (option) { - acc.render.push(option); - acc.focusable.push(item); - } - } - return acc; - }, - { render: [], focusable: [] } - ); - }; - // ============================== // Renderers // ============================== @@ -1456,7 +1515,7 @@ export default class Select extends Component { tabIndex, form, } = this.props; - const { Input } = this.components; + const { Input } = this.getComponents(); const { inputIsHidden } = this.state; const id = inputId || this.getElementId('input'); @@ -1522,7 +1581,7 @@ export default class Select extends Component { MultiValueRemove, SingleValue, Placeholder, - } = this.components; + } = this.getComponents(); const { commonProps } = this; const { controlShouldRenderValue, @@ -1591,7 +1650,7 @@ export default class Select extends Component { ); } renderClearIndicator() { - const { ClearIndicator } = this.components; + const { ClearIndicator } = this.getComponents(); const { commonProps } = this; const { isDisabled, isLoading } = this.props; const { isFocused } = this.state; @@ -1621,7 +1680,7 @@ export default class Select extends Component { ); } renderLoadingIndicator() { - const { LoadingIndicator } = this.components; + const { LoadingIndicator } = this.getComponents(); const { commonProps } = this; const { isDisabled, isLoading } = this.props; const { isFocused } = this.state; @@ -1639,7 +1698,7 @@ export default class Select extends Component { ); } renderIndicatorSeparator() { - const { DropdownIndicator, IndicatorSeparator } = this.components; + const { DropdownIndicator, IndicatorSeparator } = this.getComponents(); // separator doesn't make sense without the dropdown indicator if (!DropdownIndicator || !IndicatorSeparator) return null; @@ -1657,7 +1716,7 @@ export default class Select extends Component { ); } renderDropdownIndicator() { - const { DropdownIndicator } = this.components; + const { DropdownIndicator } = this.getComponents(); if (!DropdownIndicator) return null; const { commonProps } = this; const { isDisabled } = this.props; @@ -1688,9 +1747,9 @@ export default class Select extends Component { LoadingMessage, NoOptionsMessage, Option, - } = this.components; + } = this.getComponents(); const { commonProps } = this; - const { focusedOption, menuOptions } = this.state; + const { focusedOption } = this.state; const { captureMenuScroll, inputValue, @@ -1712,14 +1771,34 @@ export default class Select extends Component { if (!menuIsOpen) return null; // TODO: Internal Option Type here - const render = (props: OptionType) => { - // for performance, the menu options in state aren't changed when the - // focused option changes so we calculate additional props based on that - const isFocused = focusedOption === props.data; - props.innerRef = isFocused ? this.getFocusedOptionRef : undefined; + const render = (props: OptionType, id: string) => { + const { type, data, isDisabled, isSelected, label, value } = props; + const isFocused = focusedOption === data; + const onHover = isDisabled ? undefined : () => this.onOptionHover(data); + const onSelect = isDisabled ? undefined : () => this.selectOption(data); + const optionId = `${this.getElementId('option')}-${id}`; + const innerProps = { + id: optionId, + onClick: onSelect, + onMouseMove: onHover, + onMouseOver: onHover, + tabIndex: -1, + }; return ( - ); @@ -1728,15 +1807,18 @@ export default class Select extends Component { let menuUI; if (this.hasOptions()) { - menuUI = menuOptions.render.map(item => { + menuUI = this.getCategorizedOptions().map(item => { if (item.type === 'group') { - const { type, ...group } = item; - const headingId = `${item.key}-heading`; + const { data, options, index: groupIndex } = item; + const groupId = `${this.getElementId('group')}-${groupIndex}`; + const headingId = `${groupId}-heading`; return ( { }} label={this.formatGroupLabel(item.data)} > - {item.options.map(option => render(option))} + {item.options.map(option => + render(option, `${groupIndex}-${option.index}`) + )} ); } else if (item.type === 'option') { - return render(item); + return render(item, `${item.index}`); } }); } else if (isLoading) { @@ -1873,7 +1957,7 @@ export default class Select extends Component { IndicatorsContainer, SelectContainer, ValueContainer, - } = this.components; + } = this.getComponents(); const { className, id, isDisabled, menuIsOpen } = this.props; const { isFocused } = this.state;