diff --git a/pages/autosuggest/search.page.tsx b/pages/autosuggest/search.page.tsx new file mode 100644 index 0000000000..2b597e2ef3 --- /dev/null +++ b/pages/autosuggest/search.page.tsx @@ -0,0 +1,150 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext, useState } from 'react'; + +import { Badge, Box, Checkbox, SpaceBetween } from '~components'; +import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +type PageContext = React.Context< + AppContextType<{ + empty?: boolean; + hideEnteredTextOption?: boolean; + showMatchesCount?: boolean; + }> +>; + +const options: AutosuggestProps.Option[] = [ + { value: '_orange_', label: 'Orange', tags: ['sweet'] }, + { value: '_banana_', label: 'Banana', tags: ['sweet'] }, + { value: '_apple_', label: 'Apple' }, + { value: '_sweet_apple_', label: 'Apple (sweet)', tags: ['sweet'] }, + { value: '_pineapple_', label: 'Pineapple XL', description: 'pine+apple' }, +]; +const enteredTextLabel = (value: string) => `Search for: "${value}"`; + +// This performs a simple fuzzy-search to illustrate how options order can change when searching, +// which can be helpful to increase the search quality. +function findMatchedOptions(options: AutosuggestProps.Option[], searchText: string) { + searchText = searchText.toLowerCase(); + + const getOptionMatchScore = (option: AutosuggestProps.Option) => [ + getPropertyMatchScore(option.label), + getPropertyMatchScore(option.description), + getPropertyMatchScore((option.tags ?? []).join(' ')), + ]; + + const getPropertyMatchScore = (property = '') => { + property = property.toLowerCase(); + return property.indexOf(searchText) === -1 + ? Number.MAX_VALUE + : property.indexOf(searchText) + (property.length - searchText.length); + }; + + return ( + [...options] + // Remove not matched. + .filter(o => getOptionMatchScore(o).some(score => score !== Number.MAX_VALUE)) + // Sort the rest by best match using fuzzy-search with priorities. + .sort((a, b) => { + const aScore = getOptionMatchScore(a); + const bScore = getOptionMatchScore(b); + for (let index = 0; index < Math.min(aScore.length, bScore.length); index++) { + if (aScore[index] !== bScore[index]) { + return aScore[index] - bScore[index]; + } + } + return 0; + }) + ); +} + +export default function AutosuggestPage() { + const { + urlParams: { empty = false, hideEnteredTextOption = true, showMatchesCount = true }, + setUrlParams, + } = useContext(AppContext as PageContext); + const [searchText, setSearchText] = useState(''); + const [selection, setSelection] = useState(null); + const matchedOptions = findMatchedOptions(options, searchText); + + // The entered text option indicates that the search text is selectable either from the options dropdown + // or by pressing Enter. This can be used e.g. to navigate the user to a search page. + const onSelectWithFreeSearch: AutosuggestProps['onSelect'] = ({ detail }) => { + if (detail.selectedOption) { + setSelection(detail.selectedOption); + setSearchText(''); + } else { + setSelection(detail.value); + setSearchText(''); + } + }; + + // When the search text is not selectable, pressing Enter from the input can be used to select the best + // matched (first) option instead. + const onSelectWithAutoMatch: AutosuggestProps['onSelect'] = ({ detail }) => { + const selectedOption = detail.selectedOption ?? matchedOptions[0]; + if (selectedOption) { + setSelection(selectedOption); + setSearchText(''); + } + }; + + const onSelect = hideEnteredTextOption ? onSelectWithAutoMatch : onSelectWithFreeSearch; + + return ( + + + + setUrlParams({ empty: detail.checked })}> + Empty + + setUrlParams({ hideEnteredTextOption: detail.checked })} + > + Hide entered text option + + setUrlParams({ showMatchesCount: detail.checked })} + > + Show matches count + + + + setSearchText(event.detail.value)} + onSelect={onSelect} + enteredTextLabel={enteredTextLabel} + ariaLabel="website search" + selectedAriaLabel="selected" + empty="No suggestions" + hideEnteredTextOption={hideEnteredTextOption} + filteringResultsText={ + showMatchesCount + ? () => (matchedOptions.length ? `${matchedOptions.length} items` : `No matches`) + : undefined + } + /> + + + {selection && typeof selection === 'object' ? ( + + {selection?.label} ({selection?.value}) + + ) : typeof selection === 'string' ? ( + Search for "{selection}" + ) : ( + 'Nothing selected' + )} + + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ebbaca80b8..de1bef7739 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -3363,6 +3363,13 @@ Note: Manual filtering doesn't disable match highlighting.", "optional": true, "type": "string", }, + { + "defaultValue": "false", + "description": "Defines whether entered text option is shown as the first option in the dropdown when value is non-empty.", + "name": "hideEnteredTextOption", + "optional": true, + "type": "boolean", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must diff --git a/src/autosuggest/__tests__/autosuggest.test.tsx b/src/autosuggest/__tests__/autosuggest.test.tsx index 7bd790ccb0..b3f7396bca 100644 --- a/src/autosuggest/__tests__/autosuggest.test.tsx +++ b/src/autosuggest/__tests__/autosuggest.test.tsx @@ -127,6 +127,14 @@ test('should display entered text option/label', () => { expect(wrapper.findEnteredTextOption()!.getElement()).toHaveTextContent('Custom function with 1 placeholder'); }); +test('should not display entered text option when hideEnteredTextOption=false', () => { + const { wrapper } = renderAutosuggest( + 'X'} value="" options={defaultOptions} hideEnteredTextOption={true} /> + ); + wrapper.setInputValue('1'); + expect(wrapper.findEnteredTextOption()).toBe(null); +}); + test('entered text option should not get screenreader override', () => { const { wrapper } = renderAutosuggest(); wrapper.focus(); @@ -135,7 +143,7 @@ test('entered text option should not get screenreader override', () => { ).toBeFalsy(); }); -test('should not close dropdown when no realted target in blur', () => { +test('should not close dropdown when no related target in blur', () => { const { wrapper, container } = renderAutosuggest(
v} value="1" options={defaultOptions} /> @@ -447,6 +455,47 @@ describe('Check if should render dropdown', () => { expect(wrapper.findDropdown().findOpenDropdown()).not.toBe(null); }); + + test('should render dropdown when the only visible option is entered text option', () => { + const { wrapper } = renderAutosuggest( + 'X'} value="" options={defaultOptions} /> + ); + + wrapper.focus(); + wrapper.setInputValue('XXX'); + + expect(wrapper.findDropdown().findOpenDropdown()).not.toBe(null); + expect(wrapper.findDropdown().findOptions()).toHaveLength(0); + expect(wrapper.findEnteredTextOption()).not.toBe(null); + }); + + test('should render dropdown when no options matched with a message', () => { + const { wrapper } = renderAutosuggest( + 'No matches'} + /> + ); + + wrapper.focus(); + wrapper.setInputValue('XXX'); + + expect(wrapper.findDropdown().findOpenDropdown()!.getElement()).toHaveTextContent('No matches'); + expect(wrapper.findDropdown().findOptions()).toHaveLength(0); + }); + + test('should not render dropdown when no options matched with no message', () => { + const { wrapper } = renderAutosuggest( + + ); + + wrapper.focus(); + wrapper.setInputValue('XXX'); + + expect(wrapper.findDropdown().findOpenDropdown()).toBe(null); + }); }); describe('Ref', () => { diff --git a/src/autosuggest/index.tsx b/src/autosuggest/index.tsx index 2cd3863f14..6c45b9cd02 100644 --- a/src/autosuggest/index.tsx +++ b/src/autosuggest/index.tsx @@ -16,7 +16,13 @@ export { AutosuggestProps }; const Autosuggest = React.forwardRef( ( - { filteringType = 'auto', statusType = 'finished', disableBrowserAutocorrect = false, ...props }: AutosuggestProps, + { + filteringType = 'auto', + statusType = 'finished', + disableBrowserAutocorrect = false, + hideEnteredTextOption = false, + ...props + }: AutosuggestProps, ref: React.Ref ) => { const baseComponentProps = useBaseComponent('Autosuggest', { @@ -27,6 +33,7 @@ const Autosuggest = React.forwardRef( filteringType, readOnly: props.readOnly, virtualScroll: props.virtualScroll, + hideEnteredTextOption, }, }); @@ -45,6 +52,7 @@ const Autosuggest = React.forwardRef( filteringType={filteringType} statusType={statusType} disableBrowserAutocorrect={disableBrowserAutocorrect} + hideEnteredTextOption={hideEnteredTextOption} {...externalProps} {...baseComponentProps} ref={ref} diff --git a/src/autosuggest/interfaces.ts b/src/autosuggest/interfaces.ts index 785e866425..4237abb348 100644 --- a/src/autosuggest/interfaces.ts +++ b/src/autosuggest/interfaces.ts @@ -81,6 +81,11 @@ export interface AutosuggestProps */ enteredTextLabel?: AutosuggestProps.EnteredTextLabel; + /** + * Defines whether entered text option is shown as the first option in the dropdown when value is non-empty. + */ + hideEnteredTextOption?: boolean; + /** * Specifies the text to display with the number of matches at the bottom of the dropdown menu while filtering. * diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx index 4b49ffdce6..60e4f045cf 100644 --- a/src/autosuggest/internal.tsx +++ b/src/autosuggest/internal.tsx @@ -52,6 +52,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r ariaLabel, ariaRequired, enteredTextLabel, + hideEnteredTextOption, filteringResultsText, onKeyDown, virtualScroll, @@ -90,7 +91,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r filterText: value, filteringType, enteredTextLabel, - hideEnteredTextLabel: false, + hideEnteredTextLabel: hideEnteredTextOption, onSelectItem: (option: AutosuggestItem) => { const value = option.value || ''; fireNonCancelableEvent(onChange, { value }); @@ -193,7 +194,8 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r hasRecoveryCallback: !!onLoadItems, }); - const shouldRenderDropdownContent = !isEmpty || !!dropdownStatus.content; + const shouldRenderDropdownContent = + autosuggestItemsState.items.length !== 0 || !!dropdownStatus.content || (!hideEnteredTextOption && !!value); return (