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 2ec5de018c5..5def179668c 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -7,6 +7,8 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - 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 - 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 @@ -33,4 +35,6 @@ 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)) + ### Deprecations diff --git a/locales/en.json b/locales/en.json index 5d7fc27fa14..4f2163cde11 100644 --- a/locales/en.json +++ b/locales/en.json @@ -8,7 +8,8 @@ "labelWithInitials": "Avatar with initials {initials}" }, "Autocomplete": { - "spinnerAccessibilityLabel": "Loading" + "spinnerAccessibilityLabel": "Loading", + "ellipsis": "{content}…" }, "Badge": { "PROGRESS_LABELS": { diff --git a/package.json b/package.json index a26d5884ba9..b139a8b4090 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ { "name": "esm", "path": "dist/esm/index.js", - "limit": "100 kB" + "limit": "105 kB" }, { "name": "esnext", 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 44e5be9514b..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 880fe3513da..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 95f141d0988..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(ComboBox.TextField) + ?.find(TextField) + ?.trigger('onFocus'); autocomplete - .find(Autocomplete) - .simulate('keyup', {keyCode: Key.DownArrow}); - autocomplete.find(Autocomplete).simulate('keyDown', {keyCode: Key.Enter}); - expect(spy).not.toHaveBeenCalled(); + .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); }); @@ -155,18 +495,19 @@ describe('', () => { ); } - - 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}); - } - } }); + +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/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/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(