From 129e335f60a6586f01b5bcc87f84b58939defe9b Mon Sep 17 00:00:00 2001 From: Jesse Jaara Date: Mon, 28 Nov 2022 16:23:37 +0200 Subject: [PATCH] Add keepOpen prop to The keepOpen property can be used to prevent default behaviour of closing the menu and resetting the components internal state when selecting an item in multi mode. This can be for example be used to implement "select multiple when ctrl is pressed down" -functionality. For example when your users often have the need to select every item with a specific prefix. --- docs/API.md | 1 + .../Typeahead/Typeahead.stories.tsx | 7 ++ src/components/Typeahead/Typeahead.test.tsx | 93 ++++++++++++++++++- .../__snapshots__/Typeahead.test.tsx.snap | 82 ++++++++++++++++ src/core/Typeahead.tsx | 41 ++++++-- src/types.ts | 3 + 6 files changed, 219 insertions(+), 8 deletions(-) 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;