Skip to content

Commit

Permalink
[Tests] Dropdown (#47)
Browse files Browse the repository at this point in the history
* Add @cfpb-forms lib

* fix: Tab key navigates instead of selects

* story: Multi w/ Default Value

fix: Simplify helper function logic

* feat: `yarn test-report` to open HTML coverage and better see what pathways were missed by tests

* test: Dropdown

* test: DropdownPills to cover pathways missed in Dropdown.test

* fix: typescript type castings for dropdown

* fix: ESLint issues

* fix: Improve accessibility of dropdown label

* add missed yarn.lock updates

---------

Co-authored-by: James L <jlivolsi@teamraft.com>
  • Loading branch information
meissadia and the-raft-oar committed May 19, 2023
1 parent fd190c2 commit a8ac2eb
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 24 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"preview": "vite preview",
"preview:test": "start-server-and-test preview http://localhost:4173",
"test": "vitest",
"test-report": "open coverage/lcov-report/index.html",
"test:ci": "vitest run",
"test:e2e": "yarn preview:test 'cypress open'",
"test:e2e:headless": "yarn preview:test 'cypress run'",
Expand Down
218 changes: 218 additions & 0 deletions src/components/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Dropdown } from './Dropdown';
import { options } from './Dropdown.stories';

/**
* TODO
*
* We get lots of warnings about needing to wrap in act(...) because actions
* update React state but, when wrapped, some of the actions don't update
* the rendered element. I've wrapped what I could to eliminate as many
* warnings as I could.
*
* I have tried some of the workarounds outlined in the article below
* but have not had any success eliminating the warnings.
*
* https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning
*/

const onSelect = (): null => null;

const label = '-default dropdown-';
const id = 'anID';
const placeholder = 'HOLD MY PLACE';

/**
* Single select
*/
describe('Default Dropdown', () => {
const defaultProps = { id, label, options, onSelect, placeholder };

it('Renders default labels correctly', () => {
render(<Dropdown {...{ id, options, onSelect }} />);
expect(screen.queryByText(label)).not.toBeInTheDocument();
expect(screen.getByText('Dropdown w/ Multi-select')).toBeInTheDocument();
expect(screen.getByText('Select...')).toBeInTheDocument();
});

it('Renders provided labels correctly', () => {
render(<Dropdown {...defaultProps} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText(placeholder)).toBeInTheDocument();
});

it('(Mouse) Selects an option', async () => {
const optionLabel = 'Option A';
const user = userEvent.setup();

render(<Dropdown {...defaultProps} />);
await act(async () => {
await user.click(screen.getByText(label));
});

expect(screen.getByText(optionLabel)).toBeInTheDocument();
await act(async () => {
await user.click(screen.getByText(optionLabel));
});

const selectedOption = screen.getByText(optionLabel);
expect(selectedOption).toBeInTheDocument();
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);
});

it('(Keyboard) Selects an option', async () => {
const optionLabel = 'Option C';
const user = userEvent.setup();

render(<Dropdown {...defaultProps} />);

expect(screen.queryByText(optionLabel)).not.toBeInTheDocument();
await user.click(screen.getByText(label));
await user.keyboard('{Tab}{Tab}{Enter}');

const selectedOption = screen.getByText(optionLabel);
expect(selectedOption).toBeInTheDocument();
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);
});

it('Correctly displays a defaultValue', async () => {
render(
<Dropdown
{...{
id,
label,
options,
onSelect,
placeholder,
defaultValue: options.at(-1)
}}
/>
);

const selectedOption = screen.getByText('Option C');
expect(selectedOption).toBeInTheDocument();
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);

expect(screen.queryByText('Option A')).not.toBeInTheDocument();
});
});

/**
* Multi-select
*/
describe('Multi-select Dropdown', () => {
const multiProperties = {
id,
label,
options,
onSelect,
placeholder,
isMulti: true
};

it('(Mouse) Selects an option and displays pill', async () => {
const optionLabel = 'Option A';
const user = userEvent.setup();

render(<Dropdown {...multiProperties} />);
await act(async () => {
await user.click(screen.getByText(label));
});

expect(screen.getByText(optionLabel)).toBeInTheDocument();
expect(screen.getByText(optionLabel).getAttribute('class')).toMatch(
/option/gi
);
await act(async () => {
await user.click(screen.getByText(optionLabel));
});

const pills = screen.queryAllByRole('listitem');
expect(pills.length).toBe(1);

const selectedOption = pills[0];
expect(selectedOption).toHaveClass('pill');
expect(selectedOption).toHaveTextContent(optionLabel);
});

it('(Keyboard) Navigation, selection, de-selection', async () => {
const optionLabel = 'Option C';
const user = userEvent.setup();

render(<Dropdown {...multiProperties} />);

const beforeSelection = screen.queryAllByRole('listitem');

expect(beforeSelection.length).toBe(0);
expect(screen.queryByText(optionLabel)).not.toBeInTheDocument();

// Choose 'Option C' and close menu
await user.click(screen.getByText(label));
await user.keyboard(
'{Tab}{Tab}{Shift>}{Tab}{/Shift}{Tab}{Enter}{Escape}{Tab}'
);

// Verify other options are hidden
expect(screen.queryByText('Option A')).not.toBeInTheDocument();
expect(screen.queryByText('Option B')).not.toBeInTheDocument();

// Verify pill displayed
expect(screen.getByText(optionLabel)).toBeInTheDocument();
const afterSelection = screen.queryAllByRole('listitem');
expect(afterSelection.length).toBe(1);
expect(afterSelection[0]).toHaveClass('pill');
expect(afterSelection[0]).toHaveTextContent(optionLabel);

// Delete selection
await act(async () => {
await user.click(screen.getByText(label));
await user.keyboard('{Delete}');
});

// No pills
expect(screen.queryAllByRole('listitem').length).toBe(0);
});

it('(Keyboard) Pills interaction', async () => {
const optionLabel = 'Option C';
const user = userEvent.setup();

// All options selected by default
render(<Dropdown {...multiProperties} defaultValue={options} />);

// Verify pills displayed
const afterSelection = screen.queryAllByRole('listitem');
expect(afterSelection.length).toBe(3);
expect(afterSelection[2]).toHaveClass('pill');
expect(afterSelection[2]).toHaveTextContent(optionLabel);

// Focus on pill and delete selection
await act(async () => {
await user.click(screen.getByText(label));
await user.keyboard('{Shift}{Tab}{Tab}{/Shift}{Enter}');
});

// Verify correct option's pill was removed, while others remain
const afterDelete = screen.queryAllByRole('listitem');
expect(afterDelete.length).toBe(2);
expect(afterDelete[0]).toHaveTextContent('Option B');
expect(afterDelete[1]).toHaveTextContent('Option C');
});

it('Correctly displays a default option', async () => {
render(
<Dropdown
{...{
...multiProperties,
defaultValue: [options[1], options[2]]
}}
/>
);

expect(screen.queryByText('Option A')).not.toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Option C')).toBeInTheDocument();
});
});
46 changes: 25 additions & 21 deletions src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { KeyboardEvent } from 'react';
import { useRef, useState } from 'react';
import type { KeyboardEvent, Ref } from 'react';
import { useCallback, useRef, useState } from 'react';
import type {
CSSObjectWithLabel,
ControlProps,
GroupBase,
OnChangeValue,
OptionsOrGroups,
PropsValue
PropsValue,
SelectInstance
} from 'react-select';
import Select, { createFilter } from 'react-select';
import { DropdownPills } from './DropdownPills';
Expand Down Expand Up @@ -56,16 +57,15 @@ const filterOptions = (
return (options as SelectOption[]).filter(
o => !(selected as SelectOption[]).map(s => s.value).includes(o.value)
);

};

interface DropdownProperties {
id: string;
options: SelectOption[];
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
isMulti?: boolean;
defaultValue?: PropsValue<SelectOption>;
label?: string;
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
isDisabled?: boolean;
}

Expand All @@ -77,7 +77,7 @@ export function Dropdown({
isMulti = false,
options,
defaultValue,
id = 'dropdown',
id,
label = 'Dropdown w/ Multi-select',
onSelect,
...rest
Expand All @@ -86,33 +86,35 @@ export function Dropdown({
defaultValue ?? []
);

const selectReference = useRef(null);
const selectReference = useRef<SelectInstance>(null);

// Store updated list of selected items
function onChange(option: PropsValue<SelectOption>): void {
onSelect(option);
setSelected(option);
}
const onChange = useCallback(
(option: PropsValue<SelectOption>) => {
onSelect(option);
setSelected(option);
},
[onSelect]
);

function onKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (event.key === 'Tab' && selectReference.current?.state?.focusedOption) {
const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab' && selectReference.current?.state.focusedOption) {
event.preventDefault();
const direction = event.shiftKey ? 'up' : 'down';
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
selectReference.current.focusOption(direction);
}
}
}, []);

function onLabelClick(): void {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const onLabelClick = useCallback(() => {
selectReference.current?.focus();
}
}, []);

const labelID = `${id}-label`;

return (
<div className='m-form-field m-form-field__select'>
{!!label && (
<Label htmlFor={id} onClick={onLabelClick}>
<Label id={labelID} htmlFor={id} onClick={onLabelClick}>
{label}
</Label>
)}
Expand All @@ -122,8 +124,10 @@ export function Dropdown({
onChange={onChange}
/>
<Select
inputId={id}
aria-labelledby={labelID}
openMenuOnFocus
ref={selectReference}
ref={selectReference as Ref<any>}
tabSelectsValue={false}
onKeyDown={onKeyDown}
isMulti={isMulti}
Expand Down
Loading

0 comments on commit a8ac2eb

Please sign in to comment.