diff --git a/docs/API.md b/docs/API.md index 88d43d57..0a9a1949 100644 --- a/docs/API.md +++ b/docs/API.md @@ -47,6 +47,7 @@ inputProps | object | {} | Props to be applied directly to the input. `onBlur`, isInvalid | boolean | false | Adds the `is-invalid` classname to the `form-control`. Only affects Bootstrap 4. isLoading | boolean | false | Indicate whether an asynchronous data fetch is happening. isValid | boolean | false | Adds the `is-valid` classname to the `form-control`. Only affects Bootstrap 4. +keepOpen | boolean \| function | `false` | Allows selecting of multiple values from the menu at once. labelKey | string \| function | `'label'` | See full documentation in the [Rendering section](Rendering.md#labelkey-string--function). onChange | function | | Invoked when the set of selections changes (ie: an item is added or removed). For consistency, `selected` is always an array of selections, even if multi-selection is not enabled.

`(selected: Array) => void`
onInputChange | function | | Invoked when the input value changes. Receives the string value of the input (`text`), as well as the original event.

`(text: string, event: Event) => void`
diff --git a/src/components/Typeahead/Typeahead.stories.tsx b/src/components/Typeahead/Typeahead.stories.tsx index 4fc4765d..e7a2f84d 100644 --- a/src/components/Typeahead/Typeahead.stories.tsx +++ b/src/components/Typeahead/Typeahead.stories.tsx @@ -95,6 +95,13 @@ AllowNew.args = { allowNew: true, }; +export const KeepOpen = Template.bind({}); +KeepOpen.args = { + ...defaultProps, + multiple: true, + keepOpen: true, +}; + export const CustomInput = Template.bind({}); CustomInput.args = { ...defaultProps, diff --git a/src/components/Typeahead/Typeahead.test.tsx b/src/components/Typeahead/Typeahead.test.tsx index 03e4cb0c..2f6d0c4d 100644 --- a/src/components/Typeahead/Typeahead.test.tsx +++ b/src/components/Typeahead/Typeahead.test.tsx @@ -1,4 +1,4 @@ -import React, { createRef, forwardRef } from 'react'; +import React, { createRef, forwardRef, useState } from 'react'; import TypeaheadComponent, { TypeaheadComponentProps } from './Typeahead'; import Typeahead, { @@ -30,6 +30,7 @@ import { } from '../../tests/helpers'; import states from '../../tests/data'; +import { Option } from '../../types'; const ID = 'rbt-id'; @@ -1202,6 +1203,96 @@ describe('', () => { expect(items[0]).toHaveTextContent(`${newSelectionPrefix}${value}`); }); }); + + describe('keepOpen behaviour', () => { + it('should not affect single selection mode', async () => { + const user = userEvent.setup(); + render(); + getInput().focus(); + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(menu).not.toBeInTheDocument(); + }); + + it('should default to false', async () => { + const user = userEvent.setup(); + render(); + getInput().focus(); + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(menu).not.toBeInTheDocument(); + }); + + it('should keep the menu open after selection', async () => { + const user = userEvent.setup(); + render(); + getInput().focus(); + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(menu).toBeInTheDocument(); + }); + + it('should close the menu if keepOpen function returns false', async () => { + const user = userEvent.setup(); + const ctrlPressed = false; + render( ctrlPressed} />); + getInput().focus(); + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(menu).not.toBeInTheDocument(); + }); + + it('should keep the menu open if keepOpen function returns true', async () => { + const user = userEvent.setup(); + const ctrlPressed = true; + render( ctrlPressed} />); + getInput().focus(); + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(menu).toBeInTheDocument(); + }); + + it('should retain the search input after selection', async () => { + const user = userEvent.setup(); + render(); + + const search = 'Ala'; + const input = getInput(); + await user.type(input, search); + + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(input).toHaveValue(search); + }); + + it('should reset hint and active item after selection', async () => { + const user = userEvent.setup(); + const KeepOpenAndSelect = () => { + const [selected, setSelected] = useState([]); + return ( + + ); + }; + + const { container } = render(); + + const input = getInput(); + const hint = getHint(container); + + await user.type(input, 'Ala'); + expect(input).toHaveFocus(); + expect(hint).toHaveValue('Alabama'); + + await user.keyboard('{ArrowDown}{Enter}'); + expect(hint).toHaveValue('Alaska'); + }); + }); }); describe(' Public Methods', () => { diff --git a/src/components/Typeahead/__snapshots__/Typeahead.test.tsx.snap b/src/components/Typeahead/__snapshots__/Typeahead.test.tsx.snap index d4ce1a9c..fc556093 100644 --- a/src/components/Typeahead/__snapshots__/Typeahead.test.tsx.snap +++ b/src/components/Typeahead/__snapshots__/Typeahead.test.tsx.snap @@ -475,6 +475,88 @@ exports[` InputGrouping story renders snapshot 1`] = ` `; +exports[` KeepOpen story renders snapshot 1`] = ` +
+
+
+
+ + +
+
+
+
+`; + exports[` LoadingState story renders snapshot 1`] = `
{ static propTypes = propTypes; static defaultProps = defaultProps; @@ -574,7 +589,7 @@ class Typeahead extends React.Component { }; _handleSelectionAdd = (option: Option) => { - const { multiple, labelKey } = this.props; + const { keepOpen, labelKey, multiple } = this.props; let selected: Option[]; let selection = option; @@ -599,12 +614,24 @@ class Typeahead extends React.Component { } this.setState( - (state, props) => ({ - ...hideMenu(state, props), - initialItem: selection, - selected, - text, - }), + (state, props) => { + if (multiple && shouldKeepOpen(keepOpen)) { + return { + ...state, + activeIndex: -1, + activeItem: undefined, + initialItem: selection, + selected, + }; + } + + return { + ...hideMenu(state, props), + initialItem: selection, + selected, + text, + }; + }, () => this._handleChange(selected) ); }; diff --git a/src/types.ts b/src/types.ts index 8bab27bb..c2af3f90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,8 @@ export type FilterByCallback = ( export type Id = string; +export type KeepOpen = boolean | (() => boolean); + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Option = string | Record; @@ -82,6 +84,7 @@ export interface TypeaheadProps { id?: Id; ignoreDiacritics: boolean; inputProps?: InputProps; + keepOpen?: KeepOpen; labelKey: LabelKey; maxResults: number; minLength: number;