diff --git a/UNRELEASED.md b/UNRELEASED.md index 4df8f671023..7907f2b3501 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -20,4 +20,6 @@ ### Code quality +- Converted `ComboBox` to a functional component ([#2918](https://github.com/Shopify/polaris-react/pull/2918)) + ### Deprecations diff --git a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx b/src/components/Autocomplete/components/ComboBox/ComboBox.tsx index d0a40f6f123..f901efeda9a 100644 --- a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx +++ b/src/components/Autocomplete/components/ComboBox/ComboBox.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import {createUniqueIDFactory} from '@shopify/javascript-utilities/other'; +import React, {useState, useEffect, useCallback} from 'react'; +import {useUniqueId} from '../../../../utilities/unique-id'; +import {useToggle} from '../../../../utilities/use-toggle'; import {OptionList, OptionDescriptor} from '../../../OptionList'; import {ActionList} from '../../../ActionList'; import {Popover, PopoverProps} from '../../../Popover'; @@ -11,18 +12,6 @@ import {EventListener} from '../../../EventListener'; import {ComboBoxContext} from './context'; import styles from './ComboBox.scss'; -const getUniqueId = createUniqueIDFactory('ComboBox'); - -interface State { - comboBoxId: string; - selectedOption?: OptionDescriptor | ActionListItemDescriptor | undefined; - selectedIndex: number; - selectedOptions: string[]; - navigableOptions: (OptionDescriptor | ActionListItemDescriptor)[]; - popoverActive: boolean; - popoverWasActive: boolean; -} - export interface ComboBoxProps { /** A unique identifier for the ComboBox */ id?: string; @@ -54,355 +43,85 @@ export interface ComboBoxProps { onEndReached?(): void; } -export class ComboBox extends React.PureComponent { - static getDerivedStateFromProps( - { - options: nextOptions, - selected: nextSelected, - actionsBefore: nextActionsBefore, - actionsAfter: nextActionsAfter, - }: ComboBoxProps, - {navigableOptions, selectedOptions, comboBoxId}: State, - ) { - const optionsChanged = - filterForOptions(navigableOptions) && - nextOptions && - !optionsAreEqual(navigableOptions, nextOptions); - - let newNavigableOptions: ( - | OptionDescriptor - | ActionListItemDescriptor - )[] = []; - if (nextActionsBefore) { - newNavigableOptions = newNavigableOptions.concat(nextActionsBefore); - } - if (optionsChanged || nextActionsBefore) { - newNavigableOptions = newNavigableOptions.concat(nextOptions); - } - if (nextActionsAfter) { - newNavigableOptions = newNavigableOptions.concat(nextActionsAfter); - } - newNavigableOptions = assignOptionIds(newNavigableOptions, comboBoxId); - - if (optionsChanged && selectedOptions !== nextSelected) { - return { - navigableOptions: newNavigableOptions, - selectedOptions: nextSelected, - }; - } else if (optionsChanged) { - return { - navigableOptions: newNavigableOptions, - }; - } else if (selectedOptions !== nextSelected) { - return {selectedOptions: nextSelected}; - } - return null; - } - - state: State = { - comboBoxId: this.getComboBoxId(), - selectedOption: undefined, - selectedIndex: -1, - selectedOptions: this.props.selected, - navigableOptions: [], - popoverActive: false, - popoverWasActive: false, - }; - - componentDidMount() { - const {options, actionsBefore, actionsAfter} = this.props; - const comboBoxId = this.getComboBoxId(); - let navigableOptions: (OptionDescriptor | ActionListItemDescriptor)[] = []; - - if (actionsBefore) { - navigableOptions = navigableOptions.concat(actionsBefore); - } - if (options) { - navigableOptions = navigableOptions.concat(options); - } - if (actionsAfter) { - navigableOptions = navigableOptions.concat(actionsAfter); - } - navigableOptions = assignOptionIds(navigableOptions, comboBoxId); - - this.setState({ - navigableOptions, - }); - } - - componentDidUpdate(_: ComboBoxProps, prevState: State) { - const {contentBefore, contentAfter, emptyState} = this.props; - const {navigableOptions, popoverWasActive} = this.state; - - const optionsChanged = - navigableOptions && - prevState.navigableOptions && - !optionsAreEqual(navigableOptions, prevState.navigableOptions); - - if (optionsChanged) { - this.updateIndexOfSelectedOption(navigableOptions); - } - - if ( - navigableOptions && - navigableOptions.length === 0 && - !contentBefore && - !contentAfter && - !emptyState - ) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({popoverActive: false}); - } else if ( - popoverWasActive && - navigableOptions && - navigableOptions.length !== 0 - ) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({popoverActive: true}); - } - } - - getComboBoxId(): string { - if (this.state && this.state.comboBoxId) { - return this.state.comboBoxId; - } - return this.props.id || getUniqueId(); - } - - getActionsWithIds( - actions: ActionListItemDescriptor[], - before?: boolean, - ): ActionListItemDescriptor[] { - const {navigableOptions} = this.state; - - if (before) { - return navigableOptions.slice(0, actions.length); - } else { +export function ComboBox({ + id: idProp, + options, + selected, + textField, + preferredPosition, + listTitle, + allowMultiple, + actionsBefore, + actionsAfter, + contentBefore, + contentAfter, + emptyState, + onSelect, + onEndReached, +}: ComboBoxProps) { + const [selectedIndex, setSelectedIndex] = useState(-1); + const [selectedOptions, setSelectedOptions] = useState(selected); + const [navigableOptions, setNavigableOptions] = useState< + (OptionDescriptor | ActionListItemDescriptor)[] + >([]); + const { + value: popoverActive, + setTrue: forcePopoverActiveTrue, + setFalse: forcePopoverActiveFalse, + } = useToggle(false); + + const id = useUniqueId('ComboBox', idProp); + + const getActionsWithIds = useCallback( + (actions: ActionListItemDescriptor[], before?: boolean) => { + if (before) { + return navigableOptions.slice(0, actions.length); + } return navigableOptions.slice(-actions.length); - } - } - - render() { - const { - options, - textField, - listTitle, - allowMultiple, - preferredPosition, - actionsBefore, - actionsAfter, - contentBefore, - contentAfter, - onEndReached, - emptyState, - } = this.props; - const {comboBoxId, navigableOptions, selectedOptions} = this.state; - - let actionsBeforeMarkup: JSX.Element | undefined; - if (actionsBefore && actionsBefore.length > 0) { - actionsBeforeMarkup = ( - - ); - } - - let actionsAfterMarkup: JSX.Element | undefined; - if (actionsAfter && actionsAfter.length > 0) { - actionsAfterMarkup = ( - - ); - } - - const optionsMarkup = options.length > 0 && ( - - ); - - const emptyStateMarkup = !actionsAfter && - !actionsBefore && - !contentAfter && - !contentBefore && - options.length === 0 && - emptyState &&
{emptyState}
; - - const context = { - comboBoxId, - selectedOptionId: this.selectedOptionId(), - }; - - return ( - -
- - - - - - -
- {contentBefore} - {actionsBeforeMarkup} - {optionsMarkup} - {actionsAfterMarkup} - {contentAfter} - {emptyStateMarkup} -
-
-
-
-
- ); - } - - private handleDownArrow = () => { - this.selectNextOption(); - this.handlePopoverOpen; - }; - - private handleUpArrow = () => { - this.selectPreviousOption(); - this.handlePopoverOpen; - }; - - private handleEnter = (event: KeyboardEvent) => { - if (event.keyCode !== Key.Enter) { - return; - } + }, + [navigableOptions], + ); - const {selectedOption} = this.state; - if (this.state.popoverActive && selectedOption) { - if (isOption(selectedOption)) { - event.preventDefault(); - this.handleSelection(selectedOption.value); - } else { - selectedOption.onAction && selectedOption.onAction(); + const visuallyUpdateSelectedOption = useCallback( + ( + newOption: OptionDescriptor | ActionListItemDescriptor, + oldOption: OptionDescriptor | ActionListItemDescriptor | undefined, + ) => { + if (oldOption) { + oldOption.active = false; } - } - - this.handlePopoverOpen; - }; - - private handleFocus = () => { - this.setState({popoverActive: true, popoverWasActive: true}); - }; - - private handleBlur = () => { - this.setState({popoverActive: false, popoverWasActive: false}, () => { - this.resetVisuallySelectedOptions(); - }); - }; - - private handleClick = () => { - !this.state.popoverActive && this.setState({popoverActive: true}); - }; - - private handleSelection = (newSelected: string) => { - const {selected, allowMultiple} = this.props; - let newlySelectedOptions = selected; - if (selected.includes(newSelected)) { - newlySelectedOptions.splice(newlySelectedOptions.indexOf(newSelected), 1); - } else if (allowMultiple) { - newlySelectedOptions.push(newSelected); - } else { - newlySelectedOptions = [newSelected]; - } - - this.selectOptions(newlySelectedOptions); - }; - - private selectOptions = (selected: string[]) => { - const {onSelect, allowMultiple} = this.props; - selected && onSelect(selected); - if (!allowMultiple) { - this.resetVisuallySelectedOptions(); - this.setState({popoverActive: false, popoverWasActive: false}); - } - }; - - private updateIndexOfSelectedOption = ( - newOptions: (OptionDescriptor | ActionListItemDescriptor)[], - ) => { - const {selectedIndex, selectedOption} = this.state; - if (selectedOption && newOptions.includes(selectedOption)) { - this.selectOptionAtIndex(newOptions.indexOf(selectedOption)); - } else if (selectedIndex > newOptions.length - 1) { - this.resetVisuallySelectedOptions(); - } else { - this.selectOptionAtIndex(selectedIndex); - } - }; + if (newOption) { + newOption.active = true; + } + }, + [], + ); - private resetVisuallySelectedOptions = () => { - const {navigableOptions} = this.state; - this.setState({ - selectedOption: undefined, - selectedIndex: -1, + const resetVisuallySelectedOptions = useCallback(() => { + setSelectedIndex(-1); + navigableOptions.forEach((option) => { + option.active = false; }); - navigableOptions && - navigableOptions.forEach((option) => { - option.active = false; - }); - }; + }, [navigableOptions]); - private handlePopoverClose = () => { - this.setState({popoverActive: false, popoverWasActive: false}); - }; + const selectOptionAtIndex = useCallback( + (newOptionIndex: number) => { + if (navigableOptions.length === 0) { + return; + } - private handlePopoverOpen = () => { - const {popoverActive, navigableOptions} = this.state; + const oldSelectedOption = navigableOptions[selectedIndex]; + const newSelectedOption = navigableOptions[newOptionIndex]; - !popoverActive && - navigableOptions && - navigableOptions.length > 0 && - this.setState({popoverActive: true, popoverWasActive: true}); - }; + visuallyUpdateSelectedOption(newSelectedOption, oldSelectedOption); - private selectNextOption = () => { - const {selectedIndex, navigableOptions} = this.state; + setSelectedIndex(newOptionIndex); + }, + [navigableOptions, selectedIndex, visuallyUpdateSelectedOption], + ); - if (!navigableOptions || navigableOptions.length === 0) { + const selectNextOption = useCallback(() => { + if (navigableOptions.length === 0) { return; } @@ -414,13 +133,11 @@ export class ComboBox extends React.PureComponent { newIndex++; } - this.selectOptionAtIndex(newIndex); - }; - - private selectPreviousOption = () => { - const {selectedIndex, navigableOptions} = this.state; + selectOptionAtIndex(newIndex); + }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - if (!navigableOptions || navigableOptions.length === 0) { + const selectPreviousOption = useCallback(() => { + if (navigableOptions.length === 0) { return; } @@ -432,88 +149,218 @@ export class ComboBox extends React.PureComponent { newIndex--; } - this.selectOptionAtIndex(newIndex); - }; + selectOptionAtIndex(newIndex); + }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - private selectOptionAtIndex = (newOptionIndex: number) => { - this.setState((prevState) => { - if ( - !prevState.navigableOptions || - prevState.navigableOptions.length === 0 - ) { - return prevState; + const selectOptions = useCallback( + (selected: string[]) => { + selected && onSelect(selected); + if (!allowMultiple) { + resetVisuallySelectedOptions(); + forcePopoverActiveFalse(); } - const newSelectedOption = prevState.navigableOptions[newOptionIndex]; - - this.visuallyUpdateSelectedOption( - newSelectedOption, - prevState.selectedOption, - ); - - return { - ...prevState, - selectedOption: newSelectedOption, - selectedIndex: newOptionIndex, - }; - }); - }; + }, + [ + allowMultiple, + forcePopoverActiveFalse, + onSelect, + resetVisuallySelectedOptions, + ], + ); + + const handleSelection = useCallback( + (newSelected: string) => { + let newlySelectedOptions = selected; + if (selected.includes(newSelected)) { + newlySelectedOptions.splice( + newlySelectedOptions.indexOf(newSelected), + 1, + ); + } else if (allowMultiple) { + newlySelectedOptions.push(newSelected); + } else { + newlySelectedOptions = [newSelected]; + } + + selectOptions(newlySelectedOptions); + }, + [allowMultiple, selectOptions, selected], + ); - private visuallyUpdateSelectedOption = ( - newOption: OptionDescriptor | ActionListItemDescriptor, - oldOption: OptionDescriptor | ActionListItemDescriptor | undefined, - ) => { - if (oldOption) { - oldOption.active = false; + const handleEnter = useCallback( + (event: KeyboardEvent) => { + if (event.keyCode !== Key.Enter) { + return; + } + + if (popoverActive && selectedIndex > -1) { + const selectedOption = navigableOptions[selectedIndex]; + if (isOption(selectedOption)) { + event.preventDefault(); + handleSelection(selectedOption.value); + } else { + selectedOption.onAction && selectedOption.onAction(); + } + } + }, + [handleSelection, navigableOptions, popoverActive, selectedIndex], + ); + + const handleBlur = useCallback(() => { + forcePopoverActiveFalse(); + resetVisuallySelectedOptions(); + }, [forcePopoverActiveFalse, resetVisuallySelectedOptions]); + + const handleClick = useCallback(() => { + !popoverActive && forcePopoverActiveTrue(); + }, [forcePopoverActiveTrue, popoverActive]); + + const updateIndexOfSelectedOption = useCallback( + (newOptions: (OptionDescriptor | ActionListItemDescriptor)[]) => { + const selectedOption = navigableOptions[selectedIndex]; + if (selectedOption && newOptions.includes(selectedOption)) { + selectOptionAtIndex(newOptions.indexOf(selectedOption)); + } else if (selectedIndex > newOptions.length - 1) { + resetVisuallySelectedOptions(); + } else { + selectOptionAtIndex(selectedIndex); + } + }, + [ + navigableOptions, + resetVisuallySelectedOptions, + selectOptionAtIndex, + selectedIndex, + ], + ); + + useEffect(() => { + if (selectedOptions !== selected) { + setSelectedOptions(selected); } - if (newOption) { - newOption.active = true; + }, [selected, selectedOptions]); + + useEffect(() => { + let newNavigableOptions: ( + | OptionDescriptor + | ActionListItemDescriptor + )[] = []; + if (actionsBefore) { + newNavigableOptions = newNavigableOptions.concat(actionsBefore); } - }; + if (options) { + newNavigableOptions = newNavigableOptions.concat(options); + } + if (actionsAfter) { + newNavigableOptions = newNavigableOptions.concat(actionsAfter); + } + newNavigableOptions = assignOptionIds(newNavigableOptions, id); + setNavigableOptions(newNavigableOptions); + }, [actionsAfter, actionsBefore, id, options]); + + useEffect(() => { + updateIndexOfSelectedOption(navigableOptions); + }, [navigableOptions, updateIndexOfSelectedOption]); + + let actionsBeforeMarkup: JSX.Element | undefined; + if (actionsBefore && actionsBefore.length > 0) { + actionsBeforeMarkup = ( + + ); + } - private selectedOptionId(): string | undefined { - const {selectedOption, selectedIndex, comboBoxId} = this.state; - return selectedOption ? `${comboBoxId}-${selectedIndex}` : undefined; + let actionsAfterMarkup: JSX.Element | undefined; + if (actionsAfter && actionsAfter.length > 0) { + actionsAfterMarkup = ( + + ); } + + const optionsMarkup = options.length > 0 && ( + + ); + + const emptyStateMarkup = !actionsAfter && + !actionsBefore && + !contentAfter && + !contentBefore && + options.length === 0 && + emptyState &&
{emptyState}
; + + const selectedOptionId = + selectedIndex > -1 ? `${id}-${selectedIndex}` : undefined; + + const context = { + id, + selectedOptionId, + }; + + return ( + +
+ + + + + + +
+ {contentBefore} + {actionsBeforeMarkup} + {optionsMarkup} + {actionsAfterMarkup} + {contentAfter} + {emptyStateMarkup} +
+
+
+
+
+ ); } function assignOptionIds( options: (OptionDescriptor | ActionListItemDescriptor)[], - comboBoxId: string, + id: string, ): OptionDescriptor[] | ActionListItemDescriptor[] { - return options.map( - ( - option: OptionDescriptor | ActionListItemDescriptor, - optionIndex: number, - ) => ({ - ...option, - id: `${comboBoxId}-${optionIndex}`, - }), - ); -} - -function optionsAreEqual( - firstOptions: (OptionDescriptor | ActionListItemDescriptor)[], - secondOptions: (OptionDescriptor | ActionListItemDescriptor)[], -) { - if (firstOptions.length !== secondOptions.length) { - return false; - } - return firstOptions.every( - (firstItem: OptionDescriptor | ActionListItemDescriptor, index: number) => { - const secondItem = secondOptions[index]; - if (isOption(firstItem)) { - if (isOption(secondItem)) { - return firstItem.value === secondItem.value; - } - return false; - } else { - if (!isOption(secondItem)) { - return firstItem.content === secondItem.content; - } - return false; - } - }, - ); + return options.map((option, optionIndex) => ({ + ...option, + id: `${id}-${optionIndex}`, + })); } function isOption( diff --git a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx index 822ade6a327..d6577157eb1 100644 --- a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx +++ b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {OptionList, ActionList, Popover} from 'components'; import {mountWithApp} from 'test-utilities'; // eslint-disable-next-line no-restricted-imports -import {mountWithAppProvider} from 'test-utilities/legacy'; +import {mountWithAppProvider, act} from 'test-utilities/legacy'; import {TextField} from '../../TextField'; import {Key} from '../../../../../types'; @@ -401,8 +401,12 @@ describe('', () => { />, ); comboBox.find(TextField).simulate('click'); - dispatchKeyup(Key.DownArrow); - dispatchKeydown(Key.Enter); + act(() => { + dispatchKeyup(Key.DownArrow); + }); + act(() => { + dispatchKeydown(Key.Enter); + }); expect(spy).not.toHaveBeenCalled(); comboBox.setProps({ @@ -414,8 +418,12 @@ describe('', () => { }); comboBox.update(); comboBox.find(TextField).simulate('click'); - dispatchKeyup(Key.DownArrow); - dispatchKeydown(Key.Enter); + act(() => { + dispatchKeyup(Key.DownArrow); + }); + act(() => { + dispatchKeydown(Key.Enter); + }); expect(spy).toHaveBeenCalledWith(['cheese_pizza']); }); @@ -430,8 +438,12 @@ describe('', () => { />, ); comboBox.find(TextField).simulate('click'); - dispatchKeyup(Key.DownArrow); - dispatchKeydown(Key.Enter); + act(() => { + dispatchKeyup(Key.DownArrow); + }); + act(() => { + dispatchKeydown(Key.Enter); + }); expect(spy).toHaveBeenCalledWith(['cheese_pizza']); }); @@ -446,8 +458,12 @@ describe('', () => { />, ); comboBox.find(TextField).simulate('click'); - dispatchKeyup(Key.DownArrow); - dispatchKeydown(Key.RightArrow); + act(() => { + dispatchKeyup(Key.DownArrow); + }); + act(() => { + dispatchKeydown(Key.RightArrow); + }); expect(spy).not.toHaveBeenCalled(); }); @@ -478,7 +494,10 @@ describe('', () => { comboBox.find(TextField).simulate('click'); expect(comboBox.find(Popover).prop('active')).toBe(true); - dispatchKeyup(Key.Escape); + act(() => { + dispatchKeyup(Key.Escape); + }); + comboBox.update(); expect(comboBox.find(Popover).prop('active')).toBe(false); });