Add prop for user-defined selections in MultiSelectAutocomplete (#17402)
* add new prop and update specs * update storybook
This commit is contained in:
parent
0d023163ba
commit
648c86c612
6 changed files with 171 additions and 50 deletions
|
|
@ -44,6 +44,7 @@ export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => {
|
|||
onSelectionsChanged={syncSelections}
|
||||
onFocus={switchHelpContext}
|
||||
inputId="tag-input"
|
||||
allowUserDefinedSelections={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue