diff --git a/UNRELEASED.md b/UNRELEASED.md index 1c512132431..d4edffeaeb1 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,6 +14,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Enhancements - Added `id` prop to `Layout` and `Heading` for hash linking ([#4307](https://github.com/Shopify/polaris-react/pull/4307)) +- Added support for multi-sectioned options in `Autocomplete` [#4221](https://github.com/Shopify/polaris-react/pull/4221) ### Bug fixes diff --git a/src/components/Autocomplete/Autocomplete.scss b/src/components/Autocomplete/Autocomplete.scss index 1c502507ba3..187b2f7f2cf 100644 --- a/src/components/Autocomplete/Autocomplete.scss +++ b/src/components/Autocomplete/Autocomplete.scss @@ -7,3 +7,9 @@ width: 100%; padding: spacing(tight) spacing(); } + +.SectionWrapper { + > *:not(:first-child) { + margin-top: spacing(tight); + } +} diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index f9d163a2e2f..3c89ceb0f1e 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,19 +1,24 @@ import React, {useMemo, useCallback} from 'react'; +import type { + ActionListItemDescriptor, + OptionDescriptor, + SectionDescriptor, +} from 'types'; -import type {ActionListItemDescriptor} from '../../types'; -import type {OptionDescriptor} from '../OptionList'; import type {PopoverProps} from '../Popover'; +import {isSection} from '../../utilities/options'; import {useI18n} from '../../utilities/i18n'; import {ComboBox} from '../ComboBox'; import {ListBox} from '../ListBox'; -import {MappedOption, MappedAction} from './components'; +import {MappedAction, MappedOption} from './components'; +import styles from './Autocomplete.scss'; export interface AutocompleteProps { /** A unique identifier for the Autocomplete */ id?: string; /** Collection of options to be listed */ - options: OptionDescriptor[]; + options: SectionDescriptor[] | OptionDescriptor[]; /** The selected options */ selected: string[]; /** The text field component attached to the list of options */ @@ -61,18 +66,56 @@ export const Autocomplete: React.FunctionComponent & { }: AutocompleteProps) { const i18n = useI18n(); + const buildMappedOptionFromOption = useCallback( + (options: OptionDescriptor[]) => { + return options.map((option) => ( + + )); + }, + [selected, allowMultiple], + ); + const optionsMarkup = useMemo(() => { const conditionalOptions = loading && !willLoadMoreResults ? [] : options; + + if (isSection(conditionalOptions)) { + const noOptionsAvailable = conditionalOptions.every( + ({options}) => options.length === 0, + ); + + if (noOptionsAvailable) { + return null; + } + + const optionsMarkup = conditionalOptions.map(({options, title}) => { + if (options.length === 0) { + return null; + } + + const optionMarkup = buildMappedOptionFromOption(options); + + return ( + {title}} + key={title} + > + {optionMarkup} + + ); + }); + + return
{optionsMarkup}
; + } + const optionList = conditionalOptions.length > 0 - ? conditionalOptions.map((option) => ( - - )) + ? buildMappedOptionFromOption(conditionalOptions) : null; if (listTitle) { @@ -92,8 +135,7 @@ export const Autocomplete: React.FunctionComponent & { loading, options, willLoadMoreResults, - allowMultiple, - selected, + buildMappedOptionFromOption, ]); const loadingMarkup = loading ? ( diff --git a/src/components/Autocomplete/README.md b/src/components/Autocomplete/README.md index 84f064a471c..b7546132ff2 100644 --- a/src/components/Autocomplete/README.md +++ b/src/components/Autocomplete/README.md @@ -202,6 +202,101 @@ function MultiAutocompleteExample() { } ``` +### Multiple sections autocomplete + +Use to help merchants complete text input quickly from a multiple sections list of options. + +```jsx +function AutocompleteExample() { + const deselectedOptions = useMemo( + () => [ + { + title: 'Frequently used', + options: [ + {value: 'ups', label: 'UPS'}, + {value: 'usps', label: 'USPS'}, + ], + }, + { + title: 'All carriers', + options: [ + {value: 'dhl', label: 'DHL Express'}, + {value: 'canada_post', label: 'Canada Post'}, + ], + }, + ], + [], + ); + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === '') { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, 'i'); + const resultOptions = []; + + deselectedOptions.forEach((opt) => { + const lol = opt.options.filter((option) => + option.label?.match?.(filterRegex), + ); + + resultOptions.push({ + title: opt.title, + options: lol, + }); + }); + + setOptions(resultOptions); + }, + [deselectedOptions], + ); + + const updateSelection = useCallback( + (selected) => { + const selectedValue = selected.map((selectedItem) => { + const matchedOption = options.find((option) => { + return option.value.match(selectedItem); + }); + return matchedOption && matchedOption.label; + }); + + setSelectedOptions(selected); + setInputValue(selectedValue[0]); + }, + [options], + ); + + const textField = ( + } + placeholder="Search" + /> + ); + + return ( +
+ +
+ ); +} +``` + ### Autocomplete with loading Use to indicate loading state to merchants while option data is processing. diff --git a/src/components/Autocomplete/components/MappedOption/MappedOption.tsx b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx index dec94df5197..09dee16cc73 100644 --- a/src/components/Autocomplete/components/MappedOption/MappedOption.tsx +++ b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx @@ -1,8 +1,7 @@ import React, {memo} from 'react'; +import type {OptionDescriptor, ArrayElement} from '../../../../types'; import {ListBox} from '../../../ListBox'; -import type {OptionDescriptor} from '../../../OptionList'; -import type {ArrayElement} from '../../../../types'; import {classNames} from '../../../../utilities/css'; import styles from './MappedOption.scss'; diff --git a/src/components/Autocomplete/tests/Autocomplete.test.tsx b/src/components/Autocomplete/tests/Autocomplete.test.tsx index 51d2034d634..ff38c35edfa 100644 --- a/src/components/Autocomplete/tests/Autocomplete.test.tsx +++ b/src/components/Autocomplete/tests/Autocomplete.test.tsx @@ -3,7 +3,7 @@ import {mountWithApp, ReactTestingElement, CustomRoot} from 'test-utilities'; import {KeypressListener} from 'components'; import {TextField} from '../../TextField'; -import {Key} from '../../../types'; +import {Key, SectionDescriptor} from '../../../types'; import {MappedOption, MappedAction} from '../components'; import {ComboBoxTextFieldContext} from '../../../utilities/combo-box'; import {Autocomplete} from '../Autocomplete'; @@ -488,6 +488,78 @@ describe('', () => { }); }); + describe('Multiple sections', () => { + const multipleSectionsOptions: SectionDescriptor[] = [ + { + title: 'Pizzas', + options: [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ], + }, + { + title: 'Pastas', + options: [ + {value: 'spaghetti', label: 'Spaghetti'}, + {value: 'conchiglie', label: 'Conchiglie'}, + {value: 'Bucatini', label: 'Bucatini'}, + ], + }, + ]; + + it('renders one ListBox.Option for each option provided on all sections', () => { + const allOptionsLength = multipleSectionsOptions.reduce( + (lengthAccumulated, {options}) => { + return lengthAccumulated + options.length; + }, + 0, + ); + + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Option, + allOptionsLength, + ); + }); + + it('renders one ListBox.Section for each section', () => { + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + expect(autocomplete).toContainReactComponentTimes( + ListBox.Section, + multipleSectionsOptions.length, + ); + }); + + it('does not show section options and title if no options are provided', () => { + const sectionWithNoOption: SectionDescriptor = { + title: 'Candies', + options: [], + }; + + const newOptions = [...multipleSectionsOptions, sectionWithNoOption]; + + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Section, + newOptions.length - 1, + ); + }); + }); + function noop() {} function renderTextField() { diff --git a/src/components/OptionList/OptionList.tsx b/src/components/OptionList/OptionList.tsx index 1ab9446bc24..8f10364299a 100644 --- a/src/components/OptionList/OptionList.tsx +++ b/src/components/OptionList/OptionList.tsx @@ -1,39 +1,14 @@ import React, {useState, useCallback} from 'react'; +import type {Descriptor, OptionDescriptor, SectionDescriptor} from 'types'; +import {isSection} from '../../utilities/options'; import {arraysAreEqual} from '../../utilities/arrays'; -import type {IconProps} from '../Icon'; -import type {AvatarProps} from '../Avatar'; -import type {ThumbnailProps} from '../Thumbnail'; import {useUniqueId} from '../../utilities/unique-id'; import {useDeepEffect} from '../../utilities/use-deep-effect'; import {Option} from './components'; import styles from './OptionList.scss'; -export interface OptionDescriptor { - /** Value of the option */ - value: string; - /** Display label for the option */ - label: React.ReactNode; - /** Whether the option is disabled or not */ - disabled?: boolean; - /** Whether the option is active or not */ - active?: boolean; - /** Unique identifier for the option */ - id?: string; - /** Media to display to the left of the option content */ - media?: React.ReactElement; -} - -interface SectionDescriptor { - /** Collection of options within the section */ - options: OptionDescriptor[]; - /** Section title */ - title?: string; -} - -type Descriptor = OptionDescriptor | SectionDescriptor; - export interface OptionListProps { /** A unique identifier for the option list */ id?: string; @@ -179,13 +154,6 @@ function createNormalizedOptions( ]; } -function isSection(arr: Descriptor[]): arr is SectionDescriptor[] { - return ( - typeof arr[0] === 'object' && - Object.prototype.hasOwnProperty.call(arr[0], 'options') - ); -} - function optionArraysAreEqual( firstArray: Descriptor[], secondArray: Descriptor[], diff --git a/src/components/OptionList/tests/OptionList.test.tsx b/src/components/OptionList/tests/OptionList.test.tsx index 0ab5be33469..4c1ee994e3e 100644 --- a/src/components/OptionList/tests/OptionList.test.tsx +++ b/src/components/OptionList/tests/OptionList.test.tsx @@ -3,7 +3,8 @@ import React from 'react'; import {mountWithAppProvider} from 'test-utilities/legacy'; import {Option} from '../components'; -import {OptionList, OptionListProps, OptionDescriptor} from '../OptionList'; +import {OptionList, OptionListProps} from '../OptionList'; +import type {OptionDescriptor} from '../../../types'; describe('', () => { const defaultProps: OptionListProps = { diff --git a/src/types.ts b/src/types.ts index 3a7dfdf1d9a..f689032cccf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,29 @@ +import type {AvatarProps, IconProps, ThumbnailProps} from 'components'; + +export interface OptionDescriptor { + /** Value of the option */ + value: string; + /** Display label for the option */ + label: React.ReactNode; + /** Whether the option is disabled or not */ + disabled?: boolean; + /** Whether the option is active or not */ + active?: boolean; + /** Unique identifier for the option */ + id?: string; + /** Media to display to the left of the option content */ + media?: React.ReactElement; +} + +export interface SectionDescriptor { + /** Collection of options within the section */ + options: OptionDescriptor[]; + /** Section title */ + title?: string; +} + +export type Descriptor = SectionDescriptor | OptionDescriptor; + export type IconSource = | React.SFC> | 'placeholder' diff --git a/src/utilities/options.ts b/src/utilities/options.ts new file mode 100644 index 00000000000..ce713f502ce --- /dev/null +++ b/src/utilities/options.ts @@ -0,0 +1,8 @@ +import type {Descriptor, SectionDescriptor} from 'types'; + +export function isSection(arr: Descriptor[]): arr is SectionDescriptor[] { + return ( + typeof arr[0] === 'object' && + Object.prototype.hasOwnProperty.call(arr[0], 'options') + ); +}