Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add keepOpen prop to <Typeahead> #751

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br><br><pre>`(selected: Array<Object\|string>) => void`</pre>
onInputChange | function | | Invoked when the input value changes. Receives the string value of the input (`text`), as well as the original event. <br><br><pre>`(text: string, event: Event) => void`</pre>
Expand Down
7 changes: 7 additions & 0 deletions src/components/Typeahead/Typeahead.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 92 additions & 1 deletion src/components/Typeahead/Typeahead.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { createRef, forwardRef } from 'react';
import React, { createRef, forwardRef, useState } from 'react';

import TypeaheadComponent, { TypeaheadComponentProps } from './Typeahead';
import Typeahead, {
Expand Down Expand Up @@ -30,6 +30,7 @@ import {
} from '../../tests/helpers';

import states from '../../tests/data';
import { Option } from '../../types';

const ID = 'rbt-id';

Expand Down Expand Up @@ -1202,6 +1203,96 @@ describe('<Typeahead>', () => {
expect(items[0]).toHaveTextContent(`${newSelectionPrefix}${value}`);
});
});

describe('keepOpen behaviour', () => {
it('should not affect single selection mode', async () => {
const user = userEvent.setup();
render(<Default />);
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(<MultiSelect selected={[]} />);
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(<MultiSelect selected={[]} keepOpen />);
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(<MultiSelect selected={[]} keepOpen={() => 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(<MultiSelect selected={[]} keepOpen={() => 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(<MultiSelect selected={[]} keepOpen />);

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<Option[]>([]);
return (
<MultiSelect selected={selected} onChange={setSelected} keepOpen />
);
};

const { container } = render(<KeepOpenAndSelect />);

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('<Typeahead> Public Methods', () => {
Expand Down
82 changes: 82 additions & 0 deletions src/components/Typeahead/__snapshots__/Typeahead.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,88 @@ exports[`<Typeahead> InputGrouping story renders snapshot 1`] = `
</div>
`;

exports[`<Typeahead> KeepOpen story renders snapshot 1`] = `
<div
className="rbt"
style={
Object {
"outline": "none",
"position": "relative",
}
}
tabIndex={-1}
>
<div
className="rbt-input-multi form-control rbt-input"
onClick={[Function]}
onFocus={[Function]}
tabIndex={-1}
>
<div
className="rbt-input-wrapper"
>
<div
style={
Object {
"display": "flex",
"flex": 1,
"height": "100%",
"position": "relative",
}
}
>
<input
aria-autocomplete="list"
aria-haspopup="listbox"
autoComplete="off"
className="rbt-input-main"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
placeholder="Choose a state..."
style={
Object {
"backgroundColor": "transparent",
"border": 0,
"boxShadow": "none",
"cursor": "inherit",
"outline": "none",
"padding": 0,
"width": "100%",
"zIndex": 1,
}
}
type="text"
value=""
/>
<input
aria-hidden={true}
className="rbt-input-hint"
readOnly={true}
style={
Object {
"backgroundColor": "transparent",
"borderColor": "transparent",
"boxShadow": "none",
"color": "rgba(0, 0, 0, 0.54)",
"left": 0,
"pointerEvents": "none",
"position": "absolute",
"top": 0,
"width": "100%",
}
}
tabIndex={-1}
value=""
/>
</div>
</div>
</div>
</div>
`;

exports[`<Typeahead> LoadingState story renders snapshot 1`] = `
<div
className="rbt has-aux"
Expand Down
41 changes: 34 additions & 7 deletions src/core/Typeahead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { DEFAULT_LABELKEY } from '../constants';

import type {
FilterByCallback,
KeepOpen,
Option,
RefElement,
SelectEvent,
Expand Down Expand Up @@ -109,6 +110,11 @@ const propTypes = {
* Whether the filter should ignore accents and other diacritical marks.
*/
ignoreDiacritics: checkPropType(PropTypes.bool, ignoreDiacriticsType),
/**
* Whether the menu should stay open after selecting an item.
* Can be used allow selecting multiple items at once.
*/
keepOpen: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
/**
* Specify the option key to use for display or a function returning the
* display string. By default, the selector will use the `label` key.
Expand Down Expand Up @@ -194,6 +200,7 @@ const defaultProps = {
filterBy: [],
highlightOnlyResult: false,
ignoreDiacritics: true,
keepOpen: false,
labelKey: DEFAULT_LABELKEY,
maxResults: 100,
minLength: 0,
Expand Down Expand Up @@ -296,6 +303,14 @@ function triggerInputChange(input: HTMLInputElement, value: string) {
input.dispatchEvent(e);
}

function shouldKeepOpen(keepOpen: KeepOpen = false): boolean {
if (typeof keepOpen === 'function') {
return keepOpen();
}

return keepOpen;
}

class Typeahead extends React.Component<Props, TypeaheadState> {
static propTypes = propTypes;
static defaultProps = defaultProps;
Expand Down Expand Up @@ -574,7 +589,7 @@ class Typeahead extends React.Component<Props, TypeaheadState> {
};

_handleSelectionAdd = (option: Option) => {
const { multiple, labelKey } = this.props;
const { keepOpen, labelKey, multiple } = this.props;

let selected: Option[];
let selection = option;
Expand All @@ -599,12 +614,24 @@ class Typeahead extends React.Component<Props, TypeaheadState> {
}

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)
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;

Expand Down Expand Up @@ -82,6 +84,7 @@ export interface TypeaheadProps {
id?: Id;
ignoreDiacritics: boolean;
inputProps?: InputProps;
keepOpen?: KeepOpen;
labelKey: LabelKey;
maxResults: number;
minLength: number;
Expand Down