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 + +Anatomy of useSearchAutocomplete + +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 ( +
+ +
+ + {state.inputValue !== '' && + + } + {state.isOpen && + + + + } +
+
+ ); +} + + + 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 ( + +
    } + style={{...style, outline: 'none'}} + className={ + classNames( + styles, + 'spectrum-InputGroup', + { + 'spectrum-InputGroup--quiet': isQuiet, + 'is-disabled': isDisabled, + 'spectrum-InputGroup--invalid': validationState === 'invalid', + 'is-hovered': isHovered + }, + classNames( + searchAutocompleteStyles, + 'mobile-searchautocomplete' + ), + className + ) + }> +
    +
    + {icon} + + {children} + +
    + {validationState ? validation : null} + {(inputValue !== '' || validationState != null) && !isReadOnly && clearButton} +
    +
    +
    + ); +}); + +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 ( + +
    + + + loadingState !== 'loading' && ( + + {formatMessage('noResults')} + + )} + UNSAFE_className={ + classNames( + searchAutocompleteStyles, + 'tray-listbox' + ) + } + ref={listBoxRef} + onScroll={onScroll} + onLoadMore={onLoadMore} + isLoading={loadingState === 'loading' || loadingState === 'loadingMore'} /> + +
    +
    + ); +} diff --git a/packages/@react-spectrum/searchfield/src/SearchAutocomplete.tsx b/packages/@react-spectrum/searchfield/src/SearchAutocomplete.tsx new file mode 100644 index 00000000000..05043fd100d --- /dev/null +++ b/packages/@react-spectrum/searchfield/src/SearchAutocomplete.tsx @@ -0,0 +1,334 @@ +/* + * 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 {classNames, useFocusableRef, useIsMobileDevice, useResizeObserver, useUnwrapDOMRef} from '@react-spectrum/utils'; +import {ClearButton} from '@react-spectrum/button'; +import {DismissButton, useOverlayPosition} from '@react-aria/overlays'; +import {DOMRefValue, FocusableRef} from '@react-types/shared'; +import {Field} from '@react-spectrum/label'; +import {FocusRing} 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 {MobileSearchAutocomplete} from './MobileSearchAutocomplete'; +import {Placement} from '@react-types/overlays'; +import {Popover} from '@react-spectrum/overlays'; +import {ProgressCircle} from '@react-spectrum/progress'; +import React, {forwardRef, InputHTMLAttributes, RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import searchStyles from '@adobe/spectrum-css-temp/components/search/vars.css'; +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 {useComboBoxState} from '@react-stately/combobox'; +import {useFilter, useMessageFormatter} from '@react-aria/i18n'; +import {useHover} from '@react-aria/interactions'; +import {useProvider, useProviderProps} from '@react-spectrum/provider'; +import {useSearchAutocomplete} from '@react-aria/searchfield'; + +function SearchAutocomplete(props: SpectrumSearchAutocompleteProps, ref: FocusableRef) { + props = useProviderProps(props); + + let isMobile = useIsMobileDevice(); + if (isMobile) { + // menuTrigger=focus/manual don't apply to mobile searchwithin + return ; + } else { + return ; + } +} + +const SearchAutocompleteBase = React.forwardRef(function SearchAutocompleteBase(props: SpectrumSearchAutocompleteProps, ref: FocusableRef) { + props = useProviderProps(props); + + let { + menuTrigger = 'input', + shouldFlip = true, + direction = 'bottom', + isQuiet, + loadingState, + onLoadMore, + onSubmit = () => {} + } = props; + + let formatMessage = useMessageFormatter(intlMessages); + let isAsync = loadingState != null; + let popoverRef = useRef>(); + let unwrappedPopoverRef = useUnwrapDOMRef(popoverRef); + let listBoxRef = useRef(); + let inputRef = useRef(); + let domRef = useFocusableRef(ref, inputRef); + + let {contains} = useFilter({sensitivity: 'base'}); + let state = useComboBoxState( + { + ...props, + defaultFilter: contains, + allowsEmptyCollection: isAsync, + allowsCustomValue: true, + onSelectionChange: (key) => key !== null && onSubmit(null, key), + selectedKey: undefined, + defaultSelectedKey: undefined + } + ); + let layout = useListBoxLayout(state); + + let {inputProps, listBoxProps, labelProps, clearButtonProps} = useSearchAutocomplete( + { + ...props, + keyboardDelegate: layout, + popoverRef: unwrappedPopoverRef, + listBoxRef, + inputRef, + menuTrigger + }, + state + ); + + let {overlayProps, placement, updatePosition} = useOverlayPosition({ + targetRef: inputRef, + overlayRef: unwrappedPopoverRef, + scrollRef: listBoxRef, + placement: `${direction} end` as Placement, + shouldFlip: shouldFlip, + isOpen: state.isOpen, + onClose: state.close + }); + + // Measure the width of the inputfield to inform the width of the menu (below). + let [menuWidth, setMenuWidth] = useState(null); + let {scale} = useProvider(); + + let onResize = useCallback(() => { + if (inputRef.current) { + let inputWidth = inputRef.current.offsetWidth; + setMenuWidth(inputWidth); + } + }, [inputRef, setMenuWidth]); + + useResizeObserver({ + ref: domRef, + onResize: onResize + }); + + useLayoutEffect(onResize, [scale, onResize]); + + // Update position once the ListBox has rendered. This ensures that + // it flips properly when it doesn't fit in the available space. + // TODO: add ResizeObserver to useOverlayPosition so we don't need this. + useLayoutEffect(() => { + if (state.isOpen) { + requestAnimationFrame(() => { + updatePosition(); + }); + } + }, [state.isOpen, updatePosition]); + + let style = { + ...overlayProps.style, + width: isQuiet ? null : menuWidth, + minWidth: isQuiet ? `calc(${menuWidth}px + calc(2 * var(--spectrum-dropdown-quiet-offset)))` : menuWidth + }; + + return ( + <> + + + + + isAsync && ( + + {formatMessage('noResults')} + + )} /> + state.close()} /> + + + ); +}); + +interface SearchAutocompleteInputProps extends SpectrumSearchAutocompleteProps { + inputProps: InputHTMLAttributes, + inputRef: RefObject, + style?: React.CSSProperties, + className?: string, + isOpen?: boolean, + clearButtonProps: AriaButtonProps +} + +const SearchAutocompleteInput = React.forwardRef(function SearchAutocompleteInput(props: SearchAutocompleteInputProps, ref: RefObject) { + let { + isQuiet, + isDisabled, + isReadOnly, + validationState, + inputProps, + inputRef, + autoFocus, + style, + className, + loadingState, + isOpen, + menuTrigger, + clearButtonProps + } = props; + let {hoverProps, isHovered} = useHover({}); + let formatMessage = useMessageFormatter(intlMessages); + let timeout = useRef(null); + let [showLoading, setShowLoading] = useState(false); + + let loadingCircle = ( + + ); + + let searchIcon = ( + + ); + + let clearButton = ( + + ); + + let isLoading = loadingState === 'loading' || loadingState === 'filtering'; + let inputValue = inputProps.value; + let lastInputValue = useRef(inputValue); + useEffect(() => { + if (isLoading && !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 (!isLoading) { + // 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; + }, [isLoading, showLoading, inputValue]); + + return ( + +
    } + style={style} + className={ + classNames( + styles, + 'spectrum-InputGroup', + { + 'spectrum-InputGroup--quiet': isQuiet, + 'is-disabled': isDisabled, + 'spectrum-InputGroup--invalid': validationState === 'invalid', + 'is-hovered': isHovered + }, + className + ) + }> + +
    +
    + ); +}); + + +/** + * A SearchAutocomplete is a searchfield that supports a dynamic list of suggestions. + */ +let _SearchAutocomplete = forwardRef(SearchAutocomplete); +export {_SearchAutocomplete as SearchAutocomplete}; diff --git a/packages/@react-spectrum/searchfield/src/index.ts b/packages/@react-spectrum/searchfield/src/index.ts index 76c15dddad3..0e984dc475d 100644 --- a/packages/@react-spectrum/searchfield/src/index.ts +++ b/packages/@react-spectrum/searchfield/src/index.ts @@ -13,3 +13,5 @@ /// export * from './SearchField'; +export * from './SearchAutocomplete'; +export {Item, Section} from '@react-stately/collections'; diff --git a/packages/@react-spectrum/searchfield/src/searchautocomplete.css b/packages/@react-spectrum/searchfield/src/searchautocomplete.css new file mode 100644 index 00000000000..f8718c1c641 --- /dev/null +++ b/packages/@react-spectrum/searchfield/src/searchautocomplete.css @@ -0,0 +1,65 @@ +/* + * 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. + */ + +.no-results { + display: block; + /* + Renamed from padding-y to padding-height to fix docs issue where fallback var replaced this value + (due to old spectrum-css postcss-custom-properties-custom-mapping plugin). + */ + padding-top: var(--spectrum-selectlist-option-padding-height); + padding-inline-start: var(--spectrum-selectlist-option-padding); + font-size: var(--spectrum-selectlist-option-text-size); + font-weight: var(--spectrum-selectlist-option-text-font-weight); + font-style: italic; +} + +.mobile-searchautocomplete { + outline: none; +} + +.mobile-input { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mobile-value { + vertical-align: middle; +} + +.tray-dialog { + display: flex; + flex-direction: column; + height: 100%; + outline: none; +} + +.tray-textfield { + margin: var(--spectrum-global-dimension-size-150); + margin-bottom: var(--spectrum-global-dimension-size-50); + flex-shrink: 0; + width: initial !important; + + &.has-label { + margin-top: var(--spectrum-global-dimension-size-50); + } + + .tray-textfield-input { + padding-inline-start: var(--spectrum-textfield-padding-x); + } +} + +.tray-listbox { + width: 100%; + flex: 1; +} diff --git a/packages/@react-spectrum/searchfield/stories/SearchAutocomplete.stories.tsx b/packages/@react-spectrum/searchfield/stories/SearchAutocomplete.stories.tsx new file mode 100644 index 00000000000..39901f2f0ba --- /dev/null +++ b/packages/@react-spectrum/searchfield/stories/SearchAutocomplete.stories.tsx @@ -0,0 +1,188 @@ + +/* + * 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 {action} from '@storybook/addon-actions'; +import {Flex} from '@react-spectrum/layout'; +import {Item, SearchAutocomplete} from '@react-spectrum/searchfield'; +import {mergeProps} from '@react-aria/utils'; +import {Meta} from '@storybook/react'; +import React from 'react'; +import {SpectrumSearchAutocompleteProps} from '@react-types/searchfield'; + +const StoryFn = ({storyFn}) => storyFn(); + +const meta: Meta> = { + title: 'SearchAutocomplete', + component: SearchAutocomplete, + decorators: [storyFn => ] +}; + +export default meta; + +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 actions = { + onOpenChange: action('onOpenChange'), + onInputChange: action('onInputChange'), + onBlur: action('onBlur'), + onFocus: action('onFocus'), + onChange: action('onChange'), + onSubmit: action('onSubmit') +}; + +function Default(props) { + return ( + + Aerospace + Mechanical + Civil + Biomedical + Nuclear + Industrial + Chemical + Agricultural + Electrical + + ); +} + +function Dynamic(props) { + return ( + + {(item: any) => {item.name}} + + ); +} + +function Mapped(props) { + return ( + + {options.map((item) => ( + + {item.name} + + ))} + + ); +} + +function CustomOnSubmit(props) { + let [searchTerm, setSearchTerm] = React.useState(''); + + let onSubmit = (value, key) => { + if (value) { + setSearchTerm(value); + } else if (key) { + setSearchTerm(options.find(o => o.id === key).name); + } + }; + + return ( + + + {(item: any) => {item.name}} + +
    + Search results for: {searchTerm} +
    +
    + ); +} + +export const Static = (props) => ; +Static.storyName = 'static items'; + +export const DynamicItems = (props) => ; +DynamicItems.storyName = 'dynamic items'; + +export const NoItems = (props) => ; +NoItems.storyName = 'no items'; + +export const MappedItems = (props) => ; +MappedItems.storyName = 'with mapped items'; + +export const MenuTriggerFocus = (props) => ; +MenuTriggerFocus.storyName = 'menuTrigger: focus'; + +export const MenuTriggerManual = (props) => ; +MenuTriggerManual.storyName = 'menuTrigger: manual'; + +export const isQuiet = (props) => ; +isQuiet.storyName = 'isQuiet'; + +export const isDisabled = (props) => ; +isDisabled.storyName = 'isDisabled'; + +export const isReadOnly = (props) => ; +isReadOnly.storyName = 'isReadOnly'; + +export const labelAlignEnd = (props) => ; +labelAlignEnd.storyName = 'labelPosition: top, labelAlign: end'; + +export const labelPositionSide = (props) => ; +labelPositionSide.storyName = 'labelPosition: side'; + +export const noVisibleLabel = (props) => ; +noVisibleLabel.storyName = 'No visible label'; + +export const noVisibleLabelIsQuiet = (props) => ; +noVisibleLabelIsQuiet.storyName = 'No visible label, isQuiet'; + +export const isRequired = (props) => ; +isRequired.storyName = 'isRequired'; + +export const isRequiredNecessityIndicatorLabel = (props) => ; +isRequiredNecessityIndicatorLabel.storyName = 'isRequired, necessityIndicator: label'; + +export const validationStateInvalid = (props) => ; +validationStateInvalid.storyName = 'validationState: invalid'; + +export const validationStateValid = (props) => ; +validationStateValid.storyName = 'validationState: valid'; + +export const validationStateInvalidIsQuiet = (props) => ; +validationStateInvalidIsQuiet.storyName = 'validationState: invalid, isQuiet'; + +export const validationStateValidIsQuiet = (props) => ; +validationStateValidIsQuiet.storyName = 'validationState: valid, isQuiet'; + +export const placeholder = (props) => ; +placeholder.storyName = 'placeholder'; + +export const autoFocus = (props) => ; +autoFocus.storyName = 'autoFocus: true'; + +export const directionTop = (props) => ; +directionTop.storyName = 'direction: top'; + +export const customWidth500 = (props) => ; +customWidth500.storyName = 'custom width: size-500'; + +export const customWidth3000 = (props) => ; +customWidth3000.storyName = 'custom width: size-3000'; + +export const customWidth6000 = (props) => ; +customWidth6000.storyName = 'custom width: size-6000'; + +export const customOnSubmit = (props) => ; +customOnSubmit.storyName = 'custom onSubmit'; diff --git a/packages/@react-spectrum/searchfield/test/SearchAutocomplete.test.js b/packages/@react-spectrum/searchfield/test/SearchAutocomplete.test.js new file mode 100644 index 00000000000..9db1971d255 --- /dev/null +++ b/packages/@react-spectrum/searchfield/test/SearchAutocomplete.test.js @@ -0,0 +1,2664 @@ +/* + * 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. + */ + +jest.mock('@react-aria/live-announcer'); +import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import {announce} from '@react-aria/live-announcer'; +import {Button} from '@react-spectrum/button'; +import {Item, SearchAutocomplete, Section} from '../'; +import {Provider} from '@react-spectrum/provider'; +import React from 'react'; +import scaleMedium from '@adobe/spectrum-css-temp/vars/spectrum-medium-unique.css'; +import themeLight from '@adobe/spectrum-css-temp/vars/spectrum-light-unique.css'; +import {triggerPress} from '@react-spectrum/test-utils'; +import {typeText} from '@react-spectrum/test-utils'; +import userEvent from '@testing-library/user-event'; + +let theme = { + light: themeLight, + medium: scaleMedium +}; + +let onOpenChange = jest.fn(); +let onInputChange = jest.fn(); +let outerBlur = jest.fn(); +let onFocus = jest.fn(); +let onBlur = jest.fn(); + +let defaultProps = { + label: 'Test', + placeholder: 'Search for a topic...', + onOpenChange, + onInputChange, + onFocus, + onBlur +}; + +const ExampleSearchAutocomplete = React.forwardRef((props = {}, ref) => ( + + + One + Two + Three + + + )); + +function renderSearchAutocomplete(props = {}) { + return render(); +} + +function renderSectionSearchAutocomplete(props = {}) { + return render( + + +
    + One + Two + Three +
    +
    + Four + Five + Six +
    +
    +
    + ); +} + +let items = [ + {name: 'One', id: '1'}, + {name: 'Two', id: '2'}, + {name: 'Three', id: '3'} +]; + +function ControlledValueSearchAutocomplete(props) { + let [inputValue, setInputValue] = React.useState(''); + + return ( + + + {(item) => {item.name}} + + + ); +} + +let initialFilterItems = [ + {name: 'Aardvark', id: '1'}, + {name: 'Kangaroo', id: '2'}, + {name: 'Snake', id: '3'} +]; + +function testSearchAutocompleteOpen(searchAutocomplete, listbox, focusedItemIndex) { + let searchAutocompleteLabelledBy = searchAutocomplete.getAttribute('aria-labelledby'); + + expect(listbox).toBeVisible(); + expect(listbox).toHaveAttribute('aria-label', 'Suggestions'); + expect(listbox).toHaveAttribute('aria-labelledby', `${searchAutocompleteLabelledBy} ${listbox.id}`); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + expect(searchAutocomplete).toHaveAttribute('aria-expanded', 'true'); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('One'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[2]).toHaveTextContent('Three'); + + expect(listbox).not.toHaveAttribute('tabIndex'); + for (let item of items) { + expect(item).not.toHaveAttribute('tabIndex'); + } + + expect(document.activeElement).toBe(searchAutocomplete); + + if (typeof focusedItemIndex === 'undefined') { + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + } else { + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[focusedItemIndex].id); + } +} + +describe('SearchAutocomplete', function () { + beforeAll(function () { + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => setTimeout(cb, 0)); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('renders correctly', function () { + let {getAllByText, getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete).toHaveAttribute('placeholder', 'Search for a topic...'); + expect(searchAutocomplete).toHaveAttribute('autoCorrect', 'off'); + expect(searchAutocomplete).toHaveAttribute('spellCheck', 'false'); + expect(searchAutocomplete).toHaveAttribute('autoComplete', 'off'); + + let label = getAllByText('Test')[0]; + expect(label).toBeVisible(); + }); + + it('can be disabled', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete({isDisabled: true}); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'One'); + act(() => { + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('can be readonly', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete({isReadOnly: true, defaultInputValue: 'Blargh'}); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'One'); + act(() => { + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(searchAutocomplete.value).toBe('Blargh'); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onFocus).toHaveBeenCalled(); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('features default behavior of completionMode suggest and menuTrigger input', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete).not.toHaveAttribute('aria-controls'); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + expect(searchAutocomplete).toHaveAttribute('aria-autocomplete', 'list'); + + typeText(searchAutocomplete, 'On'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + expect(searchAutocomplete.value).toBe('On'); + expect(items[0]).toHaveTextContent('One'); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + describe('refs', function () { + it('attaches a ref to the label wrapper', function () { + let ref = React.createRef(); + let {getByText} = renderSearchAutocomplete({ref}); + + expect(ref.current.UNSAFE_getDOMNode()).toBe(getByText('Test').parentElement); + }); + + it('attaches a ref to the searchAutocomplete wrapper if no label', function () { + let ref = React.createRef(); + let {getByRole} = renderSearchAutocomplete({ref, label: null, 'aria-label': 'test'}); + + expect(ref.current.UNSAFE_getDOMNode()).toBe(getByRole('combobox').parentElement.parentElement); + }); + + it('calling focus() on the ref focuses the input field', function () { + let ref = React.createRef(); + let {getByRole} = renderSearchAutocomplete({ref}); + + act(() => {ref.current.focus();}); + expect(document.activeElement).toBe(getByRole('combobox')); + }); + }); + + describe('opening', function () { + describe('menuTrigger = focus', function () { + it('opens menu when searchAutocomplete is focused', function () { + let {getByRole} = renderSearchAutocomplete({menuTrigger: 'focus'}); + + let searchAutocomplete = getByRole('combobox'); + act(() => { + searchAutocomplete.focus(); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(onOpenChange).toBeCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'focus'); + testSearchAutocompleteOpen(searchAutocomplete, listbox); + }); + }); + + describe('keyboard input', function () { + it('opens the menu on down arrow press', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + act(() => {searchAutocomplete.focus();}); + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + testSearchAutocompleteOpen(searchAutocomplete, listbox, 0); + }); + + it('opens the menu on up arrow press', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + act(() => {searchAutocomplete.focus();}); + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowUp', code: 38, charCode: 38}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + testSearchAutocompleteOpen(searchAutocomplete, listbox, 2); + }); + + it('opens the menu on user typing', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + act(() => {searchAutocomplete.focus();}); + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + + typeText(searchAutocomplete, 'Two'); + act(() => { + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + expect(searchAutocomplete).toHaveAttribute('aria-expanded', 'true'); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('Two'); + + expect(document.activeElement).toBe(searchAutocomplete); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + + it('doesn\'t select an item on matching input if it is a disabled key', function () { + let {getByRole} = renderSearchAutocomplete({disabledKeys: ['2']}); + let searchAutocomplete = getByRole('combobox'); + act(() => {searchAutocomplete.focus();}); + expect(onOpenChange).not.toHaveBeenCalled(); + typeText(searchAutocomplete, 'Two'); + + act(() => { + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('Two'); + + expect(document.activeElement).toBe(searchAutocomplete); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + + it('closes the menu if there are no matching items', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + expect(onOpenChange).not.toHaveBeenCalled(); + act(() => {searchAutocomplete.focus();}); + typeText(searchAutocomplete, 'One'); + act(() => jest.runAllTimers()); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + typeText(searchAutocomplete, 'z'); + act(() => jest.runAllTimers()); + expect(queryByRole('listbox')).toBeNull(); + expect(searchAutocomplete).not.toHaveAttribute('aria-controls'); + expect(searchAutocomplete).toHaveAttribute('aria-expanded', 'false'); + }); + + it('doesn\'t open the menu if no items match', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + act(() => searchAutocomplete.focus()); + typeText(searchAutocomplete, 'X', {skipClick: true}); + act(() => { + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + }); + describe('showing menu', function () { + it('keeps the menu open if the user clears the input field if menuTrigger = focus', function () { + let {getByRole} = renderSearchAutocomplete({menuTrigger: 'focus'}); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'Two'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: ''}}); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(listbox).toBeVisible(); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + expect(searchAutocomplete).toHaveAttribute('aria-expanded', 'true'); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('One'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[2]).toHaveTextContent('Three'); + + expect(document.activeElement).toBe(searchAutocomplete); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + + it('allows the user to navigate the menu via arrow keys', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + + expect(document.activeElement).toBe(searchAutocomplete); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[1].id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowUp', code: 38, charCode: 38}); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('allows the user to select an item via Enter', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete.value).toBe(''); + typeText(searchAutocomplete, 'o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + + expect(document.activeElement).toBe(searchAutocomplete); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'Enter', code: 13, charCode: 13}); + fireEvent.keyUp(searchAutocomplete, {key: 'Enter', code: 13, charCode: 13}); + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(searchAutocomplete.value).toBe('One'); + }); + + it('doesn\'t focus the first key if the previously focused key is filtered out of the list', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'O'); + act(() => { + jest.runAllTimers(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[1].id); + expect(items[1].textContent).toBe('Two'); + + typeText(searchAutocomplete, 'n'); + act(() => { + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(searchAutocomplete.value).toBe('On'); + expect(items).toHaveLength(1); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + expect(items[0].textContent).toBe('One'); + }); + + it('closes menu when pressing Enter on an already selected item', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + act(() => searchAutocomplete.focus()); + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeTruthy(); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'Enter', code: 13, charCode: 13}); + fireEvent.keyUp(searchAutocomplete, {key: 'Enter', code: 13, charCode: 13}); + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + }); + }); + + describe('typing in the textfield', function () { + it('can be uncontrolled', function () { + let {getByRole} = render( + + + Bulbasaur + Squirtle + Charmander + + + ); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'Bul'); + + expect(onOpenChange).toHaveBeenCalled(); + }); + + it('can select by mouse', function () { + let {getByRole} = render( + + + Cheer + Cheerio + Cheeriest + + + ); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'Che'); + + act(() => { + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenCalled(); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + act(() => { + triggerPress(items[1]); + jest.runAllTimers(); + }); + }); + + it('filters searchAutocomplete items using contains strategy', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'o'); + + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('One'); + expect(items[1]).toHaveTextContent('Two'); + }); + + it('should not match any items if input is just a space', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, ' '); + + act(() => { + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + }); + + it('doesn\'t focus the first item in searchAutocomplete menu if you completely clear your textfield and menuTrigger = focus', function () { + let {getByRole} = renderSearchAutocomplete({menuTrigger: 'focus'}); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'o'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + let items = within(listbox).getAllByRole('option'); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + userEvent.clear(searchAutocomplete); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(listbox).toBeVisible(); + expect(items).toHaveLength(3); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + + it('doesn\'t closes the menu if you completely clear your textfield and menuTrigger != focus', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'o'); + + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: ''}}); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + }); + + it('clears prior item focus when input no longer matches existing value if allowsCustomValue is true', function () { + let {getByRole} = renderSearchAutocomplete({allowsCustomValue: true}); + let searchAutocomplete = getByRole('combobox'); + // Change input value to something matching a searchAutocomplete value + act(() => { + searchAutocomplete.focus(); + fireEvent.change(searchAutocomplete, {target: {value: 'Two'}}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(listbox).toBeVisible(); + expect(items).toHaveLength(1); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + expect(items[0].textContent).toBe('Two'); + + // Change input text to something that doesn't match any searchAutocomplete items but still shows the menu + act(() => { + searchAutocomplete.focus(); + fireEvent.change(searchAutocomplete, {target: {value: 'Tw'}}); + jest.runAllTimers(); + }); + + // check that no item is focused in the menu + listbox = getByRole('listbox'); + items = within(listbox).getAllByRole('option'); + expect(listbox).toBeVisible(); + expect(items).toHaveLength(1); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + expect(items[0].textContent).toBe('Two'); + }); + + it('should close the menu when no items match', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'O'); + + act(() => { + jest.runAllTimers(); + }); + + expect(getByRole('listbox')).toBeVisible(); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(true, 'input'); + + typeText(searchAutocomplete, 'x'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.change(searchAutocomplete, {target: {value: 'x'}}); + jest.runAllTimers(); + }); + + expect(queryByRole('listbox')).toBeNull(); + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); + }); + + it('should clear the focused item when typing', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'w'); + + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + + typeText(searchAutocomplete, 'o'); + + items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + }); + + describe('blur', function () { + it('closes and commits selection on blur (clicking to blur)', function () { + let {queryByRole, getByRole} = render( + + + Bulbasaur + Squirtle + Charmander + + + + ); + + let searchAutocomplete = getByRole('combobox'); + act(() => { + typeText(searchAutocomplete, 'b'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: 'Bulba'}}); + jest.runAllTimers(); + searchAutocomplete.blur(); + jest.runAllTimers(); + }); + + // SearchAutocomplete value should reset to the selected key value and menu should be closed + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); + expect(onInputChange).toHaveBeenLastCalledWith('Bulba'); + expect(searchAutocomplete.value).toBe('Bulba'); + expect(queryByRole('listbox')).toBeFalsy(); + }); + + it('closes and commits custom value', function () { + let {getByRole, queryByRole} = render( + + + Bulbasaur + Squirtle + Charmander + + + ); + + let searchAutocomplete = getByRole('combobox'); + act(() => { + userEvent.click(searchAutocomplete); + jest.runAllTimers(); + }); + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: 'Bulba'}}); + jest.runAllTimers(); + searchAutocomplete.blur(); + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenLastCalledWith(false, undefined); + + expect(queryByRole('listbox')).toBeNull(); + }); + + it('retains selected key on blur if input value matches', function () { + let {getByRole} = render( + + + Bulbasaur + Squirtle + Charmander + + + ); + + let searchAutocomplete = getByRole('combobox'); + + act(() => { + userEvent.click(searchAutocomplete); + jest.runAllTimers(); + }); + + expect(document.activeElement).toBe(searchAutocomplete); + + act(() => { + searchAutocomplete.blur(); + jest.runAllTimers(); + }); + + }); + + it('propagates blur event outside of the component', function () { + let {getByRole} = render( + +
    + + Bulbasaur + Squirtle + Charmander + + +
    +
    + ); + + let searchAutocomplete = getByRole('combobox'); + expect(document.activeElement).toBe(searchAutocomplete); + + expect(onBlur).toHaveBeenCalledTimes(0); + expect(outerBlur).toHaveBeenCalledTimes(0); + + act(() => { + userEvent.tab(); + }); + + expect(onBlur).toHaveBeenCalledTimes(1); + expect(outerBlur).toHaveBeenCalledTimes(1); + }); + }); + + describe('controlled searchAutocomplete', function () { + describe('controlled by inputValue', function () { + it('updates inputValue changes', function () { + let {getByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete.value).toBe('Two'); + + rerender(); + expect(searchAutocomplete.value).toBe('One'); + + rerender(); + expect(searchAutocomplete.value).toBe(''); + }); + }); + + describe('controlled by inputValue', function () { + it('updates the input field when inputValue prop changes', function () { + let {getByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete.value).toBe('T'); + + rerender(); + expect(searchAutocomplete.value).toBe('Tw'); + }); + }); + + describe('custom filter', function () { + it('updates items with custom filter onInputChange', () => { + let customFilterItems = [ + {name: 'The first item', id: '1'}, + {name: 'The second item', id: '2'}, + {name: 'The third item', id: '3'} + ]; + + let CustomFilterSearchAutocomplete = () => { + let [list, setList] = React.useState(customFilterItems); + let onInputChange = (value) => { + setList(customFilterItems.filter(item => item.name.includes(value))); + }; + + return ( + + {(item) => {item.name}} + + ); + }; + let {getByRole} = render( + + + + ); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'second'); + act(() => { + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + }); + }); + + it('updates the list of items when items update', function () { + let {getByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + let items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + + expect(items[0].textContent).toBe('Aardvark'); + expect(items[1].textContent).toBe('Kangaroo'); + expect(items[2].textContent).toBe('Snake'); + + let newItems = [ + {name: 'New Text', id: '1'}, + {name: 'Item 2', id: '2'}, + {name: 'Item 3', id: '3'} + ]; + + rerender(); + + expect(listbox).toBeVisible(); + items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + + expect(items[0].textContent).toBe('New Text'); + expect(items[1].textContent).toBe('Item 2'); + expect(items[2].textContent).toBe('Item 3'); + }); + + it('updates the list of items when items update (items provided by map)', function () { + function SearchAutocompleteWithMap(props) { + let defaultItems = initialFilterItems; + let { + listItems = defaultItems + } = props; + return ( + + + {listItems.map((item) => ( + + {item.name} + + ))} + + + ); + } + + let {getByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + typeText(searchAutocomplete, 'a'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + let items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + + expect(items[0].textContent).toBe('Aardvark'); + expect(items[1].textContent).toBe('Kangaroo'); + expect(items[2].textContent).toBe('Snake'); + + act(() => { + triggerPress(items[0]); + jest.runAllTimers(); + }); + + expect(searchAutocomplete.value).toBe('Aardvark'); + + act(() => { + searchAutocomplete.blur(); + jest.runAllTimers(); + }); + expect(document.activeElement).not.toBe(searchAutocomplete); + + let newItems = [ + {name: 'New Text', id: '1'}, + {name: 'Item 2', id: '2'}, + {name: 'Item 3', id: '3'} + ]; + + rerender(); + expect(searchAutocomplete.value).toBe('New Text'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.change(searchAutocomplete, {target: {value: ''}}); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + + expect(items[0].textContent).toBe('New Text'); + expect(items[1].textContent).toBe('Item 2'); + expect(items[2].textContent).toBe('Item 3'); + }); + }); + + describe('uncontrolled searchAutocomplete', function () { + it('should update both input value and selected item freely', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete.value).toBe(''); + + act(() => { + searchAutocomplete.focus(); + typeText(searchAutocomplete, 'T'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(2); + typeText(searchAutocomplete, 'wo'); + + act(() => { + jest.runAllTimers(); + }); + + expect(searchAutocomplete.value).toBe('Two'); + expect(onInputChange).toHaveBeenCalledTimes(3); + expect(onInputChange).toHaveBeenCalledWith('Two'); + + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: ''}}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete.value).toBe(''); + expect(onInputChange).toHaveBeenCalledTimes(4); + expect(onInputChange).toHaveBeenCalledWith(''); + + listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + items = within(listbox).getAllByRole('option'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[1]).not.toHaveAttribute('aria-selected', 'true'); + + act(() => { + triggerPress(items[0]); + jest.runAllTimers(); + }); + + expect(searchAutocomplete.value).toBe('One'); + expect(queryByRole('listbox')).toBeNull(); + expect(onInputChange).toHaveBeenCalledTimes(5); + expect(onInputChange).toHaveBeenCalledWith('One'); + + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: 'o'}}); + jest.runAllTimers(); + }); + + listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + items = within(listbox).getAllByRole('option'); + expect(items[0]).toHaveTextContent('One'); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + // Reset selection + act(() => { + fireEvent.change(searchAutocomplete, {target: {value: ''}}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete.value).toBe(''); + expect(onInputChange).toHaveBeenCalledTimes(7); + expect(onInputChange).toHaveBeenCalledWith(''); + }); + + it('defaultInputValue should not set selected item', function () { + let {getByRole} = renderSearchAutocomplete({defaultInputValue: 'Tw'}); + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete.value).toBe('Tw'); + + act(() => { + searchAutocomplete.focus(); + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('Two'); + expect(items[0]).toHaveAttribute('aria-selected', 'false'); + }); + }); + + it('should have aria-invalid when validationState="invalid"', function () { + let {getByRole} = renderSearchAutocomplete({validationState: 'invalid'}); + let searchAutocomplete = getByRole('combobox'); + expect(searchAutocomplete).toHaveAttribute('aria-invalid', 'true'); + }); + + describe('loadingState', function () { + it('searchAutocomplete should not render a loading circle if menu is not open', function () { + let {getByRole, queryByRole, rerender} = render(); + act(() => {jest.advanceTimersByTime(500);}); + // First time load will show progress bar so user can know that items are being fetched + expect(getByRole('progressbar')).toBeTruthy(); + + rerender(); + + expect(queryByRole('progressbar')).toBeNull(); + }); + + it('searchAutocomplete should render a loading circle if menu is not open but menuTrigger is "manual"', function () { + let {getByRole, queryByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => {jest.advanceTimersByTime(500);}); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + + rerender(); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + + rerender(); + expect(queryByRole('progressbar')).toBeNull(); + }); + + it('searchAutocomplete should not render a loading circle until a delay of 500ms passes (loadingState: loading)', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete({loadingState: 'loading'}); + let searchAutocomplete = getByRole('combobox'); + + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => {jest.advanceTimersByTime(250);}); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + }); + + it('searchAutocomplete should not render a loading circle until a delay of 500ms passes and the menu is open (loadingState: filtering)', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete({loadingState: 'filtering'}); + let searchAutocomplete = getByRole('combobox'); + + act(() => {jest.advanceTimersByTime(500);}); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + }); + + it('searchAutocomplete should hide the loading circle when loadingState changes to a non-loading state', function () { + let {getByRole, queryByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + act(() => {jest.advanceTimersByTime(500);}); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + + rerender(); + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(queryByRole('progressbar')).toBeNull(); + }); + + it('searchAutocomplete should hide the loading circle when if the menu closes', function () { + let {getByRole, queryByRole} = render(); + let searchAutocomplete = getByRole('combobox'); + + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + act(() => {jest.advanceTimersByTime(500);}); + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + + act(() => { + searchAutocomplete.blur(); + jest.runAllTimers(); + }); + expect(queryByRole('progressbar')).toBeNull(); + expect(queryByRole('listbox')).toBeNull(); + }); + + it('searchAutocomplete cancels the 500ms progress circle delay timer if the loading finishes first', function () { + let {queryByRole, rerender} = render(); + expect(queryByRole('progressbar')).toBeNull(); + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + + rerender(); + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + }); + + it('searchAutocomplete should not reset the 500ms progress circle delay timer when loadingState changes from loading to filtering', function () { + let {getByRole, queryByRole, rerender} = render(); + let searchAutocomplete = getByRole('combobox'); + + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + + rerender(); + expect(queryByRole('progressbar')).toBeNull(); + act(() => {jest.advanceTimersByTime(250);}); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + }); + + it('searchAutocomplete should reset the 500ms progress circle delay timer when input text changes', function () { + let {getByRole, queryByRole} = render(); + let searchAutocomplete = getByRole('combobox'); + + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + + typeText(searchAutocomplete, 'O'); + act(() => {jest.advanceTimersByTime(250);}); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => {jest.advanceTimersByTime(250);}); + expect(() => within(searchAutocomplete).getByRole('progressbar')).toBeTruthy(); + }); + + it.each` + LoadingState | ValidationState + ${'loading'} | ${null} + ${'filtering'} | ${null} + ${'loading'} | ${'invalid'} + ${'filtering'} | ${'invalid'} + `('should render the loading swirl in the input field when loadingState="$LoadingState" and validationState="$ValidationState"', ({LoadingState, ValidationState}) => { + let {getByRole} = renderSearchAutocomplete({loadingState: LoadingState, validationState: ValidationState}); + let searchAutocomplete = getByRole('combobox'); + act(() => {jest.advanceTimersByTime(500);}); + + if (ValidationState) { + expect(searchAutocomplete).toHaveAttribute('aria-invalid', 'true'); + } + + // validation icon should not be present + expect(within(searchAutocomplete).queryByRole('img', {hidden: true})).toBeNull(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + + let progressSpinner = getByRole('progressbar', {hidden: true}); + expect(progressSpinner).toBeTruthy(); + expect(progressSpinner).toHaveAttribute('aria-label', 'Loading...'); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(within(listbox).queryByRole('progressbar')).toBeNull(); + }); + + it('should render the loading swirl in the listbox when loadingState="loadingMore"', function () { + let {getByRole, queryByRole} = renderSearchAutocomplete({loadingState: 'loadingMore'}); + let searchAutocomplete = getByRole('combobox'); + + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + let progressSpinner = within(listbox).getByRole('progressbar'); + expect(progressSpinner).toBeTruthy(); + expect(progressSpinner).toHaveAttribute('aria-label', 'Loading more…'); + }); + }); + + describe('mobile searchAutocomplete', function () { + beforeEach(() => { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 600); + }); + + afterEach(() => { + jest.runAllTimers(); + jest.clearAllMocks(); + }); + + function testSearchAutocompleteTrayOpen(input, tray, listbox, focusedItemIndex) { + expect(tray).toBeVisible(); + + let dialog = within(tray).getByRole('dialog'); + expect(input).toHaveAttribute('aria-labelledby'); + expect(dialog).toHaveAttribute('aria-labelledby', input.getAttribute('aria-labelledby')); + + expect(input).toHaveAttribute('role', 'searchbox'); + expect(input).toHaveAttribute('aria-expanded', 'true'); + expect(input).toHaveAttribute('aria-controls', listbox.id); + expect(input).toHaveAttribute('aria-haspopup', 'listbox'); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('One'); + expect(items[1]).toHaveTextContent('Two'); + expect(items[2]).toHaveTextContent('Three'); + + expect(document.activeElement).toBe(input); + + if (typeof focusedItemIndex === 'undefined') { + expect(input).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(input, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(input, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(input).toHaveAttribute('aria-activedescendant', items[0].id); + } else { + expect(input).toHaveAttribute('aria-activedescendant', items[focusedItemIndex].id); + } + } + + it('should render a button to open the tray', function () { + let {getByRole, getByText} = renderSearchAutocomplete({}); + let button = getByRole('button'); + + expect(button).toHaveAttribute('aria-haspopup', 'dialog'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText(defaultProps.placeholder).id}`); + }); + + it('button should be labelled by external label', function () { + let {getByRole, getByText} = renderSearchAutocomplete({label: null, 'aria-labelledby': 'label-id'}); + let button = getByRole('button'); + + expect(button).toHaveAttribute('aria-labelledby', `label-id ${getByText(defaultProps.placeholder).id}`); + }); + + it('button should be labelled by aria-label', function () { + let {getByRole, getByText} = renderSearchAutocomplete({label: null, 'aria-label': 'Label'}); + let button = getByRole('button'); + + expect(button).toHaveAttribute('aria-label', 'Label'); + expect(button).toHaveAttribute('aria-labelledby', `${button.id} ${getByText(defaultProps.placeholder).id}`); + }); + + it('button should be labelled by external label and builtin label', function () { + let {getByRole, getByText} = renderSearchAutocomplete({'aria-labelledby': 'label-id'}); + let button = getByRole('button'); + + expect(button).toHaveAttribute('aria-labelledby', `label-id ${getByText('Test').id} ${getByText(defaultProps.placeholder).id}`); + }); + + it('readonly searchAutocomplete should not open on press', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete({isReadOnly: true}); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(() => getByTestId('tray')).toThrow(); + }); + + it('opening the tray autofocuses the tray input', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + + let trayInput = within(tray).getByRole('searchbox'); + testSearchAutocompleteTrayOpen(trayInput, tray, listbox); + }); + + it('closing the tray autofocuses the button', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let input = within(tray).getByRole('searchbox'); + + act(() => { + fireEvent.keyDown(input, {key: 'Escape', code: 27, charCode: 27}); + fireEvent.keyUp(input, {key: 'Escape', code: 27, charCode: 27}); + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).toThrow(); + expect(document.activeElement).toBe(button); + }); + + it('height of the tray remains fixed even if the number of items changes', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let items = within(tray).getAllByRole('option'); + expect(items.length).toBe(3); + + let trayInput = within(tray).getByRole('searchbox'); + // Save the height style for comparison later + let style = tray.getAttribute('style'); + typeText(trayInput, 'One'); + + act(() => { + jest.runAllTimers(); + }); + + items = within(tray).getAllByRole('option'); + expect(items.length).toBe(1); + tray = getByTestId('tray'); + expect(tray.getAttribute('style')).toBe(style); + }); + + it('up/down arrows still traverse the items in the tray', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + + let button = getByRole('button'); + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let input = within(tray).getByRole('searchbox'); + expect(document.activeElement).toBe(input); + + act(() => { + fireEvent.keyDown(input, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(input, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + testSearchAutocompleteTrayOpen(input, tray, listbox, 0); + + act(() => { + fireEvent.keyDown(input, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(input, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let items = within(tray).getAllByRole('option'); + + expect(input).toHaveAttribute('aria-activedescendant', items[1].id); + + act(() => { + fireEvent.keyDown(input, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent.keyUp(input, {key: 'ArrowUp', code: 38, charCode: 38}); + jest.runAllTimers(); + }); + + expect(input).toHaveAttribute('aria-activedescendant', items[0].id); + }); + + it('user can filter the menu options by typing in the tray input', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + let trayInput = within(tray).getByRole('searchbox'); + + testSearchAutocompleteTrayOpen(trayInput, tray, listbox); + typeText(trayInput, 'r'); + + act(() => { + jest.runAllTimers(); + }); + + let items = within(tray).getAllByRole('option'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('Three'); + + act(() => { + fireEvent.change(trayInput, {target: {value: ''}}); + jest.runAllTimers(); + }); + + items = within(tray).getAllByRole('option'); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe('One'); + expect(items[1].textContent).toBe('Two'); + expect(items[2].textContent).toBe('Three'); + }); + + it('tray input can be cleared using a clear button', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + let trayInput = within(tray).getByRole('searchbox'); + + expect(() => within(tray).getByLabelText('Clear')).toThrow(); + + testSearchAutocompleteTrayOpen(trayInput, tray, listbox); + typeText(trayInput, 'r'); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.activeElement).toBe(trayInput); + expect(trayInput.value).toBe('r'); + + let clearButton = within(tray).getByLabelText('Clear'); + expect(clearButton.tagName).toBe('DIV'); + expect(clearButton).not.toHaveAttribute('tabIndex'); + act(() => { + triggerPress(clearButton); + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.activeElement).toBe(trayInput); + expect(trayInput.value).toBe(''); + }); + + it('"No results" placeholder is shown if user types something that doesnt match any of the available options', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + + let trayInput = within(tray).getByRole('searchbox'); + testSearchAutocompleteTrayOpen(trayInput, tray, listbox); + typeText(trayInput, 'blah'); + + act(() => { + jest.runAllTimers(); + }); + + // check that tray is still visible and placeholder text exists + expect(tray).toBeVisible(); + let items = within(tray).getAllByRole('option'); + expect(items.length).toBe(1); + + let placeholderText = within(items[0]).getByText('No results'); + expect(placeholderText).toBeVisible(); + + + act(() => { + fireEvent.change(trayInput, {target: {value: ''}}); + jest.runAllTimers(); + }); + + items = within(tray).getAllByRole('option'); + expect(items.length).toBe(3); + expect(() => within(tray).getByText('No results')).toThrow(); + }); + + it('user can select options by pressing them', function () { + let {getByRole, getByText, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + expect(onOpenChange).toHaveBeenCalledTimes(1); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + let trayInput = within(tray).getByRole('searchbox'); + testSearchAutocompleteTrayOpen(trayInput, tray, listbox); + + let items = within(tray).getAllByRole('option'); + + act(() => { + triggerPress(items[1]); + jest.runAllTimers(); + }); + + expect(onInputChange).toHaveBeenCalledWith('Two'); + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(() => getByTestId('tray')).toThrow(); + expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText('Two').id}`); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + tray = getByTestId('tray'); + expect(tray).toBeVisible(); + trayInput = within(tray).getByRole('searchbox'); + items = within(tray).getAllByRole('option'); + expect(items.length).toBe(3); + expect(items[1].textContent).toBe('Two'); + expect(trayInput).not.toHaveAttribute('aria-activedescendant'); + expect(trayInput.value).toBe('Two'); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('user can select options by focusing them and hitting enter', function () { + let {getByRole, getByText, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + + let trayInput = within(tray).getByRole('searchbox'); + + act(() => { + fireEvent.keyDown(trayInput, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent.keyUp(trayInput, {key: 'ArrowUp', code: 38, charCode: 38}); + jest.runAllTimers(); + }); + + expect(onOpenChange).toHaveBeenCalledWith(true, 'manual'); + expect(onOpenChange).toHaveBeenCalledTimes(1); + + testSearchAutocompleteTrayOpen(trayInput, tray, listbox, 2); + + act(() => { + fireEvent.keyDown(trayInput, {key: 'Enter', code: 13, charCode: 13}); + fireEvent.keyUp(trayInput, {key: 'Enter', code: 13, charCode: 13}); + jest.runAllTimers(); + }); + + expect(onInputChange).toHaveBeenCalledWith('Three'); + expect(onInputChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false, undefined); + expect(onOpenChange).toHaveBeenCalledTimes(2); + expect(() => getByTestId('tray')).toThrow(); + expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText('Three').id}`); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + tray = getByTestId('tray'); + expect(tray).toBeVisible(); + trayInput = within(tray).getByRole('searchbox'); + let items = within(tray).getAllByRole('option'); + expect(items.length).toBe(3); + expect(items[2].textContent).toBe('Three'); + expect(trayInput).not.toHaveAttribute('aria-activedescendant'); + expect(trayInput.value).toBe('Three'); + expect(items[2]).toHaveAttribute('aria-selected', 'true'); + }); + + it('input is blurred when the user scrolls the listbox with touch', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + + let trayInput = within(tray).getByRole('searchbox'); + + act(() => { + trayInput.focus(); + jest.runAllTimers(); + }); + + expect(document.activeElement).toBe(trayInput); + + act(() => { + fireEvent.touchStart(listbox); + fireEvent.scroll(listbox); + jest.runAllTimers(); + }); + + expect(document.activeElement).not.toBe(trayInput); + }); + + it('label of the tray input should match label of button', function () { + let {getByRole, getByTestId, getByText} = renderSearchAutocomplete(); + let button = getByRole('button'); + let label = getByText(defaultProps.label); + + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${getByText(defaultProps.placeholder).id}`); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let trayInput = within(tray).getByRole('searchbox'); + let trayInputLabel = within(tray).getByText(defaultProps.label); + expect(trayInput).toHaveAttribute('aria-labelledby', trayInputLabel.id); + }); + + it('tray input should recieve the same aria-labelledby as the button if an external label is provided', function () { + let {getByRole, getByTestId, getByText} = render( + + + + Item One + + + ); + + let button = getByRole('button'); + let label = getByText('SearchAutocomplete'); + + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.getElementsByClassName('mobile-value')[0].id}`); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-label', 'Suggestions'); + expect(listbox).toHaveAttribute('aria-labelledby', `${label.id} ${listbox.id}`); + let trayInput = within(tray).getByRole('searchbox'); + expect(trayInput).toHaveAttribute('aria-labelledby', label.id); + }); + + it('user can open the tray even if there aren\'t any items to show', function () { + let {getAllByRole, getByTestId} = render( + + + {(item) => {item.name}} + + + ); + let button = getAllByRole('button')[0]; + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let items = within(tray).getAllByRole('option'); + expect(items.length).toBe(1); + + let placeholderText = within(items[0]).getByText('No results'); + expect(placeholderText).toBeVisible(); + }); + + it('searchAutocomplete tray remains open on blur', function () { + let {getAllByRole, getByTestId} = renderSearchAutocomplete({defaultInputValue: 'Blah'}); + let button = getAllByRole('button')[0]; + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let trayInput = within(tray).getByRole('searchbox'); + expect(trayInput.value).toBe('Blah'); + + act(() => { + trayInput.blur(); + jest.runAllTimers(); + }); + + tray = getByTestId('tray'); + expect(tray).toBeVisible(); + expect(trayInput.value).toBe('Blah'); // does not reset on blur + }); + + it('searchAutocomplete tray can be closed using the dismiss buttons', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let dismissButtons = within(tray).getAllByRole('button'); + expect(dismissButtons.length).toBe(2); + expect(dismissButtons[0]).toHaveAttribute('aria-label', 'Dismiss'); + expect(dismissButtons[1]).toHaveAttribute('aria-label', 'Dismiss'); + + act(() => { + triggerPress(dismissButtons[0]); + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).toThrow(); + }); + + it('searchAutocomplete tray doesn\'t close when tray input is virtually clicked', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let trayInput = within(tray).getByRole('searchbox'); + + jest.spyOn(trayInput, 'getBoundingClientRect').mockImplementation(() => ({ + left: 100, + top: 100, + width: 100, + height: 50 + })); + + // virtual click on the exact center + act(() => { + fireEvent.touchEnd(trayInput, { + changedTouches: [{ + clientX: 150, + clientY: 125 + }] + }); + + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).not.toThrow(); + }); + + it('should focus the button when clicking on the label', function () { + let {getByRole, getByText} = renderSearchAutocomplete(); + let label = getByText('Test'); + let button = getByRole('button'); + + act(() => { + userEvent.click(label); + jest.runAllTimers(); + }); + + expect(document.activeElement).toBe(button); + }); + + it('should include invalid in label when validationState="invalid"', function () { + let {getAllByRole, getByText, getByLabelText} = renderSearchAutocomplete({validationState: 'invalid'}); + let button = getAllByRole('button')[0]; + expect(button).toHaveAttribute('aria-labelledby', `${getByText('Test').id} ${getByText(defaultProps.placeholder).id} ${getByLabelText('(invalid)').id}`); + }); + + it('menutrigger=focus doesn\'t reopen the tray on close', function () { + let {getByRole, getByTestId} = renderSearchAutocomplete({menuTrigger: 'focus'}); + let button = getByRole('button'); + + act(() => { + button.focus(); + jest.runAllTimers(); + }); + + // menutrigger = focus is inapplicable for mobile SearchAutocomplete + expect(() => getByTestId('tray')).toThrow(); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let trayInput = within(tray).getByRole('searchbox'); + + act(() => { + trayInput.blur(); + triggerPress(document.body); + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).toThrow(); + }); + + it('searchAutocomplete button is focused when autoFocus is true', function () { + let {getByRole} = renderSearchAutocomplete({autoFocus: true}); + let button = getByRole('button'); + expect(document.activeElement).toBe(button); + }); + + it('searchAutocomplete tray doesn\'t open when controlled input value is updated', function () { + let {getAllByRole, rerender, getByTestId} = render(); + let button = getAllByRole('button')[0]; + + act(() => { + button.focus(); + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let trayInput = within(tray).getByRole('searchbox'); + + act(() => { + trayInput.blur(); + triggerPress(document.body); + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).toThrow(); + + act(() => { + button.blur(); + jest.runAllTimers(); + }); + + rerender(); + act(() => { + jest.runAllTimers(); + }); + + expect(() => getByTestId('tray')).toThrow(); + }); + + describe('refs', function () { + it('attaches a ref to the label wrapper', function () { + let ref = React.createRef(); + let {getByText} = renderSearchAutocomplete({ref}); + + expect(ref.current.UNSAFE_getDOMNode()).toBe(getByText('Test').parentElement); + }); + + it('attaches a ref to the button if no label', function () { + let ref = React.createRef(); + let {getByRole} = renderSearchAutocomplete({ref, label: null, 'aria-label': 'test'}); + + expect(ref.current.UNSAFE_getDOMNode()).toBe(getByRole('button')); + }); + + it('calling focus() on the ref focuses the button', function () { + let ref = React.createRef(); + let {getByRole} = renderSearchAutocomplete({ref}); + + act(() => {ref.current.focus();}); + expect(document.activeElement).toBe(getByRole('button')); + }); + }); + + describe('isLoading', function () { + it('tray input should render a loading circle after a delay of 500ms if loadingState="filtering"', function () { + let {getByRole, queryByRole, getByTestId, rerender} = render(); + let button = getByRole('button'); + act(() => {jest.advanceTimersByTime(500);}); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + expect(within(listbox).getByRole('progressbar')).toBeTruthy(); + expect(within(tray).getAllByRole('progressbar').length).toBe(1); + + rerender(); + act(() => {jest.advanceTimersByTime(500);}); + + expect(within(tray).getByRole('progressbar')).toBeTruthy(); + expect(within(listbox).queryByRole('progressbar')).toBeNull(); + }); + + it('tray input should hide the loading circle if loadingState is no longer "filtering"', function () { + let {getByRole, queryByRole, getByTestId, rerender} = render(); + let button = getByRole('button'); + act(() => {jest.advanceTimersByTime(500);}); + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + let listbox = getByRole('listbox'); + expect(within(tray).getByRole('progressbar')).toBeTruthy(); + expect(within(listbox).queryByRole('progressbar')).toBeNull(); + + rerender(); + expect(within(tray).queryByRole('progressbar')).toBeNull(); + }); + + it('tray input loading circle timer should reset on input value change', function () { + let {getByRole, getByTestId, rerender} = render(); + let button = getByRole('button'); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + rerender(); + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + expect(within(tray).queryByRole('progressbar')).toBeNull(); + act(() => {jest.advanceTimersByTime(250);}); + + let trayInput = within(tray).getByRole('searchbox'); + typeText(trayInput, 'One'); + act(() => {jest.advanceTimersByTime(250);}); + expect(within(tray).queryByRole('progressbar')).toBeNull(); + + act(() => {jest.advanceTimersByTime(250);}); + expect(within(tray).getByRole('progressbar')).toBeTruthy(); + }); + + it.each` + LoadingState | ValidationState + ${'loading'} | ${null} + ${'filtering'} | ${null} + ${'loading'} | ${'invalid'} + ${'filtering'} | ${'invalid'} + `('should render the loading swirl in the tray input field when loadingState="$LoadingState" and validationState="$ValidationState"', ({LoadingState, ValidationState}) => { + let {getAllByRole, getByRole, getByTestId} = renderSearchAutocomplete({loadingState: LoadingState, validationState: ValidationState, defaultInputValue: 'O'}); + let button = getAllByRole('button')[0]; + act(() => {jest.advanceTimersByTime(500);}); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let trayProgressSpinner = within(tray).getByRole('progressbar'); + expect(trayProgressSpinner).toBeTruthy(); + + if (LoadingState === 'loading') { + expect(trayProgressSpinner).toHaveAttribute('aria-label', 'Loading more…'); + } else { + expect(trayProgressSpinner).toHaveAttribute('aria-label', 'Loading...'); + } + + let clearButton = within(tray).getByLabelText('Clear'); + expect(clearButton).toBeTruthy(); + + let listbox = getByRole('listbox'); + + if (LoadingState === 'loading') { + expect(within(listbox).getByRole('progressbar')).toBeTruthy(); + } else { + expect(within(listbox).queryByRole('progressbar')).toBeNull(); + } + + if (ValidationState) { + let trayInput = within(tray).getByRole('searchbox'); + expect(trayInput).toHaveAttribute('aria-invalid', 'true'); + } + + if (ValidationState && LoadingState === 'loading') { + // validation icon should be present along with the clear button and search icon + expect(within(tray).getAllByRole('img', {hidden: true})).toHaveLength(3); + } else { + // validation icon should not be present, only imgs are the clear button and search icon + expect(within(tray).getAllByRole('img', {hidden: true})).toHaveLength(2); + } + }); + + it('should render the loading swirl in the listbox when loadingState="loadingMore"', function () { + let {getAllByRole, getByRole, queryByRole, getByTestId} = renderSearchAutocomplete({loadingState: 'loadingMore', validationState: 'invalid'}); + let button = getAllByRole('button')[0]; + + expect(queryByRole('progressbar')).toBeNull(); + + act(() => { + triggerPress(button); + jest.runAllTimers(); + }); + + let tray = getByTestId('tray'); + expect(tray).toBeVisible(); + + let allProgressSpinners = within(tray).getAllByRole('progressbar'); + expect(allProgressSpinners.length).toBe(1); + + let icons = within(tray).getAllByRole('img', {hidden: true}); + expect(icons.length).toBe(3); + + let clearButton = within(tray).getByLabelText('Clear'); + expect(clearButton).toBeTruthy(); + + expect(within(clearButton).getByRole('img', {hidden: true})).toBe(icons[2]); + + let trayInput = within(tray).getByRole('searchbox'); + expect(trayInput).toHaveAttribute('aria-invalid', 'true'); + + let listbox = getByRole('listbox'); + let progressSpinner = within(listbox).getByRole('progressbar'); + expect(progressSpinner).toBeTruthy(); + expect(progressSpinner).toHaveAttribute('aria-label', 'Loading more…'); + }); + }); + }); + + describe('accessibility', function () { + beforeAll(function () { + jest.spyOn(window.screen, 'width', 'get').mockImplementation(() => 1024); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + // NVDA workaround so that letters are read out when user presses left/right arrow to navigate through what they typed + it('clears aria-activedescendant when user presses left/right arrow (NVDA fix)', function () { + let {getByRole} = renderSearchAutocomplete(); + + let searchAutocomplete = getByRole('combobox'); + typeText(searchAutocomplete, 'One'); + act(() => { + jest.runAllTimers(); + }); + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(searchAutocomplete).toHaveAttribute('aria-controls', listbox.id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + let items = within(listbox).getAllByRole('option'); + expect(items).toHaveLength(1); + expect(items[0]).toHaveTextContent('One'); + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowLeft', code: 37, charCode: 37}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowLeft', code: 37, charCode: 37}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown', code: 40, charCode: 40}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete).toHaveAttribute('aria-activedescendant', items[0].id); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowRight', code: 39, charCode: 39}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowRight', code: 39, charCode: 39}); + jest.runAllTimers(); + }); + + expect(searchAutocomplete).not.toHaveAttribute('aria-activedescendant'); + }); + + describe('announcements', function () { + // Live announcer is (mostly) only used on apple devices for VoiceOver. + // Mock navigator.platform so we take that codepath. + let platformMock; + beforeEach(() => { + platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'MacIntel'); + }); + + afterEach(() => { + platformMock.mockRestore(); + }); + + describe('keyboard navigating', function () { + it('should announce items when navigating with the arrow keys', function () { + let {getByRole} = renderSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('One'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('Two'); + }); + + it('should announce when navigating into a section with multiple items', function () { + let {getByRole} = renderSectionSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('Entered group Section One, with 3 options. One'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('Two'); + }); + + it('should announce when navigating into a section with a single item', function () { + let {getByRole} = renderSectionSearchAutocomplete({defaultInputValue: 'Tw'}); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('Entered group Section One, with 1 option. Two'); + }); + }); + + describe('filtering', function () { + it('should announce the number of options available when filtering', function () { + let {getByRole} = renderSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + + typeText(searchAutocomplete, 'o'); + act(() => { + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('2 options available.'); + + typeText(searchAutocomplete, 'n'); + act(() => { + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('1 option available.'); + }); + + it('should announce the number of options available when opening the menu', function () { + let {getByRole} = renderSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + typeText(searchAutocomplete, 'o'); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenCalledWith('2 options available.'); + }); + }); + + describe('selection', function () { + it('should announce when a selection occurs', function () { + let {getByRole} = renderSearchAutocomplete(); + let searchAutocomplete = getByRole('combobox'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('One'); + + act(() => { + fireEvent.keyDown(searchAutocomplete, {key: 'Enter'}); + fireEvent.keyUp(searchAutocomplete, {key: 'Enter'}); + jest.runAllTimers(); + }); + + expect(announce).toHaveBeenLastCalledWith('One, selected'); + }); + }); + }); + + describe('hiding surrounding content', function () { + it('should hide elements outside the searchAutocomplete with aria-hidden', function () { + let {getByRole, queryAllByRole, getAllByRole} = render( + <> + + + + + ); + + let outside = getAllByRole('checkbox'); + let searchAutocomplete = getByRole('combobox'); + + expect(outside).toHaveLength(2); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(outside[0]).toHaveAttribute('aria-hidden', 'true'); + expect(outside[1]).toHaveAttribute('aria-hidden', 'true'); + + expect(queryAllByRole('checkbox')).toEqual([]); + expect(getByRole('combobox')).toBeVisible(); + }); + + it('should not traverse into a hidden container', function () { + let {getByRole, queryAllByRole, getAllByRole} = render( + <> +
    + +
    + + + + ); + + let outside = getAllByRole('checkbox'); + let searchAutocomplete = getByRole('combobox'); + + expect(outside).toHaveLength(2); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(outside[0].parentElement).toHaveAttribute('aria-hidden', 'true'); + expect(outside[0]).not.toHaveAttribute('aria-hidden', 'true'); + expect(outside[1]).toHaveAttribute('aria-hidden', 'true'); + + expect(queryAllByRole('checkbox')).toEqual([]); + expect(getByRole('combobox')).toBeVisible(); + }); + + it('should not hide the live announcer element', function () { + let platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'MacIntel'); + let {getByRole} = render(); + + // Use the real live announcer implementation just for this one test + let {announce: realAnnounce} = jest.requireActual('@react-aria/live-announcer'); + announce.mockImplementationOnce(realAnnounce); + + let searchAutocomplete = getByRole('combobox'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + expect(screen.getAllByRole('log')).toHaveLength(2); + platformMock.mockRestore(); + }); + + it('should handle when a new element is added outside while open', async function () { + let Test = (props) => ( +
    + {props.show && } + + {props.show && } +
    + ); + + let {getByRole, getAllByRole, queryAllByRole, rerender} = render(); + + let searchAutocomplete = getByRole('combobox'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + expect(listbox).toBeVisible(); + + rerender(); + + await waitFor(() => expect(queryAllByRole('checkbox')).toEqual([])); + expect(getByRole('combobox')).toBeVisible(); + expect(getByRole('listbox')).toBeVisible(); + + let outside = getAllByRole('checkbox', {hidden: true}); + expect(outside[0]).toHaveAttribute('aria-hidden', 'true'); + expect(outside[1]).toHaveAttribute('aria-hidden', 'true'); + }); + + it('should handle when a new element is added to an already hidden container', async function () { + let Test = (props) => ( +
    +
    + {props.show && } +
    + + {props.show && } +
    + ); + + let {getByRole, getAllByRole, queryAllByRole, getByTestId, rerender} = render(); + + let searchAutocomplete = getByRole('combobox'); + let outer = getByTestId('test'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + + expect(listbox).toBeVisible(); + expect(outer).toHaveAttribute('aria-hidden', 'true'); + + rerender(); + + await waitFor(() => expect(queryAllByRole('checkbox')).toEqual([])); + expect(getByRole('combobox')).toBeVisible(); + expect(getByRole('listbox')).toBeVisible(); + + let outside = getAllByRole('checkbox', {hidden: true}); + expect(outer).toHaveAttribute('aria-hidden', 'true'); + expect(outside[0]).not.toHaveAttribute('aria-hidden'); + expect(outside[1]).toHaveAttribute('aria-hidden', 'true'); + }); + + it('should handle when a new element is added inside the listbox', async function () { + let Test = (props) => ( +
    + + + + {item => {item.name}} + + + +
    + ); + + let {getByRole, queryAllByRole, rerender} = render( + + ); + + let searchAutocomplete = getByRole('combobox'); + + act(() => { + searchAutocomplete.focus(); + fireEvent.keyDown(searchAutocomplete, {key: 'ArrowDown'}); + fireEvent.keyUp(searchAutocomplete, {key: 'ArrowDown'}); + jest.runAllTimers(); + }); + + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(1); + expect(queryAllByRole('checkbox')).toEqual([]); + + rerender(); + + // Wait for mutation observer tick + await Promise.resolve(); + + options = within(listbox).getAllByRole('option'); + expect(options).toHaveLength(2); + + expect(queryAllByRole('checkbox')).toEqual([]); + expect(getByRole('combobox')).toBeVisible(); + expect(getByRole('listbox')).toBeVisible(); + }); + }); + }); +}); diff --git a/packages/@react-stately/searchfield/package.json b/packages/@react-stately/searchfield/package.json index ebfd56bf802..2ec3cc190e0 100644 --- a/packages/@react-stately/searchfield/package.json +++ b/packages/@react-stately/searchfield/package.json @@ -18,7 +18,11 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@react-stately/list": "^3.3.0", + "@react-stately/menu": "^3.2.3", + "@react-stately/select": "^3.1.3", "@react-stately/utils": "^3.2.2", + "@react-types/combobox": "^3.0.1", "@react-types/searchfield": "^3.1.2", "@react-types/shared": "^3.8.0" }, diff --git a/packages/@react-types/searchfield/package.json b/packages/@react-types/searchfield/package.json index d5e1c5cbd72..e8dbaaa619d 100644 --- a/packages/@react-types/searchfield/package.json +++ b/packages/@react-types/searchfield/package.json @@ -9,7 +9,9 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-types/textfield": "^3.2.3" + "@react-types/combobox": "^3.0.1", + "@react-types/shared": "^3.7.1", + "@react-types/textfield": "^3.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1" diff --git a/packages/@react-types/searchfield/src/index.d.ts b/packages/@react-types/searchfield/src/index.d.ts index e6bb29547f8..ec33f74275e 100644 --- a/packages/@react-types/searchfield/src/index.d.ts +++ b/packages/@react-types/searchfield/src/index.d.ts @@ -11,6 +11,9 @@ */ import {AriaTextFieldProps, SpectrumTextFieldProps, TextFieldProps} from '@react-types/textfield'; +import {AsyncLoadable, CollectionBase, LoadingState, SpectrumLabelableProps, StyleProps} from '@react-types/shared'; +import {Key} from 'react'; +import {MenuTriggerAction} from '@react-types/combobox'; export interface SearchFieldProps extends TextFieldProps { /** Handler that is called when the SearchField is submitted. */ @@ -20,5 +23,49 @@ export interface SearchFieldProps extends TextFieldProps { onClear?: () => void } +export interface SearchAutocompleteProps extends CollectionBase, Omit { + /** The list of SearchAutocomplete items (uncontrolled). */ + defaultItems?: Iterable, + /** The list of SearchAutocomplete items (controlled). */ + items?: Iterable, + /** Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. */ + onOpenChange?: (isOpen: boolean, menuTrigger?: MenuTriggerAction) => void, + /** The value of the SearchAutocomplete input (controlled). */ + inputValue?: string, + /** The default value of the SearchAutocomplete input (uncontrolled). */ + defaultInputValue?: string, + /** Handler that is called when the SearchAutocomplete input value changes. */ + onInputChange?: (value: string) => void, + /** + * The interaction required to display the SearchAutocomplete menu. + * @default 'input' + */ + menuTrigger?: MenuTriggerAction, + onSubmit?: (value: string, key: Key | null) => void +} + +export interface SpectrumSearchAutocompleteProps extends Omit, 'menuTrigger'>, SpectrumLabelableProps, StyleProps, Omit { + /** + * The interaction required to display the SearchAutocomplete menu. Note that this prop has no effect on the mobile SearchAutocomplete experience. + * @default 'input' + */ + menuTrigger?: MenuTriggerAction, + /** Whether the SearchAutocomplete should be displayed with a quiet style. */ + isQuiet?: boolean, + /** + * Direction the menu will render relative to the SearchAutocomplete. + * @default 'bottom' + */ + direction?: 'bottom' | 'top', + /** The current loading state of the SearchAutocomplete. Determines whether or not the progress circle should be shown. */ + loadingState?: LoadingState, + /** + * Whether the menu should automatically flip direction when space is limited. + * @default true + */ + shouldFlip?: boolean, + onLoadMore?: () => void +} + export interface AriaSearchFieldProps extends SearchFieldProps, AriaTextFieldProps {} export interface SpectrumSearchFieldProps extends AriaSearchFieldProps, SpectrumTextFieldProps {}