Skip to content
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
1 change: 1 addition & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/components/Autocomplete/Autocomplete.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@
width: 100%;
padding: spacing(tight) spacing();
}

.SectionWrapper {
> *:not(:first-child) {
margin-top: spacing(tight);
}
}
70 changes: 56 additions & 14 deletions src/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -61,18 +66,56 @@ export const Autocomplete: React.FunctionComponent<AutocompleteProps> & {
}: AutocompleteProps) {
const i18n = useI18n();

const buildMappedOptionFromOption = useCallback(
(options: OptionDescriptor[]) => {
return options.map((option) => (
<MappedOption
{...option}
key={option.id || option.value}
selected={selected.includes(option.value)}
singleSelection={!allowMultiple}
/>
));
},
[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 (
<ListBox.Section
divider={false}
title={<ListBox.Header>{title}</ListBox.Header>}
key={title}
>
{optionMarkup}
</ListBox.Section>
);
});

return <div className={styles.SectionWrapper}>{optionsMarkup}</div>;
}

const optionList =
conditionalOptions.length > 0
? conditionalOptions.map((option) => (
<MappedOption
{...option}
key={option.id || option.value}
selected={selected.includes(option.value)}
singleSelection={!allowMultiple}
/>
))
? buildMappedOptionFromOption(conditionalOptions)
: null;

if (listTitle) {
Expand All @@ -92,8 +135,7 @@ export const Autocomplete: React.FunctionComponent<AutocompleteProps> & {
loading,
options,
willLoadMoreResults,
allowMultiple,
selected,
buildMappedOptionFromOption,
]);

const loadingMarkup = loading ? (
Expand Down
95 changes: 95 additions & 0 deletions src/components/Autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<Autocomplete.TextField
onChange={updateText}
label="Tags"
value={inputValue}
prefix={<Icon source={SearchMinor} color="base" />}
placeholder="Search"
/>
);

return (
<div style={{height: '225px'}}>
<Autocomplete
textField={textField}
selected={selectedOptions}
options={options}
onSelect={updateSelection}
/>
</div>
);
}
```

### Autocomplete with loading

Use to indicate loading state to merchants while option data is processing.
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
74 changes: 73 additions & 1 deletion src/components/Autocomplete/tests/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -488,6 +488,78 @@ describe('<Autocomplete/>', () => {
});
});

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(
<Autocomplete {...defaultProps} options={multipleSectionsOptions} />,
);

triggerFocus(autocomplete.find(ComboBox));

expect(autocomplete).toContainReactComponentTimes(
ListBox.Option,
allOptionsLength,
);
});

it('renders one ListBox.Section for each section', () => {
const autocomplete = mountWithApp(
<Autocomplete {...defaultProps} options={multipleSectionsOptions} />,
);

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(
<Autocomplete {...defaultProps} options={newOptions} />,
);

triggerFocus(autocomplete.find(ComboBox));

expect(autocomplete).toContainReactComponentTimes(
ListBox.Section,
newOptions.length - 1,
);
});
});

function noop() {}

function renderTextField() {
Expand Down
36 changes: 2 additions & 34 deletions src/components/OptionList/OptionList.tsx
Original file line number Diff line number Diff line change
@@ -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<IconProps | ThumbnailProps | AvatarProps>;
}

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;
Expand Down Expand Up @@ -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[],
Expand Down
3 changes: 2 additions & 1 deletion src/components/OptionList/tests/OptionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('<OptionList />', () => {
const defaultProps: OptionListProps = {
Expand Down
Loading