import React from 'react';
import {
createEvent,
fireEvent,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { renderSearchInput } from '../utils/SearchInput.testutils';
import { SearchInput, SearchResult } from '..';
import { State } from './SearchInput.types';
const resultClickHandler = jest.fn();
const defaultProps = {
className: 'test-text-input-class',
placeholder: 'This is some placeholder text',
children: [
Apple
,
Banana
,
Carrot
,
Dragonfruit
,
],
};
describe('packages/search-input', () => {
describe('a11y', () => {
test('does not have basic accessibility issues', async () => {
const { container } = renderSearchInput();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('does not set aria-label if aria-labelledby is defined', () => {
const ariaLabelledby = 'custom-label-id';
const { searchBoxEl } = renderSearchInput({
...defaultProps,
'aria-label': 'Label',
'aria-labelledby': ariaLabelledby,
});
expect(searchBoxEl?.hasAttribute('aria-label')).toBeFalsy();
expect(searchBoxEl?.getAttribute('aria-labelledby')).toBe(ariaLabelledby);
});
});
describe('Basic rendering', () => {
test('renders type as "search"', () => {
const { inputEl } = renderSearchInput();
expect(inputEl.getAttribute('type')).toBe('search');
});
test(`renders provided placeholder text`, () => {
const { getByPlaceholderText } = renderSearchInput(defaultProps);
expect(getByPlaceholderText(defaultProps.placeholder)).toBeVisible();
});
test(`passes className to root element`, () => {
const { containerEl } = renderSearchInput(defaultProps);
expect(
containerEl.classList.contains(defaultProps.className),
).toBeTruthy();
});
test('clear button is not rendered when there is no text', () => {
const { queryByRole } = renderSearchInput();
expect(queryByRole('button')).not.toBeInTheDocument();
});
test('clear button is rendered when there is text', () => {
const { queryByRole, inputEl } = renderSearchInput();
userEvent.type(inputEl, 'abc');
expect(queryByRole('button')).toBeInTheDocument();
});
});
describe('Basic Search Results rendering', () => {
test('no results appear when there are no children', () => {
const { getMenuElements, openMenu } = renderSearchInput();
openMenu();
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeInTheDocument();
});
test('all children render in the menu', () => {
const { openMenu } = renderSearchInput({
...defaultProps,
});
const { resultsElements } = openMenu();
expect(resultsElements).toHaveLength(4);
});
test('results change dynamically while menu is open', () => {
const { getMenuElements, openMenu, rerenderWithProps } =
renderSearchInput({
children: defaultProps.children,
});
openMenu();
rerenderWithProps({
children: Result 1,
});
const { resultsElements } = getMenuElements();
expect(resultsElements).toHaveLength(1);
});
});
describe('Interaction', () => {
test('menu is not initially opened', () => {
const { getMenuElements } = renderSearchInput({
...defaultProps,
});
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeInTheDocument();
});
describe('When disabled', () => {
test(`renders with aria-disabled attribute but not disabled attribute`, () => {
const { inputEl } = renderSearchInput({
...defaultProps,
disabled: true,
});
expect(inputEl?.hasAttribute('aria-disabled')).toBeTruthy();
expect(inputEl?.hasAttribute('disabled')).toBeFalsy();
});
test(`renders with readonly attribute`, () => {
const { inputEl } = renderSearchInput({
...defaultProps,
disabled: true,
});
expect(inputEl?.hasAttribute('readonly')).toBeTruthy();
});
test('searchbox is focusable when `disabled`', () => {
const { inputEl } = renderSearchInput({
disabled: true,
...defaultProps,
});
userEvent.tab();
expect(inputEl).toHaveFocus();
});
test('searchbox is NOT clickable when `disabled`', () => {
const { searchBoxEl } = renderSearchInput({
disabled: true,
...defaultProps,
});
userEvent.click(searchBoxEl);
expect(document.body).toHaveFocus();
});
test('searchbox is NOT keyboard interactive when `disabled`', () => {
const { inputEl, getMenuElements } = renderSearchInput({
disabled: true,
...defaultProps,
});
userEvent.type(inputEl, '{arrowdown}');
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeInTheDocument();
});
test('clear button is not clickable', () => {
const changeHandler = jest.fn();
const { inputEl, queryByRole } = renderSearchInput({
...defaultProps,
disabled: true,
value: 'abc',
onChange: changeHandler,
});
userEvent.click(queryByRole('button')!);
expect(inputEl).toHaveValue('abc');
expect(changeHandler).not.toHaveBeenCalled();
});
test('clear button does not focus on {tab}', () => {
const { queryByRole } = renderSearchInput({
...defaultProps,
disabled: true,
value: 'abc',
});
const button = queryByRole('button');
userEvent.tab(); // could focus on input (but shouldn't)
expect(button).not.toHaveFocus();
userEvent.tab(); // check again in case input got focused
expect(button).not.toHaveFocus();
});
});
describe('Any character key', () => {
test('updates the input', () => {
const changeHandler = jest.fn();
const { inputEl } = renderSearchInput({
onChange: changeHandler,
});
expect(inputEl.value).toBe('');
userEvent.type(inputEl, 'a');
expect(inputEl.value).toBe('a');
expect(changeHandler).toHaveBeenCalledTimes(1);
});
test("opens the menu if it's closed", () => {
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, 'abc');
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).toBeInTheDocument();
});
});
describe('Enter key', () => {
test('keydown event is called', () => {
const keyDownHandler = jest.fn();
const { inputEl } = renderSearchInput({
onKeyDown: keyDownHandler,
});
userEvent.type(inputEl, '{enter}');
expect(keyDownHandler).toHaveBeenCalledTimes(1);
});
// https://jira.mongodb.org/browse/LG-3195
// https://github.com/silx-kit/h5web/pull/814
// This can be done after testing-library's version is bumped to at least 13.5.0
test.todo('test multiple keys being pressed at once');
});
describe('Clear button', () => {
test('clears any input', () => {
const { queryByRole, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, 'abc');
userEvent.click(queryByRole('button')!);
expect(inputEl).toHaveValue('');
});
test('fires `onChange`', () => {
const changeHandler = jest.fn();
const { queryByRole, inputEl } = renderSearchInput({
...defaultProps,
onChange: changeHandler,
});
userEvent.type(inputEl, 'abc');
userEvent.click(queryByRole('button')!);
expect(changeHandler).toHaveBeenCalled();
});
test('focuses input, but does not open the menu', () => {
const { queryByRole, inputEl, getMenuElements } = renderSearchInput({
...defaultProps,
value: 'abc',
});
userEvent.click(queryByRole('button')!);
const { menuContainerEl } = getMenuElements();
expect(inputEl).toHaveFocus();
expect(menuContainerEl).not.toBeInTheDocument();
});
});
describe('Mouse interaction', () => {
test('clicking the input sets focus to the input', () => {
const { inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(inputEl);
expect(inputEl).toHaveFocus();
});
test('clicking the input opens the menu', () => {
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(inputEl);
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeNull();
expect(menuContainerEl).toBeInTheDocument();
});
test('clicking anywhere on the searchBox opens the menu & sets focus', () => {
const { getMenuElements, searchBoxEl, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(searchBoxEl);
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeNull();
expect(menuContainerEl).toBeInTheDocument();
expect(inputEl).toHaveFocus();
});
describe('Click-away', () => {
test('Basic (without menu): click-away un-focuses the input', async () => {
const { containerEl, inputEl } = renderSearchInput({
...defaultProps,
children: undefined,
});
userEvent.click(inputEl);
userEvent.click(containerEl.parentElement!);
await waitFor(() => {
expect(inputEl).not.toHaveFocus();
});
});
test('With menu: click-away keeps focus on the input', () => {
const { containerEl, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(inputEl);
userEvent.click(containerEl.parentElement!);
expect(inputEl).toHaveFocus();
});
test('menu closes on click-away', async () => {
const { openMenu, containerEl } = renderSearchInput({
...defaultProps,
});
const { menuContainerEl } = openMenu();
userEvent.click(containerEl.parentElement!);
await waitForElementToBeRemoved(menuContainerEl);
expect(menuContainerEl).not.toBeInTheDocument();
});
test('text remains when the menu closes', async () => {
const { openMenu, containerEl, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, 'abc');
const { menuContainerEl } = openMenu();
userEvent.click(containerEl.parentElement!);
await waitForElementToBeRemoved(menuContainerEl);
expect(inputEl).toHaveValue('abc');
});
});
describe('clicking a result', () => {
test('fires its onClick handler', () => {
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(inputEl);
const { resultsElements } = getMenuElements();
userEvent.click(resultsElements![0]);
expect(resultClickHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
}),
);
});
test('fires the onSubmit handler', () => {
const submitHandler = jest.fn();
const { getMenuElements, inputEl, containerEl } = renderSearchInput({
...defaultProps,
onSubmit: submitHandler,
});
userEvent.click(inputEl);
const { resultsElements } = getMenuElements();
userEvent.click(resultsElements![0]);
expect(submitHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'submit',
}),
);
const submitEvent = submitHandler.mock.calls[0][0]; // the first parameter of the first call
expect(submitEvent.target).toBe(containerEl);
});
test('fires the change handler', () => {
const changeHandler = jest.fn();
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
onChange: changeHandler,
});
userEvent.click(inputEl);
const { resultsElements } = getMenuElements();
userEvent.click(resultsElements![0]);
expect(changeHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'change',
}),
);
});
test('does not populate the input with the result text', () => {
// https://mongodb.slack.com/archives/G01500NFVPS/p1676059715272479
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.click(inputEl);
const { resultsElements } = getMenuElements();
userEvent.click(resultsElements![0]);
expect(inputEl.value).toBe('');
});
});
});
describe('Keyboard interaction', () => {
test('first result is highlighted on menu open', () => {
const { openMenu } = renderSearchInput({ ...defaultProps });
const { resultsElements } = openMenu();
expect(resultsElements).not.toBeUndefined();
expect(resultsElements![0]).toHaveAttribute('aria-selected', 'true');
});
describe('Tab key', () => {
test('tab focuses the input', () => {
const { inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.tab();
expect(inputEl).toHaveFocus();
});
test('menu does NOT open on first focus', () => {
const { getMenuElements } = renderSearchInput({
...defaultProps,
});
userEvent.tab();
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeInTheDocument();
});
test('focuses clear button', () => {
const { inputEl, queryByRole } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, 'abc');
userEvent.tab();
expect(queryByRole('button')).toHaveFocus();
});
test('moves focus off input if there is no input value', () => {
const { inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.tab();
expect(inputEl).toHaveFocus();
userEvent.tab();
expect(inputEl).not.toHaveFocus();
});
// Can't get jest to verify the menu closes. Can verify in browser
// eslint-disable-next-line jest/no-disabled-tests
test.skip('Closes menu when tabbing away', async () => {
const { getMenuElements, inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.tab();
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).toBeInTheDocument();
userEvent.tab();
expect(inputEl).not.toHaveFocus();
await waitForElementToBeRemoved(menuContainerEl);
expect(menuContainerEl).not.toBeInTheDocument();
});
});
describe('Escape key', () => {
test('closes the menu', async () => {
const { inputEl, openMenu } = renderSearchInput({
...defaultProps,
});
const { menuContainerEl } = openMenu();
userEvent.type(inputEl, '{esc}');
await waitForElementToBeRemoved(menuContainerEl);
expect(menuContainerEl).not.toBeInTheDocument();
});
test('returns focus to the input', () => {
const { containerEl, openMenu, inputEl } = renderSearchInput({
...defaultProps,
});
openMenu();
userEvent.type(containerEl, '{esc}');
expect(inputEl).toHaveFocus();
});
});
test('space key types a space character', () => {
const { inputEl } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, ' ');
expect(inputEl).toHaveValue(' ');
});
describe('Arrow keys', () => {
test('down arrow opens menu', () => {
const { inputEl, getMenuElements } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, '{arrowdown}');
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).toBeInTheDocument();
});
test('down arrow moves highlight down', () => {
const { openMenu, inputEl, getByRole } = renderSearchInput({
...defaultProps,
});
openMenu();
userEvent.type(inputEl, '{arrowdown}');
const highlight = getByRole('option', {
selected: true,
});
expect(highlight).toBeInTheDocument();
expect(highlight).toHaveTextContent('Banana');
});
test('up arrow moves highlight up', () => {
const { openMenu, inputEl, getByRole } = renderSearchInput({
...defaultProps,
});
openMenu();
userEvent.type(inputEl, '{arrowdown}{arrowdown}{arrowup}');
const highlight = getByRole('option', {
selected: true,
});
expect(highlight).toBeInTheDocument();
expect(highlight).toHaveTextContent('Banana');
});
test('up arrow cycles highlight to bottom', () => {
const { openMenu, inputEl, getByRole } = renderSearchInput({
...defaultProps,
});
openMenu();
userEvent.type(inputEl, '{arrowup}');
const highlight = getByRole('option', {
selected: true,
});
expect(highlight).toBeInTheDocument();
expect(highlight).toHaveTextContent('Dragonfruit');
});
test('down arrow cycles highlight to top', () => {
const { openMenu, inputEl, getByRole } = renderSearchInput({
...defaultProps,
});
openMenu();
userEvent.type(inputEl, '{arrowup}{arrowdown}');
const highlight = getByRole('option', {
selected: true,
});
expect(highlight).toBeInTheDocument();
expect(highlight).toHaveTextContent('Apple');
});
test('down arrow key opens menu when its closed', () => {
const { inputEl, getMenuElements } = renderSearchInput({
...defaultProps,
});
userEvent.type(inputEl, '{arrowdown}');
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).toBeInTheDocument();
});
});
describe('Enter key', () => {
test('submit event prevents default with typeahead', () => {
const { containerEl } = renderSearchInput({
...defaultProps,
});
const submitEvent = createEvent.submit(containerEl);
fireEvent(containerEl, submitEvent);
expect(submitEvent.defaultPrevented).toBeTruthy();
});
test('submit event prevents default without typeahead', () => {
const { containerEl } = renderSearchInput({
...defaultProps,
children: undefined,
});
const submitEvent = createEvent.submit(containerEl);
fireEvent(containerEl, submitEvent);
expect(submitEvent.defaultPrevented).toBeTruthy();
});
test('fires onSubmit without typeahead', () => {
const submitHandler = jest.fn();
const { inputEl, containerEl } = renderSearchInput({
...defaultProps,
children: undefined,
onSubmit: submitHandler,
});
userEvent.type(inputEl, 'abc{enter}');
expect(submitHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'submit',
}),
);
const submitEvent = submitHandler.mock.calls[0][0]; // the first parameter of the first call
expect(submitEvent.target).toBe(containerEl);
// TODO: test that the event has the correct value
// expect(submitEvent.target.elements[0].value).toBe('Banana');
});
test('selects the highlighted result and fires onSubmit with typeahead', async () => {
const submitHandler = jest.fn();
const { inputEl, containerEl, openMenu } = renderSearchInput({
...defaultProps,
onSubmit: submitHandler,
});
openMenu();
userEvent.type(inputEl, '{arrowdown}{enter}');
expect(resultClickHandler).toHaveBeenCalled();
expect(submitHandler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'submit',
}),
);
const submitEvent = submitHandler.mock.calls[0][0]; // the first parameter of the first call
expect(submitEvent.target).toBe(containerEl);
});
});
});
test.todo(
'highlight moves to first result if the previously highlighted result no longer exists',
);
});
describe('`state` prop', () => {
test('shows a loading menu when the input is focused', () => {
const { getMenuElements, inputEl, getByTestId } = renderSearchInput({
...defaultProps,
state: State.Loading,
});
const { menuContainerEl: initialMenu } = getMenuElements();
expect(initialMenu).not.toBeInTheDocument();
userEvent.click(inputEl);
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeNull();
expect(menuContainerEl).toBeInTheDocument();
const loadingOption = getByTestId('lg-search-input-loading-option');
expect(loadingOption).toBeInTheDocument();
});
test('has no effect when there are no children', () => {
const { inputEl, getMenuElements } = renderSearchInput({
state: State.Loading,
});
userEvent.click(inputEl);
const { menuContainerEl } = getMenuElements();
expect(menuContainerEl).not.toBeInTheDocument();
});
});
/* eslint-disable jest/no-disabled-tests */
describe.skip('types behave as expected', () => {
test('SearchInput throws error when no `aria-label` or `aria-labelledby` is supplied', () => {
// @ts-expect-error
;
;
;
});
});
/* eslint-enable jest/no-disabled-tests */
});