diff --git a/packages/@react-aria/searchfield/docs/searchautocomplete-anatomy.png b/packages/@react-aria/searchfield/docs/searchautocomplete-anatomy.png
new file mode 100644
index 00000000000..fef5422570e
Binary files /dev/null and b/packages/@react-aria/searchfield/docs/searchautocomplete-anatomy.png differ
diff --git a/packages/@react-aria/searchfield/docs/useSearchAutocomplete.mdx b/packages/@react-aria/searchfield/docs/useSearchAutocomplete.mdx
new file mode 100644
index 00000000000..406c403bbcc
--- /dev/null
+++ b/packages/@react-aria/searchfield/docs/useSearchAutocomplete.mdx
@@ -0,0 +1,596 @@
+
+
+import {Layout} from '@react-spectrum/docs';
+export default Layout;
+
+import docs from 'docs:@react-aria/searchfield';
+import collectionsDocs from 'docs:@react-types/shared/src/collections.d.ts';
+import {FunctionAPI, HeaderInfo, InterfaceType, TypeContext, TypeLink} from '@react-spectrum/docs';
+import i18nDocs from 'docs:@react-aria/i18n';
+import overlaysDocs from 'docs:@react-aria/overlays';
+import packageData from '@react-aria/searchfield/package.json';
+import selectionDocs from 'docs:@react-stately/selection';
+import statelyDocs from 'docs:@react-stately/combobox';
+import anatomy from 'url:./searchautocomplete-anatomy.png';
+import ChevronRight from '@spectrum-icons/workflow/ChevronRight';
+import {ExampleCard} from '@react-spectrum/docs/src/ExampleCard';
+
+---
+category: Pickers
+keywords: [autocomplete, autosuggest, typeahead, search, aria]
+after_version: 3.0.0
+---
+
+# useSearchAutocomplete
+
+
{docs.exports.useSearchAutocomplete.description}
+
+
+
+## API
+
+
+
+## Features
+
+Autocomplete for search fields can be implemented using the [<datalist>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/datalist) HTML element, but this has limited functionality and behaves differently across browsers.
+`useSearchAutocomplete` helps achieve accessible search field and autocomplete components that can be styled as needed.
+
+* Support for filtering a list of options by typing
+* Support for selecting a single option
+* Support for disabled options
+* Support for groups of items in sections
+* Support for custom user input values
+* Support for controlled and uncontrolled options, selection, input value, and open state
+* Support for custom filter functions
+* Async loading and infinite scrolling support
+* Support for virtualized scrolling for performance with long lists
+* Exposed to assistive technology as a `combobox` with ARIA
+* Labeling support for accessibility
+* Required and invalid states exposed to assistive technology via ARIA
+* Support for mouse, touch, and keyboard interactions
+* Keyboard support for opening the list box using the arrow keys, including automatically focusing
+ the first or last item accordingly
+* Support for opening the list box when typing, on focus, or manually
+* Handles virtual clicks on the input from touch screen readers to toggle the list box
+* Virtual focus management for list box option navigation
+* Hides elements outside the input and list box from assistive technology while the list box is open in a portal
+* Custom localized announcements for option focusing, filtering, and selection using an ARIA live region to work around VoiceOver bugs
+
+## Anatomy
+
+
+
+A search autocomplete consists of a label, an input which displays the current value, and a list box popup. Users can type within the input
+to see search suggestions within the list box. The list box popup may be opened by a variety of input field interactions specified
+by the `menuTrigger` prop provided to `useSearchAutocomplete`. `useSearchAutocomplete` handles exposing
+the correct ARIA attributes for accessibility for each of the elements comprising the search autocomplete. It should be combined
+with [useListBox](useListBox.html), which handles the implementation of the popup list box.
+
+`useSearchAutocomplete` returns props that you should spread onto the appropriate elements:
+
+
+
+
+
+State is managed by the hook from `@react-stately/combobox`.
+The state object should be passed as an option to `useSearchAutocomplete`.
+
+If the search field does not have a visible label, an `aria-label` or `aria-labelledby` prop must be provided instead to
+identify it to assistive technology.
+
+## State management
+
+`useSearchAutocomplete` requires knowledge of the options in order to handle keyboard
+navigation and other interactions. It does this using the
+interface, which is a generic interface to access sequential unique keyed data. You can
+implement this interface yourself, e.g. by using a prop to pass a list of item objects,
+but from
+`@react-stately/combobox` implements a JSX based interface for building collections instead.
+See [Collection Components](/react-stately/collections.html) for more information,
+and [Collection Interface](/react-stately/Collection.html) for internal details.
+
+In addition,
+manages the state necessary for single selection and exposes
+a ,
+which makes use of the collection to provide an interface to update the selection state.
+It also holds state to track if the popup is open, if the search field is focused, and the current input value.
+For more information about selection, see [Selection](/react-stately/selection.html).
+
+## Example
+
+This example uses an `` element for the search field.
+A "contains" filter function is obtained from and is passed to so
+that the list box can be filtered based on the option text and the current input text.
+
+The list box popup should use the same `Popover` and `ListBox` components created with [useOverlay](useOverlay.html)
+and [useListBox](useListBox.html) that you may already have in your component library or application. These can be shared with other
+components such as a `Select` created with [useSelect](useSelect.html) or a `Dialog` popover created with [useDialog](useDialog.html).
+The code for these components is also included below in the collapsed sections.
+
+This example does not do any advanced popover positioning or portaling to escape its visual container.
+See [useOverlayTrigger](useOverlayTrigger.html) for an example of how to implement this
+using .
+
+In addition, see [useListBox](useListBox.html) for examples of sections (option groups), and more complex
+options.
+
+```tsx example export=true
+import {Item} from '@react-stately/collections';
+import {useButton} from '@react-aria/button';
+import {useComboBoxState} from '@react-stately/combobox'
+import {useSearchAutocomplete} from '@react-aria/searchfield';
+import {useFilter} from '@react-aria/i18n';
+
+// Reuse the ListBox and Popover from your component library. See below for details.
+import {ListBox, Popover} from 'your-component-library';
+
+function SearchAutocomplete(props) {
+ // Setup filter function and state.
+ let {contains} = useFilter({sensitivity: 'base'});
+ let state = useComboBoxState({...props, defaultFilter: contains});
+
+ // Setup refs and get props for child elements.
+ let inputRef = React.useRef(null);
+ let listBoxRef = React.useRef(null);
+ let popoverRef = React.useRef(null);
+
+ let {inputProps, listBoxProps, labelProps, clearButtonProps} = useSearchAutocomplete(
+ {
+ ...props,
+ popoverRef,
+ listBoxRef,
+ inputRef,
+ },
+ state
+ );
+
+ let {buttonProps} = useButton(clearButtonProps);
+
+ return (
+
+ );
+}
+
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Popover
+
+The `Popover` component is used to contain the popup listbox for the SearchAutocomplete.
+It can be shared between many other components, including [Select](useSelect.html),
+[Menu](useMenuTrigger.html), [Dialog](useOverlayTrigger.html), and others.
+See [useOverlayTrigger](useOverlayTrigger.html) for more examples of popovers.
+
+
+ Show code
+
+```tsx example export=true render=false
+import {useOverlay, DismissButton} from '@react-aria/overlays';
+import {FocusScope} from '@react-aria/focus';
+
+function Popover(props) {
+ let ref = React.useRef();
+ let {
+ popoverRef = ref,
+ isOpen,
+ onClose,
+ children
+ } = props;
+
+ // Handle events that should cause the popup to close,
+ // e.g. blur, clicking outside, or pressing the escape key.
+ let {overlayProps} = useOverlay({
+ isOpen,
+ onClose,
+ shouldCloseOnBlur: true,
+ isDismissable: true
+ }, popoverRef);
+
+ // Add a hidden component at the end of the popover
+ // to allow screen reader users to dismiss the popup easily.
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+
+
+### ListBox
+
+The `ListBox` and `Option` components are used to show the filtered list of options as the
+user types in the SearchAutocomplete. They can also be shared with other components like a [Select](useSelect.html). See
+[useListBox](useListBox.html) for more examples, including sections and more complex items.
+
+
+ Show code
+
+```tsx example export=true render=false
+import {useListBox, useOption} from '@react-aria/listbox';
+
+function ListBox(props) {
+ let ref = React.useRef();
+ let {listBoxRef = ref, state} = props;
+ let {listBoxProps} = useListBox(props, state, listBoxRef);
+
+ return (
+
+ {[...state.collection].map(item => (
+
+ ))}
+
+ );
+}
+
+function Option({item, state}) {
+ let ref = React.useRef();
+ let {optionProps, isSelected, isFocused, isDisabled} = useOption({key: item.key}, state, ref);
+
+ let backgroundColor;
+ let color = 'black';
+
+ if (isSelected) {
+ backgroundColor = 'blueviolet';
+ color = 'white';
+ } else if (isFocused) {
+ backgroundColor = 'gray';
+ } else if (isDisabled) {
+ backgroundColor = 'transparent';
+ color = 'gray';
+ }
+
+ return (
+
+ {item.rendered}
+
+ );
+}
+```
+
+
+
+## Usage
+
+The following examples show how to use the SearchAutocomplete component created in the above example.
+
+### Uncontrolled
+
+The following example shows how you would create an uncontrolled SearchAutocomplete. The input value, selected option, and open state is completely
+uncontrolled.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Dynamic collections
+
+SearchAutocomplete follows the [Collection Components API](../react-stately/collections.html), accepting both static and dynamic collections.
+The examples above show static collections, which can be used when the full list of options is known ahead of time. Dynamic collections,
+as shown below, can be used when the options come from an external data source such as an API call, or update over time.
+
+As seen below, an iterable list of options is passed to the SearchAutocomplete using the `defaultItems` prop. The input's value is passed to the
+`onSubmit` handler, along with a key if the event was triggered by selecting an item from the listbox.
+
+```tsx example
+function Example() {
+ let options = [
+ {id: 1, name: 'Aerospace'},
+ {id: 2, name: 'Mechanical'},
+ {id: 3, name: 'Civil'},
+ {id: 4, name: 'Biomedical'},
+ {id: 5, name: 'Nuclear'},
+ {id: 6, name: 'Industrial'},
+ {id: 7, name: 'Chemical'},
+ {id: 8, name: 'Agricultural'},
+ {id: 9, name: 'Electrical'}
+ ];
+ let [major, setMajor] = React.useState();
+
+ let onSubmit = (value, key) => {
+ if (value) {
+ setMajor(value);
+ } else if (key) {
+ setMajor(options.find(o => o.id === key).name);
+ }
+ };
+
+ return (
+ <>
+
+ {(item) => {item.name}}
+
+
Results for: {major}
+ >
+ );
+}
+```
+
+### Custom filtering
+
+By default, `useComboBoxState` uses the filter function passed to the `defaultFilter` prop (in the above example, a
+"contains" function from `useFilter`). The filter function can be overridden by users of the `SearchAutocomplete` component by
+using the `items` prop to control the filtered list. When `items` is provided rather than `defaultItems`, `useComboBoxState`
+does no filtering of its own.
+
+The following example makes the `inputValue` controlled, and updates the filtered list that is passed to the `items`
+prop when the input changes value.
+
+```tsx example
+function Example() {
+ let options = [
+ {id: 1, email: 'fake@email.com'},
+ {id: 2, email: 'anotherfake@email.com'},
+ {id: 3, email: 'bob@email.com'},
+ {id: 4, email: 'joe@email.com'},
+ {id: 5, email: 'yourEmail@email.com'},
+ {id: 6, email: 'valid@email.com'},
+ {id: 7, email: 'spam@email.com'},
+ {id: 8, email: 'newsletter@email.com'},
+ {id: 9, email: 'subscribe@email.com'}
+ ];
+
+ let {startsWith} = useFilter({sensitivity: 'base'});
+ let [filterValue, setFilterValue] = React.useState('');
+ let filteredItems = React.useMemo(
+ () => options.filter((item) => startsWith(item.email, filterValue)),
+ [options, filterValue]
+ );
+
+ return (
+
+ {(item) => {item.email}}
+
+ );
+}
+```
+
+### Fully controlled
+
+The following example shows how you would create a controlled SearchAutocomplete, by controlling the input value (`inputValue`)
+and the autocomplete options (`items`). By passing in `inputValue` and `items` to the `SearchAutocomplete` you can control
+exactly what your SearchAutocomplete should display. For example, note that the item filtering for the controlled SearchAutocomplete below now follows a "starts with"
+filter strategy, accomplished by controlling the exact set of items available to the SearchAutocomplete whenever the input value updates.
+
+```tsx example
+function ControlledSearchAutocomplete() {
+ let optionList = [
+ {name: 'Red Panda', id: '1'},
+ {name: 'Cat', id: '2'},
+ {name: 'Dog', id: '3'},
+ {name: 'Aardvark', id: '4'},
+ {name: 'Kangaroo', id: '5'},
+ {name: 'Snake', id: '6'}
+ ];
+
+ // Store SearchAutocomplete input value, selected option, open state, and items
+ // in a state tracker
+ let [fieldState, setFieldState] = React.useState({
+ inputValue: '',
+ items: optionList
+ });
+
+ // Implement custom filtering logic and control what items are
+ // available to the SearchAutocomplete.
+ let {startsWith} = useFilter({sensitivity: 'base'});
+
+ // Specify how each of the SearchAutocomplete values should change when an
+ // option is selected from the list box
+ let onSubmit = (value, key) => {
+ setFieldState(prevState => {
+ let selectedItem = prevState.items.find(option => option.id === key);
+ return ({
+ inputValue: selectedItem?.name ?? '',
+ items: optionList.filter(item => startsWith(item.name, selectedItem?.name ?? ''))
+ })
+ });
+ };
+
+ // Specify how each of the SearchAutocomplete values should change when the input
+ // field is altered by the user
+ let onInputChange = (value) => {
+ setFieldState(prevState => ({
+ inputValue: value,
+ items: optionList.filter(item => startsWith(item.name, value))
+ }));
+ };
+
+ // Show entire list if user opens the menu manually
+ let onOpenChange = (isOpen, menuTrigger) => {
+ if (menuTrigger === 'manual' && isOpen) {
+ setFieldState(prevState => ({
+ inputValue: prevState.inputValue,
+ items: optionList
+ }));
+ }
+ };
+
+ // Pass each controlled prop to useSearchAutocomplete along with their
+ // change handlers
+ return (
+
+ {item => {item.name}}
+
+ )
+}
+
+
+```
+
+### Menu trigger behavior
+
+`useComboBoxState` supports three different `menuTrigger` prop values:
+
+* `input` (default): SearchAutocomplete menu opens when the user edits the input text.
+* `focus`: SearchAutocomplete menu opens when the user focuses the SearchAutocomplete input.
+* `manual`: SearchAutocomplete menu only opens when the user presses the trigger button or uses the arrow keys.
+
+The example below has `menuTrigger` set to `focus`.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Disabled options
+
+You can disable specific options by providing an array of keys to `useComboBoxState`
+via the `disabledKeys` prop. This will prevent options with matching keys from being pressable and
+receiving keyboard focus as shown in the example below. Note that you are responsible for the styling of disabled options.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Asynchronous loading
+
+This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle asynchronous loading
+and filtering of data from a server. You may additionally want to display a spinner to indicate the loading
+state to the user, or support features like infinite scroll to load more data.
+
+```tsx example
+import {useAsyncList} from '@react-stately/data';
+
+function AsyncLoadingExample() {
+ let list = useAsyncList({
+ async load({signal, filterText}) {
+ let res = await fetch(
+ `https://swapi.dev/api/people/?search=${filterText}`,
+ {signal}
+ );
+ let json = await res.json();
+
+ return {
+ items: json.results
+ };
+ }
+ });
+
+ return (
+
+ {(item) => {item.name}}
+
+ );
+}
+```
+
+## Internationalization
+
+`useSearchAutocomplete` handles some aspects of internationalization automatically.
+For example, the item focus, count, and selection VoiceOver announcements are localized.
+You are responsible for localizing all labels and option
+content that is passed into the autocomplete.
diff --git a/packages/@react-aria/searchfield/package.json b/packages/@react-aria/searchfield/package.json
index 47dab04053e..43d84347df1 100644
--- a/packages/@react-aria/searchfield/package.json
+++ b/packages/@react-aria/searchfield/package.json
@@ -18,10 +18,13 @@
},
"dependencies": {
"@babel/runtime": "^7.6.2",
+ "@react-aria/combobox": "^3.0.1",
"@react-aria/i18n": "^3.3.2",
"@react-aria/interactions": "^3.6.0",
+ "@react-aria/listbox": "^3.3.1",
"@react-aria/textfield": "^3.4.0",
"@react-aria/utils": "^3.9.0",
+ "@react-stately/combobox": "^3.0.1",
"@react-stately/searchfield": "^3.1.3",
"@react-types/button": "^3.4.1",
"@react-types/searchfield": "^3.1.2",
diff --git a/packages/@react-aria/searchfield/src/index.ts b/packages/@react-aria/searchfield/src/index.ts
index b14076e4c4e..5bc9c5737ef 100644
--- a/packages/@react-aria/searchfield/src/index.ts
+++ b/packages/@react-aria/searchfield/src/index.ts
@@ -11,3 +11,4 @@
*/
export * from './useSearchField';
+export * from './useSearchAutocomplete';
diff --git a/packages/@react-aria/searchfield/src/useSearchAutocomplete.ts b/packages/@react-aria/searchfield/src/useSearchAutocomplete.ts
new file mode 100644
index 00000000000..07d1299649d
--- /dev/null
+++ b/packages/@react-aria/searchfield/src/useSearchAutocomplete.ts
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {AriaButtonProps} from '@react-types/button';
+import {AriaListBoxOptions} from '@react-aria/listbox';
+import {ComboBoxState} from '@react-stately/combobox';
+import {HTMLAttributes, InputHTMLAttributes, RefObject} from 'react';
+import {KeyboardDelegate} from '@react-types/shared';
+import {mergeProps} from '@react-aria/utils';
+import {SearchAutocompleteProps} from '@react-types/searchfield';
+import {useComboBox} from '@react-aria/combobox';
+import {useSearchField} from './useSearchField';
+
+interface AriaSearchAutocompleteProps extends SearchAutocompleteProps {
+ /** The ref for the input element. */
+ inputRef: RefObject,
+ /** The ref for the list box popover. */
+ popoverRef: RefObject,
+ /** The ref for the list box. */
+ listBoxRef: RefObject,
+ /** An optional keyboard delegate implementation, to override the default. */
+ keyboardDelegate?: KeyboardDelegate
+}
+
+interface SearchAutocompleteAria {
+ /** Props for the label element. */
+ labelProps: HTMLAttributes,
+ /** Props for the search input element. */
+ inputProps: InputHTMLAttributes,
+ /** Props for the list box, to be passed to [useListBox](useListBox.html). */
+ listBoxProps: AriaListBoxOptions,
+ /** Props for the search input's clear button. */
+ clearButtonProps: AriaButtonProps
+}
+
+/**
+ * Provides the behavior and accessibility implementation for a search autocomplete component.
+ * A search autocomplete combines a combobox with a searchfield, allowing users to filter a list of options to items matching a query.
+ * @param props - Props for the search autocomplete.
+ * @param state - State for the search autocomplete, as returned by `useSearchAutocomplete`.
+ */
+export function useSearchAutocomplete(props: AriaSearchAutocompleteProps, state: ComboBoxState): SearchAutocompleteAria {
+ let {
+ popoverRef,
+ inputRef,
+ listBoxRef,
+ keyboardDelegate,
+ onSubmit = () => {}
+ } = props;
+
+ let {inputProps, clearButtonProps} = useSearchField({
+ ...props,
+ value: state.inputValue,
+ onChange: state.setInputValue,
+ autoComplete: 'off',
+ onClear: () => state.setInputValue(''),
+ onSubmit: (value) => {
+ // Prevent submission from search field if menu item was selected
+ if (state.selectionManager.focusedKey === null) {
+ onSubmit(value, null);
+ }
+ }
+ }, {
+ value: state.inputValue,
+ setValue: state.setInputValue
+ }, inputRef);
+
+
+ let {listBoxProps, labelProps, inputProps: comboBoxInputProps} = useComboBox(
+ {
+ ...props,
+ keyboardDelegate,
+ popoverRef,
+ listBoxRef,
+ inputRef,
+ onFocus: undefined,
+ onBlur: undefined
+ },
+ state
+ );
+
+ return {
+ labelProps,
+ inputProps: mergeProps(inputProps, comboBoxInputProps),
+ listBoxProps,
+ clearButtonProps
+ };
+}
diff --git a/packages/@react-spectrum/searchfield/chromatic/SearchAutocomplete.chromatic.tsx b/packages/@react-spectrum/searchfield/chromatic/SearchAutocomplete.chromatic.tsx
new file mode 100644
index 00000000000..240ad477b7e
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/chromatic/SearchAutocomplete.chromatic.tsx
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {generatePowerset} from '@react-spectrum/story-utils';
+import {Grid, repeat} from '@react-spectrum/layout';
+import {Item, SearchAutocomplete} from '../';
+import {Meta, Story} from '@storybook/react';
+import React from 'react';
+import {SpectrumSearchAutocompleteProps} from '@react-types/searchfield';
+
+// Skipping focus styles because don't have a way of applying it via classnames
+// No controlled open state also means no menu
+let states = [
+ {isQuiet: true},
+ {isReadOnly: true},
+ {isDisabled: true},
+ {validationState: ['valid', 'invalid']},
+ {isRequired: true},
+ {necessityIndicator: 'label'}
+];
+
+let combinations = generatePowerset(states);
+
+function shortName(key, value) {
+ let returnVal = '';
+ switch (key) {
+ case 'isQuiet':
+ returnVal = 'quiet';
+ break;
+ case 'isReadOnly':
+ returnVal = 'ro';
+ break;
+ case 'isDisabled':
+ returnVal = 'disable';
+ break;
+ case 'validationState':
+ returnVal = `vs ${value}`;
+ break;
+ case 'isRequired':
+ returnVal = 'req';
+ break;
+ case 'necessityIndicator':
+ returnVal = 'necInd=label';
+ break;
+ }
+ return returnVal;
+}
+
+const meta: Meta> = {
+ title: 'SearchAutocomplete',
+ parameters: {
+ chromaticProvider: {colorSchemes: ['light', 'dark', 'lightest', 'darkest'], locales: ['en-US'], scales: ['medium', 'large']}
+ }
+};
+
+export default meta;
+
+let items = [
+ {name: 'Aardvark', id: '1'},
+ {name: 'Kangaroo', id: '2'},
+ {name: 'Snake', id: '3'}
+];
+
+const Template: Story> = (args) => (
+
+ {combinations.map(c => {
+ let key = Object.keys(c).map(k => shortName(k, c[k])).join(' ');
+ if (!key) {
+ key = 'empty';
+ }
+
+ return (
+
+ {(item: any) => {item.name}}
+
+ );
+ })}
+
+);
+
+// Chromatic can't handle the size of the side label story so removed some extraneous props that don't matter for side label case.
+const TemplateSideLabel: Story> = (args) => (
+
+ {combinations.filter(combo => !(combo.isReadOnly || combo.isDisabled)).map(c => {
+ let key = Object.keys(c).map(k => shortName(k, c[k])).join(' ');
+ if (!key) {
+ key = 'empty';
+ }
+
+ return (
+
+ {(item: any) => {item.name}}
+
+ );
+ })}
+
+);
+
+export const PropDefaults = Template.bind({});
+PropDefaults.storyName = 'default';
+PropDefaults.args = {};
+
+export const PropInputValue = Template.bind({});
+PropInputValue.storyName = 'inputValue: Blah';
+PropInputValue.args = {inputValue: 'Blah'};
+
+export const PropAriaLabelled = Template.bind({});
+PropAriaLabelled.storyName = 'aria-label';
+PropAriaLabelled.args = {'aria-label': 'Label'};
+
+export const PropLabelEnd = Template.bind({});
+PropLabelEnd.storyName = 'label end';
+PropLabelEnd.args = {...PropDefaults.args, labelAlign: 'end'};
+
+export const PropLabelSide = TemplateSideLabel.bind({});
+PropLabelSide.storyName = 'label side';
+PropLabelSide.args = {...PropDefaults.args, labelPosition: 'side'};
+
+export const PropCustomWidth = Template.bind({});
+PropCustomWidth.storyName = 'custom width';
+PropCustomWidth.args = {...PropDefaults.args, width: 'size-1600'};
diff --git a/packages/@react-spectrum/searchfield/chromatic/SearchAutocompleteRTL.chromatic.tsx b/packages/@react-spectrum/searchfield/chromatic/SearchAutocompleteRTL.chromatic.tsx
new file mode 100644
index 00000000000..6f9f023966d
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/chromatic/SearchAutocompleteRTL.chromatic.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {Meta} from '@storybook/react';
+
+// Original SearchAutocomplete chromatic story was too large to be processed
+const meta: Meta = {
+ title: 'SearchAutocompleteRTL',
+ parameters: {
+ chromaticProvider: {colorSchemes: ['light', 'dark', 'lightest', 'darkest'], locales: ['ar-AE'], scales: ['medium', 'large']}
+ }
+};
+
+export default meta;
+
+export {PropDefaults, PropInputValue, PropAriaLabelled, PropLabelEnd, PropLabelSide, PropCustomWidth} from './SearchAutocomplete.chromatic';
diff --git a/packages/@react-spectrum/searchfield/docs/SearchAutocomplete.mdx b/packages/@react-spectrum/searchfield/docs/SearchAutocomplete.mdx
new file mode 100644
index 00000000000..ab45041959b
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/docs/SearchAutocomplete.mdx
@@ -0,0 +1,479 @@
+
+
+import {Layout} from '@react-spectrum/docs';
+export default Layout;
+
+import docs from 'docs:@react-spectrum/searchfield';
+import {HeaderInfo, PropTable} from '@react-spectrum/docs';
+import packageData from '@react-spectrum/searchfield/package.json';
+
+```jsx import
+import {SearchAutocomplete, Section, Item} from '@react-spectrum/searchfield';
+import {useFilter} from '@react-aria/i18n';
+```
+
+---
+category: Pickers
+keywords: [search field, input]
+after_version: 3.0.0
+---
+
+# SearchAutocomplete
+
+
{docs.exports.SearchAutocomplete.description}
+
+
+
+## Example
+
+```tsx example
+
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+## Content
+SearchAutocomplete follows the [Collection Components](../react-stately/collections.html) API, accepting both static and dynamic collections.
+Similar to [ComboBox](ComboBox.html), SearchAutocomplete accepts `` elements as children, each with a `key` prop. Basic usage of SearchAutocomplete, seen in the example above, shows multiple options populated with a string.
+Static collections, as in this example, can be used when the full list of options is known ahead of time.
+
+Dynamic collections, as shown below, can be used when the options come from an external data source such as an API call, or update over time.
+Providing the data in this way allows SearchAutocomplete to automatically cache the rendering of each item, which dramatically improves performance.
+
+As seen below, an iterable list of options is passed to the SearchAutocomplete using the `defaultItems` prop.
+
+```tsx example
+function Example() {
+ let options = [
+ {id: 1, name: 'Aerospace'},
+ {id: 2, name: 'Mechanical'},
+ {id: 3, name: 'Civil'},
+ {id: 4, name: 'Biomedical'},
+ {id: 5, name: 'Nuclear'},
+ {id: 6, name: 'Industrial'},
+ {id: 7, name: 'Chemical'},
+ {id: 8, name: 'Agricultural'},
+ {id: 9, name: 'Electrical'}
+ ];
+
+ return (
+
+ {item => {item.name}}
+
+ );
+}
+```
+
+Alternatively, passing your list of options to SearchAutocomplete's `items` prop will cause the list of items to be controlled, useful for when you want to provide your own
+filtering logic. See the [Custom Filtering](#custom-filtering) section for more detail.
+
+### Internationalization
+To internationalize a SearchAutocomplete, a localized string should be passed to the `children` of each `Item`.
+For languages that are read right-to-left (e.g. Hebrew and Arabic), the layout of the SearchAutocomplete is automatically flipped.
+
+### Placeholder
+
+Placeholder text that describes the expected value or formatting for the SearchAutocomplete can be provided using the `placeholder` prop.
+Placeholder text will only appear when the SearchAutocomplete is empty, and should not be used as a substitute for labeling the component with a visible label.
+
+```tsx example
+function Example() {
+ let options = [
+ {id: 1, name: 'Aerospace'},
+ {id: 2, name: 'Mechanical'},
+ {id: 3, name: 'Civil'},
+ {id: 4, name: 'Biomedical'},
+ {id: 5, name: 'Nuclear'},
+ {id: 6, name: 'Industrial'},
+ {id: 7, name: 'Chemical'},
+ {id: 8, name: 'Agricultural'},
+ {id: 9, name: 'Electrical'}
+ ];
+
+ return (
+
+ {item => {item.name}}
+
+ );
+}
+```
+
+## Labeling
+SearchAutocomplete can be labeled using the `label` prop. If the SearchAutocomplete is a required field, the `isRequired` and `necessityIndicator` props can be used to show a required state.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Accessibility
+
+If a visible label isn't specified, an `aria-label` must be provided to the SearchAutocomplete for
+accessibility. If the field is labeled by a separate element, an `aria-labelledby` prop must be provided using
+the `id` of the labeling element instead.
+
+### Internationalization
+
+In order to internationalize a SearchAutocomplete, a localized string should be passed to the `label` or `aria-label` prop.
+When the `necessityIndicator` prop is set to `"label"`, a localized string will be provided for `"(required)"` or `"(optional)"` automatically.
+
+
+## Sections
+SearchAutocomplete supports sections in order to group options. Sections can be used by wrapping groups of items in a `Section` element. Each `Section` takes a `title` and `key` prop.
+
+### Static items
+```tsx example
+
+
+ Apple
+ Banana
+ Orange
+ Honeydew
+ Grapes
+ Watermelon
+ Cantaloupe
+ Pear
+
+
+ Cabbage
+ Broccoli
+ Carrots
+ Lettuce
+ Spinach
+ Bok Choy
+ Cauliflower
+ Potatoes
+
+
+```
+
+### Dynamic items
+Sections used with dynamic items are populated from a hierarchical data structure. Please note that `Section` takes an array of data using the `items` prop only.
+
+```tsx example
+function Example() {
+ let options = [
+ {name: 'Fruit', children: [
+ {name: 'Apple'},
+ {name: 'Banana'},
+ {name: 'Orange'},
+ {name: 'Honeydew'},
+ {name: 'Grapes'},
+ {name: 'Watermelon'},
+ {name: 'Cantaloupe'},
+ {name: 'Pear'}
+ ]},
+ {name: 'Vegetable', children: [
+ {name: 'Cabbage'},
+ {name: 'Broccoli'},
+ {name: 'Carrots'},
+ {name: 'Lettuce'},
+ {name: 'Spinach'},
+ {name: 'Bok Choy'},
+ {name: 'Cauliflower'},
+ {name: 'Potatoes'}
+ ]}
+ ];
+
+ return (
+
+ {item => (
+
+ {item => {item.name}}
+
+ )}
+
+ );
+}
+```
+
+## Asynchronous loading
+
+SearchAutocomplete supports loading data asynchronously, and will display a progress circle reflecting the current load state,
+set by the `loadingState` prop. It also supports infinite scrolling to load more data on demand as the user scrolls, via the `onLoadMore` prop.
+
+This example uses the [useAsyncList](../react-stately/useAsyncList.html) hook to handle loading the data. See the docs for more information.
+
+```tsx example
+import {useAsyncList} from '@react-stately/data';
+
+function AsyncLoadingExample() {
+ let list = useAsyncList({
+ async load({signal, cursor, filterText}) {
+ if (cursor) {
+ cursor = cursor.replace(/^http:\/\//i, 'https://');
+ }
+
+ // If no cursor is available, then we're loading the first page,
+ // filtering the results returned via a query string that
+ // mirrors the SearchAutocomplete input text.
+ // Otherwise, the cursor is the next URL to load,
+ // as returned from the previous page.
+ let res = await fetch(cursor || `https://swapi.dev/api/people/?search=${filterText}`, {signal});
+ let json = await res.json();
+
+ return {
+ items: json.results,
+ cursor: json.next
+ };
+ }
+ });
+
+ return (
+
+ {item => {item.name}}
+
+ );
+}
+```
+
+## Validation
+SearchAutocomplete can display a validation state to communicate to the user whether the current value is valid or invalid.
+Implement your own validation logic in your app and pass either `"valid"` or `"invalid"` to the SearchAutocomplete via the `validationState` prop.
+
+The example below illustrates how one would validate if the user has entered a valid email into the SearchAutocomplete.
+```tsx example
+function Example() {
+ let [value, setValue] = React.useState('me@email.com');
+ let isValid = React.useMemo(() => /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(value), [value]);
+
+ let options = [
+ {id: 1, email: 'fake@email.com'},
+ {id: 2, email: 'anotherfake@email.com'},
+ {id: 3, email: 'bob@email.com'},
+ {id: 4, email: 'joe@email.com'},
+ {id: 5, email: 'yourEmail@email.com'},
+ {id: 6, email: 'valid@email.com'},
+ {id: 7, email: 'spam@email.com'},
+ {id: 8, email: 'newsletter@email.com'},
+ {id: 9, email: 'subscribe@email.com'}
+ ];
+
+ return (
+
+ {item => {item.email}}
+
+ );
+}
+```
+
+## Custom Filtering
+By default, SearchAutocomplete uses a string "contains" filtering strategy when deciding what items to display in the dropdown menu. This filtering strategy can be overwritten
+by filtering the list of items yourself and passing the filtered list to the SearchAutocomplete via the `items` prop.
+
+The example below uses a string "startsWith" filter function obtained from the `useFilter` hook to display items that start with the SearchAutocomplete's current input
+value only. By using the `menuTrigger` returned by `onOpenChange`, it also handles displaying the entire option list regardless of the current filter value when the SearchAutocomplete menu is
+opened via the trigger button or arrow keys. `menuTrigger` tells you if the menu was opened manually by the user ("manual"), by focusing the SearchAutocomplete ("focus"), or by
+changes in the input field ("input"), allowing you to make updates to other controlled aspects of your SearchAutocomplete accordingly.
+
+```tsx example
+function Example() {
+ let options = [
+ {id: 1, email: 'fake@email.com'},
+ {id: 2, email: 'anotherfake@email.com'},
+ {id: 3, email: 'bob@email.com'},
+ {id: 4, email: 'joe@email.com'},
+ {id: 5, email: 'yourEmail@email.com'},
+ {id: 6, email: 'valid@email.com'},
+ {id: 7, email: 'spam@email.com'},
+ {id: 8, email: 'newsletter@email.com'},
+ {id: 9, email: 'subscribe@email.com'}
+ ];
+
+ let [showAll, setShowAll] = React.useState(false);
+ let [filterValue, setFilterValue] = React.useState('');
+ let {startsWith} = useFilter({sensitivity: 'base'});
+ let filteredItems = React.useMemo(() => options.filter(item => startsWith(item.email, filterValue)), [options, filterValue]);
+
+ return (
+ {
+ // Show all items if menu is opened manually
+ // i.e. by the arrow keys or trigger button
+ if (menuTrigger === 'manual' && isOpen) {
+ setShowAll(true);
+ }
+ }}
+ width="size-3000"
+ label="Search Email Addresses"
+ items={showAll ? options : filteredItems}
+ inputValue={filterValue}
+ onInputChange={(value) => {
+ setShowAll(false);
+ setFilterValue(value);
+ }}
+ placeholder="Enter email">
+ {item => {item.email}}
+
+ );
+}
+```
+
+## Trigger options
+By default, the SearchAutocomplete's menu is opened when the user types into the input field ("input"). There are two other supported modes: one where the menu opens when the SearchAutocomplete is focused ("focus") and
+the other where the menu only opens when the user clicks or taps on the SearchAutocomplete's field button ("manual"). These can be set by providing "focus" or "manual" to the `menuTrigger` prop.
+Guidelines on when to use a specific mode can be found [here](https://spectrum.adobe.com/page/combo-box/#Menu-trigger). Note that the mobile SearchAutocomplete experience requires the end user to press the SearchAutocomplete button
+to open the tray regardless of the `menuTrigger` setting.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+## Props
+
+
+
+## Visual options
+
+### Label alignment and position
+
+By default, the label is positioned above the SearchAutocomplete. The `labelPosition` prop can be used to position the label to the side.
+The `labelAlign` prop can be used to align the label as "start" or "end". For left-to-right (LTR) languages, "start" refers to the left most edge of the SearchAutocomplete and "end" refers to the right most edge.
+For right-to-left (RTL) languages, this is flipped.
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Quiet
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Disabled
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Read only
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Custom widths
+
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
+
+### Menu direction
+```tsx example
+
+ Red Panda
+ Cat
+ Dog
+ Aardvark
+ Kangaroo
+ Snake
+
+```
diff --git a/packages/@react-spectrum/searchfield/intl/ar-AE.json b/packages/@react-spectrum/searchfield/intl/ar-AE.json
new file mode 100644
index 00000000000..fd974a25e7d
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/ar-AE.json
@@ -0,0 +1,6 @@
+{
+ "clear": "مسح",
+ "invalid": "(غير صالح)",
+ "loading": "جارٍ التحميل...",
+ "noResults": "لا توجد نتائج"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/bg-BG.json b/packages/@react-spectrum/searchfield/intl/bg-BG.json
new file mode 100644
index 00000000000..8236633f413
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/bg-BG.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Изчисти",
+ "invalid": "(невалиден)",
+ "loading": "Зареждане...",
+ "noResults": "Няма резултати"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/cs-CZ.json b/packages/@react-spectrum/searchfield/intl/cs-CZ.json
new file mode 100644
index 00000000000..4ad93cabaec
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/cs-CZ.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Vymazat",
+ "invalid": "(neplatné)",
+ "loading": "Načítání...",
+ "noResults": "Žádné výsledky"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/da-DK.json b/packages/@react-spectrum/searchfield/intl/da-DK.json
new file mode 100644
index 00000000000..60c3ace6b4c
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/da-DK.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Ryd",
+ "invalid": "(ugyldig)",
+ "loading": "Indlæser ...",
+ "noResults": "Ingen resultater"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/de-DE.json b/packages/@react-spectrum/searchfield/intl/de-DE.json
new file mode 100644
index 00000000000..2efed8d7213
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/de-DE.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Löschen",
+ "invalid": "(ungültig)",
+ "loading": "Wird geladen...",
+ "noResults": "Keine Ergebnisse"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/el-GR.json b/packages/@react-spectrum/searchfield/intl/el-GR.json
new file mode 100644
index 00000000000..ad34adae4d9
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/el-GR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Καθαρισμός",
+ "invalid": "(δεν ισχύει)",
+ "loading": "Φόρτωση...",
+ "noResults": "Χωρίς αποτέλεσμα"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/en-US.json b/packages/@react-spectrum/searchfield/intl/en-US.json
new file mode 100644
index 00000000000..38d1892e05d
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/en-US.json
@@ -0,0 +1,6 @@
+{
+ "loading": "Loading...",
+ "noResults": "No results",
+ "clear": "Clear",
+ "invalid": "(invalid)"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/es-ES.json b/packages/@react-spectrum/searchfield/intl/es-ES.json
new file mode 100644
index 00000000000..cbc0d01fc3c
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/es-ES.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Borrar",
+ "invalid": "(no válido)",
+ "loading": "Cargando...",
+ "noResults": "Sin resultados"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/et-EE.json b/packages/@react-spectrum/searchfield/intl/et-EE.json
new file mode 100644
index 00000000000..56fbff4d908
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/et-EE.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Puhasta",
+ "invalid": "(kehtetu)",
+ "loading": "Laadimine...",
+ "noResults": "Tulemusi pole"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/fi-FI.json b/packages/@react-spectrum/searchfield/intl/fi-FI.json
new file mode 100644
index 00000000000..3bf91ab734c
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/fi-FI.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Kirkas",
+ "invalid": "(epäkelpo)",
+ "loading": "Ladataan...",
+ "noResults": "Ei tuloksia"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/fr-FR.json b/packages/@react-spectrum/searchfield/intl/fr-FR.json
new file mode 100644
index 00000000000..d8096edf048
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/fr-FR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Effacer",
+ "invalid": "(non valide)",
+ "loading": "Chargement en cours...",
+ "noResults": "Aucun résultat"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/he-IL.json b/packages/@react-spectrum/searchfield/intl/he-IL.json
new file mode 100644
index 00000000000..33475277631
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/he-IL.json
@@ -0,0 +1,6 @@
+{
+ "clear": "נקי",
+ "invalid": "(לא חוקי)",
+ "loading": "טוען...",
+ "noResults": "אין תוצאות"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/hr-HR.json b/packages/@react-spectrum/searchfield/intl/hr-HR.json
new file mode 100644
index 00000000000..8af07bfa377
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/hr-HR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Izbriši",
+ "invalid": "(nevažeće)",
+ "loading": "Učitavam...",
+ "noResults": "Nema rezultata"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/hu-HU.json b/packages/@react-spectrum/searchfield/intl/hu-HU.json
new file mode 100644
index 00000000000..84c8b9e0fcf
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/hu-HU.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Törlés",
+ "invalid": "(érvénytelen)",
+ "loading": "Betöltés folyamatban…",
+ "noResults": "Nincsenek találatok"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/it-IT.json b/packages/@react-spectrum/searchfield/intl/it-IT.json
new file mode 100644
index 00000000000..e1707d8b496
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/it-IT.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Cancella",
+ "invalid": "(non valido)",
+ "loading": "Caricamento in corso...",
+ "noResults": "Nessun risultato"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/ja-JP.json b/packages/@react-spectrum/searchfield/intl/ja-JP.json
new file mode 100644
index 00000000000..da42db48eb0
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/ja-JP.json
@@ -0,0 +1,6 @@
+{
+ "clear": "クリア",
+ "invalid": "(無効)",
+ "loading": "読み込み中...",
+ "noResults": "結果なし"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/ko-KR.json b/packages/@react-spectrum/searchfield/intl/ko-KR.json
new file mode 100644
index 00000000000..e9e0a53d789
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/ko-KR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "지우기",
+ "invalid": "(유효하지 않음)",
+ "loading": "로드 중...",
+ "noResults": "결과 없음"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/lt-LT.json b/packages/@react-spectrum/searchfield/intl/lt-LT.json
new file mode 100644
index 00000000000..92b0985a069
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/lt-LT.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Skaidrus",
+ "invalid": "(netinkama)",
+ "loading": "Įkeliama...",
+ "noResults": "Be rezultatų"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/lv-LV.json b/packages/@react-spectrum/searchfield/intl/lv-LV.json
new file mode 100644
index 00000000000..6a1c086b921
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/lv-LV.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Notīrīt",
+ "invalid": "(nederīgs)",
+ "loading": "Notiek ielāde...",
+ "noResults": "Nav rezultātu"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/nb-NO.json b/packages/@react-spectrum/searchfield/intl/nb-NO.json
new file mode 100644
index 00000000000..ebeca406b2a
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/nb-NO.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Tøm",
+ "invalid": "(ugyldig)",
+ "loading": "Laster inn ...",
+ "noResults": "Ingen resultater"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/nl-NL.json b/packages/@react-spectrum/searchfield/intl/nl-NL.json
new file mode 100644
index 00000000000..4e47fd259d2
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/nl-NL.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Helder",
+ "invalid": "(ongeldig)",
+ "loading": "Laden...",
+ "noResults": "Geen resultaten"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/pl-PL.json b/packages/@react-spectrum/searchfield/intl/pl-PL.json
new file mode 100644
index 00000000000..d79e59d69da
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/pl-PL.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Wyczyść",
+ "invalid": "(nieprawidłowy)",
+ "loading": "Trwa ładowanie...",
+ "noResults": "Brak wyników"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/pt-BR.json b/packages/@react-spectrum/searchfield/intl/pt-BR.json
new file mode 100644
index 00000000000..30704eaa572
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/pt-BR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Limpar",
+ "invalid": "(inválido)",
+ "loading": "Carregando...",
+ "noResults": "Nenhum resultado"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/pt-PT.json b/packages/@react-spectrum/searchfield/intl/pt-PT.json
new file mode 100644
index 00000000000..99be1c10657
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/pt-PT.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Limpar",
+ "invalid": "(inválido)",
+ "loading": "A carregar...",
+ "noResults": "Sem resultados"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/ro-RO.json b/packages/@react-spectrum/searchfield/intl/ro-RO.json
new file mode 100644
index 00000000000..97b80519213
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/ro-RO.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Golire",
+ "invalid": "(nevalid)",
+ "loading": "Se încarcă...",
+ "noResults": "Niciun rezultat"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/ru-RU.json b/packages/@react-spectrum/searchfield/intl/ru-RU.json
new file mode 100644
index 00000000000..9cef0322480
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/ru-RU.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Очистить",
+ "invalid": "(недействительно)",
+ "loading": "Загрузка...",
+ "noResults": "Результаты отсутствуют"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/sk-SK.json b/packages/@react-spectrum/searchfield/intl/sk-SK.json
new file mode 100644
index 00000000000..6d78b2de7f9
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/sk-SK.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Vymazať",
+ "invalid": "(neplatné)",
+ "loading": "Načítava sa...",
+ "noResults": "Žiadne výsledky"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/sl-SI.json b/packages/@react-spectrum/searchfield/intl/sl-SI.json
new file mode 100644
index 00000000000..4ceacc6cec6
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/sl-SI.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Jasen",
+ "invalid": "(neveljavno)",
+ "loading": "Nalaganje...",
+ "noResults": "Ni rezultatov"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/sr-SP.json b/packages/@react-spectrum/searchfield/intl/sr-SP.json
new file mode 100644
index 00000000000..8af07bfa377
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/sr-SP.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Izbriši",
+ "invalid": "(nevažeće)",
+ "loading": "Učitavam...",
+ "noResults": "Nema rezultata"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/sv-SE.json b/packages/@react-spectrum/searchfield/intl/sv-SE.json
new file mode 100644
index 00000000000..d2221d8110c
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/sv-SE.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Rensa",
+ "invalid": "(ogiltigt)",
+ "loading": "Läser in...",
+ "noResults": "Inga resultat"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/tr-TR.json b/packages/@react-spectrum/searchfield/intl/tr-TR.json
new file mode 100644
index 00000000000..b5a5f050bc5
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/tr-TR.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Temizle",
+ "invalid": "(geçersiz)",
+ "loading": "Yükleniyor...",
+ "noResults": "Sonuç yok"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/uk-UA.json b/packages/@react-spectrum/searchfield/intl/uk-UA.json
new file mode 100644
index 00000000000..bb9b44fb418
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/uk-UA.json
@@ -0,0 +1,6 @@
+{
+ "clear": "Очистити",
+ "invalid": "(недійсне)",
+ "loading": "Завантаження...",
+ "noResults": "Результатів немає"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/zh-CN.json b/packages/@react-spectrum/searchfield/intl/zh-CN.json
new file mode 100644
index 00000000000..7002f5a14fb
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/zh-CN.json
@@ -0,0 +1,6 @@
+{
+ "clear": "透明",
+ "invalid": "(无效)",
+ "loading": "正在加载...",
+ "noResults": "无结果"
+}
diff --git a/packages/@react-spectrum/searchfield/intl/zh-TW.json b/packages/@react-spectrum/searchfield/intl/zh-TW.json
new file mode 100644
index 00000000000..4f8c9345700
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/intl/zh-TW.json
@@ -0,0 +1,6 @@
+{
+ "clear": "清除",
+ "invalid": "(無效)",
+ "loading": "正在載入...",
+ "noResults": "無任何結果"
+}
diff --git a/packages/@react-spectrum/searchfield/package.json b/packages/@react-spectrum/searchfield/package.json
index cedf33f4f85..003f1c9918f 100644
--- a/packages/@react-spectrum/searchfield/package.json
+++ b/packages/@react-spectrum/searchfield/package.json
@@ -32,12 +32,29 @@
},
"dependencies": {
"@babel/runtime": "^7.6.2",
+ "@react-aria/button": "^3.3.3",
+ "@react-aria/dialog": "^3.1.4",
+ "@react-aria/focus": "^3.4.1",
+ "@react-aria/i18n": "^3.3.2",
+ "@react-aria/interactions": "^3.5.1",
+ "@react-aria/label": "^3.1.3",
+ "@react-aria/overlays": "^3.7.2",
"@react-aria/searchfield": "^3.2.0",
+ "@react-aria/utils": "^3.8.2",
+ "@react-spectrum/label": "^3.3.4",
+ "@react-spectrum/listbox": "^3.5.1",
+ "@react-spectrum/overlays": "^3.4.4",
+ "@react-spectrum/progress": "^3.1.3",
"@react-spectrum/button": "^3.6.0",
"@react-spectrum/textfield": "^3.2.0",
"@react-spectrum/utils": "^3.6.2",
+ "@react-stately/collections": "^3.3.3",
+ "@react-stately/combobox": "^3.0.1",
"@react-stately/searchfield": "^3.1.3",
+ "@react-types/button": "^3.4.1",
+ "@react-types/overlays": "^3.5.1",
"@react-types/searchfield": "^3.1.2",
+ "@react-types/shared": "^3.8.0",
"@react-types/textfield": "^3.3.0",
"@spectrum-icons/ui": "^3.2.1"
},
diff --git a/packages/@react-spectrum/searchfield/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/searchfield/src/MobileSearchAutocomplete.tsx
new file mode 100644
index 00000000000..56d50f153b6
--- /dev/null
+++ b/packages/@react-spectrum/searchfield/src/MobileSearchAutocomplete.tsx
@@ -0,0 +1,547 @@
+/*
+ * Copyright 2020 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import AlertMedium from '@spectrum-icons/ui/AlertMedium';
+import {AriaButtonProps} from '@react-types/button';
+import CheckmarkMedium from '@spectrum-icons/ui/CheckmarkMedium';
+import {classNames} from '@react-spectrum/utils';
+import {ClearButton} from '@react-spectrum/button';
+import {ComboBoxState, useComboBoxState} from '@react-stately/combobox';
+import {DismissButton} from '@react-aria/overlays';
+import {Field} from '@react-spectrum/label';
+import {FocusableRef, ValidationState} from '@react-types/shared';
+import {FocusRing, FocusScope} from '@react-aria/focus';
+import {focusSafely} from '@react-aria/focus';
+// @ts-ignore
+import intlMessages from '../intl/*.json';
+import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox';
+import Magnifier from '@spectrum-icons/ui/Magnifier';
+import {mergeProps, useId} from '@react-aria/utils';
+import {ProgressCircle} from '@react-spectrum/progress';
+import React, {HTMLAttributes, ReactElement, ReactNode, RefObject, useCallback, useEffect, useRef, useState} from 'react';
+import searchAutocompleteStyles from './searchautocomplete.css';
+import searchStyles from '@adobe/spectrum-css-temp/components/search/vars.css';
+import {setInteractionModality, useHover} from '@react-aria/interactions';
+import {SpectrumSearchAutocompleteProps} from '@react-types/searchfield';
+import styles from '@adobe/spectrum-css-temp/components/inputgroup/vars.css';
+import {TextFieldBase} from '@react-spectrum/textfield';
+import textfieldStyles from '@adobe/spectrum-css-temp/components/textfield/vars.css';
+import {Tray} from '@react-spectrum/overlays';
+import {useButton} from '@react-aria/button';
+import {useDialog} from '@react-aria/dialog';
+import {useFilter, useMessageFormatter} from '@react-aria/i18n';
+import {useFocusableRef} from '@react-spectrum/utils';
+import {useLabel} from '@react-aria/label';
+import {useOverlayTrigger} from '@react-aria/overlays';
+import {useProviderProps} from '@react-spectrum/provider';
+import {useSearchAutocomplete} from '@react-aria/searchfield';
+
+export const MobileSearchAutocomplete = React.forwardRef(function MobileSearchAutocomplete(props: SpectrumSearchAutocompleteProps, ref: FocusableRef) {
+ props = useProviderProps(props);
+
+ let {
+ isQuiet,
+ isDisabled,
+ validationState,
+ isReadOnly,
+ onSubmit = () => {}
+ } = props;
+
+ let {contains} = useFilter({sensitivity: 'base'});
+ let state = useComboBoxState({
+ ...props,
+ defaultFilter: contains,
+ allowsEmptyCollection: true,
+ // Needs to be false here otherwise we double up on commitSelection/commitCustomValue calls when
+ // user taps on underlay (i.e. initial tap will call setFocused(false) -> commitSelection/commitCustomValue via onBlur,
+ // then the closing of the tray will call setFocused(false) again due to cleanup effect)
+ shouldCloseOnBlur: false,
+ allowsCustomValue: true,
+ onSelectionChange: (key) => key !== null && onSubmit(null, key),
+ selectedKey: undefined,
+ defaultSelectedKey: undefined
+ });
+
+ let buttonRef = useRef();
+ let domRef = useFocusableRef(ref, buttonRef);
+ let {triggerProps, overlayProps} = useOverlayTrigger({type: 'listbox'}, state, buttonRef);
+
+ let {labelProps, fieldProps} = useLabel({
+ ...props,
+ labelElementType: 'span'
+ });
+
+ // Focus the button and show focus ring when clicking on the label
+ labelProps.onClick = () => {
+ if (!props.isDisabled) {
+ buttonRef.current.focus();
+ setInteractionModality('keyboard');
+ }
+ };
+
+ let onClose = () => state.commit();
+
+ return (
+ <>
+
+ state.setInputValue('')}
+ onPress={() => !isReadOnly && state.open(null, 'manual')}>
+ {state.inputValue || props.placeholder || ''}
+
+
+
+
+
+ >
+ );
+});
+
+interface SearchAutocompleteButtonProps extends AriaButtonProps {
+ isQuiet?: boolean,
+ isDisabled?: boolean,
+ isReadOnly?: boolean,
+ isPlaceholder?: boolean,
+ validationState?: ValidationState,
+ inputValue?: string,
+ clearInput?: () => void,
+ children?: ReactNode,
+ style?: React.CSSProperties,
+ className?: string
+}
+
+const SearchAutocompleteButton = React.forwardRef(function SearchAutocompleteButton(props: SearchAutocompleteButtonProps, ref: RefObject) {
+ let {
+ isQuiet,
+ isDisabled,
+ isReadOnly,
+ isPlaceholder,
+ validationState,
+ inputValue,
+ clearInput,
+ children,
+ style,
+ className
+} = props;
+ let formatMessage = useMessageFormatter(intlMessages);
+ let valueId = useId();
+ let invalidId = useId();
+ let validationIcon = validationState === 'invalid'
+ ?
+ : ;
+
+ let searchIcon = (
+
+ );
+
+ let icon = React.cloneElement(searchIcon, {
+ UNSAFE_className: classNames(
+ textfieldStyles,
+ 'spectrum-Textfield-icon'
+ ),
+ size: 'S'
+ });
+
+ let clearButton = (
+ {
+ clearInput();
+ props.onPress(e);
+ }}
+ preventFocus
+ aria-label={formatMessage('clear')}
+ excludeFromTabOrder
+ UNSAFE_className={
+ classNames(
+ searchStyles,
+ 'spectrum-ClearButton'
+ )
+ }
+ isDisabled={isDisabled} />
+ );
+
+
+ let validation = React.cloneElement(validationIcon, {
+ UNSAFE_className: classNames(
+ textfieldStyles,
+ 'spectrum-Textfield-validationIcon',
+ classNames(
+ styles,
+ 'spectrum-InputGroup-input-validationIcon'
+ )
+ )
+ });
+
+ let {hoverProps, isHovered} = useHover({});
+ let {buttonProps} = useButton({
+ ...props,
+ 'aria-labelledby': [
+ props['aria-labelledby'],
+ props['aria-label'] && !props['aria-labelledby'] ? props.id : null,
+ valueId,
+ validationState === 'invalid' ? invalidId : null
+ ].filter(Boolean).join(' '),
+ elementType: 'div'
+ }, ref);
+
+ return (
+
+
+
+ );
+});
+
+interface SearchAutocompleteTrayProps extends SpectrumSearchAutocompleteProps {
+ state: ComboBoxState,
+ overlayProps: HTMLAttributes,
+ loadingIndicator?: ReactElement,
+ onClose: () => void
+}
+
+function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) {
+ let {
+ // completionMode = 'suggest',
+ state,
+ isDisabled,
+ validationState,
+ label,
+ overlayProps,
+ loadingState,
+ onLoadMore,
+ onClose,
+ onSubmit
+ } = props;
+
+ let timeout = useRef(null);
+ let [showLoading, setShowLoading] = useState(false);
+ let inputRef = useRef();
+ let popoverRef = useRef();
+ let listBoxRef = useRef();
+ let layout = useListBoxLayout(state);
+ let formatMessage = useMessageFormatter(intlMessages);
+
+ let {inputProps, listBoxProps, labelProps, clearButtonProps} = useSearchAutocomplete(
+ {
+ ...props,
+ keyboardDelegate: layout,
+ popoverRef: popoverRef,
+ listBoxRef,
+ inputRef
+ },
+ state
+ );
+
+ React.useEffect(() => {
+ focusSafely(inputRef.current);
+
+ // When the tray unmounts, set state.isFocused (i.e. the tray input's focus tracker) to false.
+ // This is to prevent state.isFocused from being set to true when the tray closes via tapping on the underlay
+ // (FocusScope attempts to restore focus to the tray input when tapping outside the tray due to "contain")
+ // Have to do this manually since React doesn't call onBlur when a component is unmounted: https://github.com/facebook/react/issues/12363
+ return () => {
+ state.setFocused(false);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ let {dialogProps} = useDialog({
+ 'aria-labelledby': useId(labelProps.id)
+ }, popoverRef);
+
+ // Override the role of the input to "searchbox" instead of "combobox".
+ // Since the listbox is always visible, the combobox role doesn't really give us anything.
+ // VoiceOver on iOS reads "double tap to collapse" when focused on the input rather than
+ // "double tap to edit text", as with a textbox or searchbox. We'd like double tapping to
+ // open the virtual keyboard rather than closing the tray.
+ inputProps.role = 'searchbox';
+ inputProps['aria-haspopup'] = 'listbox';
+ delete inputProps.onTouchEnd;
+
+ let clearButton = (
+
+ );
+
+ let loadingCircle = (
+
+ );
+
+ // Close the software keyboard on scroll to give the user a bigger area to scroll.
+ // But only do this if scrolling with touch, otherwise it can cause issues with touch
+ // screen readers.
+ let isTouchDown = useRef(false);
+ let onTouchStart = () => {
+ isTouchDown.current = true;
+ };
+
+ let onTouchEnd = () => {
+ isTouchDown.current = false;
+ };
+
+ let onScroll = useCallback(() => {
+ if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) {
+ return;
+ }
+
+ popoverRef.current.focus();
+ }, [inputRef, popoverRef, isTouchDown]);
+
+ let inputValue = inputProps.value;
+ let lastInputValue = useRef(inputValue);
+ useEffect(() => {
+ if (loadingState === 'filtering' && !showLoading) {
+ if (timeout.current === null) {
+ timeout.current = setTimeout(() => {
+ setShowLoading(true);
+ }, 500);
+ }
+
+ // If user is typing, clear the timer and restart since it is a new request
+ if (inputValue !== lastInputValue.current) {
+ clearTimeout(timeout.current);
+ timeout.current = setTimeout(() => {
+ setShowLoading(true);
+ }, 500);
+ }
+ } else if (loadingState !== 'filtering') {
+ // If loading is no longer happening, clear any timers and hide the loading circle
+ setShowLoading(false);
+ clearTimeout(timeout.current);
+ timeout.current = null;
+ }
+
+ lastInputValue.current = inputValue;
+ }, [loadingState, inputValue, showLoading]);
+
+ let onKeyDown = (e) => {
+ // Close virtual keyboard, close tray, and fire onSubmit if user hits Enter w/o any focused options
+ if (e.key === 'Enter' && state.selectionManager.focusedKey == null) {
+ popoverRef.current.focus();
+ onClose();
+ onSubmit(inputValue.toString(), null);
+ } else {
+ inputProps.onKeyDown(e);
+ }
+ };
+
+ let searchIcon = (
+
+ );
+
+ let icon = React.cloneElement(searchIcon, {
+ UNSAFE_className: classNames(
+ textfieldStyles,
+ 'spectrum-Textfield-icon'
+ ),
+ size: 'S'
+ });
+
+ return (
+
+