+ );
+
+ function titleCase(string) {
+ return string
+ .toLowerCase()
+ .split(' ')
+ .map((word) => word.replace(word[0], word[0].toUpperCase()))
+ .join('');
+ }
+}
+```
+
+### Autocomplete with loading
+
+Use to indicate loading state to merchants while option data is processing.
+
+```jsx
+function LoadingAutocompleteExample() {
+ const deselectedOptions = useMemo(
+ () => [
+ {value: 'rustic', label: 'Rustic'},
+ {value: 'antique', label: 'Antique'},
+ {value: 'vinyl', label: 'Vinyl'},
+ {value: 'vintage', label: 'Vintage'},
+ {value: 'refurbished', label: 'Refurbished'},
+ ],
+ [],
+ );
+
+ const [selectedOption, setSelectedOption] = useState();
+ const [inputValue, setInputValue] = useState('');
+ const [options, setOptions] = useState(deselectedOptions);
+ const [loading, setLoading] = useState(false);
+
+ const updateText = useCallback(
+ (value) => {
+ setInputValue(value);
+
+ if (!loading) {
+ setLoading(true);
+ }
+
+ setTimeout(() => {
+ if (value === '') {
+ setOptions(deselectedOptions);
+ setLoading(false);
+ return;
+ }
+ const filterRegex = new RegExp(value, 'i');
+ const resultOptions = options.filter((option) =>
+ option.label.match(filterRegex),
+ );
+ setOptions(resultOptions);
+ setLoading(false);
+ }, 300);
+ },
+ [deselectedOptions, loading, options],
+ );
+
+ const updateSelection = useCallback(
+ (selected) => {
+ const matchedOption = options.find((option) => {
+ return option.value.match(selected);
+ });
+
+ setSelectedOption(selected);
+ setInputValue((matchedOption && matchedOption.label) || '');
+ },
+ [options],
+ );
+
+ const optionsMarkup =
+ options.length > 0
+ ? options.map((option) => {
+ const {label, value} = option;
+
+ return (
+
+ {label}
+
+ );
+ })
+ : null;
+
+ const loadingMarkup = loading ? : null;
+
+ const listBoxMarkup =
+ optionsMarkup || loadingMarkup ? (
+
+ {optionsMarkup && !loading ? optionsMarkup : null}
+ {loadingMarkup}
+
+ ) : null;
+
+ return (
+ }
+ onChange={updateText}
+ label="Search customers"
+ labelHidden
+ value={inputValue}
+ placeholder="Search customers"
+ />
+ }
+ >
+ {listBoxMarkup}
+
+ );
+}
+```
+
+---
+
+## Related components
+
+- For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field)
+- For a list of selectable options not linked to an input field, [use the list box component](https://polaris.shopify.com/components/lists-and-tables/list-box)
+- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of `ComboBox` and `ListBox`.
+
+---
+
+## Accessibility
+
+
+
+See Material Design and development documentation about accessibility for Android:
+
+- [Accessible design on Android](https://material.io/design/usability/accessibility.html)
+- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/)
+
+
+
+
+
+See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS:
+
+- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/)
+- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/)
+
+
+
+
+
+### Structure
+
+The `ComboBox` component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). It is a combination of a single-line `TextField` and a `Popover`. The current implementation expects a [`ListBox`] component to be used.
+
+The `ComboBox` popover displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop.
+
+`ComboBox` features can be challenging for merchants with visual, motor, and cognitive disabilities. Even when they’re built using best practices, these features can be difficult to use with some assistive technologies. Merchants should always be able to search, enter data, or perform other activities without relying on the combobox.
+
+
+
+#### Do
+
+- Use combobox as progressive enhancement to make the interface easier to use for most merchants.
+
+#### Don’t
+
+- Require that merchants make a selection from the combobox to complete a task.
+
+
+
+### Keyboard support
+
+- Give the combobox's text input keyboard focus with the tab key (or shift + tab when tabbing backwards)
+
+
diff --git a/src/components/ComboBox/components/TextField/TextField.tsx b/src/components/ComboBox/components/TextField/TextField.tsx
new file mode 100644
index 00000000000..14c9831b492
--- /dev/null
+++ b/src/components/ComboBox/components/TextField/TextField.tsx
@@ -0,0 +1,78 @@
+import React, {useMemo, useCallback, useEffect} from 'react';
+
+import {labelID} from '../../../Label';
+import {useUniqueId} from '../../../../utilities/unique-id';
+import {TextField as PolarisTextField} from '../../../TextField';
+import type {TextFieldProps} from '../../../TextField';
+import {useComboBoxTextField} from '../../../../utilities/combo-box';
+
+export function TextField({
+ value,
+ id: idProp,
+ onFocus,
+ onBlur,
+ onChange,
+ ...rest
+}: TextFieldProps) {
+ const comboboxTextFieldContext = useComboBoxTextField();
+
+ const {
+ activeOptionId,
+ listBoxId,
+ expanded,
+ setTextFieldFocused,
+ setTextFieldLabelId,
+ onTextFieldFocus,
+ onTextFieldChange,
+ onTextFieldBlur,
+ } = comboboxTextFieldContext;
+
+ const uniqueId = useUniqueId('ComboBoxTextField');
+ const textFieldId = useMemo(() => idProp || uniqueId, [uniqueId, idProp]);
+
+ const labelId = useMemo(() => labelID(idProp || uniqueId), [
+ uniqueId,
+ idProp,
+ ]);
+
+ useEffect(() => {
+ if (setTextFieldLabelId) setTextFieldLabelId(labelId);
+ }, [labelId, setTextFieldLabelId]);
+
+ const handleFocus = useCallback(() => {
+ if (onFocus) onFocus();
+ if (onTextFieldFocus) onTextFieldFocus();
+ if (setTextFieldFocused) setTextFieldFocused(true);
+ }, [onFocus, onTextFieldFocus, setTextFieldFocused]);
+
+ const handleBlur = useCallback(() => {
+ if (onBlur) onBlur();
+ if (onTextFieldBlur) onTextFieldBlur();
+ if (setTextFieldFocused) setTextFieldFocused(false);
+ }, [onBlur, onTextFieldBlur, setTextFieldFocused]);
+
+ const handleChange = useCallback(
+ (value: string, id: string) => {
+ if (onChange) onChange(value, id);
+ if (onTextFieldChange) onTextFieldChange();
+ },
+ [onChange, onTextFieldChange],
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/ComboBox/components/TextField/index.ts b/src/components/ComboBox/components/TextField/index.ts
new file mode 100644
index 00000000000..35e9e6ed312
--- /dev/null
+++ b/src/components/ComboBox/components/TextField/index.ts
@@ -0,0 +1 @@
+export {TextField} from './TextField';
diff --git a/src/components/ComboBox/components/TextField/tests/TextField.test.tsx b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx
new file mode 100644
index 00000000000..3f3ca4b9fc9
--- /dev/null
+++ b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx
@@ -0,0 +1,254 @@
+import React from 'react';
+import {mountWithApp} from 'test-utilities';
+
+import {TextField as PolarisTextField} from '../../../../TextField';
+import type {TextFieldProps} from '../../../../TextField';
+import {TextField} from '../TextField';
+import {labelID} from '../../../../Label';
+import {
+ ComboBoxTextFieldContext,
+ ComboBoxTextFieldType,
+} from '../../../../../utilities/combo-box';
+
+const textFieldContextDefaultValue = {
+ activeOptionId: undefined,
+ listBoxId: undefined,
+ expanded: false,
+ setTextFieldLabelId: noop,
+ setTextFieldFocused: noop,
+ onTextFieldFocus: noop,
+ onTextFieldBlur: noop,
+ onTextFieldChange: noop,
+};
+
+function mountWithProvider(
+ props: {
+ textFieldProps?: Partial;
+ textFieldProviderValue?: Partial;
+ } = {},
+) {
+ const providerValue = {
+ ...textFieldContextDefaultValue,
+ ...props.textFieldProviderValue,
+ };
+
+ const textField = mountWithApp(
+
+
+ ,
+ );
+
+ return textField;
+}
+
+describe('ComboBox.TextField', () => {
+ it('throws if not wrapped in ComboBoxTextFieldContext', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ expect(() =>
+ mountWithApp(
+ ,
+ ),
+ ).toThrow('No ComboBox was provided.');
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('renders a PolarisTextField', () => {
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ },
+ });
+
+ expect(combobox).toContainReactComponent(PolarisTextField, {
+ value: 'value',
+ autoComplete: 'off',
+ id: 'textFieldId',
+ onFocus: expect.any(Function),
+ onBlur: expect.any(Function),
+ onChange: expect.any(Function),
+ ariaAutocomplete: 'list',
+ ariaActiveDescendant: undefined,
+ ariaControls: undefined,
+ role: 'combobox',
+ ariaExpanded: false,
+ });
+ });
+
+ it('passes the activeOptionId to the aria-activedescendant of the PolarisTextField', () => {
+ const activeOptionId = 'activeOptionId';
+ const combobox = mountWithProvider({
+ textFieldProviderValue: {
+ activeOptionId,
+ },
+ });
+
+ expect(combobox).toContainReactComponent(PolarisTextField, {
+ ariaActiveDescendant: activeOptionId,
+ });
+ });
+
+ it('passes the listBoxId to the aria-controls of the PolarisTextField', () => {
+ const listBoxId = 'listBoxId';
+ const combobox = mountWithProvider({
+ textFieldProviderValue: {
+ listBoxId,
+ },
+ });
+
+ expect(combobox).toContainReactComponent(PolarisTextField, {
+ ariaControls: listBoxId,
+ });
+ });
+
+ it('passes the expanded to the aria-expanded of the PolarisTextField', () => {
+ const combobox = mountWithProvider({
+ textFieldProviderValue: {
+ expanded: true,
+ },
+ });
+
+ expect(combobox).toContainReactComponent(PolarisTextField, {
+ ariaExpanded: true,
+ });
+ });
+
+ it('calls setTextFieldLabelId with the expected ID', () => {
+ const textFieldId = 'textFieldId';
+ const setTextFieldLabelIdSpy = jest.fn();
+ const expectedId = labelID(textFieldId);
+ mountWithProvider({
+ textFieldProps: {
+ id: textFieldId,
+ },
+ textFieldProviderValue: {
+ setTextFieldLabelId: setTextFieldLabelIdSpy,
+ },
+ });
+
+ expect(setTextFieldLabelIdSpy).toHaveBeenCalledWith(expectedId);
+ });
+
+ describe('onFocus', () => {
+ it('calls the onFocus prop on focus', () => {
+ const onFocusSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ onFocus: onFocusSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onFocus');
+
+ expect(onFocusSpy).toHaveBeenCalled();
+ });
+
+ it('calls the onTextFieldFocus on Context', () => {
+ const onTextFieldFocusSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ },
+ textFieldProviderValue: {
+ onTextFieldFocus: onTextFieldFocusSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onFocus');
+
+ expect(onTextFieldFocusSpy).toHaveBeenCalled();
+ });
+
+ it('calls the setTextFieldFocused on Context', () => {
+ const setTextFieldFocusSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ },
+ textFieldProviderValue: {
+ setTextFieldFocused: setTextFieldFocusSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onFocus');
+
+ expect(setTextFieldFocusSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('onBlur', () => {
+ it('calls the onBlur prop', () => {
+ const onBlurSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ onBlur: onBlurSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onBlur');
+
+ expect(onBlurSpy).toHaveBeenCalled();
+ });
+
+ it('calls the onTextFieldBlur on Context', () => {
+ const onTextFieldBlurSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ },
+ textFieldProviderValue: {
+ onTextFieldBlur: onTextFieldBlurSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onBlur');
+
+ expect(onTextFieldBlurSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('onChange', () => {
+ it('calls the onChange prop', () => {
+ const onChangeSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ onChange: onChangeSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onChange');
+
+ expect(onChangeSpy).toHaveBeenCalled();
+ });
+
+ it('calls the onTextFieldChange on Context', () => {
+ const onTextFieldChangeSpy = jest.fn();
+ const combobox = mountWithProvider({
+ textFieldProps: {
+ value: 'value',
+ id: 'textFieldId',
+ },
+ textFieldProviderValue: {
+ onTextFieldChange: onTextFieldChangeSpy,
+ },
+ });
+ combobox.find(PolarisTextField)!.trigger('onChange');
+
+ expect(onTextFieldChangeSpy).toHaveBeenCalled();
+ });
+ });
+});
+
+function noop() {}
diff --git a/src/components/ComboBox/components/index.ts b/src/components/ComboBox/components/index.ts
new file mode 100644
index 00000000000..35e9e6ed312
--- /dev/null
+++ b/src/components/ComboBox/components/index.ts
@@ -0,0 +1 @@
+export {TextField} from './TextField';
diff --git a/src/components/ComboBox/index.ts b/src/components/ComboBox/index.ts
new file mode 100644
index 00000000000..f50eeedf616
--- /dev/null
+++ b/src/components/ComboBox/index.ts
@@ -0,0 +1,2 @@
+export * from './ComboBox';
+export {TextField as ComboBoxTextField} from './components';
diff --git a/src/components/ComboBox/tests/ComboBox.test.tsx b/src/components/ComboBox/tests/ComboBox.test.tsx
new file mode 100644
index 00000000000..e98356ff697
--- /dev/null
+++ b/src/components/ComboBox/tests/ComboBox.test.tsx
@@ -0,0 +1,373 @@
+import React from 'react';
+import {mountWithApp} from 'test-utilities';
+
+import {TextField} from '../../TextField';
+import {ComboBox} from '../ComboBox';
+import {ListBox} from '../../ListBox';
+import {Popover} from '../../Popover';
+import {
+ ComboBoxTextFieldContext,
+ ComboBoxListBoxContext,
+} from '../../../utilities/combo-box';
+import {Key} from '../../../types';
+
+describe('', () => {
+ const activator = (
+
+ );
+ const listBox = (
+
+
+
+ );
+
+ it('renders a Popover in the providers', () => {
+ const combobox = mountWithApp(
+ {listBox},
+ );
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ onClose: expect.any(Function),
+ autofocusTarget: 'none',
+ fullWidth: true,
+ preferInputActivator: false,
+ });
+ });
+
+ it('renders the activator in ComboBoxTextFieldContext provider', () => {
+ const combobox = mountWithApp(
+ {listBox},
+ );
+
+ expect(combobox.find(ComboBoxTextFieldContext.Provider)).toHaveReactProps({
+ children: activator,
+ });
+ });
+
+ it('renders the popover children in a ComboBoxListBoxContext provider', () => {
+ const combobox = mountWithApp(
+ {listBox},
+ );
+
+ triggerFocus(combobox);
+
+ expect(
+ combobox.find(ComboBoxListBoxContext.Provider),
+ ).toContainReactComponent(ListBox);
+ });
+
+ it('does not open Popover when the ComboBoxTextFieldContext onTextFieldFocus and there are no children', () => {
+ const combobox = mountWithApp();
+
+ triggerFocus(combobox);
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ it('renders an active Popover when the activator is focused and there are children', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: true,
+ });
+ });
+
+ it('closes the Popover when onOptionSelected is triggered and allowMultiple is false', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: true,
+ });
+
+ triggerOptionSelected(combobox);
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ it('does not close the Popover when onOptionSelected is triggered and allowMultiple is true and there are children', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: true,
+ });
+
+ combobox
+ .find(ComboBoxListBoxContext.Provider)!
+ .triggerKeypath('value.onOptionSelected');
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: true,
+ });
+ });
+
+ it('calls the onScrolledToBottom when the Popovers onScrolledToBottom is triggered', () => {
+ const onScrolledToBottomSpy = jest.fn();
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ combobox.find(Popover.Pane)!.trigger('onScrolledToBottom');
+
+ expect(onScrolledToBottomSpy).toHaveBeenCalled();
+ });
+
+ it('closes the Popover when onClose is called', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+ combobox.find(Popover)?.trigger('onClose');
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ it('opens the Popover when the TextField activator is changed', () => {
+ const activator = (
+
+ );
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ combobox.find(TextField)?.trigger('onChange');
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: true,
+ });
+ });
+
+ it('closes the Popover when TextField is blurred', () => {
+ const activator = (
+
+ );
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+ combobox.find(TextField)?.trigger('onBlur');
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ describe('popover', () => {
+ it('defaults active to false', () => {
+ const combobox = mountWithApp();
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ it('has fullWidth', () => {
+ const combobox = mountWithApp();
+
+ expect(combobox).toContainReactComponent(Popover, {
+ fullWidth: true,
+ });
+ });
+
+ it('has autofocusTarget of none', () => {
+ const combobox = mountWithApp();
+
+ expect(combobox).toContainReactComponent(Popover, {
+ autofocusTarget: 'none',
+ });
+ });
+
+ it('sets active to false when escape is pressed', () => {
+ const activator = (
+
+ );
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ combobox.act(() => {
+ dispatchKeyup(Key.Escape);
+ });
+
+ expect(combobox).toContainReactComponent(Popover, {
+ active: false,
+ });
+ });
+
+ it('passes the preferredPosition', () => {
+ const preferredPosition = 'above';
+ const combobox = mountWithApp(
+ ,
+ );
+
+ expect(combobox).toContainReactComponent(Popover, {
+ preferredPosition,
+ });
+ });
+ });
+
+ describe('Context', () => {
+ it('sets expanded to true on the ComboBoxTextFieldContext when the popover is active', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ expect(
+ combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')!
+ .expanded,
+ ).toBe(true);
+ });
+
+ it('sets expanded to false on the ComboBoxTextFieldContext when the popover is not active', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ combobox
+ .find(ComboBoxListBoxContext.Provider)!
+ .triggerKeypath('value.onOptionSelected');
+
+ expect(
+ combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')!
+ .expanded,
+ ).toBe(false);
+ });
+
+ it('sets the activeOptionId on the ComboBoxTextFieldContext to undefined the popover is not closed', () => {
+ const combobox = mountWithApp(
+
+
+
+
+ ,
+ );
+
+ triggerFocus(combobox);
+
+ combobox
+ .find(ComboBoxListBoxContext.Provider)!
+ .triggerKeypath('value.setActiveOptionId', 'id');
+
+ expect(
+ combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')!
+ .activeOptionId,
+ ).toBe('id');
+
+ triggerOptionSelected(combobox);
+
+ expect(
+ combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')!
+ .activeOptionId,
+ ).toBeUndefined();
+ });
+ });
+});
+
+function triggerFocus(combobox: any) {
+ combobox
+ .find(ComboBoxTextFieldContext.Provider)!
+ .triggerKeypath('value.onTextFieldFocus');
+}
+
+function triggerOptionSelected(combobox: any) {
+ combobox
+ .find(ComboBoxListBoxContext.Provider)!
+ .triggerKeypath('value.onOptionSelected');
+}
+
+function noop() {}
+
+function dispatchKeyup(key: Key) {
+ const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key};
+ document.dispatchEvent(new KeyboardEvent('keyup', event));
+}
diff --git a/src/components/Filters/Filters.tsx b/src/components/Filters/Filters.tsx
index ad65dab43d2..a9efe1bf107 100644
--- a/src/components/Filters/Filters.tsx
+++ b/src/components/Filters/Filters.tsx
@@ -287,6 +287,7 @@ class FiltersInner extends Component {
clearButton
onClearButtonClick={onQueryClear}
disabled={disabled}
+ autoComplete="off"
/>
)}
diff --git a/src/components/Filters/README.md b/src/components/Filters/README.md
index e4880e96c7e..fcfb3d4d003 100644
--- a/src/components/Filters/README.md
+++ b/src/components/Filters/README.md
@@ -211,6 +211,7 @@ function ResourceListFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -423,6 +424,7 @@ function DataTableFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -555,6 +557,7 @@ function FiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -681,6 +684,7 @@ function DisableAllFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -816,6 +820,7 @@ function DisableSomeFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -829,6 +834,7 @@ function DisableSomeFiltersExample() {
label="Vendor"
value={vendor}
onChange={handleVendorChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -960,6 +966,7 @@ function Playground() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -1131,6 +1138,7 @@ function ResourceListFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -1331,6 +1339,7 @@ function ResourceListFiltersExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
diff --git a/src/components/Form/README.md b/src/components/Form/README.md
index 15ba3eefef2..4faf1c6b79f 100644
--- a/src/components/Form/README.md
+++ b/src/components/Form/README.md
@@ -65,6 +65,7 @@ function FormOnSubmitExample() {
onChange={handleEmailChange}
label="Email"
type="email"
+ autoComplete="email"
helpText={
We’ll use this email address to inform you on future changes to
@@ -100,6 +101,7 @@ function FormWithoutNativeValidationExample() {
onChange={handleUrlChange}
label="App URL"
type="url"
+ autoComplete="off"
/>
diff --git a/src/components/FormLayout/README.md b/src/components/FormLayout/README.md
index 0a56066ecd1..7e0870bf5c7 100644
--- a/src/components/FormLayout/README.md
+++ b/src/components/FormLayout/README.md
@@ -102,8 +102,13 @@ Use to stack form fields vertically, which makes them easier to scan and complet
```jsx
- {}} />
- {}} />
+ {}} autoComplete="off" />
+ {}}
+ autoComplete="email"
+ />
```
@@ -130,8 +135,18 @@ Field groups will wrap automatically on smaller screens.
```jsx
- {}} />
- {}} />
+ {}}
+ autoComplete="off"
+ />
+ {}}
+ autoComplete="off"
+ />
```
@@ -157,10 +172,10 @@ For very short inputs, the width of the inputs may be reduced in order to fit mo
```jsx
- {}} />
- {}} />
- {}} />
- {}} />
+ {}} autoComplete="off" />
+ {}} autoComplete="off" />
+ {}} autoComplete="off" />
+ {}} autoComplete="off" />
```
diff --git a/src/components/FormLayout/components/Group/tests/Group.test.tsx b/src/components/FormLayout/components/Group/tests/Group.test.tsx
index ef592a2fbcc..dbe1e5cb3a7 100644
--- a/src/components/FormLayout/components/Group/tests/Group.test.tsx
+++ b/src/components/FormLayout/components/Group/tests/Group.test.tsx
@@ -12,7 +12,7 @@ describe('', () => {
let item: any;
beforeAll(() => {
- children = ;
+ children = ;
title = 'Title';
helpText = 'Help text';
item = mountWithAppProvider(
diff --git a/src/components/FormLayout/components/Item/tests/Item.test.tsx b/src/components/FormLayout/components/Item/tests/Item.test.tsx
index cb7d28448cc..895f980beb6 100644
--- a/src/components/FormLayout/components/Item/tests/Item.test.tsx
+++ b/src/components/FormLayout/components/Item/tests/Item.test.tsx
@@ -7,7 +7,9 @@ import {Item} from '../Item';
describe('', () => {
it('renders its children', () => {
- const children = ;
+ const children = (
+
+ );
const item = mountWithAppProvider({children});
expect(item.contains(children)).toBe(true);
});
diff --git a/src/components/FormLayout/tests/FormLayout.test.tsx b/src/components/FormLayout/tests/FormLayout.test.tsx
index 016dc0ca970..f79ce9a08c5 100644
--- a/src/components/FormLayout/tests/FormLayout.test.tsx
+++ b/src/components/FormLayout/tests/FormLayout.test.tsx
@@ -7,7 +7,9 @@ import {FormLayout} from '../FormLayout';
describe('', () => {
it('renders its children', () => {
- const children = ;
+ const children = (
+
+ );
const formLayout = mountWithAppProvider(
{children},
);
diff --git a/src/components/Frame/README.md b/src/components/Frame/README.md
index 2725d9c6c9c..bde4a651b9c 100644
--- a/src/components/Frame/README.md
+++ b/src/components/Frame/README.md
@@ -245,12 +245,14 @@ function FrameExample() {
label="Full name"
value={nameFieldValue}
onChange={handleNameFieldChange}
+ autoComplete="name"
/>
@@ -292,11 +294,13 @@ function FrameExample() {
label="Subject"
value={supportSubject}
onChange={handleSubjectChange}
+ autoComplete="off"
/>
@@ -579,12 +583,14 @@ function FrameExample() {
label="Full name"
value={nameFieldValue}
onChange={handleNameFieldChange}
+ autoComplete="name"
/>
@@ -626,11 +632,13 @@ function FrameExample() {
label="Subject"
value={supportSubject}
onChange={handleSubjectChange}
+ autoComplete="off"
/>
diff --git a/src/components/IndexTable/README.md b/src/components/IndexTable/README.md
index 7c69e72bb47..46c3b1c75a0 100644
--- a/src/components/IndexTable/README.md
+++ b/src/components/IndexTable/README.md
@@ -603,6 +603,7 @@ function IndexTableWithFilteringExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -864,6 +865,7 @@ function IndexTableWithAllElementsExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
@@ -1049,6 +1051,7 @@ function SmallScreenIndexTableWithAllElementsExample() {
label="Tagged with"
value={taggedWith}
onChange={handleTaggedWithChange}
+ autoComplete="off"
labelHidden
/>
),
diff --git a/src/components/Layout/README.md b/src/components/Layout/README.md
index 4cb377c7ba3..bbbdee4a637 100644
--- a/src/components/Layout/README.md
+++ b/src/components/Layout/README.md
@@ -417,8 +417,13 @@ Use for settings pages. When settings are grouped thematically in annotated sect
>
- {}} />
- {}} />
+ {}} autoComplete="off" />
+ {}}
+ autoComplete="email"
+ />
@@ -443,8 +448,13 @@ Use for settings pages that need a banner or other content at the top.
>
- {}} />
- {}} />
+ {}} autoComplete="off" />
+ {}}
+ autoComplete="email"
+ />
diff --git a/src/components/ListBox/ListBox.scss b/src/components/ListBox/ListBox.scss
new file mode 100644
index 00000000000..7355fd7b068
--- /dev/null
+++ b/src/components/ListBox/ListBox.scss
@@ -0,0 +1,10 @@
+.ListBox {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ max-width: 100%;
+
+ &:focus {
+ outline: none;
+ }
+}
diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx
new file mode 100644
index 00000000000..c2b062c6482
--- /dev/null
+++ b/src/components/ListBox/ListBox.tsx
@@ -0,0 +1,330 @@
+import React, {
+ useState,
+ useRef,
+ useEffect,
+ useCallback,
+ ReactNode,
+ useMemo,
+} from 'react';
+import debounce from 'lodash/debounce';
+
+import {classNames} from '../../utilities/css';
+import {useToggle} from '../../utilities/use-toggle';
+import {useUniqueId} from '../../utilities/unique-id';
+import {Key} from '../../types';
+import {KeypressListener} from '../KeypressListener';
+import {VisuallyHidden} from '../VisuallyHidden';
+import {useComboBoxListBox} from '../../utilities/combo-box';
+import {closestParentMatch} from '../../utilities/closest-parent-match';
+import {scrollIntoView} from '../../utilities/scroll-into-view';
+import {ListBoxContext, WithinListBoxContext} from '../../utilities/list-box';
+import type {NavigableOption} from '../../utilities/list-box';
+
+import {
+ Option,
+ Section,
+ Header,
+ Action,
+ Loading,
+ TextOption,
+ listBoxSectionDataSelector,
+} from './components';
+import styles from './ListBox.scss';
+
+export interface ListBoxProps {
+ /** Inner content of the listbox */
+ children: ReactNode;
+ /** Explicitly enable keyboard control */
+ enableKeyboardControl?: boolean;
+ /** Visually hidden text for screen readers */
+ accessibilityLabel?: string;
+ /** Callback when an option is selected */
+ onSelect?(value: string): void;
+}
+
+export type ArrowKeys = 'up' | 'down';
+
+export const scrollable = {
+ props: {'data-polaris-scrollable': true},
+ selector: '[data-polaris-scrollable]',
+};
+
+const LISTBOX_OPTION_SELECTOR = '[data-listbox-option]';
+const LISTBOX_OPTION_VALUE_ATTRIBUTE = 'data-listbox-option-value';
+
+const DATA_ATTRIBUTE = 'data-focused';
+
+export function ListBox({
+ children,
+ enableKeyboardControl,
+ accessibilityLabel,
+ onSelect,
+}: ListBoxProps) {
+ const listBoxClassName = classNames(styles.ListBox);
+ const {
+ value: keyboardEventsEnabled,
+ setTrue: enableKeyboardEvents,
+ setFalse: disableKeyboardEvents,
+ } = useToggle(Boolean(enableKeyboardControl));
+ const listId = useUniqueId('ListBox');
+ const scrollableRef = useRef(null);
+ const listBoxRef = useRef(null);
+ const [loading, setLoading] = useState();
+ const [currentActiveOption, setCurrentActiveOption] = useState<
+ NavigableOption
+ >();
+ const {
+ setActiveOptionId,
+ setListBoxId,
+ listBoxId,
+ textFieldLabelId,
+ onOptionSelected,
+ onKeyToBottom,
+ textFieldFocused,
+ } = useComboBoxListBox();
+
+ const inComboBox = Boolean(setActiveOptionId);
+
+ useEffect(() => {
+ if (setListBoxId && !listBoxId) {
+ setListBoxId(listId);
+ }
+ }, [setListBoxId, listBoxId, listId]);
+
+ useEffect(() => {
+ if (!currentActiveOption || !setActiveOptionId) return;
+ setActiveOptionId(currentActiveOption.domId);
+ }, [currentActiveOption, setActiveOptionId]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const handleScrollIntoView = useCallback(
+ debounce((option: NavigableOption, first: boolean) => {
+ if (scrollableRef.current) {
+ const {element} = option;
+ const focusTarget = first
+ ? closestParentMatch(element, listBoxSectionDataSelector.selector) ||
+ element
+ : element;
+
+ scrollIntoView(focusTarget, scrollableRef.current);
+ }
+ }, 15),
+ [],
+ );
+
+ const handleChangeActiveOption = useCallback(
+ (nextOption?: NavigableOption) => {
+ setCurrentActiveOption((currentActiveOption) => {
+ if (currentActiveOption) {
+ currentActiveOption.element.removeAttribute(DATA_ATTRIBUTE);
+ }
+
+ if (nextOption) {
+ nextOption.element.setAttribute(DATA_ATTRIBUTE, 'true');
+ if (scrollableRef.current) {
+ const first =
+ getNavigableOptions().findIndex(
+ (element) => element.id === nextOption.element.id,
+ ) === 0;
+
+ handleScrollIntoView(nextOption, first);
+ }
+ return nextOption;
+ } else {
+ return undefined;
+ }
+ });
+ },
+ [handleScrollIntoView],
+ );
+
+ useEffect(() => {
+ if (listBoxRef.current) {
+ scrollableRef.current = listBoxRef.current.closest(scrollable.selector);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (enableKeyboardControl && !keyboardEventsEnabled) {
+ enableKeyboardEvents();
+ }
+ }, [enableKeyboardControl, keyboardEventsEnabled, enableKeyboardEvents]);
+
+ const onOptionSelect = useCallback(
+ (option: NavigableOption) => {
+ handleChangeActiveOption(option);
+
+ if (onOptionSelected) {
+ onOptionSelected();
+ }
+ if (onSelect) onSelect(option.value);
+ },
+ [handleChangeActiveOption, onSelect, onOptionSelected],
+ );
+
+ const listBoxContext = useMemo(
+ () => ({
+ onOptionSelect,
+ setLoading,
+ }),
+ [onOptionSelect],
+ );
+
+ function findNextValidOption(type: ArrowKeys) {
+ const isUp = type === 'up';
+ const navItems = getNavigableOptions();
+ let nextElement: HTMLElement | null | undefined =
+ currentActiveOption?.element;
+ let count = -1;
+
+ while (count++ < navItems.length) {
+ let nextIndex;
+ if (nextElement) {
+ const currentId = nextElement?.id;
+ const currentIndex = navItems.findIndex(
+ (currentNavItem) => currentNavItem.id === currentId,
+ );
+
+ let increment = isUp ? -1 : 1;
+ if (currentIndex === 0 && isUp) {
+ increment = navItems.length - 1;
+ } else if (currentIndex === navItems.length - 1 && !isUp) {
+ increment = -(navItems.length - 1);
+ }
+
+ nextIndex = currentIndex + increment;
+ nextElement = navItems[nextIndex];
+ } else {
+ nextIndex = isUp ? navItems.length - 1 : 0;
+ nextElement = navItems[nextIndex];
+ }
+
+ if (nextElement?.getAttribute('aria-disabled') === 'true') continue;
+
+ if (nextIndex === navItems.length - 1 && onKeyToBottom) {
+ onKeyToBottom();
+ }
+ return nextElement;
+ }
+
+ return null;
+ }
+
+ function handleArrow(type: ArrowKeys, evt: KeyboardEvent) {
+ evt.preventDefault();
+
+ const nextValidElement = findNextValidOption(type);
+
+ if (!nextValidElement) return;
+
+ const nextOption = {
+ domId: nextValidElement.id,
+ value:
+ nextValidElement.getAttribute(LISTBOX_OPTION_VALUE_ATTRIBUTE) || '',
+ element: nextValidElement,
+ disabled: nextValidElement.getAttribute('aria-disabled') === 'true',
+ };
+
+ handleChangeActiveOption(nextOption);
+ }
+
+ function handleDownArrow(evt: KeyboardEvent) {
+ handleArrow('down', evt);
+ }
+
+ function handleUpArrow(evt: KeyboardEvent) {
+ handleArrow('up', evt);
+ }
+
+ function handleEnter(evt: KeyboardEvent) {
+ evt.preventDefault();
+ evt.stopPropagation();
+ if (currentActiveOption) {
+ onOptionSelect(currentActiveOption);
+ }
+ }
+
+ function handleFocus() {
+ if (enableKeyboardControl) return;
+ enableKeyboardEvents();
+ }
+
+ function handleBlur(event: React.FocusEvent) {
+ event.stopPropagation();
+ if (keyboardEventsEnabled) {
+ handleChangeActiveOption();
+ }
+ if (enableKeyboardControl) return;
+ disableKeyboardEvents();
+ }
+
+ const listeners =
+ keyboardEventsEnabled || textFieldFocused ? (
+ <>
+
+
+
+ >
+ ) : null;
+
+ return (
+ <>
+ {listeners}
+
+
{loading ? loading : null}
+
+
+
+ {children ? (
+
+ {children}
+
+ ) : null}
+
+
+ >
+ );
+
+ function getNavigableOptions() {
+ return [
+ ...new Set(
+ listBoxRef.current?.querySelectorAll(
+ LISTBOX_OPTION_SELECTOR,
+ ),
+ ),
+ ];
+ }
+}
+
+ListBox.Option = Option;
+ListBox.TextOption = TextOption;
+ListBox.Loading = Loading;
+ListBox.Section = Section;
+ListBox.Header = Header;
+ListBox.Action = Action;
diff --git a/src/components/ListBox/README.md b/src/components/ListBox/README.md
new file mode 100644
index 00000000000..c742a952b6c
--- /dev/null
+++ b/src/components/ListBox/README.md
@@ -0,0 +1,180 @@
+---
+name: ListBox
+category: Lists and tables
+keywords:
+ - list
+ - listbox
+ - interactive list
+---
+
+# ListBox
+
+The `ListBox` component is a list component that implements part of the [Aria 1.2 ListBox specs](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). It presents a list of options and allows users to select one or more of them. If you need more structure than the standard component offers, use composition to customize the presentation of these lists by using headers or custom elements.
+
+---
+
+## Best practices
+
+Listboxes should:
+
+- Be clearly labeled so it’s noticeable to the merchant what type of options will be available
+- Limit the number of options displayed at once
+- Indicate a loading state to the merchant while option data is being populated
+
+---
+
+## Content guidelines
+
+### Option lists
+
+Each item in a `ListBox` should be clear and descriptive.
+
+
+
+#### Do
+
+- Traffic referrer source
+
+#### Don’t
+
+- Source
+
+
+
+---
+
+## Examples
+
+### Basic ListBox
+
+Basic implementation of a control element used to let merchants select options
+
+```jsx
+function BaseListBoxExample() {
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+
+ );
+}
+```
+
+### ListBox with Loading
+
+Implementation of a control element showing a loading indicator to let merchants know more options are being loaded
+
+```jsx
+function ListBoxWithLoadingExample() {
+ return (
+
+ Item 1
+ Item 2
+ Item 3
+
+
+ );
+}
+```
+
+### ListBox with Action
+
+Implementation of a control element used to let merchants take an action
+
+```jsx
+function ListBoxWithActionExample() {
+ return (
+
+
+
Add item
+
+ Item 1
+ Item 2
+
+ );
+}
+```
+
+### ListBox with custom element
+
+Implementation of a control with custom rendering of options
+
+```jsx
+function ListBoxWithCustomElementExample() {
+ return (
+
+
+ Add item
+
+
+
Item 1
+
+
+
Item 2
+
+
+
Item 3
+
+
+
+ );
+}
+```
+
+---
+
+## Related components
+
+- For a text field and popover container, [use the combobox component](https://polaris.shopify.com/components/forms/combobox)
+- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of ComboBox and ListBox.
+
+---
+
+## Accessibility
+
+
+
+See Material Design and development documentation about accessibility for Android:
+
+- [Accessible design on Android](https://material.io/design/usability/accessibility.html)
+- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/)
+
+
+
+
+
+See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS:
+
+- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/)
+- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/)
+
+
+
+
+
+### Structure
+
+The `ListBox` component is based on the [Aria 1.2 ListBox pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox).
+
+It is important to not present interactive elements inside of list box options as they can interfere with navigation
+for assistive technology users.
+
+
+
+#### Do
+
+- Use labels
+
+#### Don’t
+
+- Use interactive elements inside the list
+
+
+
+### Keyboard support
+
+- Access the list of options with the up and down arrow keys
+- Select an option that has focus with the enter/return key
+
+
diff --git a/src/components/ListBox/components/Action/Action.scss b/src/components/ListBox/components/Action/Action.scss
new file mode 100644
index 00000000000..4a896235601
--- /dev/null
+++ b/src/components/ListBox/components/Action/Action.scss
@@ -0,0 +1,10 @@
+@import '../../../../styles/common';
+
+.Action {
+ display: flex;
+ flex: 1;
+}
+
+.Icon {
+ padding-right: spacing(tight);
+}
diff --git a/src/components/ListBox/components/Action/Action.tsx b/src/components/ListBox/components/Action/Action.tsx
new file mode 100644
index 00000000000..9494eb969fc
--- /dev/null
+++ b/src/components/ListBox/components/Action/Action.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+import {Icon} from '../../../Icon';
+import type {IconProps} from '../../../Icon';
+import {Option, OptionProps} from '../Option';
+import {TextOption} from '../TextOption';
+
+import styles from './Action.scss';
+
+interface ActionProps extends OptionProps {
+ icon?: IconProps['source'];
+}
+
+export function Action(props: ActionProps) {
+ const {selected, disabled, children, icon} = props;
+
+ const iconMarkup = icon && (
+