Add prop for user-defined selections in MultiSelectAutocomplete (#17402)

* add new prop and update specs

* update storybook
This commit is contained in:
Suzanne Aitchison 2022-04-29 09:39:25 +01:00 committed by GitHub
parent 0d023163ba
commit 648c86c612
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 171 additions and 50 deletions

View file

@ -44,6 +44,7 @@ export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => {
onSelectionsChanged={syncSelections}
onFocus={switchHelpContext}
inputId="tag-input"
allowUserDefinedSelections={true}
/>
);
};

View file

@ -66,6 +66,7 @@ const reducer = (state, action) => {
* @param {number} props.maxSelections Optional maximum number of allowed selections
* @param {Function} props.onSelectionsChanged Callback for when selections are added or removed
* @param {Function} props.onFocus Callback for when the component receives focus
* @param {boolean} props.allowUserDefinedSelections Whether a user can create new selections other than the defined suggestions
* @param {Function} props.SuggestionTemplate Optional Preact component to render suggestion items
* @param {Function} props.SelectionTemplate Optional Preact component to render selected items
*/
@ -82,6 +83,7 @@ export const MultiSelectAutocomplete = ({
maxSelections,
onSelectionsChanged,
onFocus,
allowUserDefinedSelections = false,
SuggestionTemplate,
SelectionTemplate = DefaultSelectionTemplate,
}) => {
@ -200,13 +202,31 @@ export const MultiSelectAutocomplete = ({
const matchingSuggestion = suggestions.find(
(suggestion) => suggestion.name === textValue,
);
selectItem({
selectedItem: matchingSuggestion
? matchingSuggestion
: { name: textValue },
nextInputValue,
keepSelecting,
});
if (matchingSuggestion) {
selectItem({
selectedItem: matchingSuggestion,
nextInputValue,
keepSelecting,
});
return;
}
// If we allow user's own input as a selection, fallback to that
if (allowUserDefinedSelections) {
selectItem({
selectedItem: { name: textValue },
nextInputValue,
keepSelecting,
});
return;
}
// If we couldn't select any valid input, and search is terminated, clear the input
if (!keepSelecting) {
inputRef.current.value = '';
dispatch('setSuggestions', { payload: [] });
}
};
const enterEditState = (editItem, editItemIndex) => {
@ -309,8 +329,8 @@ export const MultiSelectAutocomplete = ({
return;
}
// If no results, display current search term as an option
if (results.length === 0 && value !== '') {
// If no results, and user-generated selections are allowed, display current search term as an option
if (allowUserDefinedSelections && results.length === 0 && value !== '') {
results.push({ name: value });
}

View file

@ -2,7 +2,7 @@
The MultiSelectAutocomplete component can be used in situations where a user can search for matching options, and select multiple values.
When the user starts typing, an async search will be triggered, and any fetched suggestions displayed in the dropdown. If no suggestions are available, the currently entered search term will be displayed as an option.
When the user starts typing, an async search will be triggered, and any fetched suggestions displayed in the dropdown.
## Browsing and selecting an option
@ -18,6 +18,12 @@ When a user selects a suggestion, it will be displayed as a button group. The fi
Both buttons appear in the tab order, and are semantically grouped together for assistive technology.
## User-defined selections
By default, only suggestions returned by the `fetchSuggestions` callback may be selected. However, an optional prop `allowUserDefinedSelections` may be passed in to allow a user to create their own values to select.
If this is enabled, the user's current input will also be shown as a suggestion if the `fetchSuggestions` callback fails to return any options.
## Technical implementation notes
### Search functionality

View file

@ -10,6 +10,12 @@ export default {
},
},
argTypes: {
allowUserDefinedSelections: {
table: {
defaultValue: { summary: false },
},
description: 'Whether or not a user can create new options to select',
},
border: {
table: {
defaultValue: { summary: false },
@ -73,6 +79,7 @@ Default.args = {
placeholder: 'Add a number...',
maxSelections: 4,
staticSuggestionsHeading: 'Static suggestions',
allowUserDefinedSelections: false,
};
Default.story = {

View file

@ -123,13 +123,14 @@ describe('<MultiSelectAutocomplete />', () => {
).toBeInTheDocument();
});
it('displays search term as an option if no suggestions', async () => {
it('when user-generated selections are allowed, displays search term as an option if no suggestions', async () => {
const mockFetchSuggestions = jest.fn(async () => []);
const { getByLabelText, getByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
fetchSuggestions={mockFetchSuggestions}
allowUserDefinedSelections={true}
/>,
);
@ -210,7 +211,7 @@ describe('<MultiSelectAutocomplete />', () => {
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
});
it('should select current text on spacebar press', async () => {
it('should select current text on spacebar press, when it matches a suggestion', async () => {
const mockFetchSuggestions = jest.fn(async () => [
{ name: 'option1' },
{ name: 'option2' },
@ -223,6 +224,55 @@ describe('<MultiSelectAutocomplete />', () => {
/>,
);
const input = getByLabelText('Example label');
input.focus();
await userEvent.type(input, 'option1 ');
// It should now be added to the list of selected items
await waitFor(() =>
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(),
);
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
});
it('should select current text on comma press, when it matches a suggestion', async () => {
const mockFetchSuggestions = jest.fn(async () => [
{ name: 'option1' },
{ name: 'option2' },
]);
const { getByLabelText, getByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
fetchSuggestions={mockFetchSuggestions}
/>,
);
const input = getByLabelText('Example label');
input.focus();
userEvent.type(input, 'option1,');
// It should now be added to the list of selected items
await waitFor(() =>
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(),
);
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
});
it('should select current text, when it does not match a suggestion, but user generated selections are allowed', async () => {
const mockFetchSuggestions = jest.fn(async () => [
{ name: 'option1' },
{ name: 'option2' },
]);
const { getByLabelText, getByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
fetchSuggestions={mockFetchSuggestions}
allowUserDefinedSelections={true}
/>,
);
const input = getByLabelText('Example label');
input.focus();
await userEvent.type(input, 'example ');
@ -234,50 +284,31 @@ describe('<MultiSelectAutocomplete />', () => {
expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument();
});
it('should select current text on comma press', async () => {
it("doesn't select manual text entry if already selected", async () => {
const mockFetchSuggestions = jest.fn(async () => [
{ name: 'option1' },
{ name: 'option2' },
]);
const { getByLabelText, getByRole } = render(
const { getByLabelText, getByRole, getAllByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
defaultValue={[{ name: 'option1' }]}
fetchSuggestions={mockFetchSuggestions}
/>,
);
const input = getByLabelText('Example label');
input.focus();
userEvent.type(input, 'example,');
// It should now be added to the list of selected items
await waitFor(() =>
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument(),
);
expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument();
});
it("doesn't select manual text entry if already selected", async () => {
const { getByLabelText, getByRole, getAllByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
defaultValue={[{ name: 'example' }]}
fetchSuggestions={() => []}
/>,
);
// Item should already be selected, as passed as a default value
expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument();
const input = getByLabelText('Example label');
input.focus();
// Try to select the same value by manually typing
await userEvent.type(input, 'example,');
await userEvent.type(input, 'option1,');
expect(input).toHaveValue('');
// Verify there is still only one selected 'example'
expect(getAllByRole('button', { name: 'Edit example' })).toHaveLength(1);
// Verify there is still only one selected 'option1', and text field has not been cleared
expect(input).toHaveValue('option1');
expect(getAllByRole('button', { name: 'Edit option1' })).toHaveLength(1);
});
it('displays selections in a custom template, if provided', async () => {
@ -298,12 +329,12 @@ describe('<MultiSelectAutocomplete />', () => {
const input = getByLabelText('Example label');
await userEvent.type(input, 'example,');
await userEvent.type(input, 'option1,');
// It should now be added to the list of selected items using the custom template
await waitFor(() =>
expect(
getByRole('button', { name: 'Selected: example' }),
getByRole('button', { name: 'Selected: option1' }),
).toBeInTheDocument(),
);
});
@ -410,7 +441,7 @@ describe('<MultiSelectAutocomplete />', () => {
expect(getByLabelText('Example label')).toHaveFocus();
});
it('closes dropdown and selects current input value on blur', async () => {
it('closes dropdown and selects current input value on blur if it matches a suggestion', async () => {
const mockFetchSuggestions = jest.fn(async () => [
{ name: 'option1' },
{ name: 'option2' },
@ -425,7 +456,7 @@ describe('<MultiSelectAutocomplete />', () => {
const input = getByLabelText('Example label');
input.focus();
await userEvent.type(input, 'a');
await userEvent.type(input, 'option1');
await waitFor(() =>
expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(),
@ -439,8 +470,63 @@ describe('<MultiSelectAutocomplete />', () => {
);
// Text value should be selected
expect(getByRole('button', { name: 'Edit a' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Remove a' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument();
});
it('clears input without selecting text on blur if no matching suggestion', async () => {
const { getByLabelText, queryByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
fetchSuggestions={() => []}
/>,
);
const input = getByLabelText('Example label');
input.focus();
await userEvent.type(input, 'example');
input.blur();
// Text value should not be selected
expect(
queryByRole('button', { name: 'Edit example' }),
).not.toBeInTheDocument();
expect(
queryByRole('button', { name: 'Remove example' }),
).not.toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue('');
});
it('clears input and selects current text on blur if no matching suggestion and user-defined selections are permitted', async () => {
const { getByLabelText, queryByRole } = render(
<MultiSelectAutocomplete
labelText="Example label"
fetchSuggestions={() => []}
allowUserDefinedSelections={true}
/>,
);
const input = getByLabelText('Example label');
input.focus();
await userEvent.type(input, 'example');
input.blur();
await waitFor(() => {
// Text value should be selected
expect(
queryByRole('button', { name: 'Edit example' }),
).toBeInTheDocument();
expect(
queryByRole('button', { name: 'Remove example' }),
).toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue('');
});
});
it('clears the input on Escape press', async () => {
@ -534,10 +620,10 @@ describe('<MultiSelectAutocomplete />', () => {
expect(queryByText('Only 2 selections allowed')).toBeNull();
expect(input).toHaveAttribute('aria-disabled', 'false');
await userEvent.type(input, 'example,');
await userEvent.type(input, 'option1,');
// Make sure option has been selected
await waitFor(() => getByRole('button', { name: 'Edit example' }));
await waitFor(() => getByRole('button', { name: 'Edit option1' }));
// Check input is in the max reached state
expect(input).toHaveAttribute('placeholder', '');
@ -545,13 +631,13 @@ describe('<MultiSelectAutocomplete />', () => {
expect(input).toHaveAttribute('aria-disabled', 'true');
// Start typing and make sure further options not shown
await userEvent.type(input, 'a');
await userEvent.type(input, 'option2');
expect(queryAllByRole('option')).toHaveLength(0);
expect(getByText('Only 2 selections allowed')).toBeInTheDocument();
// Try to select a value by typing a comma
await userEvent.type(input, 'b,');
await userEvent.type(input, ',');
// Verify the selection wasn't made, and the text is still in the input
expect(input).toHaveValue('ab');
expect(input).toHaveValue('option2');
});
});

View file

@ -96,6 +96,7 @@ export const ListingTagsField = ({
onSelectionsChanged={syncSelections}
inputId="tag-input"
onFocus={onFocus}
allowUserDefinedSelections={true}
/>
{/* Hidden input to store the selected tags and be sent via form data */}
{name && <input type="hidden" name={name} value={defaultValue} />}