diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23d8c54c408..d0ea32ce6af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '10.24.0' + node-version: '12.13.0' - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -39,7 +39,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '10.24.0' + node-version: '12.13.0' - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.nvmrc b/.nvmrc index e3653a9a7e9..bce43c253fe 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.24.0 +v12.13.0 diff --git a/.storybook/polaris-readme-loader.js b/.storybook/polaris-readme-loader.js index 5c24dc6b889..90916ac44d3 100644 --- a/.storybook/polaris-readme-loader.js +++ b/.storybook/polaris-readme-loader.js @@ -75,6 +75,7 @@ import { ChoiceList, Collapsible, ColorPicker, + ComboBox, Connected, ContextualSaveBar, DataTable, @@ -105,6 +106,7 @@ import { Layout, Link, List, + ListBox, Loading, MediaCard, Modal, diff --git a/UNRELEASED.md b/UNRELEASED.md index d959379d8ff..1c512132431 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,6 +4,13 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Breaking changes +- Updated `react` and `react-dom` to version 16.14.0. This is now the minimum version of React required to use the `@shopify/polaris` library. +- Dropping support for node 10.x +- Dropped support for Desktop Safari versions less than 13.1, and ios Safari versions less than 13.6. ([#4304](https://github.com/Shopify/polaris-react/pull/4304)) +- Made `autoComplete` prop in `TextField` a required string ([#4267](https://github.com/Shopify/polaris-react/pull/4267)). If you do not want the browser to autofill a user's information (for example an email input which is a customer's email, but not the email of the user who is entering the information), we recommend setting `autoComplete` to `"off"`. +- `Autocomplete` now requires `Autocomplete.TextField` to be used ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) +- Removed ComboBox as a named export on `Autocomplete` ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) + ### Enhancements - Added `id` prop to `Layout` and `Heading` for hash linking ([#4307](https://github.com/Shopify/polaris-react/pull/4307)) @@ -18,6 +25,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Code quality +- Rebuilt `Autocomplete` internals using new `ComboBox` and `ListBox` components built on the ARIA 1.2 spec for improved accessibility ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) - Modernized tests for Avatar, Backdrop, Badge, Banner components([#4306](https://github.com/Shopify/polaris-react/pull/4306)) - Modernized tests for Tooltip, Toast components([#4314](https://github.com/Shopify/polaris-react/pull/4314)) - Modernized tests for AccountConnection, ActionList components([#4316](https://github.com/Shopify/polaris-react/pull/4316)) diff --git a/dev.yml b/dev.yml index f0fc57c75d5..af0f83f8058 100644 --- a/dev.yml +++ b/dev.yml @@ -2,7 +2,7 @@ name: polaris-react up: - node: yarn: v1.13.0 - version: v10.24.0 # to be kept in sync with .nvmrc and ci.yml + version: v12.13.0 # to be kept in sync with .nvmrc and ci.yml - git_hooks: pre-commit: pre-commit diff --git a/examples/create-react-app-ts-react-testing/src/App.tsx b/examples/create-react-app-ts-react-testing/src/App.tsx index 3bdbb40f06a..b7a9e445fb6 100644 --- a/examples/create-react-app-ts-react-testing/src/App.tsx +++ b/examples/create-react-app-ts-react-testing/src/App.tsx @@ -99,12 +99,14 @@ export function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -113,6 +115,7 @@ export function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> @@ -106,6 +108,7 @@ export function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> { - setConnected(!connected); - }, - [connected], - ); + const toggleConnection = useCallback(() => { + setConnected(!connected); + }, [connected]); const breadcrumbs = [{content: 'Sample apps'}, {content: 'next.js'}]; const primaryAction = {content: 'New product'}; @@ -95,12 +92,14 @@ export default function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -109,6 +108,7 @@ export default function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> { - setConnected(!connected); - }, - [connected], - ); + const toggleConnection = useCallback(() => { + setConnected(!connected); + }, [connected]); const breadcrumbs = [{content: 'Sample apps'}, {content: 'webpack'}]; const primaryAction = {content: 'New product'}; @@ -95,12 +92,14 @@ export default function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -109,6 +108,7 @@ export default function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> = 10", - "ios >= 10" + "last 3 edge versions", + "last 3 safari versions", + "last 3 chromeandroid versions", + "last 1 firefoxandroid versions", + "ios >= 13.4" ], "prettier": "@shopify/prettier-config", "stylelint": { @@ -178,7 +179,7 @@ { "name": "esm", "path": "dist/esm/index.js", - "limit": "100 kB" + "limit": "105 kB" }, { "name": "esnext", diff --git a/playground/DetailsPage.tsx b/playground/DetailsPage.tsx index 686d54048d5..838a7aa8c22 100644 --- a/playground/DetailsPage.tsx +++ b/playground/DetailsPage.tsx @@ -577,11 +577,13 @@ export function DetailsPage() { label="Title" value="M60-A" onChange={() => setIsDirty(true)} + autoComplete="off" /> @@ -653,11 +655,13 @@ export function DetailsPage() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index b0dd2deee7d..f9d163a2e2f 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,23 +1,25 @@ -import React from 'react'; +import React, {useMemo, useCallback} from 'react'; -import {useI18n} from '../../utilities/i18n'; import type {ActionListItemDescriptor} from '../../types'; -import {Spinner} from '../Spinner'; +import type {OptionDescriptor} from '../OptionList'; +import type {PopoverProps} from '../Popover'; +import {useI18n} from '../../utilities/i18n'; +import {ComboBox} from '../ComboBox'; +import {ListBox} from '../ListBox'; -import {TextField, ComboBox, ComboBoxProps} from './components'; -import styles from './Autocomplete.scss'; +import {MappedOption, MappedAction} from './components'; export interface AutocompleteProps { /** A unique identifier for the Autocomplete */ id?: string; /** Collection of options to be listed */ - options: ComboBoxProps['options']; + options: OptionDescriptor[]; /** The selected options */ selected: string[]; /** The text field component attached to the list of options */ textField: React.ReactElement; /** The preferred direction to open the popover */ - preferredPosition?: ComboBoxProps['preferredPosition']; + preferredPosition?: PopoverProps['preferredPosition']; /** Title of the list of options */ listTitle?: string; /** Allow more than one option to be selected */ @@ -42,10 +44,8 @@ export interface AutocompleteProps { // generated *.d.ts files. export const Autocomplete: React.FunctionComponent & { - ComboBox: typeof ComboBox; - TextField: typeof TextField; + TextField: typeof ComboBox.TextField; } = function Autocomplete({ - id, options, selected, textField, @@ -61,38 +61,89 @@ export const Autocomplete: React.FunctionComponent & { }: AutocompleteProps) { const i18n = useI18n(); - const spinnerMarkup = loading ? ( -
- -
+ const optionsMarkup = useMemo(() => { + const conditionalOptions = loading && !willLoadMoreResults ? [] : options; + const optionList = + conditionalOptions.length > 0 + ? conditionalOptions.map((option) => ( + + )) + : null; + + if (listTitle) { + return ( + {listTitle}} + > + {optionList} + + ); + } + + return optionList; + }, [ + listTitle, + loading, + options, + willLoadMoreResults, + allowMultiple, + selected, + ]); + + const loadingMarkup = loading ? ( + ) : null; - const conditionalOptions = loading && !willLoadMoreResults ? [] : options; - const conditionalAction = - actionBefore && actionBefore !== [] ? [actionBefore] : undefined; + const updateSelection = useCallback( + (newSelection: string) => { + if (allowMultiple) { + if (selected.includes(newSelection)) { + onSelect(selected.filter((option) => option !== newSelection)); + } else { + onSelect([...selected, newSelection]); + } + } else { + onSelect([newSelection]); + } + }, + [allowMultiple, onSelect, selected], + ); + + const actionMarkup = actionBefore && ; + + const emptyStateMarkup = emptyState && options.length < 1 && !loading && ( +
{emptyState}
+ ); return ( + onScrolledToBottom={onLoadMoreResults} + preferredPosition={preferredPosition} + > + {actionMarkup || optionsMarkup || loadingMarkup || emptyStateMarkup ? ( + + {actionMarkup} + {optionsMarkup && (!loading || willLoadMoreResults) + ? optionsMarkup + : null} + {loadingMarkup} + {emptyStateMarkup} + + ) : null} + ); }; -Autocomplete.ComboBox = ComboBox; -Autocomplete.TextField = TextField; +Autocomplete.TextField = ComboBox.TextField; diff --git a/src/components/Autocomplete/README.md b/src/components/Autocomplete/README.md index 700868152f0..84f064a471c 100644 --- a/src/components/Autocomplete/README.md +++ b/src/components/Autocomplete/README.md @@ -5,11 +5,13 @@ keywords: - autocomplete - searchable - typeahead + - combobox + - listbox --- # Autocomplete -The autocomplete component is an input field that provides selectable suggestions as a merchant types into it. It allows merchants to quickly search through and select from large collections of options. +The autocomplete component is an input field that provides selectable suggestions as a merchant types into it. It allows merchants to quickly search through and select from large collections of options. It's a convenience wrapper around the `ComboBox` and `ListBox` components with minor UI differences. --- @@ -77,7 +79,7 @@ function AutocompleteExample() { }); setSelectedOptions(selected); - setInputValue(selectedValue); + setInputValue(selectedValue[0]); }, [options], ); @@ -229,11 +231,11 @@ function AutocompleteExample() { setTimeout(() => { if (value === '') { setOptions(deselectedOptions); - setLoading(true); + setLoading(false); return; } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); @@ -252,7 +254,7 @@ function AutocompleteExample() { return matchedOption && matchedOption.label; }); setSelectedOptions(selected); - setInputValue(selectedText); + setInputValue(selectedText[0]); }, [options], ); @@ -287,23 +289,39 @@ function AutocompleteExample() { function AutoCompleteLazyLoadExample() { const paginationInterval = 25; const deselectedOptions = Array.from(Array(100)).map((_, index) => ({ - value: `rustic ${index}`, - label: `Rustic ${index}`, + value: `rustic ${index + 1}`, + label: `Rustic ${index + 1}`, })); const [selectedOptions, setSelectedOptions] = useState([]); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(deselectedOptions); + const [isLoading, setIsLoading] = useState(false); + const [willLoadMoreResults, setWillLoadMoreResults] = useState(true); const [visibleOptionIndex, setVisibleOptionIndex] = useState( paginationInterval, ); const handleLoadMoreResults = useCallback(() => { - const nextVisibleOptionIndex = visibleOptionIndex + paginationInterval; - if (nextVisibleOptionIndex <= options.length - 1) { - setVisibleOptionIndex(nextVisibleOptionIndex); + if (willLoadMoreResults) { + setIsLoading(true); + + setTimeout(() => { + const remainingOptionCount = options.length - visibleOptionIndex; + const nextVisibleOptionIndex = + remainingOptionCount >= paginationInterval + ? visibleOptionIndex + paginationInterval + : visibleOptionIndex + remainingOptionCount; + + setIsLoading(false); + setVisibleOptionIndex(nextVisibleOptionIndex); + + if (remainingOptionCount <= paginationInterval) { + setWillLoadMoreResults(false); + } + }, 1000); } - }, [visibleOptionIndex, options.length]); + }, [willLoadMoreResults, visibleOptionIndex, options.length]); const removeTag = useCallback( (tag) => () => { @@ -324,7 +342,7 @@ function AutoCompleteLazyLoadExample() { } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); @@ -333,6 +351,7 @@ function AutoCompleteLazyLoadExample() { endIndex = 0; } setOptions(resultOptions); + setInputValue; }, [deselectedOptions, options], ); @@ -375,7 +394,9 @@ function AutoCompleteLazyLoadExample() { textField={textField} onSelect={setSelectedOptions} listTitle="Suggested Tags" + loading={isLoading} onLoadMoreResults={handleLoadMoreResults} + willLoadMoreResults={willLoadMoreResults} /> ); @@ -425,7 +446,7 @@ function AutocompleteExample() { return; } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); @@ -444,7 +465,7 @@ function AutocompleteExample() { return matchedOption && matchedOption.label; }); setSelectedOptions(selected); - setInputValue(selectedText); + setInputValue(selectedText[0]); }, [options], ); @@ -483,12 +504,194 @@ function AutocompleteExample() { } ``` +### Autocomplete with action + +Use to indicate there are no search results. + +```jsx +function AutocompleteActionBeforeExample() { + const deselectedOptions = [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ]; + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const selectedText = selected.map((selectedItem) => { + const matchedOption = options.find((option) => { + return option.value.match(selectedItem); + }); + return matchedOption && matchedOption.label; + }); + setSelectedOptions(selected); + setInputValue(selectedText[0]); + }, + [options], + ); + + const textField = ( + } + placeholder="Search" + /> + ); + + return ( +
+ +
+ ); +} +``` + +### Autocomplete with destructive action + +Use to indicate there are no search results. + +```jsx +function AutocompleteActionBeforeExample() { + const deselectedOptions = [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ]; + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const selectedText = selected.map((selectedItem) => { + const matchedOption = options.find((option) => { + return option.value.match(selectedItem); + }); + return matchedOption && matchedOption.label; + }); + setSelectedOptions(selected); + setInputValue(selectedText[0]); + }, + [options], + ); + + const textField = ( + } + placeholder="Search" + /> + ); + + return ( +
+ +
+ ); +} +``` + --- ## Related components - For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field) - For a list of selectable options not linked to an input field, [use the option list component](https://polaris.shopify.com/components/lists-and-tables/option-list) +- For a text field that triggers a popover, [use the combo box component](https://polaris.shopify.com/components/forms/combobox) --- @@ -516,7 +719,7 @@ See Apple’s Human Interface Guidelines and API documentation about accessibili ### Structure -The autocomplete component is based on the [ARIA 1.1 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). See the [text field component](https://polaris.shopify.com/components/forms/text-field) for information on implementing the autocomplete component with a text field. +The autocomplete component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox) and the [Aria 1.2 ListBox pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). The autocomplete list displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop. diff --git a/src/components/Autocomplete/components/ComboBox/ComboBox.scss b/src/components/Autocomplete/components/ComboBox/ComboBox.scss deleted file mode 100644 index 36779ad0060..00000000000 --- a/src/components/Autocomplete/components/ComboBox/ComboBox.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import '../../../../styles/common'; - -.EmptyState { - padding: spacing(tight) spacing(); -} diff --git a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx b/src/components/Autocomplete/components/ComboBox/ComboBox.tsx deleted file mode 100644 index 0de83583d7f..00000000000 --- a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import React, {useState, useEffect, useCallback} from 'react'; - -import {useUniqueId} from '../../../../utilities/unique-id'; -import {useToggle} from '../../../../utilities/use-toggle'; -import {OptionList, OptionDescriptor} from '../../../OptionList'; -import {ActionList} from '../../../ActionList'; -import {Popover, PopoverProps} from '../../../Popover'; -import {ActionListItemDescriptor, Key} from '../../../../types'; -import {KeypressListener} from '../../../KeypressListener'; -import {EventListener} from '../../../EventListener'; -import {useIsomorphicLayoutEffect} from '../../../../utilities/use-isomorphic-layout-effect'; - -import {ComboBoxContext} from './context'; -import styles from './ComboBox.scss'; - -export interface ComboBoxProps { - /** A unique identifier for the ComboBox */ - id?: string; - /** Collection of options to be listed */ - options: OptionDescriptor[]; - /** The selected options */ - selected: string[]; - /** The text field component attached to the list of options */ - textField: React.ReactElement; - /** The preferred direction to open the popover */ - preferredPosition?: PopoverProps['preferredPosition']; - /** Title of the list of options */ - listTitle?: string; - /** Allow more than one option to be selected */ - allowMultiple?: boolean; - /** Actions to be displayed before the list of options */ - actionsBefore?: ActionListItemDescriptor[]; - /** Actions to be displayed after the list of options */ - actionsAfter?: ActionListItemDescriptor[]; - /** Content to be displayed before the list of options */ - contentBefore?: React.ReactNode; - /** Content to be displayed after the list of options */ - contentAfter?: React.ReactNode; - /** Is rendered when there are no options */ - emptyState?: React.ReactNode; - /** Callback when the selection of options is changed */ - onSelect(selected: string[]): void; - /** Callback when the end of the list is reached */ - onEndReached?(): void; -} - -export function ComboBox({ - id: idProp, - options, - selected, - textField, - preferredPosition, - listTitle, - allowMultiple, - actionsBefore, - actionsAfter, - contentBefore, - contentAfter, - emptyState, - onSelect, - onEndReached, -}: ComboBoxProps) { - const [selectedIndex, setSelectedIndex] = useState(-1); - const [selectedOptions, setSelectedOptions] = useState(selected); - const [navigableOptions, setNavigableOptions] = useState< - (OptionDescriptor | ActionListItemDescriptor)[] - >([]); - const { - value: popoverActive, - setTrue: forcePopoverActiveTrue, - setFalse: forcePopoverActiveFalse, - } = useToggle(false); - - const id = useUniqueId('ComboBox', idProp); - - const getActionsWithIds = useCallback( - (actions: ActionListItemDescriptor[], before?: boolean) => { - if (before) { - return navigableOptions.slice(0, actions.length); - } - return navigableOptions.slice(-actions.length); - }, - [navigableOptions], - ); - - const visuallyUpdateSelectedOption = useCallback( - ( - newOption: OptionDescriptor | ActionListItemDescriptor, - oldOption: OptionDescriptor | ActionListItemDescriptor | undefined, - ) => { - if (oldOption) { - oldOption.active = false; - } - if (newOption) { - newOption.active = true; - } - }, - [], - ); - - const resetVisuallySelectedOptions = useCallback(() => { - setSelectedIndex(-1); - navigableOptions.forEach((option) => { - option.active = false; - }); - }, [navigableOptions]); - - const selectOptionAtIndex = useCallback( - (newOptionIndex: number) => { - if (navigableOptions.length === 0) { - return; - } - - const oldSelectedOption = navigableOptions[selectedIndex]; - const newSelectedOption = navigableOptions[newOptionIndex]; - - visuallyUpdateSelectedOption(newSelectedOption, oldSelectedOption); - - setSelectedIndex(newOptionIndex); - }, - [navigableOptions, selectedIndex, visuallyUpdateSelectedOption], - ); - - const selectNextOption = useCallback(() => { - if (navigableOptions.length === 0) { - return; - } - - let newIndex = selectedIndex; - - if (selectedIndex + 1 >= navigableOptions.length) { - newIndex = 0; - } else { - newIndex++; - } - - selectOptionAtIndex(newIndex); - }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - - const selectPreviousOption = useCallback(() => { - if (navigableOptions.length === 0) { - return; - } - - let newIndex = selectedIndex; - - if (selectedIndex <= 0) { - newIndex = navigableOptions.length - 1; - } else { - newIndex--; - } - - selectOptionAtIndex(newIndex); - }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - - const selectOptions = useCallback( - (selected: string[]) => { - selected && onSelect(selected); - if (!allowMultiple) { - resetVisuallySelectedOptions(); - forcePopoverActiveFalse(); - } - }, - [ - allowMultiple, - forcePopoverActiveFalse, - onSelect, - resetVisuallySelectedOptions, - ], - ); - - const handleSelection = useCallback( - (newSelected: string) => { - let newlySelectedOptions = selected; - if (selected.includes(newSelected)) { - newlySelectedOptions.splice( - newlySelectedOptions.indexOf(newSelected), - 1, - ); - } else if (allowMultiple) { - newlySelectedOptions.push(newSelected); - } else { - newlySelectedOptions = [newSelected]; - } - - selectOptions(newlySelectedOptions); - }, - [allowMultiple, selectOptions, selected], - ); - - const handleEnter = useCallback( - (event: KeyboardEvent) => { - if (event.keyCode !== Key.Enter) { - return; - } - - if (popoverActive && selectedIndex > -1) { - const selectedOption = navigableOptions[selectedIndex]; - if (isOption(selectedOption)) { - event.preventDefault(); - handleSelection(selectedOption.value); - } else { - selectedOption.onAction && selectedOption.onAction(); - } - } - }, - [handleSelection, navigableOptions, popoverActive, selectedIndex], - ); - - const handleBlur = useCallback(() => { - forcePopoverActiveFalse(); - resetVisuallySelectedOptions(); - }, [forcePopoverActiveFalse, resetVisuallySelectedOptions]); - - const activatePopover = useCallback(() => { - !popoverActive && forcePopoverActiveTrue(); - }, [forcePopoverActiveTrue, popoverActive]); - - const updateIndexOfSelectedOption = useCallback( - (newOptions: (OptionDescriptor | ActionListItemDescriptor)[]) => { - const selectedOption = navigableOptions[selectedIndex]; - if (selectedOption && newOptions.includes(selectedOption)) { - selectOptionAtIndex(newOptions.indexOf(selectedOption)); - } else if (selectedIndex > newOptions.length - 1) { - resetVisuallySelectedOptions(); - } else { - selectOptionAtIndex(selectedIndex); - } - }, - [ - navigableOptions, - resetVisuallySelectedOptions, - selectOptionAtIndex, - selectedIndex, - ], - ); - - useEffect(() => { - if (selectedOptions !== selected) { - setSelectedOptions(selected); - } - }, [selected, selectedOptions]); - - useIsomorphicLayoutEffect(() => { - let newNavigableOptions: ( - | OptionDescriptor - | ActionListItemDescriptor - )[] = []; - if (actionsBefore) { - newNavigableOptions = newNavigableOptions.concat(actionsBefore); - } - if (options) { - newNavigableOptions = newNavigableOptions.concat(options); - } - if (actionsAfter) { - newNavigableOptions = newNavigableOptions.concat(actionsAfter); - } - newNavigableOptions = assignOptionIds(newNavigableOptions, id); - setNavigableOptions(newNavigableOptions); - }, [actionsAfter, actionsBefore, id, options]); - - useEffect(() => { - updateIndexOfSelectedOption(navigableOptions); - }, [navigableOptions, updateIndexOfSelectedOption]); - - let actionsBeforeMarkup: JSX.Element | undefined; - if (actionsBefore && actionsBefore.length > 0) { - actionsBeforeMarkup = ( - - ); - } - - let actionsAfterMarkup: JSX.Element | undefined; - if (actionsAfter && actionsAfter.length > 0) { - actionsAfterMarkup = ( - - ); - } - - const optionsMarkup = options.length > 0 && ( - - ); - - const emptyStateMarkup = !actionsAfter && - !actionsBefore && - !contentAfter && - !contentBefore && - options.length === 0 && - emptyState &&
{emptyState}
; - - const selectedOptionId = - selectedIndex > -1 ? `${id}-${selectedIndex}` : undefined; - - const context = { - id, - selectedOptionId, - }; - - return ( - -
- - - - - - -
- {contentBefore} - {actionsBeforeMarkup} - {optionsMarkup} - {actionsAfterMarkup} - {contentAfter} - {emptyStateMarkup} -
-
-
-
-
- ); -} - -function assignOptionIds( - options: (OptionDescriptor | ActionListItemDescriptor)[], - id: string, -): OptionDescriptor[] | ActionListItemDescriptor[] { - return options.map((option, optionIndex) => ({ - ...option, - id: `${id}-${optionIndex}`, - })); -} - -function isOption( - navigableOption: OptionDescriptor | ActionListItemDescriptor, -): navigableOption is OptionDescriptor { - return 'value' in navigableOption && navigableOption.value !== undefined; -} - -function filterForOptions( - mixedArray: (ActionListItemDescriptor | OptionDescriptor)[], -): OptionDescriptor[] { - return mixedArray.filter(isOption); -} diff --git a/src/components/Autocomplete/components/ComboBox/context.tsx b/src/components/Autocomplete/components/ComboBox/context.tsx deleted file mode 100644 index f4a7c72edb3..00000000000 --- a/src/components/Autocomplete/components/ComboBox/context.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import {createContext} from 'react'; - -interface ComboBoxContextType { - comboBoxId?: string; - selectedOptionId?: string; -} - -export const ComboBoxContext = createContext({}); diff --git a/src/components/Autocomplete/components/ComboBox/index.ts b/src/components/Autocomplete/components/ComboBox/index.ts deleted file mode 100644 index d122f03ba63..00000000000 --- a/src/components/Autocomplete/components/ComboBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ComboBox'; diff --git a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx deleted file mode 100644 index dd7de0edffc..00000000000 --- a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import React from 'react'; -import {OptionList, ActionList, Popover} from 'components'; -import {mountWithApp} from 'test-utilities'; -// eslint-disable-next-line no-restricted-imports -import {mountWithAppProvider, act} from 'test-utilities/legacy'; - -import {TextField} from '../../TextField'; -import {Key} from '../../../../../types'; -import {ComboBox} from '../ComboBox'; - -describe('', () => { - const options = [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, - ]; - - const action = [ - { - content: 'Add tag', - onAction: noop, - }, - ]; - - describe('options', () => { - it('passes options to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - - const optionListOptions = comboBox.find(OptionList).prop('options') || [ - { - value: '', - label: '', - }, - ]; - - expect(optionListOptions[0].value).toBe('cheese_pizza'); - expect(optionListOptions[0].label).toBe('Cheese Pizza'); - expect(optionListOptions[1].value).toBe('macaroni_pizza'); - expect(optionListOptions[1].label).toBe('Macaroni Pizza'); - }); - - it.each([ - [options, 0], - [[], -1], - ])('sets tabIndex depending of number of options', (options, tabIndex) => { - const comboBox = mountWithApp( - , - ); - - expect(comboBox.find('div')).toHaveReactProps({ - tabIndex, - }); - }); - }); - - describe('contentBefore and contentAfter', () => { - it('renders content passed into contentBefore', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('#CustomNode')).toHaveLength(1); - }); - - it('renders content passed into contentAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('#CustomNode')).toHaveLength(1); - }); - }); - - describe('actionsBefore and actionsAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - - it('passes actionsBefore to the options in the first ActionList', () => { - comboBox.simulate('click'); - - const actionListItems = comboBox - .find(ActionList) - .first() - .prop('items') || [ - { - image: '', - role: '', - }, - ]; - - expect(actionListItems[0].image).toBe('../image/path'); - expect(actionListItems[0].role).toBe('option'); - }); - - it('passes actionsAfter to the options in the second ActionList', () => { - comboBox.simulate('click'); - - const actionListItems = comboBox - .find(ActionList) - .last() - .prop('items') || [ - { - image: '', - role: '', - }, - ]; - - expect(actionListItems[0].image).toBe('../image/path'); - expect(actionListItems[0].role).toBe('option'); - }); - }); - - describe('ids', () => { - it('passes an id to the options in OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(0).prop('id')).toBe('TestId-0'); - }); - - it('passes an id to the actions in ActionList', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(0).prop('id')).toBe('TestId-0'); - }); - }); - - describe('actions', () => { - it('renders an action in actionsBefore', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - - expect(comboBox.find('button').at(0).text()).toBe('Add tag'); - }); - - it('renders an action in actionsAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(3).text()).toBe('Add tag'); - }); - }); - - describe('select', () => { - it('passes the selected options to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - expect(comboBox.find(OptionList).prop('selected')).toStrictEqual([ - 'cheese_pizza', - ]); - }); - }); - - describe('listTitle', () => { - it('passes the listTitle as title to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - expect(comboBox.find(OptionList).prop('title')).toBe('List title'); - }); - }); - - describe('', () => { - it('renders TextField by default', () => { - const comboBox = mountWithAppProvider( - , - ); - expect(comboBox.find(TextField)).toHaveLength(1); - }); - - it('renders a custom given input', () => { - const comboBox = mountWithAppProvider( - } - onSelect={noop} - />, - ); - expect(comboBox.find('input')).toHaveLength(1); - expect(comboBox.find(TextField)).toHaveLength(0); - }); - - it('is passed to Popover as the activator', () => { - const comboBox = mountWithAppProvider( - , - ); - - expect(comboBox.find(Popover).find(TextField)).toHaveLength(1); - }); - }); - - describe('', () => { - const comboBox = mountWithAppProvider( - , - ); - - it('does not set Popover to active before being clicked', () => { - expect(comboBox.find(Popover).prop('active')).toBe(false); - }); - - it('sets Popover to active when clicked', () => { - comboBox.simulate('click'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('sets Popover to active on keyDown', () => { - comboBox.simulate('keydown'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('sets Popover to fullWidth', () => { - expect(comboBox.find(Popover).prop('fullWidth')).toBe(true); - }); - - it('prevents autofocus on Popover', () => { - expect(comboBox.find(Popover).prop('autofocusTarget')).toBe('none'); - }); - - it('passes the preferredPosition to Popover', () => { - expect(comboBox.find(Popover).prop('preferredPosition')).toBe('above'); - }); - }); - - describe('allowMultiple', () => { - it('renders a button if the prop is false', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button')).toHaveLength(options.length); - }); - - it('renders a checkbox if the prop is set to true', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('input[type="checkbox"]')).toHaveLength( - options.length, - ); - }); - }); - - describe('onSelect', () => { - it('gets called when an item is clicked', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - comboBox.find('button').at(0).simulate('click'); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('gets called when a checkbox is changed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - comboBox - .find('input[type="checkbox"]') - .at(0) - .simulate('change', {target: {checked: true}}); - expect(spy).toHaveBeenCalledTimes(1); - }); - }); - - describe('onEndReached', () => { - it('gets called when the end of the option list is reached', () => { - const spy = jest.fn(); - const comboBox = mountWithApp( - , - ); - - // Focus the combobox so that the popover pane is rendered - comboBox.find('div')!.trigger('onFocus'); - - comboBox.find(Popover.Pane)!.trigger('onScrolledToBottom'); - expect(spy).toHaveBeenCalledTimes(1); - }); - }); - - describe('keypress events', () => { - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('handles key events when there are no previous options', () => { - const spy = jest.fn(); - const options: {value: string; label: string}[] = []; - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).not.toHaveBeenCalled(); - - comboBox.setProps({ - options: [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, - ], - }); - comboBox.update(); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).toHaveBeenCalledWith(['cheese_pizza']); - }); - - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('adds to selected options when the down arrow and enter keys are pressed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).toHaveBeenCalledWith(['cheese_pizza']); - }); - - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('does not add to selected options when the down arrow and key other than enter is pressed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.RightArrow); - }); - expect(spy).not.toHaveBeenCalled(); - }); - - it('activates the popover when the combobox is focused', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('focus'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('deactivates the popover when the escape key is pressed', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.find(TextField).simulate('click'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - - act(() => { - dispatchKeyup(Key.Escape); - }); - - comboBox.update(); - expect(comboBox.find(Popover).prop('active')).toBe(false); - }); - }); - - describe('empty state', () => { - const EmptyState = () =>
No results
; - - it('renders an empty state when no options are passed in', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(1); - }); - - it('does not render empty state if actionsBefore exist', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if actionsAfter exist', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if contentAfter exist', () => { - const comboBox = mountWithAppProvider( - Content after} - emptyState={} - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if contentBefore exist', () => { - const comboBox = mountWithAppProvider( - Content before} - emptyState={} - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - }); -}); - -function noop() {} - -function renderTextField() { - return ; -} - -function renderNodeWithId() { - return
; -} - -function dispatchKeyup(key: Key) { - const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; - document.dispatchEvent(new KeyboardEvent('keyup', event)); -} - -function dispatchKeydown(key: Key) { - const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; - window.dispatchEvent(new KeyboardEvent('keydown', event)); -} diff --git a/src/components/Autocomplete/components/MappedAction/MappedAction.scss b/src/components/Autocomplete/components/MappedAction/MappedAction.scss new file mode 100644 index 00000000000..3094c223bd4 --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/MappedAction.scss @@ -0,0 +1,112 @@ +@import '../../../../styles/common'; + +$image-size: rem(20px); +$item-min-height: rem(40px); +$item-vertical-padding: ($item-min-height - line-height(body)) / 2; + +.ActionContainer { + margin-bottom: spacing(base-tight); +} + +[data-focused] { + .Action { + @include recolor-icon(var(--p-interactive)); + + &.destructive { + background-color: var(--p-surface-critical-subdued-pressed); + } + } +} + +.Action { + @include focus-ring; + display: block; + width: 100%; + min-height: $item-min-height; + text-align: left; + cursor: pointer; + padding: $item-vertical-padding spacing(tight); + border-radius: var(--p-border-radius-base); + border-top: 1px solid var(--p-surface); // 1px gap between elements + + &:hover { + background-color: var(--p-surface-hovered); + text-decoration: none; + + @media (-ms-high-contrast: active) { + outline: 1px solid ms-high-contrast-color('text'); + } + } + + &.selected { + @include recolor-icon(var(--p-interactive)); + background-color: var(--p-surface-selected); + } + + &:active { + @include recolor-icon(var(--p-interactive)); + background-color: var(--p-surface-pressed); + } + + &:focus:not(:active) { + @include focus-ring($style: 'focused'); + } + + &.destructive { + @include recolor-icon(var(--p-icon-critical)); + color: var(--p-interactive-critical); + + &:hover { + background-color: var(--p-surface-critical-subdued-hovered); + } + + // stylelint-disable-next-line selector-max-class + &:active, + &.selected { + background-color: var(--p-surface-critical-subdued-pressed); + } + } + + &.disabled { + background-image: none; + color: var(--p-text-disabled); + + // stylelint-disable-next-line selector-max-class + .Prefix, + .Suffix { + @include recolor-icon(var(--p-icon-disabled)); + } + } +} + +.Content { + display: flex; + align-items: center; +} + +.Prefix { + @include recolor-icon(var(--p-icon)); + display: flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + height: $image-size; + width: $image-size; + border-radius: border-radius(); + + // We need the negative margin to ensure that the image does not set + // the minimum height of the action item. + margin: (-0.5 * $image-size) spacing() (-0.5 * $image-size) 0; + background-size: cover; + background-position: center center; +} + +.Suffix { + @include recolor-icon(var(--p-icon)); + margin-left: spacing(); +} + +.Text { + @include layout-flex-fix; + flex: 1 1 auto; +} diff --git a/src/components/Autocomplete/components/MappedAction/MappedAction.tsx b/src/components/Autocomplete/components/MappedAction/MappedAction.tsx new file mode 100644 index 00000000000..86d42218f68 --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/MappedAction.tsx @@ -0,0 +1,122 @@ +import React, {useMemo} from 'react'; + +import type {ActionListItemDescriptor} from '../../../../types'; +import {Badge} from '../../../Badge'; +import {classNames} from '../../../../utilities/css'; +import {MappedActionContext} from '../../../../utilities/autocomplete'; +import {ListBox} from '../../../ListBox'; +import {Icon} from '../../../Icon'; +import {TextStyle} from '../../../TextStyle'; +import {useI18n} from '../../../../utilities/i18n'; + +import styles from './MappedAction.scss'; + +interface MappedAction extends ActionListItemDescriptor {} + +export function MappedAction({ + active, + content, + disabled, + icon, + image, + prefix, + suffix, + ellipsis, + role, + url, + external, + onAction, + destructive, + badge, + helpText, +}: MappedAction) { + const i18n = useI18n(); + + let prefixMarkup: React.ReactNode | null = null; + + if (prefix) { + prefixMarkup =
{prefix}
; + } else if (icon) { + prefixMarkup = ( +
+ +
+ ); + } else if (image) { + prefixMarkup = ( +
+ ); + } + + const badgeMarkup = badge && ( + + {badge.content} + + ); + + const suffixMarkup = suffix && ( + {suffix} + ); + + const contentText = + ellipsis && content + ? i18n.translate('Polaris.Autocomplete.ellipsis', {content}) + : content; + + const contentMarkup = ( +
+ {helpText ? ( + <> +
{contentText}
+ {helpText} + + ) : ( + contentText + )} +
+ ); + + const context = useMemo( + () => ({ + role, + url, + external, + onAction, + destructive, + isAction: true, + }), + [role, url, external, onAction, destructive], + ); + + const actionClassNames = classNames( + styles.Action, + disabled && styles.disabled, + destructive && styles.destructive, + active && styles.selected, + ); + + return ( + +
+ +
+
+ {prefixMarkup} + {contentMarkup} + {badgeMarkup} + {suffixMarkup} +
+
+
+
+
+ ); +} diff --git a/src/components/Autocomplete/components/MappedAction/index.ts b/src/components/Autocomplete/components/MappedAction/index.ts new file mode 100644 index 00000000000..a4230c37cdf --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/index.ts @@ -0,0 +1 @@ +export * from './MappedAction'; diff --git a/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx b/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx new file mode 100644 index 00000000000..cbd2256e83e --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {ListBox} from '../../../../ListBox'; +import {MappedAction} from '../MappedAction'; +import {MappedActionContext} from '../../../../../utilities/autocomplete'; +import {Badge} from '../../../../Badge'; +import {Icon} from '../../../../Icon'; + +describe('MappedAction', () => { + it('renders badge when provided', () => { + const badge = { + status: 'new' as const, + content: 'new', + }; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Badge, { + status: badge.status, + children: badge.content, + }); + }); + + it('renders suffix when provided', () => { + const mappedAction = mountWithListBoxProvider( + } />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + }); + + it('renders helpText when provided', () => { + const helpText = 'help text'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactText(helpText); + }); + + it('renders ellipsis when true', () => { + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactText('…'); + }); + + it('renders MappedActionContext provider with values', () => { + const props = { + role: 'role', + url: 'url', + external: false, + onAction: () => {}, + destructive: false, + }; + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(MappedActionContext.Provider, { + value: { + ...props, + isAction: true, + }, + }); + }); + + describe('ListBox.Action', () => { + it('renders', () => { + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action); + }); + + it('passes active', () => { + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + selected: true, + }); + }); + + it('passes disabled', () => { + const disabled = true; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + disabled, + }); + }); + + it('passes value', () => { + const value = 'value'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + value, + }); + }); + + it('defaults value to an empty string', () => { + const value = ''; + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + value, + }); + }); + }); + + describe('prefix markup', () => { + it('renders images', () => { + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders icon', () => { + const source = 'icon'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Icon, {source}); + }); + + it('renders prefix', () => { + const mappedAction = mountWithListBoxProvider( + } />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + }); + + it('renders icon instead of image', () => { + const source = 'icon'; + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Icon, {source}); + expect(mappedAction).not.toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders prefix instead of image', () => { + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + } image={image} />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + expect(mappedAction).not.toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders prefix instead of icon', () => { + const source = 'icon'; + const mappedAction = mountWithListBoxProvider( + } icon={source} />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + expect(mappedAction).not.toContainReactComponent(Icon, {source}); + }); + }); +}); + +function MockComponent() { + return null; +} diff --git a/src/components/Autocomplete/components/MappedOption/MappedOption.scss b/src/components/Autocomplete/components/MappedOption/MappedOption.scss new file mode 100644 index 00000000000..6cbdbc342e0 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/MappedOption.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/common'; + +.Content { + display: flex; + flex: 1; +} + +.Media { + @include recolor-icon(var(--p-icon, color('ink', 'light')), color('white')); + padding: 0 spacing(tight); +} + +.singleSelectionMedia { + padding: 0 spacing(tight) 0 0; +} + +.disabledMedia { + @include recolor-icon( + var(--p-icon-disabled, color('ink', 'lightest')), + color('white') + ); +} diff --git a/src/components/Autocomplete/components/MappedOption/MappedOption.tsx b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx new file mode 100644 index 00000000000..dec94df5197 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx @@ -0,0 +1,51 @@ +import React, {memo} from 'react'; + +import {ListBox} from '../../../ListBox'; +import type {OptionDescriptor} from '../../../OptionList'; +import type {ArrayElement} from '../../../../types'; +import {classNames} from '../../../../utilities/css'; + +import styles from './MappedOption.scss'; + +type MappedOption = ArrayElement & { + selected: boolean; + singleSelection: boolean; +}; + +export const MappedOption = memo(function MappedOption({ + label, + value, + disabled, + media, + selected, + singleSelection, +}: MappedOption) { + const mediaClassNames = classNames( + styles.Media, + disabled && styles.disabledMedia, + singleSelection && styles.singleSelectionMedia, + ); + + const mediaMarkup = media ? ( +
{media}
+ ) : null; + + const accessibilityLabel = typeof label === 'string' ? label : undefined; + + return ( + + +
+ {mediaMarkup} + {label} +
+
+
+ ); +}); diff --git a/src/components/Autocomplete/components/MappedOption/index.ts b/src/components/Autocomplete/components/MappedOption/index.ts new file mode 100644 index 00000000000..58a02ae72a3 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/index.ts @@ -0,0 +1 @@ +export * from './MappedOption'; diff --git a/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx b/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx new file mode 100644 index 00000000000..1d5ec593c8d --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {ListBox} from '../../../../ListBox'; +import {MappedOption} from '../MappedOption'; + +describe('MappedOption', () => { + const defaultProps = { + value: 'value', + label: 'label', + selected: false, + singleSelection: false, + }; + + it('renders label markup', () => { + const label = 'Test label'; + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactText(label); + }); + + describe('accessibility', () => { + it('does not apply an accessibility label when label is not a string', () => { + const label =
test label
; + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.Option, { + accessibilityLabel: undefined, + }); + }); + }); + + describe('ListBox', () => { + it('renders ListBox.Option', () => { + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.Option); + }); + + it('renders ListBox.TextOption', () => { + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.TextOption); + }); + }); + + describe('media', () => { + it('renders markup when provided', () => { + const mappedOption = mountWithListBoxProvider( + } />, + ); + + expect(mappedOption).toContainReactComponent(MockComponent); + }); + + it('renders with disabled styles when disabled', () => { + const mappedOption = mountWithListBoxProvider( + } />, + ); + + expect(mappedOption).toContainReactComponent('div', { + className: 'Media disabledMedia', + }); + }); + + it('renders with single selection styles when singleSelection is true', () => { + const mappedOption = mountWithListBoxProvider( + } + />, + ); + + expect(mappedOption).toContainReactComponent('div', { + className: 'Media singleSelectionMedia', + }); + }); + }); +}); + +function MockComponent() { + return null; +} diff --git a/src/components/Autocomplete/components/TextField/TextField.tsx b/src/components/Autocomplete/components/TextField/TextField.tsx deleted file mode 100644 index 5597c8b7988..00000000000 --- a/src/components/Autocomplete/components/TextField/TextField.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -// eslint-disable-next-line @shopify/strict-component-boundaries -import {ComboBoxContext} from '../ComboBox/context'; -import {TextField as BaseTextField, TextFieldProps} from '../../../TextField'; - -export function TextField(props: TextFieldProps) { - return ( - - {({selectedOptionId, comboBoxId}) => ( - - )} - - ); -} diff --git a/src/components/Autocomplete/components/TextField/index.ts b/src/components/Autocomplete/components/TextField/index.ts deleted file mode 100644 index 665fa3cb54f..00000000000 --- a/src/components/Autocomplete/components/TextField/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TextField'; diff --git a/src/components/Autocomplete/components/index.ts b/src/components/Autocomplete/components/index.ts index c8e78f10ae4..42e65fc4cbf 100644 --- a/src/components/Autocomplete/components/index.ts +++ b/src/components/Autocomplete/components/index.ts @@ -1,3 +1,2 @@ -export * from './ComboBox'; - -export * from './TextField'; +export * from './MappedOption'; +export * from './MappedAction'; diff --git a/src/components/Autocomplete/tests/Autocomplete.test.tsx b/src/components/Autocomplete/tests/Autocomplete.test.tsx index bfc942b6623..51d2034d634 100644 --- a/src/components/Autocomplete/tests/Autocomplete.test.tsx +++ b/src/components/Autocomplete/tests/Autocomplete.test.tsx @@ -1,22 +1,33 @@ import React from 'react'; -import {CirclePlusMinor} from '@shopify/polaris-icons'; -// eslint-disable-next-line no-restricted-imports -import {mountWithAppProvider, trigger} from 'test-utilities/legacy'; -import {Spinner} from 'components'; +import {mountWithApp, ReactTestingElement, CustomRoot} from 'test-utilities'; +import {KeypressListener} from 'components'; +import {TextField} from '../../TextField'; import {Key} from '../../../types'; -import {ComboBox} from '../components'; +import {MappedOption, MappedAction} from '../components'; +import {ComboBoxTextFieldContext} from '../../../utilities/combo-box'; import {Autocomplete} from '../Autocomplete'; +import {ComboBox} from '../../ComboBox'; +import type {ComboBoxProps} from '../../ComboBox'; +import {ListBox} from '../../ListBox'; describe('', () => { const options = [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'cheese_pizza', label: 'Cheese Pizza', id: '1'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza', id: '2'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza', id: '3'}, ]; + const defaultProps = { + options, + selected: [], + textField: ( + + ), + onSelect: noop, + }; it('mounts', () => { - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { onSelect={noop} />, ); - expect(autocomplete.find(Autocomplete).exists()).toBe(true); + expect(autocomplete).toContainReactComponent(ComboBox); }); it('displays a spinner when loading is true', () => { - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { loading />, ); - autocomplete.simulate('click'); - expect(autocomplete.find(Spinner).exists()).toBe(true); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Loading); }); describe('', () => { - it('passes props to ComboBox', () => { - const actionBefore = { - content: "Add 'f'", - icon: CirclePlusMinor, - id: 'ComboBox3-0', - }; + describe('props', () => { + describe('id', () => { + // id is a noop in the new implementation - test is to ensure we keep the id prop + it('does nothing', () => { + const id = 'unique_id_Jf939sjf8js8NNsJ8'; + const autocomplete = mountWithApp( + , + ); - const EmptyState = () => No results; + expect(autocomplete).not.toContainReactHtml(id); + }); + }); - const autocomplete = mountWithAppProvider( - } - />, - ); + describe('options', () => { + it('renders a ListBox.Option for each option', () => { + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); - expect(autocomplete.find(ComboBox).prop('id')).toBe('Autocomplete-ID'); - expect(autocomplete.find(ComboBox).prop('options')).toBe(options); - expect(autocomplete.find(ComboBox).prop('selected')).toStrictEqual([ - 'cheese_pizza', - ]); - expect(autocomplete.find(ComboBox).prop('textField')).toStrictEqual( - renderTextField(), - ); - expect(autocomplete.find(ComboBox).prop('preferredPosition')).toBe( - 'mostSpace', - ); - expect(autocomplete.find(ComboBox).prop('listTitle')).toBe('List title'); - expect(autocomplete.find(ComboBox).prop('allowMultiple')).toBe(true); - expect(autocomplete.find(ComboBox).prop('actionsBefore')).toStrictEqual([ - actionBefore, - ]); - expect(autocomplete.find(ComboBox).prop('onSelect')).toBe(handleOnSelect); - expect(autocomplete.find(ComboBox).prop('emptyState')).toStrictEqual( - , - ); + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Option, + options.length, + ); + }); + + it('passes selected to ListBox.Option', () => { + const selected = 'cheese_pizza'; + const options = [ + {value: selected, label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedOption, { + ...options[0], + selected: true, + }); + }); + }); + + describe('selected', () => { + it('renders selected values on options', () => { + const selectedOption = { + value: 'cheese_pizza', + label: 'Cheese Pizza', + id: '1', + }; + const options = [ + selectedOption, + {value: 'macaroni_pizza', label: 'Macaroni Pizza', id: '2'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza', id: '3'}, + {value: 'other_pizza', label: 'Other Pizza', id: '4'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedOption, { + ...selectedOption, + selected: true, + }); + }); + }); + + describe('textField', () => { + it('is passed to ComboBox', () => { + const textField = ( + + ); + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + activator: textField, + }); + }); + }); + + describe('preferredPosition', () => { + it('is passed to ComboBox', () => { + const preferredPosition = 'above'; + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + preferredPosition, + }); + }); + }); + + describe('listTitle', () => { + it('renders a ListBoxSection with a ListBoxHeader', () => { + const listTitle = 'title'; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Section, { + divider: false, + }); + }); + }); + + describe('allowMultiple', () => { + it('is passed to ComboBox', () => { + const allowMultiple = true; + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + allowMultiple, + }); + }); + }); + + describe('actionBefore', () => { + it('renders MappedAction', () => { + const actionBefore = { + accessibilityLabel: 'label', + helpText: 'help text', + image: '', + prefix: null, + suffix: null, + ellipsis: false, + active: false, + role: 'option', + icon: 'icon', + disabled: false, + destructive: true, + badge: { + status: 'new' as const, + content: 'new', + }, + }; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedAction); + }); + }); + + describe('loading', () => { + it('renders ListBox.Loading', () => { + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Loading); + }); + }); + + describe('willLoadMoreResults', () => { + it('renders options while loading', () => { + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Option, + options.length, + ); + }); + }); + + describe('emptyState', () => { + function EmptyState() { + return null; + } + + it('does not render if an action exists', () => { + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it('does not render when options exists', () => { + const emptyState = ; + const options = [{value: 'cheese_pizza', label: 'Cheese Pizza'}]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it('does not render when loading is true', () => { + const emptyState = ; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it("renders while loading is false and options don't exists", () => { + const emptyState = ; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(EmptyState); + }); + }); + + describe('onSelect', () => { + it('is called when the newly selected value', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const value = options[0].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, value); + + expect(onSelectSpy).toHaveBeenLastCalledWith([value]); + }); + + it('is not called with the deselected value when allowMultiple is true', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const value = options[0].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, value); + + expect(onSelectSpy).toHaveBeenLastCalledWith([]); + }); + + it('is called with multiple values when allowMultiple is true', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const valueOne = options[0].value; + const valueTwo = options[1].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, valueTwo); + + expect(onSelectSpy).toHaveBeenLastCalledWith([valueOne, valueTwo]); + }); + }); + + describe('onLoadMoreResults', () => { + it('is passed to ComboBox', () => { + const onLoadMoreResults = jest.fn(); + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + onScrolledToBottom: onLoadMoreResults, + }); + }); + }); }); - it('`Enter` keypress in does not trigger `onSubmit` when wrapped in a
', () => { - const spy = jest.fn(); + it('`Enter` keypress in prevents default to stop `onSubmit` from being called when wrapped in a ', () => { + const preventDefaultSpy = jest.fn(); - const autocomplete = mountWithAppProvider( - + const autocomplete = mountWithApp( + ', () => { , ); - autocomplete.find(Autocomplete).simulate('click'); + triggerFocus(autocomplete.find(ComboBox)); autocomplete - .find(Autocomplete) - .simulate('keyup', {keyCode: Key.DownArrow}); - autocomplete.find(Autocomplete).simulate('keyDown', {keyCode: Key.Enter}); - expect(spy).not.toHaveBeenCalled(); + .find(ComboBox.TextField) + ?.find(TextField) + ?.trigger('onFocus'); + autocomplete + .find(KeypressListener, {keyCode: Key.Enter})! + .trigger('handler', { + preventDefault: preventDefaultSpy, + stopPropagation: noop, + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); }); }); describe('loading', () => { - it('passes an empty array as options and contentAfter to ComboBox when loading is true', () => { - const autocomplete = mountWithAppProvider( + it('does not render options when loading is true', () => { + const autocomplete = mountWithApp( ', () => { loading />, ); - expect(autocomplete.find(ComboBox).prop('options')).toStrictEqual([]); - expect(autocomplete.find(ComboBox).prop('contentAfter')).not.toBeNull(); + + expect(autocomplete).not.toContainReactComponent(ListBox.Option); }); }); describe('onLoadMoreResults', () => { it('gets called when then end of the option list is reached', () => { const spy = jest.fn(); - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { />, ); - const comboBox = autocomplete.find(ComboBox); - trigger(comboBox, 'onEndReached'); + autocomplete.find(ComboBox)?.trigger('onScrolledToBottom'); expect(spy).toHaveBeenCalledTimes(1); }); @@ -151,20 +491,23 @@ describe('', () => { function noop() {} function renderTextField() { - return ; - } - - function handleOnSelect(this: any, updatedSelection: string[]) { - const selectedText = updatedSelection.map((selectedItem: string) => { - const matchedOption = this.options.filter((option: any) => { - return option.value.match(selectedItem); - }); - return matchedOption[0] && matchedOption[0].label; - }); - if (this.ALLOW_MULTIPLE) { - this.setState({selected: updatedSelection}); - } else { - this.setState({selected: selectedText, inputText: selectedText}); - } + return ( + + ); } }); + +function triggerFocus(combobox: ReactTestingElement | null) { + combobox && + combobox + .find(ComboBoxTextFieldContext.Provider)! + .triggerKeypath('value.onTextFieldFocus'); +} + +function triggerOnSelect( + autocomplete: CustomRoot | null, + values: string, +) { + const listbox = autocomplete!.find(ListBox); + listbox!.trigger('onSelect', values); +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f4eb6156109..dd5f25296eb 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -1,4 +1,10 @@ -import React, {forwardRef, useRef, useImperativeHandle, useState} from 'react'; +import React, { + forwardRef, + useRef, + useImperativeHandle, + useState, + useContext, +} from 'react'; import {MinusMinor, TickSmallMinor} from '@shopify/polaris-icons'; import {classNames} from '../../utilities/css'; @@ -8,6 +14,7 @@ import {Choice, helpTextID} from '../Choice'; import {errorTextID} from '../InlineError'; import {Icon} from '../Icon'; import {Error, Key, CheckboxHandles} from '../../types'; +import {WithinListBoxContext} from '../../utilities/list-box/context'; import styles from './Checkbox.scss'; @@ -67,6 +74,7 @@ export const Checkbox = forwardRef( setFalse: handleMouseOut, } = useToggle(false); const [keyFocused, setKeyFocused] = useState(false); + const isWithinListBox = useContext(WithinListBoxContext); useImperativeHandle(ref, () => ({ focus: () => { @@ -134,7 +142,6 @@ export const Checkbox = forwardRef( ); return ( - /* eslint-disable jsx-a11y/no-redundant-roles */ ( onChange={noop} aria-invalid={error != null} aria-describedby={ariaDescribedBy} - role="checkbox" + role={isWithinListBox ? 'presentation' : 'checkbox'} {...indeterminateAttributes} /> @@ -172,7 +179,6 @@ export const Checkbox = forwardRef( - /* eslint-enable jsx-a11y/no-redundant-roles */ ); }, ); diff --git a/src/components/ChoiceList/README.md b/src/components/ChoiceList/README.md index e63693e2903..87b417f0cc2 100644 --- a/src/components/ChoiceList/README.md +++ b/src/components/ChoiceList/README.md @@ -322,6 +322,7 @@ function SingleOrMultiChoiceListWithChildrenContextExample() { labelHidden onChange={handleTextFieldChange} value={textFieldValue} + autoComplete="off" /> ), [handleTextFieldChange, textFieldValue], @@ -372,6 +373,7 @@ function SingleOrMultuChoiceListWithChildrenContextWhenSelectedExample() { labelHidden onChange={handleTextFieldChange} value={textFieldValue} + autoComplete="off" /> ), [handleTextFieldChange, textFieldValue], diff --git a/src/components/ComboBox/ComboBox.scss b/src/components/ComboBox/ComboBox.scss new file mode 100644 index 00000000000..977141bcbff --- /dev/null +++ b/src/components/ComboBox/ComboBox.scss @@ -0,0 +1,6 @@ +@import '../../styles/common'; + +.ListBox { + padding: spacing(tight) 0; + overflow: visible; +} diff --git a/src/components/ComboBox/ComboBox.tsx b/src/components/ComboBox/ComboBox.tsx new file mode 100644 index 00000000000..1941e903b91 --- /dev/null +++ b/src/components/ComboBox/ComboBox.tsx @@ -0,0 +1,153 @@ +import React, {useState, useCallback, useMemo, Children} from 'react'; + +import {Popover} from '../Popover'; +import type {PopoverProps} from '../Popover'; +import type {TextFieldProps} from '../TextField'; +import type {ListBoxProps} from '../ListBox'; +import { + ComboBoxTextFieldContext, + ComboBoxTextFieldType, + ComboBoxListBoxContext, + ComboBoxListBoxType, + ComboBoxListBoxOptionType, + ComboBoxListBoxOptionContext, +} from '../../utilities/combo-box'; + +import styles from './ComboBox.scss'; +import {TextField} from './components'; + +export interface ComboBoxProps { + children?: React.ReactElement | null; + activator: React.ReactElement; + allowMultiple?: boolean; + onScrolledToBottom?(): void; + preferredPosition?: PopoverProps['preferredPosition']; +} + +export function ComboBox({ + children, + activator, + allowMultiple, + onScrolledToBottom, + preferredPosition = 'below', +}: ComboBoxProps) { + const [popoverActive, setPopoverActive] = useState(false); + const [activeOptionId, setActiveOptionId] = useState(); + const [textFieldLabelId, setTextFieldLabelId] = useState(); + const [listBoxId, setListBoxId] = useState(); + const [textFieldFocused, setTextFieldFocused] = useState(false); + const shouldOpen = Boolean(!popoverActive && Children.count(children) > 0); + + const onOptionSelected = useCallback(() => { + if (!allowMultiple) { + setPopoverActive(false); + setActiveOptionId(undefined); + } + }, [allowMultiple]); + + const handleClose = useCallback(() => { + setPopoverActive(false); + setActiveOptionId(undefined); + }, []); + + const handleFocus = useCallback(() => { + if (shouldOpen) { + setPopoverActive(true); + } + }, [shouldOpen]); + + const handleChange = useCallback(() => { + if (shouldOpen) { + setPopoverActive(true); + } + }, [shouldOpen]); + + const handleBlur = useCallback(() => { + if (popoverActive) { + setPopoverActive(false); + setActiveOptionId(undefined); + } + }, [popoverActive]); + + const textFieldContextValue: ComboBoxTextFieldType = useMemo( + () => ({ + activeOptionId, + expanded: popoverActive, + listBoxId, + setTextFieldFocused, + setTextFieldLabelId, + onTextFieldFocus: handleFocus, + onTextFieldChange: handleChange, + onTextFieldBlur: handleBlur, + }), + [ + activeOptionId, + popoverActive, + listBoxId, + setTextFieldFocused, + setTextFieldLabelId, + handleFocus, + handleChange, + handleBlur, + ], + ); + + const listBoxOptionContextValue: ComboBoxListBoxOptionType = useMemo( + () => ({ + allowMultiple, + }), + [allowMultiple], + ); + + const listBoxContextValue: ComboBoxListBoxType = useMemo( + () => ({ + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + textFieldFocused, + onKeyToBottom: onScrolledToBottom, + }), + [ + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + textFieldFocused, + onScrolledToBottom, + ], + ); + + return ( + + {activator} + + } + autofocusTarget="none" + preventFocusOnClose + fullWidth + preferInputActivator={false} + preferredPosition={preferredPosition} + > + + {Children.count(children) > 0 ? ( + + +
{children}
+
+
+ ) : null} +
+
+ ); +} + +ComboBox.TextField = TextField; diff --git a/src/components/ComboBox/README.md b/src/components/ComboBox/README.md new file mode 100644 index 00000000000..b6e8c0cfd4e --- /dev/null +++ b/src/components/ComboBox/README.md @@ -0,0 +1,420 @@ +--- +name: ComboBox +category: Forms +keywords: + - autocomplete + - searchable + - typeahead + - combobox + - listbox +--- + +# ComboBox + +The `ComboBox` component implements part of the [Aria 1.2 combobox](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox) specs on a TextField and a popover containing a ListBox. Like `Autocomplete`, `ComboBox` allows merchants to quickly search through and select from large collections of options. + +--- + +## Best practices + +The `ComboBox` component should: + +- Be clearly labeled so it’s noticeable to the merchant what type of options will be available +- Not be used within a popover +- Indicate a loading state to the merchant while option data is being populated + +--- + +## Content guidelines + +The input field for `ComboBox` should follow the [content guidelines](https://polaris.shopify.com/components/forms/text-field) for text fields. + +--- + +## Examples + +### Basic autocomplete + +Use to help merchants complete text input quickly from a list of options. + +```jsx +function ComboboxExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOption, setSelectedOption] = useState(); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === '') { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, 'i'); + const resultOptions = deselectedOptions.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + }, + [deselectedOptions], + ); + + const updateSelection = useCallback( + (selected) => { + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + + setSelectedOption(selected); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options], + ); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + return ( +
+ } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {options.length > 0 ? ( + {optionsMarkup} + ) : null} + +
+ ); +} +``` + +### Multiple tags autocomplete + +Use to help merchants select multiple options from a list curated by the text input. + +```jsx +function MultiComboboxExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === '') { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, 'i'); + const resultOptions = deselectedOptions.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + }, + [deselectedOptions], + ); + + const updateSelection = useCallback( + (selected) => { + if (selectedOptions.includes(selected)) { + setSelectedOptions( + selectedOptions.filter((option) => option !== selected), + ); + } else { + setSelectedOptions([...selectedOptions, selected]); + } + + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options, selectedOptions], + ); + + const removeTag = useCallback( + (tag) => () => { + const options = [...selectedOptions]; + options.splice(options.indexOf(tag), 1); + setSelectedOptions(options); + }, + [selectedOptions], + ); + + const tagsMarkup = selectedOptions.map((option) => { + let tagLabel = ''; + tagLabel = option.replace('_', ' '); + tagLabel = titleCase(tagLabel); + return ( + + {tagLabel} + + ); + }); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + return ( +
+ } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {optionsMarkup ? ( + {optionsMarkup} + ) : null} + + + {tagsMarkup} + +
+ ); + + function titleCase(string) { + return string + .toLowerCase() + .split(' ') + .map((word) => word.replace(word[0], word[0].toUpperCase())) + .join(''); + } +} +``` + +### Autocomplete with loading + +Use to indicate loading state to merchants while option data is processing. + +```jsx +function LoadingAutocompleteExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOption, setSelectedOption] = useState(); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + + setSelectedOption(selected); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options], + ); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + const loadingMarkup = loading ? : null; + + const listBoxMarkup = + optionsMarkup || loadingMarkup ? ( + + {optionsMarkup && !loading ? optionsMarkup : null} + {loadingMarkup} + + ) : null; + + return ( + } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {listBoxMarkup} + + ); +} +``` + +--- + +## Related components + +- For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field) +- For a list of selectable options not linked to an input field, [use the list box component](https://polaris.shopify.com/components/lists-and-tables/list-box) +- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of `ComboBox` and `ListBox`. + +--- + +## Accessibility + + + +See Material Design and development documentation about accessibility for Android: + +- [Accessible design on Android](https://material.io/design/usability/accessibility.html) +- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/) + + + + + +See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS: + +- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/) +- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/) + + + + + +### Structure + +The `ComboBox` component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). It is a combination of a single-line `TextField` and a `Popover`. The current implementation expects a [`ListBox`] component to be used. + +The `ComboBox` popover displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop. + +`ComboBox` features can be challenging for merchants with visual, motor, and cognitive disabilities. Even when they’re built using best practices, these features can be difficult to use with some assistive technologies. Merchants should always be able to search, enter data, or perform other activities without relying on the combobox. + + + +#### Do + +- Use combobox as progressive enhancement to make the interface easier to use for most merchants. + +#### Don’t + +- Require that merchants make a selection from the combobox to complete a task. + + + +### Keyboard support + +- Give the combobox's text input keyboard focus with the tab key (or shift + tab when tabbing backwards) + + diff --git a/src/components/ComboBox/components/TextField/TextField.tsx b/src/components/ComboBox/components/TextField/TextField.tsx new file mode 100644 index 00000000000..14c9831b492 --- /dev/null +++ b/src/components/ComboBox/components/TextField/TextField.tsx @@ -0,0 +1,78 @@ +import React, {useMemo, useCallback, useEffect} from 'react'; + +import {labelID} from '../../../Label'; +import {useUniqueId} from '../../../../utilities/unique-id'; +import {TextField as PolarisTextField} from '../../../TextField'; +import type {TextFieldProps} from '../../../TextField'; +import {useComboBoxTextField} from '../../../../utilities/combo-box'; + +export function TextField({ + value, + id: idProp, + onFocus, + onBlur, + onChange, + ...rest +}: TextFieldProps) { + const comboboxTextFieldContext = useComboBoxTextField(); + + const { + activeOptionId, + listBoxId, + expanded, + setTextFieldFocused, + setTextFieldLabelId, + onTextFieldFocus, + onTextFieldChange, + onTextFieldBlur, + } = comboboxTextFieldContext; + + const uniqueId = useUniqueId('ComboBoxTextField'); + const textFieldId = useMemo(() => idProp || uniqueId, [uniqueId, idProp]); + + const labelId = useMemo(() => labelID(idProp || uniqueId), [ + uniqueId, + idProp, + ]); + + useEffect(() => { + if (setTextFieldLabelId) setTextFieldLabelId(labelId); + }, [labelId, setTextFieldLabelId]); + + const handleFocus = useCallback(() => { + if (onFocus) onFocus(); + if (onTextFieldFocus) onTextFieldFocus(); + if (setTextFieldFocused) setTextFieldFocused(true); + }, [onFocus, onTextFieldFocus, setTextFieldFocused]); + + const handleBlur = useCallback(() => { + if (onBlur) onBlur(); + if (onTextFieldBlur) onTextFieldBlur(); + if (setTextFieldFocused) setTextFieldFocused(false); + }, [onBlur, onTextFieldBlur, setTextFieldFocused]); + + const handleChange = useCallback( + (value: string, id: string) => { + if (onChange) onChange(value, id); + if (onTextFieldChange) onTextFieldChange(); + }, + [onChange, onTextFieldChange], + ); + + return ( + + ); +} diff --git a/src/components/ComboBox/components/TextField/index.ts b/src/components/ComboBox/components/TextField/index.ts new file mode 100644 index 00000000000..35e9e6ed312 --- /dev/null +++ b/src/components/ComboBox/components/TextField/index.ts @@ -0,0 +1 @@ +export {TextField} from './TextField'; diff --git a/src/components/ComboBox/components/TextField/tests/TextField.test.tsx b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx new file mode 100644 index 00000000000..3f3ca4b9fc9 --- /dev/null +++ b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {TextField as PolarisTextField} from '../../../../TextField'; +import type {TextFieldProps} from '../../../../TextField'; +import {TextField} from '../TextField'; +import {labelID} from '../../../../Label'; +import { + ComboBoxTextFieldContext, + ComboBoxTextFieldType, +} from '../../../../../utilities/combo-box'; + +const textFieldContextDefaultValue = { + activeOptionId: undefined, + listBoxId: undefined, + expanded: false, + setTextFieldLabelId: noop, + setTextFieldFocused: noop, + onTextFieldFocus: noop, + onTextFieldBlur: noop, + onTextFieldChange: noop, +}; + +function mountWithProvider( + props: { + textFieldProps?: Partial; + textFieldProviderValue?: Partial; + } = {}, +) { + const providerValue = { + ...textFieldContextDefaultValue, + ...props.textFieldProviderValue, + }; + + const textField = mountWithApp( + + + , + ); + + return textField; +} + +describe('ComboBox.TextField', () => { + it('throws if not wrapped in ComboBoxTextFieldContext', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + mountWithApp( + , + ), + ).toThrow('No ComboBox was provided.'); + + consoleErrorSpy.mockRestore(); + }); + + it('renders a PolarisTextField', () => { + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + value: 'value', + autoComplete: 'off', + id: 'textFieldId', + onFocus: expect.any(Function), + onBlur: expect.any(Function), + onChange: expect.any(Function), + ariaAutocomplete: 'list', + ariaActiveDescendant: undefined, + ariaControls: undefined, + role: 'combobox', + ariaExpanded: false, + }); + }); + + it('passes the activeOptionId to the aria-activedescendant of the PolarisTextField', () => { + const activeOptionId = 'activeOptionId'; + const combobox = mountWithProvider({ + textFieldProviderValue: { + activeOptionId, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaActiveDescendant: activeOptionId, + }); + }); + + it('passes the listBoxId to the aria-controls of the PolarisTextField', () => { + const listBoxId = 'listBoxId'; + const combobox = mountWithProvider({ + textFieldProviderValue: { + listBoxId, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaControls: listBoxId, + }); + }); + + it('passes the expanded to the aria-expanded of the PolarisTextField', () => { + const combobox = mountWithProvider({ + textFieldProviderValue: { + expanded: true, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaExpanded: true, + }); + }); + + it('calls setTextFieldLabelId with the expected ID', () => { + const textFieldId = 'textFieldId'; + const setTextFieldLabelIdSpy = jest.fn(); + const expectedId = labelID(textFieldId); + mountWithProvider({ + textFieldProps: { + id: textFieldId, + }, + textFieldProviderValue: { + setTextFieldLabelId: setTextFieldLabelIdSpy, + }, + }); + + expect(setTextFieldLabelIdSpy).toHaveBeenCalledWith(expectedId); + }); + + describe('onFocus', () => { + it('calls the onFocus prop on focus', () => { + const onFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onFocus: onFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(onFocusSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldFocus on Context', () => { + const onTextFieldFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldFocus: onTextFieldFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(onTextFieldFocusSpy).toHaveBeenCalled(); + }); + + it('calls the setTextFieldFocused on Context', () => { + const setTextFieldFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + setTextFieldFocused: setTextFieldFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(setTextFieldFocusSpy).toHaveBeenCalled(); + }); + }); + + describe('onBlur', () => { + it('calls the onBlur prop', () => { + const onBlurSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onBlur: onBlurSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onBlur'); + + expect(onBlurSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldBlur on Context', () => { + const onTextFieldBlurSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldBlur: onTextFieldBlurSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onBlur'); + + expect(onTextFieldBlurSpy).toHaveBeenCalled(); + }); + }); + + describe('onChange', () => { + it('calls the onChange prop', () => { + const onChangeSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onChange: onChangeSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onChange'); + + expect(onChangeSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldChange on Context', () => { + const onTextFieldChangeSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldChange: onTextFieldChangeSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onChange'); + + expect(onTextFieldChangeSpy).toHaveBeenCalled(); + }); + }); +}); + +function noop() {} diff --git a/src/components/ComboBox/components/index.ts b/src/components/ComboBox/components/index.ts new file mode 100644 index 00000000000..35e9e6ed312 --- /dev/null +++ b/src/components/ComboBox/components/index.ts @@ -0,0 +1 @@ +export {TextField} from './TextField'; diff --git a/src/components/ComboBox/index.ts b/src/components/ComboBox/index.ts new file mode 100644 index 00000000000..f50eeedf616 --- /dev/null +++ b/src/components/ComboBox/index.ts @@ -0,0 +1,2 @@ +export * from './ComboBox'; +export {TextField as ComboBoxTextField} from './components'; diff --git a/src/components/ComboBox/tests/ComboBox.test.tsx b/src/components/ComboBox/tests/ComboBox.test.tsx new file mode 100644 index 00000000000..e98356ff697 --- /dev/null +++ b/src/components/ComboBox/tests/ComboBox.test.tsx @@ -0,0 +1,373 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {TextField} from '../../TextField'; +import {ComboBox} from '../ComboBox'; +import {ListBox} from '../../ListBox'; +import {Popover} from '../../Popover'; +import { + ComboBoxTextFieldContext, + ComboBoxListBoxContext, +} from '../../../utilities/combo-box'; +import {Key} from '../../../types'; + +describe('', () => { + const activator = ( + + ); + const listBox = ( + + + + ); + + it('renders a Popover in the providers', () => { + const combobox = mountWithApp( + {listBox}, + ); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + onClose: expect.any(Function), + autofocusTarget: 'none', + fullWidth: true, + preferInputActivator: false, + }); + }); + + it('renders the activator in ComboBoxTextFieldContext provider', () => { + const combobox = mountWithApp( + {listBox}, + ); + + expect(combobox.find(ComboBoxTextFieldContext.Provider)).toHaveReactProps({ + children: activator, + }); + }); + + it('renders the popover children in a ComboBoxListBoxContext provider', () => { + const combobox = mountWithApp( + {listBox}, + ); + + triggerFocus(combobox); + + expect( + combobox.find(ComboBoxListBoxContext.Provider), + ).toContainReactComponent(ListBox); + }); + + it('does not open Popover when the ComboBoxTextFieldContext onTextFieldFocus and there are no children', () => { + const combobox = mountWithApp(); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('renders an active Popover when the activator is focused and there are children', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('closes the Popover when onOptionSelected is triggered and allowMultiple is false', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + + triggerOptionSelected(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('does not close the Popover when onOptionSelected is triggered and allowMultiple is true and there are children', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('calls the onScrolledToBottom when the Popovers onScrolledToBottom is triggered', () => { + const onScrolledToBottomSpy = jest.fn(); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox.find(Popover.Pane)!.trigger('onScrolledToBottom'); + + expect(onScrolledToBottomSpy).toHaveBeenCalled(); + }); + + it('closes the Popover when onClose is called', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + combobox.find(Popover)?.trigger('onClose'); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('opens the Popover when the TextField activator is changed', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + combobox.find(TextField)?.trigger('onChange'); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('closes the Popover when TextField is blurred', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + combobox.find(TextField)?.trigger('onBlur'); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + describe('popover', () => { + it('defaults active to false', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('has fullWidth', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + fullWidth: true, + }); + }); + + it('has autofocusTarget of none', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + autofocusTarget: 'none', + }); + }); + + it('sets active to false when escape is pressed', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox.act(() => { + dispatchKeyup(Key.Escape); + }); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('passes the preferredPosition', () => { + const preferredPosition = 'above'; + const combobox = mountWithApp( + , + ); + + expect(combobox).toContainReactComponent(Popover, { + preferredPosition, + }); + }); + }); + + describe('Context', () => { + it('sets expanded to true on the ComboBoxTextFieldContext when the popover is active', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .expanded, + ).toBe(true); + }); + + it('sets expanded to false on the ComboBoxTextFieldContext when the popover is not active', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .expanded, + ).toBe(false); + }); + + it('sets the activeOptionId on the ComboBoxTextFieldContext to undefined the popover is not closed', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.setActiveOptionId', 'id'); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .activeOptionId, + ).toBe('id'); + + triggerOptionSelected(combobox); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .activeOptionId, + ).toBeUndefined(); + }); + }); +}); + +function triggerFocus(combobox: any) { + combobox + .find(ComboBoxTextFieldContext.Provider)! + .triggerKeypath('value.onTextFieldFocus'); +} + +function triggerOptionSelected(combobox: any) { + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); +} + +function noop() {} + +function dispatchKeyup(key: Key) { + const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; + document.dispatchEvent(new KeyboardEvent('keyup', event)); +} diff --git a/src/components/Filters/Filters.tsx b/src/components/Filters/Filters.tsx index ad65dab43d2..a9efe1bf107 100644 --- a/src/components/Filters/Filters.tsx +++ b/src/components/Filters/Filters.tsx @@ -287,6 +287,7 @@ class FiltersInner extends Component { clearButton onClearButtonClick={onQueryClear} disabled={disabled} + autoComplete="off" /> )} diff --git a/src/components/Filters/README.md b/src/components/Filters/README.md index e4880e96c7e..fcfb3d4d003 100644 --- a/src/components/Filters/README.md +++ b/src/components/Filters/README.md @@ -211,6 +211,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -423,6 +424,7 @@ function DataTableFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -555,6 +557,7 @@ function FiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -681,6 +684,7 @@ function DisableAllFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -816,6 +820,7 @@ function DisableSomeFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -829,6 +834,7 @@ function DisableSomeFiltersExample() { label="Vendor" value={vendor} onChange={handleVendorChange} + autoComplete="off" labelHidden /> ), @@ -960,6 +966,7 @@ function Playground() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1131,6 +1138,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1331,6 +1339,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/Form/README.md b/src/components/Form/README.md index 15ba3eefef2..4faf1c6b79f 100644 --- a/src/components/Form/README.md +++ b/src/components/Form/README.md @@ -65,6 +65,7 @@ function FormOnSubmitExample() { onChange={handleEmailChange} label="Email" type="email" + autoComplete="email" helpText={ We’ll use this email address to inform you on future changes to @@ -100,6 +101,7 @@ function FormWithoutNativeValidationExample() { onChange={handleUrlChange} label="App URL" type="url" + autoComplete="off" /> diff --git a/src/components/FormLayout/README.md b/src/components/FormLayout/README.md index 0a56066ecd1..7e0870bf5c7 100644 --- a/src/components/FormLayout/README.md +++ b/src/components/FormLayout/README.md @@ -102,8 +102,13 @@ Use to stack form fields vertically, which makes them easier to scan and complet ```jsx - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> ``` @@ -130,8 +135,18 @@ Field groups will wrap automatically on smaller screens. ```jsx - {}} /> - {}} /> + {}} + autoComplete="off" + /> + {}} + autoComplete="off" + /> ``` @@ -157,10 +172,10 @@ For very short inputs, the width of the inputs may be reduced in order to fit mo ```jsx - {}} /> - {}} /> - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> ``` diff --git a/src/components/FormLayout/components/Group/tests/Group.test.tsx b/src/components/FormLayout/components/Group/tests/Group.test.tsx index ef592a2fbcc..dbe1e5cb3a7 100644 --- a/src/components/FormLayout/components/Group/tests/Group.test.tsx +++ b/src/components/FormLayout/components/Group/tests/Group.test.tsx @@ -12,7 +12,7 @@ describe('', () => { let item: any; beforeAll(() => { - children = ; + children = ; title = 'Title'; helpText = 'Help text'; item = mountWithAppProvider( diff --git a/src/components/FormLayout/components/Item/tests/Item.test.tsx b/src/components/FormLayout/components/Item/tests/Item.test.tsx index cb7d28448cc..895f980beb6 100644 --- a/src/components/FormLayout/components/Item/tests/Item.test.tsx +++ b/src/components/FormLayout/components/Item/tests/Item.test.tsx @@ -7,7 +7,9 @@ import {Item} from '../Item'; describe('', () => { it('renders its children', () => { - const children = ; + const children = ( + + ); const item = mountWithAppProvider({children}); expect(item.contains(children)).toBe(true); }); diff --git a/src/components/FormLayout/tests/FormLayout.test.tsx b/src/components/FormLayout/tests/FormLayout.test.tsx index 016dc0ca970..f79ce9a08c5 100644 --- a/src/components/FormLayout/tests/FormLayout.test.tsx +++ b/src/components/FormLayout/tests/FormLayout.test.tsx @@ -7,7 +7,9 @@ import {FormLayout} from '../FormLayout'; describe('', () => { it('renders its children', () => { - const children = ; + const children = ( + + ); const formLayout = mountWithAppProvider( {children}, ); diff --git a/src/components/Frame/README.md b/src/components/Frame/README.md index 2725d9c6c9c..bde4a651b9c 100644 --- a/src/components/Frame/README.md +++ b/src/components/Frame/README.md @@ -245,12 +245,14 @@ function FrameExample() { label="Full name" value={nameFieldValue} onChange={handleNameFieldChange} + autoComplete="name" /> @@ -292,11 +294,13 @@ function FrameExample() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> @@ -579,12 +583,14 @@ function FrameExample() { label="Full name" value={nameFieldValue} onChange={handleNameFieldChange} + autoComplete="name" /> @@ -626,11 +632,13 @@ function FrameExample() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> diff --git a/src/components/IndexTable/README.md b/src/components/IndexTable/README.md index 7c69e72bb47..46c3b1c75a0 100644 --- a/src/components/IndexTable/README.md +++ b/src/components/IndexTable/README.md @@ -603,6 +603,7 @@ function IndexTableWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -864,6 +865,7 @@ function IndexTableWithAllElementsExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1049,6 +1051,7 @@ function SmallScreenIndexTableWithAllElementsExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/Layout/README.md b/src/components/Layout/README.md index 4cb377c7ba3..bbbdee4a637 100644 --- a/src/components/Layout/README.md +++ b/src/components/Layout/README.md @@ -417,8 +417,13 @@ Use for settings pages. When settings are grouped thematically in annotated sect > - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> @@ -443,8 +448,13 @@ Use for settings pages that need a banner or other content at the top. > - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> diff --git a/src/components/ListBox/ListBox.scss b/src/components/ListBox/ListBox.scss new file mode 100644 index 00000000000..7355fd7b068 --- /dev/null +++ b/src/components/ListBox/ListBox.scss @@ -0,0 +1,10 @@ +.ListBox { + padding: 0; + margin: 0; + list-style: none; + max-width: 100%; + + &:focus { + outline: none; + } +} diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx new file mode 100644 index 00000000000..c2b062c6482 --- /dev/null +++ b/src/components/ListBox/ListBox.tsx @@ -0,0 +1,330 @@ +import React, { + useState, + useRef, + useEffect, + useCallback, + ReactNode, + useMemo, +} from 'react'; +import debounce from 'lodash/debounce'; + +import {classNames} from '../../utilities/css'; +import {useToggle} from '../../utilities/use-toggle'; +import {useUniqueId} from '../../utilities/unique-id'; +import {Key} from '../../types'; +import {KeypressListener} from '../KeypressListener'; +import {VisuallyHidden} from '../VisuallyHidden'; +import {useComboBoxListBox} from '../../utilities/combo-box'; +import {closestParentMatch} from '../../utilities/closest-parent-match'; +import {scrollIntoView} from '../../utilities/scroll-into-view'; +import {ListBoxContext, WithinListBoxContext} from '../../utilities/list-box'; +import type {NavigableOption} from '../../utilities/list-box'; + +import { + Option, + Section, + Header, + Action, + Loading, + TextOption, + listBoxSectionDataSelector, +} from './components'; +import styles from './ListBox.scss'; + +export interface ListBoxProps { + /** Inner content of the listbox */ + children: ReactNode; + /** Explicitly enable keyboard control */ + enableKeyboardControl?: boolean; + /** Visually hidden text for screen readers */ + accessibilityLabel?: string; + /** Callback when an option is selected */ + onSelect?(value: string): void; +} + +export type ArrowKeys = 'up' | 'down'; + +export const scrollable = { + props: {'data-polaris-scrollable': true}, + selector: '[data-polaris-scrollable]', +}; + +const LISTBOX_OPTION_SELECTOR = '[data-listbox-option]'; +const LISTBOX_OPTION_VALUE_ATTRIBUTE = 'data-listbox-option-value'; + +const DATA_ATTRIBUTE = 'data-focused'; + +export function ListBox({ + children, + enableKeyboardControl, + accessibilityLabel, + onSelect, +}: ListBoxProps) { + const listBoxClassName = classNames(styles.ListBox); + const { + value: keyboardEventsEnabled, + setTrue: enableKeyboardEvents, + setFalse: disableKeyboardEvents, + } = useToggle(Boolean(enableKeyboardControl)); + const listId = useUniqueId('ListBox'); + const scrollableRef = useRef(null); + const listBoxRef = useRef(null); + const [loading, setLoading] = useState(); + const [currentActiveOption, setCurrentActiveOption] = useState< + NavigableOption + >(); + const { + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + onKeyToBottom, + textFieldFocused, + } = useComboBoxListBox(); + + const inComboBox = Boolean(setActiveOptionId); + + useEffect(() => { + if (setListBoxId && !listBoxId) { + setListBoxId(listId); + } + }, [setListBoxId, listBoxId, listId]); + + useEffect(() => { + if (!currentActiveOption || !setActiveOptionId) return; + setActiveOptionId(currentActiveOption.domId); + }, [currentActiveOption, setActiveOptionId]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleScrollIntoView = useCallback( + debounce((option: NavigableOption, first: boolean) => { + if (scrollableRef.current) { + const {element} = option; + const focusTarget = first + ? closestParentMatch(element, listBoxSectionDataSelector.selector) || + element + : element; + + scrollIntoView(focusTarget, scrollableRef.current); + } + }, 15), + [], + ); + + const handleChangeActiveOption = useCallback( + (nextOption?: NavigableOption) => { + setCurrentActiveOption((currentActiveOption) => { + if (currentActiveOption) { + currentActiveOption.element.removeAttribute(DATA_ATTRIBUTE); + } + + if (nextOption) { + nextOption.element.setAttribute(DATA_ATTRIBUTE, 'true'); + if (scrollableRef.current) { + const first = + getNavigableOptions().findIndex( + (element) => element.id === nextOption.element.id, + ) === 0; + + handleScrollIntoView(nextOption, first); + } + return nextOption; + } else { + return undefined; + } + }); + }, + [handleScrollIntoView], + ); + + useEffect(() => { + if (listBoxRef.current) { + scrollableRef.current = listBoxRef.current.closest(scrollable.selector); + } + }, []); + + useEffect(() => { + if (enableKeyboardControl && !keyboardEventsEnabled) { + enableKeyboardEvents(); + } + }, [enableKeyboardControl, keyboardEventsEnabled, enableKeyboardEvents]); + + const onOptionSelect = useCallback( + (option: NavigableOption) => { + handleChangeActiveOption(option); + + if (onOptionSelected) { + onOptionSelected(); + } + if (onSelect) onSelect(option.value); + }, + [handleChangeActiveOption, onSelect, onOptionSelected], + ); + + const listBoxContext = useMemo( + () => ({ + onOptionSelect, + setLoading, + }), + [onOptionSelect], + ); + + function findNextValidOption(type: ArrowKeys) { + const isUp = type === 'up'; + const navItems = getNavigableOptions(); + let nextElement: HTMLElement | null | undefined = + currentActiveOption?.element; + let count = -1; + + while (count++ < navItems.length) { + let nextIndex; + if (nextElement) { + const currentId = nextElement?.id; + const currentIndex = navItems.findIndex( + (currentNavItem) => currentNavItem.id === currentId, + ); + + let increment = isUp ? -1 : 1; + if (currentIndex === 0 && isUp) { + increment = navItems.length - 1; + } else if (currentIndex === navItems.length - 1 && !isUp) { + increment = -(navItems.length - 1); + } + + nextIndex = currentIndex + increment; + nextElement = navItems[nextIndex]; + } else { + nextIndex = isUp ? navItems.length - 1 : 0; + nextElement = navItems[nextIndex]; + } + + if (nextElement?.getAttribute('aria-disabled') === 'true') continue; + + if (nextIndex === navItems.length - 1 && onKeyToBottom) { + onKeyToBottom(); + } + return nextElement; + } + + return null; + } + + function handleArrow(type: ArrowKeys, evt: KeyboardEvent) { + evt.preventDefault(); + + const nextValidElement = findNextValidOption(type); + + if (!nextValidElement) return; + + const nextOption = { + domId: nextValidElement.id, + value: + nextValidElement.getAttribute(LISTBOX_OPTION_VALUE_ATTRIBUTE) || '', + element: nextValidElement, + disabled: nextValidElement.getAttribute('aria-disabled') === 'true', + }; + + handleChangeActiveOption(nextOption); + } + + function handleDownArrow(evt: KeyboardEvent) { + handleArrow('down', evt); + } + + function handleUpArrow(evt: KeyboardEvent) { + handleArrow('up', evt); + } + + function handleEnter(evt: KeyboardEvent) { + evt.preventDefault(); + evt.stopPropagation(); + if (currentActiveOption) { + onOptionSelect(currentActiveOption); + } + } + + function handleFocus() { + if (enableKeyboardControl) return; + enableKeyboardEvents(); + } + + function handleBlur(event: React.FocusEvent) { + event.stopPropagation(); + if (keyboardEventsEnabled) { + handleChangeActiveOption(); + } + if (enableKeyboardControl) return; + disableKeyboardEvents(); + } + + const listeners = + keyboardEventsEnabled || textFieldFocused ? ( + <> + + + + + ) : null; + + return ( + <> + {listeners} + +
{loading ? loading : null}
+
+ + + {children ? ( +
    + {children} +
+ ) : null} +
+
+ + ); + + function getNavigableOptions() { + return [ + ...new Set( + listBoxRef.current?.querySelectorAll( + LISTBOX_OPTION_SELECTOR, + ), + ), + ]; + } +} + +ListBox.Option = Option; +ListBox.TextOption = TextOption; +ListBox.Loading = Loading; +ListBox.Section = Section; +ListBox.Header = Header; +ListBox.Action = Action; diff --git a/src/components/ListBox/README.md b/src/components/ListBox/README.md new file mode 100644 index 00000000000..c742a952b6c --- /dev/null +++ b/src/components/ListBox/README.md @@ -0,0 +1,180 @@ +--- +name: ListBox +category: Lists and tables +keywords: + - list + - listbox + - interactive list +--- + +# ListBox + +The `ListBox` component is a list component that implements part of the [Aria 1.2 ListBox specs](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). It presents a list of options and allows users to select one or more of them. If you need more structure than the standard component offers, use composition to customize the presentation of these lists by using headers or custom elements. + +--- + +## Best practices + +Listboxes should: + +- Be clearly labeled so it’s noticeable to the merchant what type of options will be available +- Limit the number of options displayed at once +- Indicate a loading state to the merchant while option data is being populated + +--- + +## Content guidelines + +### Option lists + +Each item in a `ListBox` should be clear and descriptive. + + + +#### Do + +- Traffic referrer source + +#### Don’t + +- Source + + + +--- + +## Examples + +### Basic ListBox + +Basic implementation of a control element used to let merchants select options + +```jsx +function BaseListBoxExample() { + return ( + + Item 1 + Item 2 + Item 3 + + ); +} +``` + +### ListBox with Loading + +Implementation of a control element showing a loading indicator to let merchants know more options are being loaded + +```jsx +function ListBoxWithLoadingExample() { + return ( + + Item 1 + Item 2 + Item 3 + + + ); +} +``` + +### ListBox with Action + +Implementation of a control element used to let merchants take an action + +```jsx +function ListBoxWithActionExample() { + return ( + + +
Add item
+
+ Item 1 + Item 2 +
+ ); +} +``` + +### ListBox with custom element + +Implementation of a control with custom rendering of options + +```jsx +function ListBoxWithCustomElementExample() { + return ( + + + Add item + + +
Item 1
+
+ +
Item 2
+
+ +
Item 3
+
+ +
+ ); +} +``` + +--- + +## Related components + +- For a text field and popover container, [use the combobox component](https://polaris.shopify.com/components/forms/combobox) +- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of ComboBox and ListBox. + +--- + +## Accessibility + + + +See Material Design and development documentation about accessibility for Android: + +- [Accessible design on Android](https://material.io/design/usability/accessibility.html) +- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/) + + + + + +See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS: + +- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/) +- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/) + + + + + +### Structure + +The `ListBox` component is based on the [Aria 1.2 ListBox pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). + +It is important to not present interactive elements inside of list box options as they can interfere with navigation +for assistive technology users. + + + +#### Do + +- Use labels + +#### Don’t + +- Use interactive elements inside the list + + + +### Keyboard support + +- Access the list of options with the up and down arrow keys +- Select an option that has focus with the enter/return key + + diff --git a/src/components/ListBox/components/Action/Action.scss b/src/components/ListBox/components/Action/Action.scss new file mode 100644 index 00000000000..4a896235601 --- /dev/null +++ b/src/components/ListBox/components/Action/Action.scss @@ -0,0 +1,10 @@ +@import '../../../../styles/common'; + +.Action { + display: flex; + flex: 1; +} + +.Icon { + padding-right: spacing(tight); +} diff --git a/src/components/ListBox/components/Action/Action.tsx b/src/components/ListBox/components/Action/Action.tsx new file mode 100644 index 00000000000..9494eb969fc --- /dev/null +++ b/src/components/ListBox/components/Action/Action.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {Icon} from '../../../Icon'; +import type {IconProps} from '../../../Icon'; +import {Option, OptionProps} from '../Option'; +import {TextOption} from '../TextOption'; + +import styles from './Action.scss'; + +interface ActionProps extends OptionProps { + icon?: IconProps['source']; +} + +export function Action(props: ActionProps) { + const {selected, disabled, children, icon} = props; + + const iconMarkup = icon && ( +
+ +
+ ); + + return ( + + ); +} diff --git a/src/components/ListBox/components/Action/index.ts b/src/components/ListBox/components/Action/index.ts new file mode 100644 index 00000000000..b9a68fea5ab --- /dev/null +++ b/src/components/ListBox/components/Action/index.ts @@ -0,0 +1 @@ +export {Action} from './Action'; diff --git a/src/components/ListBox/components/Action/tests/Action.test.tsx b/src/components/ListBox/components/Action/tests/Action.test.tsx new file mode 100644 index 00000000000..4f6cdbc6da1 --- /dev/null +++ b/src/components/ListBox/components/Action/tests/Action.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {CirclePlusMinor, AddMajor} from '@shopify/polaris-icons'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {Action} from '../Action'; +import {Option} from '../../Option'; +import {TextOption} from '../../TextOption'; +import {Icon} from '../../../../Icon'; + +describe('Action', () => { + const defaultProps = { + value: 'value', + selected: false, + disabled: false, + accessibilityLabel: 'accessibility label', + }; + + it('passes props to Option', () => { + const action = mountWithListBoxProvider(); + + expect(action).toContainReactComponent(Option, defaultProps); + }); + + it('passes select, disabled from props to text option', () => { + const action = mountWithListBoxProvider( + , + ); + + expect(action).toContainReactComponent(TextOption, { + selected: true, + disabled: true, + }); + }); + + it('does not renders a default Icon', () => { + const action = mountWithListBoxProvider(); + + expect(action).not.toContainReactComponent(Icon, { + source: CirclePlusMinor, + }); + }); + + it('renders the Icon from the prop', () => { + const action = mountWithListBoxProvider( + , + ); + + expect(action).toContainReactComponent(Icon, { + source: AddMajor, + }); + }); + + it('renders the children', () => { + const label = 'test label'; + const action = mountWithListBoxProvider( + {label}, + ); + + expect(action).toContainReactText(label); + }); +}); diff --git a/src/components/ListBox/components/Header/Header.scss b/src/components/ListBox/components/Header/Header.scss new file mode 100644 index 00000000000..ed0b9fa754d --- /dev/null +++ b/src/components/ListBox/components/Header/Header.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/common'; + +.Header { + @include text-style-subheading; + padding: spacing(tight) spacing(base); + color: var(--p-text-subdued); +} diff --git a/src/components/ListBox/components/Header/Header.tsx b/src/components/ListBox/components/Header/Header.tsx new file mode 100644 index 00000000000..a2b1418c3c7 --- /dev/null +++ b/src/components/ListBox/components/Header/Header.tsx @@ -0,0 +1,26 @@ +import React, {ReactNode} from 'react'; + +import {useSection} from '../Section'; + +import styles from './Header.scss'; + +interface HeaderProps { + children: ReactNode; +} + +export function Header({children}: HeaderProps) { + const sectionId = useSection() || ''; + + const content = + typeof children === 'string' ? ( +
{children}
+ ) : ( + children + ); + + return ( +
+ {content} +
+ ); +} diff --git a/src/components/ListBox/components/Header/index.ts b/src/components/ListBox/components/Header/index.ts new file mode 100644 index 00000000000..5e4d6a204ba --- /dev/null +++ b/src/components/ListBox/components/Header/index.ts @@ -0,0 +1 @@ +export {Header} from './Header'; diff --git a/src/components/ListBox/components/Header/tests/Header.test.tsx b/src/components/ListBox/components/Header/tests/Header.test.tsx new file mode 100644 index 00000000000..9a279ab43a1 --- /dev/null +++ b/src/components/ListBox/components/Header/tests/Header.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {Header} from '../Header'; +import {Section} from '../../Section'; + +jest.mock('../../Section', () => ({ + ...jest.requireActual('../../Section'), + useSection: jest.fn(), +})); + +describe('Header', () => { + afterEach(() => { + mockUseSection(''); + }); + + it('renders an element with aria hidden', () => { + const header = mountWithApp(
Header
); + + expect(header).toContainReactComponent('div', {'aria-hidden': true}); + }); + + it('renders string headers with standard styling', () => { + const header = mountWithApp(
Header
); + + expect(header).toContainReactComponent('div', {className: 'Header'}); + }); + + it('renders headers without default wrapper when not type string', () => { + const header = mountWithApp( +
+ +
, + ); + + expect(header).toContainReactComponent('button'); + expect(header).not.toContainReactComponent('div', {className: 'Header'}); + }); + + it('renders an element with id from Section', () => { + const id = 'mock-id'; + mockUseSection(id); + const section = mountWithApp(
Header} />); + + expect(section).toContainReactComponent('div', { + id, + }); + }); +}); + +function mockUseSection(id: string) { + const useSection: jest.Mock = jest.requireMock('../../Section').useSection; + + useSection.mockReturnValue(id); +} diff --git a/src/components/ListBox/components/Loading/Loading.scss b/src/components/ListBox/components/Loading/Loading.scss new file mode 100644 index 00000000000..7bc69025c17 --- /dev/null +++ b/src/components/ListBox/components/Loading/Loading.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/common'; + +$item-min-height: rem(40px); + +.ListItem { + padding: 0; + margin: 0; +} + +.Loading { + padding: spacing(tight) spacing(); + display: grid; + place-items: center; +} diff --git a/src/components/ListBox/components/Loading/Loading.tsx b/src/components/ListBox/components/Loading/Loading.tsx new file mode 100644 index 00000000000..1e0f43c8160 --- /dev/null +++ b/src/components/ListBox/components/Loading/Loading.tsx @@ -0,0 +1,37 @@ +import React, {memo, useEffect} from 'react'; + +import {Spinner} from '../../../Spinner'; +import {useListBox} from '../../../../utilities/list-box'; + +import styles from './Loading.scss'; + +export interface LoadingProps { + children?: React.ReactNode; + accessibilityLabel: string; +} + +export const Loading = memo(function LoadingOption({ + children, + accessibilityLabel: label, +}: LoadingProps) { + const {setLoading} = useListBox(); + + useEffect(() => { + setLoading(label); + return () => { + setLoading(undefined); + }; + }, [label, setLoading]); + + return ( +
  • + {children ? ( + children + ) : ( +
    + +
    + )} +
  • + ); +}); diff --git a/src/components/ListBox/components/Loading/index.ts b/src/components/ListBox/components/Loading/index.ts new file mode 100644 index 00000000000..b32e0f6f8fd --- /dev/null +++ b/src/components/ListBox/components/Loading/index.ts @@ -0,0 +1 @@ +export {Loading} from './Loading'; diff --git a/src/components/ListBox/components/Loading/tests/Loading.test.tsx b/src/components/ListBox/components/Loading/tests/Loading.test.tsx new file mode 100644 index 00000000000..7e56192d8c1 --- /dev/null +++ b/src/components/ListBox/components/Loading/tests/Loading.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {ListBoxContext} from '../../../../../utilities/list-box'; +import {Loading} from '../Loading'; +import {Spinner} from '../../../../Spinner'; + +const listBoxContext = { + addNavigableOption: noop, + updateNavigableOption: noop, + removeNavigableOption: noop, + onOptionSelect: noop, + setLoading: noop, +}; + +describe('Loading', () => { + const defaultProps = {accessibilityLabel: 'accessibility label'}; + + it('throws if not inside a listBox Context', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => mountWithApp()).toThrow( + 'No ListBox was provided. ListBox components must be wrapped in a Listbox', + ); + + consoleErrorSpy.mockRestore(); + }); + + it('renders children instead of default spinner when passed', () => { + const accessibilityLabel = 'label'; + const customLoadingState = 'customLoadingState'; + const loading = mountWithApp( + + +
    {customLoadingState}
    +
    +
    , + ); + + expect(loading).toContainReactComponent('div', { + children: customLoadingState, + }); + expect(loading).not.toContainReactComponent(Spinner); + }); + + it('calls setLoading on context with the default loading text if there is no accessibilityLabel', () => { + const accessibilityLabel = 'label'; + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + mountWithApp( + + + , + ); + + expect(setLoadingSpy).toHaveBeenCalledWith(accessibilityLabel); + }); + + it('calls setLoading on context with the accessibilityLabel', () => { + const accessibilityLabel = 'label'; + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + mountWithApp( + + + , + ); + + expect(setLoadingSpy).toHaveBeenCalledWith(accessibilityLabel); + }); + + it('calls setLoading with undefined when it unmounts', () => { + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + const listbox = mountWithApp( + + + , + ); + + listbox.find(Loading)!.root.unmount(); + + expect(setLoadingSpy).toHaveBeenCalledWith(undefined); + }); +}); + +function noop() {} diff --git a/src/components/ListBox/components/Option/Option.scss b/src/components/ListBox/components/Option/Option.scss new file mode 100644 index 00000000000..4a033a3785a --- /dev/null +++ b/src/components/ListBox/components/Option/Option.scss @@ -0,0 +1,15 @@ +@import '../../../../styles/common'; + +.Option { + display: flex; + margin: 0; + padding: 0; + + &:focus { + outline: none; + } +} + +.divider { + border-bottom: border('divider'); +} diff --git a/src/components/ListBox/components/Option/Option.tsx b/src/components/ListBox/components/Option/Option.tsx new file mode 100644 index 00000000000..d8a6b0d2e04 --- /dev/null +++ b/src/components/ListBox/components/Option/Option.tsx @@ -0,0 +1,110 @@ +import React, {useRef, useCallback, memo, useContext} from 'react'; + +import {classNames} from '../../../../utilities/css'; +import {useUniqueId} from '../../../../utilities/unique-id'; +import {useListBox} from '../../../../utilities/list-box'; +import {useSection, listBoxWithinSectionDataSelector} from '../Section'; +import {TextOption} from '../TextOption'; +import {UnstyledLink} from '../../../UnstyledLink'; +import {MappedActionContext} from '../../../../utilities/autocomplete'; + +import styles from './Option.scss'; + +export interface OptionProps { + // Unique item value + value: string; + // Visually hidden text for screen readers + accessibilityLabel?: string; + // Children. When a string, children are rendered in a styled TextOption + children?: string | React.ReactNode; + // Option is selected + selected?: boolean; + // Option is disabled + disabled?: boolean; + // Adds a border-bottom to the Option + divider?: boolean; +} + +export const Option = memo(function Option({ + value, + children, + selected, + disabled = false, + accessibilityLabel, + divider, +}: OptionProps) { + const {onOptionSelect} = useListBox(); + const {role, url, external, onAction, destructive, isAction} = useContext( + MappedActionContext, + ); + const listItemRef = useRef(null); + const domId = useUniqueId('ListBoxOption'); + const sectionId = useSection(); + const isWithinSection = Boolean(sectionId); + + const handleOptionClick = useCallback( + (evt: React.MouseEvent) => { + evt.preventDefault(); + onAction && onAction(); + if (onOptionSelect && listItemRef.current && !isAction) { + onOptionSelect({ + domId, + value, + element: listItemRef.current, + disabled, + }); + } + }, + [domId, onOptionSelect, value, disabled, onAction, isAction], + ); + + // prevents lost of focus on Textfield + const handleMouseDown = (evt: React.MouseEvent) => { + evt.preventDefault(); + }; + + const content = + typeof children === 'string' ? ( + + {children} + + ) : ( + children + ); + + const sectionAttributes = { + [listBoxWithinSectionDataSelector.attribute]: isWithinSection, + }; + + const legacyRoleSupport = role || 'option'; + + const contentMarkup = url ? ( + + {content} + + ) : ( + content + ); + + return ( +
  • + {contentMarkup} +
  • + ); +}); diff --git a/src/components/ListBox/components/Option/index.ts b/src/components/ListBox/components/Option/index.ts new file mode 100644 index 00000000000..e149829a484 --- /dev/null +++ b/src/components/ListBox/components/Option/index.ts @@ -0,0 +1 @@ +export * from './Option'; diff --git a/src/components/ListBox/components/Option/tests/Option.test.tsx b/src/components/ListBox/components/Option/tests/Option.test.tsx new file mode 100644 index 00000000000..4459b816904 --- /dev/null +++ b/src/components/ListBox/components/Option/tests/Option.test.tsx @@ -0,0 +1,380 @@ +import React from 'react'; +import {mount} from 'test-utilities'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import type {ListBoxContext} from '../../../../../utilities/list-box'; +import {Option} from '../Option'; +import {TextOption} from '../../TextOption'; +import {MappedActionContext} from '../../../../../utilities/autocomplete'; +import {UnstyledLink} from '../../../../UnstyledLink'; + +jest.mock('components', () => ({ + ...jest.requireActual('components'), + Icon() { + return null; + }, +})); + +const defaultProps = { + accessibilityLabel: 'label', + value: 'value', +}; + +const defaultContext: React.ContextType = { + onOptionSelect: noop, + setLoading: noop, +}; + +describe('Option', () => { + it("throws when the Option does not have 'ListBoxContext'", () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const attemptMount = () => { + mount(
    diff --git a/src/components/ResourceList/README.md b/src/components/ResourceList/README.md index 0dfbc97453e..4a8baa5eb01 100644 --- a/src/components/ResourceList/README.md +++ b/src/components/ResourceList/README.md @@ -595,6 +595,7 @@ function ResourceListWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -710,6 +711,7 @@ function ResourceListWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1094,6 +1096,7 @@ function ResourceListExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/ResourceList/components/FilterControl/FilterControl.tsx b/src/components/ResourceList/components/FilterControl/FilterControl.tsx index e88283077ad..ed5d4808b15 100644 --- a/src/components/ResourceList/components/FilterControl/FilterControl.tsx +++ b/src/components/ResourceList/components/FilterControl/FilterControl.tsx @@ -159,6 +159,7 @@ export function FilterControl({ onBlur={onSearchBlur} focused={focused} disabled={selectMode} + autoComplete="off" /> {appliedFiltersWrapper} diff --git a/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx b/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx index df2a817bead..e4f99bf8dc3 100644 --- a/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx +++ b/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx @@ -202,7 +202,7 @@ export const DateSelector = memo(function DateSelector({ value={dateTextFieldValue} error={userInputDateError} prefix={} - autoComplete={false} + autoComplete="off" onChange={handleDateFieldChange} onBlur={handleDateBlur} /> diff --git a/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx b/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx index ef0c53bf5b0..e07e467b5d8 100644 --- a/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx +++ b/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx @@ -86,6 +86,7 @@ export function FilterValueSelector({ value={value} type={filter.textFieldType} onChange={onChange} + autoComplete="off" /> ); diff --git a/src/components/Select/README.md b/src/components/Select/README.md index 778f35b1e93..c2c8baa41c3 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -294,6 +294,7 @@ function SeparateValidationErrorExample() { value={weight} onChange={handleWeightChange} error={Boolean(!weight && unit)} + autoComplete="off" /> ); } @@ -400,6 +451,7 @@ function RightAlignExample() { labelHidden value={textFieldValue} onChange={handleTextFieldChange} + autoComplete="off" align="right" /> @@ -426,6 +478,7 @@ function PlaceholderExample() { value={textFieldValue} onChange={handleTextFieldChange} placeholder="Example: North America, Europe" + autoComplete="off" /> ); } @@ -465,6 +518,7 @@ function HelpTextExample() { value={textFieldValue} onChange={handleTextFieldChange} helpText="We’ll use this address if we need to contact you about your account." + autoComplete="email" /> ); } @@ -505,6 +559,7 @@ function PrefixExample() { value={textFieldValue} onChange={handleTextFieldChange} prefix="$" + autoComplete="off" /> ); } @@ -550,6 +605,7 @@ function ConnectedFieldsExample() { type="number" value={textFieldValue} onChange={handleTextFieldChange} + autoComplete="off" connectedLeft={ `onClick`', () => { const textField = mountWithApp( - , + , ); expect(document.activeElement).not.toBe(textField.find('input')!.domNode); @@ -1062,6 +1166,7 @@ describe('', () => { onChange={noop} type="text" value="test value" + autoComplete="off" clearButton />, ); @@ -1075,6 +1180,7 @@ describe('', () => { label="TextField" type="text" onChange={noop} + autoComplete="off" clearButton />, ); @@ -1094,6 +1200,7 @@ describe('', () => { onChange={noop} onClearButtonClick={spy} value="test value" + autoComplete="off" clearButton />, ); @@ -1109,6 +1216,7 @@ describe('', () => { onChange={noop} type="text" value="test value" + autoComplete="off" />, ); expect(findByTestID(textField, 'clearButton').exists()).toBeFalsy(); @@ -1121,6 +1229,7 @@ describe('', () => { onChange={noop} connectedLeft={
    } connectedRight={
    } + autoComplete="off" />, ); expect(textField).toContainReactComponent('div', { @@ -1132,7 +1241,12 @@ describe('', () => { describe('requiredIndicator', () => { it('passes requiredIndicator prop to Labelled', () => { const element = mountWithAppProvider( - , + , ); const labelled = element.find(Labelled); @@ -1143,14 +1257,24 @@ describe('', () => { describe('monospaced', () => { it('passes monospaced prop to TextField', () => { const element = mountWithAppProvider( - , + , ); expect(element.prop('monospaced')).toBe(true); }); it('applies the monospaced style', () => { const input = mountWithAppProvider( - , + , ).find('input'); expect(input.prop('className')).toContain('monospaced'); diff --git a/src/components/Tooltip/README.md b/src/components/Tooltip/README.md index e8b9cb81071..e91a2bc8490 100644 --- a/src/components/Tooltip/README.md +++ b/src/components/Tooltip/README.md @@ -94,7 +94,7 @@ Use when the tooltip overlays interactive elements when active, for example a fo - +
    ``` diff --git a/src/components/TrapFocus/tests/TrapFocus.test.tsx b/src/components/TrapFocus/tests/TrapFocus.test.tsx index 9d4711508d3..0ab42aecee8 100644 --- a/src/components/TrapFocus/tests/TrapFocus.test.tsx +++ b/src/components/TrapFocus/tests/TrapFocus.test.tsx @@ -113,7 +113,13 @@ describe('', () => { const trapFocus = mountWithApp( - + , ); @@ -124,7 +130,7 @@ describe('', () => { const trapFocus = mountWithApp( - + , ); @@ -159,7 +165,13 @@ describe('', () => { it('allows default when trapping is false', () => { const trapFocus = mountWithApp( - + , ); @@ -175,7 +187,13 @@ describe('', () => { it('allows default when the related target is a child', () => { const trapFocus = mountWithApp( - + , ); diff --git a/src/components/VisuallyHidden/README.md b/src/components/VisuallyHidden/README.md index bf47e597366..a7c39d5d539 100644 --- a/src/components/VisuallyHidden/README.md +++ b/src/components/VisuallyHidden/README.md @@ -51,8 +51,14 @@ Always provide a heading for a major page section such as a card. In rare cases label="Title" value="Artisanal Wooden Spoon" onChange={() => {}} + autoComplete="off" + /> + {}} + autoComplete="off" /> - {}} /> ``` diff --git a/src/components/index.ts b/src/components/index.ts index 5d14b8e5200..4eb04d7be2f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -67,6 +67,9 @@ export type {CollapsibleProps} from './Collapsible'; export {ColorPicker} from './ColorPicker'; export type {ColorPickerProps} from './ColorPicker'; +export {ComboBox} from './ComboBox'; +export type {ComboBoxProps} from './ComboBox'; + export {Connected} from './Connected'; export type {ConnectedProps} from './Connected'; @@ -174,6 +177,9 @@ export type {LinkProps} from './Link'; export {List} from './List'; export type {ListProps} from './List'; +export {ListBox} from './ListBox'; +export type {ListBoxProps} from './ListBox'; + export {Loading} from './Loading'; export type {LoadingProps} from './Loading'; diff --git a/src/test-utilities/list-box.tsx b/src/test-utilities/list-box.tsx new file mode 100644 index 00000000000..41e94254b43 --- /dev/null +++ b/src/test-utilities/list-box.tsx @@ -0,0 +1,59 @@ +import React, {ReactElement} from 'react'; +import {createMount} from '@shopify/react-testing'; + +import {PolarisTestProvider} from '../components'; +import {ListBoxContext} from '../utilities/list-box'; +import { + ComboBoxListBoxContext, + ComboBoxListBoxType, +} from '../utilities/combo-box'; +import translations from '../../locales/en.json'; + +import {mountWithApp} from './react-testing'; + +const defaultContext: React.ContextType = { + onOptionSelect: noop, + setLoading: noop, +}; + +export function mountWithListBoxProvider( + element: React.ReactElement, + context: React.ContextType = defaultContext, +) { + return createMount({ + context: () => { + return {context}; + }, + render(element: React.ReactElement) { + return ( + + + {element} + + + ); + }, + })(element); +} + +export function mountWithComboBoxListContext( + listbox: ReactElement, + context: ComboBoxListBoxType = {}, +) { + const comboxBox = mountWithApp( + null, + ...context, + }} + > + {listbox} + , + ); + return comboxBox; +} + +function noop() {} diff --git a/src/test-utilities/react-testing.tsx b/src/test-utilities/react-testing.tsx index c4142e30cc6..2180ce4e9a9 100644 --- a/src/test-utilities/react-testing.tsx +++ b/src/test-utilities/react-testing.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import {createMount, mount} from '@shopify/react-testing'; +import { + createMount, + mount, + Element as ReactTestingElement, + CustomRoot, +} from '@shopify/react-testing'; import translations from '../../locales/en.json'; import { @@ -7,7 +12,7 @@ import { WithPolarisTestProviderOptions, } from '../components'; -export {createMount, mount}; +export {createMount, mount, ReactTestingElement, CustomRoot}; export const mountWithApp = createMount< WithPolarisTestProviderOptions, diff --git a/src/types.ts b/src/types.ts index b4d67846585..3a7dfdf1d9a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -306,3 +306,5 @@ export interface CheckboxHandles { } export type NonEmptyArray = [T, ...T[]]; + +export type ArrayElement = T extends (infer U)[] ? U : never; diff --git a/src/utilities/autocomplete/context.ts b/src/utilities/autocomplete/context.ts new file mode 100644 index 00000000000..0030be0b1f3 --- /dev/null +++ b/src/utilities/autocomplete/context.ts @@ -0,0 +1,14 @@ +import {createContext} from 'react'; + +interface MappedActionContextType { + role?: string; + url?: string; + external?: boolean; + onAction?(): void; + destructive?: boolean; + isAction: boolean; +} + +export const MappedActionContext = createContext({ + isAction: false, +}); diff --git a/src/utilities/autocomplete/index.ts b/src/utilities/autocomplete/index.ts new file mode 100644 index 00000000000..c38e8e82152 --- /dev/null +++ b/src/utilities/autocomplete/index.ts @@ -0,0 +1 @@ +export * from './context'; diff --git a/src/utilities/closest-parent-match.ts b/src/utilities/closest-parent-match.ts new file mode 100644 index 00000000000..702e67ca3e7 --- /dev/null +++ b/src/utilities/closest-parent-match.ts @@ -0,0 +1,10 @@ +export function closestParentMatch(element: HTMLElement, matcher: string) { + let parent = element.parentElement; + + while (parent) { + if (parent.matches(matcher)) return parent; + parent = parent.parentElement; + } + + return parent; +} diff --git a/src/utilities/combo-box/context.tsx b/src/utilities/combo-box/context.tsx new file mode 100644 index 00000000000..ebc77cda240 --- /dev/null +++ b/src/utilities/combo-box/context.tsx @@ -0,0 +1,52 @@ +import {createContext} from 'react'; + +export interface ComboBoxTextFieldType { + // Value for the TextField aria-activedescendant. (also on list context when not in combobox) + activeOptionId?: string; + // Value for the ComboBox aria-owns and TextField aria-control + listBoxId?: string; + // Value for aria-expanded on TextField + expanded?: boolean; + // Sets the value for the ListBox aria-labelledby + setTextFieldLabelId?(id: string): void; + // Sets a boolean to enable/disable keyboard control for the ListBox + setTextFieldFocused?(value: boolean): void; + // Callback when TextField is focused + onTextFieldFocus?(): void; + // Callback when TextField is blured + onTextFieldBlur?(): void; + // Callback when TextField is changed + onTextFieldChange?(): void; +} + +export interface ComboBoxListBoxType { + // Value of the Texfields ID for listBox aria-labelledby + textFieldLabelId?: string; + // Enables/disables keyboard control + textFieldFocused?: boolean; + // Sets the value for the TextFields aria-activedescendant. + setActiveOptionId?(id: string): void; + // Sets the value of the listBoxId use for the ComboBox aria-owns and TextField aria-control + setListBoxId?(id: string): void; + // Value of listBoxId to avoid calling setListBoxId + listBoxId?: string; + // Handler used in ComboBox to brings to manage popover state and focus based on multi or single select + onOptionSelected?(): void; + // Callback to onScrolledToBottom when using keyboard navigation navigates to the last item + onKeyToBottom?(): void; +} + +export interface ComboBoxListBoxOptionType { + // Whether the option should visually support multiple selection + allowMultiple?: boolean; +} + +export const ComboBoxTextFieldContext = createContext< + ComboBoxTextFieldType | undefined +>(undefined); + +export const ComboBoxListBoxContext = createContext({}); + +export const ComboBoxListBoxOptionContext = createContext< + ComboBoxListBoxOptionType +>({}); diff --git a/src/utilities/combo-box/hooks.tsx b/src/utilities/combo-box/hooks.tsx new file mode 100644 index 00000000000..7df1da73fb1 --- /dev/null +++ b/src/utilities/combo-box/hooks.tsx @@ -0,0 +1,18 @@ +import {useContext} from 'react'; + +import {ComboBoxTextFieldContext, ComboBoxListBoxContext} from './context'; + +export function useComboBoxTextField() { + const context = useContext(ComboBoxTextFieldContext); + if (!context) { + throw new Error( + 'No ComboBox was provided. Your component must be wrapped in a component.', + ); + } + return context; +} + +export function useComboBoxListBox() { + const context = useContext(ComboBoxListBoxContext); + return context; +} diff --git a/src/utilities/combo-box/index.ts b/src/utilities/combo-box/index.ts new file mode 100644 index 00000000000..472021f7e91 --- /dev/null +++ b/src/utilities/combo-box/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './hooks'; diff --git a/src/utilities/combo-box/tests/hook.test.tsx b/src/utilities/combo-box/tests/hook.test.tsx new file mode 100644 index 00000000000..627407c5497 --- /dev/null +++ b/src/utilities/combo-box/tests/hook.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {useComboBoxTextField} from '../hooks'; +import {ComboBoxTextFieldContext} from '../context'; + +function TextFieldComponent() { + const textFieldContext = useComboBoxTextField(); + + return textFieldContext ?
    : null; +} + +describe('textFieldContent', () => { + it('throws if not wrapped in ComboBoxTextFieldContext', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => mountWithApp()).toThrow( + 'No ComboBox was provided. Your component must be wrapped in a component.', + ); + + consoleErrorSpy.mockRestore(); + }); + + it('does not throw if wrapped in a ComboBoxTextFieldContext provide', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + mountWithApp( + + + , + ), + ).not.toThrow('No ComboBox was provided.'); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/utilities/list-box/context.ts b/src/utilities/list-box/context.ts new file mode 100644 index 00000000000..bbdc5478079 --- /dev/null +++ b/src/utilities/list-box/context.ts @@ -0,0 +1,14 @@ +import {createContext} from 'react'; + +import type {NavigableOption} from './types'; + +export interface ListBoxContextType { + onOptionSelect(option: NavigableOption): void; + setLoading(label?: string): void; +} + +export const ListBoxContext = createContext( + undefined, +); + +export const WithinListBoxContext = createContext(false); diff --git a/src/utilities/list-box/hooks.ts b/src/utilities/list-box/hooks.ts new file mode 100644 index 00000000000..03d9346147b --- /dev/null +++ b/src/utilities/list-box/hooks.ts @@ -0,0 +1,15 @@ +import {useContext} from 'react'; + +import {ListBoxContext} from './context'; + +export function useListBox() { + const listBox = useContext(ListBoxContext); + + if (!listBox) { + throw new Error( + 'No ListBox was provided. ListBox components must be wrapped in a Listbox', + ); + } + + return listBox; +} diff --git a/src/utilities/list-box/index.ts b/src/utilities/list-box/index.ts new file mode 100644 index 00000000000..a9fae143c8a --- /dev/null +++ b/src/utilities/list-box/index.ts @@ -0,0 +1,3 @@ +export * from './context'; +export * from './hooks'; +export * from './types'; diff --git a/src/utilities/list-box/types.ts b/src/utilities/list-box/types.ts new file mode 100644 index 00000000000..3681afb885c --- /dev/null +++ b/src/utilities/list-box/types.ts @@ -0,0 +1,6 @@ +export interface NavigableOption { + domId: string; + value: string; + element: HTMLElement; + disabled: boolean; +} diff --git a/src/utilities/scroll-into-view.ts b/src/utilities/scroll-into-view.ts new file mode 100644 index 00000000000..8b6e94850d4 --- /dev/null +++ b/src/utilities/scroll-into-view.ts @@ -0,0 +1,8 @@ +export function scrollIntoView(element: HTMLElement, container: HTMLElement) { + requestAnimationFrame(() => { + if (element) { + const offset = element.offsetTop - container.scrollTop; + container.scrollBy({top: offset}); + } + }); +} diff --git a/src/utilities/tests/closest-parent-match.test.ts b/src/utilities/tests/closest-parent-match.test.ts new file mode 100644 index 00000000000..b23862a0225 --- /dev/null +++ b/src/utilities/tests/closest-parent-match.test.ts @@ -0,0 +1,30 @@ +import {closestParentMatch} from '../closest-parent-match'; + +describe('closest-parent-match', () => { + it('matches first parent element', () => { + const {parent, child, matcher} = setUpParentAndChild(); + const closestParent = closestParentMatch(child, matcher); + + expect(parent).toBe(closestParent); + }); + + it('matches nested elements', () => { + const {parent, nestedChild, matcher} = setUpParentAndChild(); + const closestParent = closestParentMatch(nestedChild, matcher); + + expect(parent).toBe(closestParent); + }); +}); + +function setUpParentAndChild() { + const id = 'parent'; + + const parent = document.createElement('div'); + const child = document.createElement('div'); + const nestedChild = document.createElement('div'); + + parent.id = id; + parent.append(child, nestedChild); + + return {parent, child, nestedChild, matcher: `#${id}`}; +} diff --git a/tests/setup.ts b/tests/setup.ts index c5b090306d2..bf38c4557c6 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -18,9 +18,7 @@ const IGNORE_ERROR_REGEXES = [ /React does not recognize the `%s` prop on a DOM element/, ]; -const IGNORE_WARN_REGEXES: RegExp[] = [ - /Deprecation: is deprecated\. This is a private component, do not use it\. This component might be removed in a minor version update\. Use instead\./, -]; +const IGNORE_WARN_REGEXES: RegExp[] = [/Deprecation:.*/]; // eslint-disable-next-line no-console const originalConsoleError = console.error.bind(console); diff --git a/yarn.lock b/yarn.lock index 06e9a760a2c..feebd541922 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4038,12 +4038,12 @@ dependencies: "@types/react" "*" -"@types/react-dom@^16.9.4": - version "16.9.4" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.4.tgz#0b58df09a60961dcb77f62d4f1832427513420df" - integrity sha512-fya9xteU/n90tda0s+FtN5Ym4tbgxpq/hb/Af24dvs6uYnYn+fspaxw5USlw0R8apDNwxsqumdRoCoKitckQqw== +"@types/react-dom@^16.9.13": + version "16.9.13" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.13.tgz#5898f0ee68fe200685e6b61d3d7d8828692814d0" + integrity sha512-34Hr3XnmUSJbUVDxIw/e7dhQn2BJZhJmlAaPyPwfTQyuVS9mV/CeyghFcXyvkJXxI7notQJz8mF8FeCVvloJrA== dependencies: - "@types/react" "*" + "@types/react" "^16" "@types/react-syntax-highlighter@11.0.4": version "11.0.4" @@ -4066,13 +4066,23 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.9.12": - version "16.9.16" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.16.tgz#4f12515707148b1f53a8eaa4341dae5dfefb066d" - integrity sha512-dQ3wlehuBbYlfvRXfF5G+5TbZF3xqgkikK7DWAsQXe2KnzV+kjD4W2ea+ThCrKASZn9h98bjjPzoTYzfRqyBkw== +"@types/react@*": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451" + integrity sha512-yFRQbD+whVonItSk7ZzP/L+gPTJVBkL/7shLEF+i9GC/1cV3JmUxEQz6+9ylhUpWSDuqo1N9qEvqS6vTj4USUA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/react@^16", "@types/react@^16.14.8": + version "16.14.8" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.8.tgz#4aee3ab004cb98451917c9b7ada3c7d7e52db3fe" + integrity sha512-QN0/Qhmx+l4moe7WJuTxNiTsjBwlBGHqKGvInSQCBdo7Qio0VtOqwsC0Wq7q3PbJlB0cR4Y4CVo1OOe6BOsOmA== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + "@types/scheduler" "*" + csstype "^3.0.2" "@types/resolve@0.0.8": version "0.0.8" @@ -4088,6 +4098,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + "@types/scss-parser@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/scss-parser/-/scss-parser-1.0.0.tgz#1f2a69880e475e5d31ed11e0b1c1eab22752935a" @@ -6307,15 +6322,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001010, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135: - version "1.0.30001151" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001151.tgz#1ddfde5e6fff02aad7940b4edb7d3ac76b0cb00b" - integrity sha512-Zh3sHqskX6mHNrqUerh+fkf0N72cMxrmflzje/JyVImfpknscMnkeJrlFGJcqTmaa0iszdYptGpWMJCRQDkBVw== - -caniuse-lite@^1.0.30001173: - version "1.0.30001177" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001177.tgz#2c3b384933aafda03e29ccca7bb3d8c3389e1ece" - integrity sha512-6Ld7t3ifCL02jTj3MxPMM5wAYjbo4h/TAQGFTgv1inihP1tWnWp8mxxT4ut4JBEHLbpFXEXJJQ119JCJTBkYDw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001010, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001173: + version "1.0.30001243" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz#d9250155c91e872186671c523f3ae50cfc94a3aa" + integrity sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA== capital-case@^1.0.3: version "1.0.3" @@ -7474,11 +7484,16 @@ cssstyle@^2.0.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.5.7, csstype@^2.6.7: version "2.6.11" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.11.tgz#452f4d024149ecf260a852b025e36562a253ffc5" integrity sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw== +csstype@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" + integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -15911,15 +15926,15 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.8.3, react-dom@^16.9.0: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.9.0.tgz#5e65527a5e26f22ae3701131bcccaee9fb0d3962" - integrity sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ== +react-dom@^16.14.0, react-dom@^16.8.3: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.15.0" + scheduler "^0.19.1" react-draggable@^4.0.3: version "4.0.3" @@ -16012,12 +16027,12 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.12.0: +react-is@^16.12.0, react-is@^16.7.2: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: version "16.9.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw== @@ -16138,10 +16153,10 @@ react-syntax-highlighter@^13.5.0: prismjs "^1.21.0" refractor "^3.1.0" -react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" - integrity sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ== +react-test-renderer@^16.0.0-0, react-test-renderer@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" + integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" @@ -16185,10 +16200,10 @@ react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react@^16.8.3, react@^16.9.0: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.9.0.tgz#40ba2f9af13bc1a38d75dbf2f4359a5185c4f7aa" - integrity sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w== +react@^16.14.0, react@^16.8.3: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -17028,14 +17043,6 @@ scheduler@^0.13.6: loose-envify "^1.1.0" object-assign "^4.1.1" -scheduler@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.15.0.tgz#6bfcf80ff850b280fed4aeecc6513bc0b4f17f8e" - integrity sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - scheduler@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"