Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions pages/autosuggest/search.page.tsx
Original file line number Diff line number Diff line change
@@ -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 | string | AutosuggestProps.Option>(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 (
<SimplePage title="Search" subtitle="This demo shows how Autosuggest can be used as a search input">
<SpaceBetween size="m">
<SpaceBetween size="s" direction="horizontal">
<Checkbox checked={empty} onChange={({ detail }) => setUrlParams({ empty: detail.checked })}>
Empty
</Checkbox>
<Checkbox
checked={hideEnteredTextOption}
onChange={({ detail }) => setUrlParams({ hideEnteredTextOption: detail.checked })}
>
Hide entered text option
</Checkbox>
<Checkbox
checked={showMatchesCount}
onChange={({ detail }) => setUrlParams({ showMatchesCount: detail.checked })}
>
Show matches count
</Checkbox>
</SpaceBetween>

<Autosuggest
value={searchText}
options={empty ? [] : matchedOptions}
onChange={event => 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
}
/>

<Box>
{selection && typeof selection === 'object' ? (
<Badge color="green">
{selection?.label} ({selection?.value})
</Badge>
) : typeof selection === 'string' ? (
<Badge color="grey">Search for &quot;{selection}&quot;</Badge>
) : (
'Nothing selected'
)}
</Box>
</SpaceBetween>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion src/autosuggest/__tests__/autosuggest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StatefulAutosuggest enteredTextLabel={() => '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(<Autosuggest {...defaultProps} value="1" />);
wrapper.focus();
Expand All @@ -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(
<div>
<Autosuggest enteredTextLabel={v => v} value="1" options={defaultOptions} />
Expand Down Expand Up @@ -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(
<StatefulAutosuggest enteredTextLabel={() => '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(
<StatefulAutosuggest
value=""
options={defaultOptions}
hideEnteredTextOption={true}
filteringResultsText={() => '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(
<StatefulAutosuggest value="" options={defaultOptions} hideEnteredTextOption={true} />
);

wrapper.focus();
wrapper.setInputValue('XXX');

expect(wrapper.findDropdown().findOpenDropdown()).toBe(null);
});
});

describe('Ref', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/autosuggest/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutosuggestProps.Ref>
) => {
const baseComponentProps = useBaseComponent('Autosuggest', {
Expand All @@ -27,6 +33,7 @@ const Autosuggest = React.forwardRef(
filteringType,
readOnly: props.readOnly,
virtualScroll: props.virtualScroll,
hideEnteredTextOption,
},
});

Expand All @@ -45,6 +52,7 @@ const Autosuggest = React.forwardRef(
filteringType={filteringType}
statusType={statusType}
disableBrowserAutocorrect={disableBrowserAutocorrect}
hideEnteredTextOption={hideEnteredTextOption}
{...externalProps}
{...baseComponentProps}
ref={ref}
Expand Down
5 changes: 5 additions & 0 deletions src/autosuggest/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
6 changes: 4 additions & 2 deletions src/autosuggest/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
ariaLabel,
ariaRequired,
enteredTextLabel,
hideEnteredTextOption,
filteringResultsText,
onKeyDown,
virtualScroll,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 (
<AutosuggestInput
Expand Down
Loading