Skip to content

Commit a8ac2eb

Browse files
[Tests] Dropdown (#47)
* 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>
1 parent fd190c2 commit a8ac2eb

5 files changed

Lines changed: 320 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"preview": "vite preview",
2929
"preview:test": "start-server-and-test preview http://localhost:4173",
3030
"test": "vitest",
31+
"test-report": "open coverage/lcov-report/index.html",
3132
"test:ci": "vitest run",
3233
"test:e2e": "yarn preview:test 'cypress open'",
3334
"test:e2e:headless": "yarn preview:test 'cypress run'",

src/components/Dropdown.test.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import '@testing-library/jest-dom';
2+
import { act, render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Dropdown } from './Dropdown';
5+
import { options } from './Dropdown.stories';
6+
7+
/**
8+
* TODO
9+
*
10+
* We get lots of warnings about needing to wrap in act(...) because actions
11+
* update React state but, when wrapped, some of the actions don't update
12+
* the rendered element. I've wrapped what I could to eliminate as many
13+
* warnings as I could.
14+
*
15+
* I have tried some of the workarounds outlined in the article below
16+
* but have not had any success eliminating the warnings.
17+
*
18+
* https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning
19+
*/
20+
21+
const onSelect = (): null => null;
22+
23+
const label = '-default dropdown-';
24+
const id = 'anID';
25+
const placeholder = 'HOLD MY PLACE';
26+
27+
/**
28+
* Single select
29+
*/
30+
describe('Default Dropdown', () => {
31+
const defaultProps = { id, label, options, onSelect, placeholder };
32+
33+
it('Renders default labels correctly', () => {
34+
render(<Dropdown {...{ id, options, onSelect }} />);
35+
expect(screen.queryByText(label)).not.toBeInTheDocument();
36+
expect(screen.getByText('Dropdown w/ Multi-select')).toBeInTheDocument();
37+
expect(screen.getByText('Select...')).toBeInTheDocument();
38+
});
39+
40+
it('Renders provided labels correctly', () => {
41+
render(<Dropdown {...defaultProps} />);
42+
expect(screen.getByText(label)).toBeInTheDocument();
43+
expect(screen.getByText(placeholder)).toBeInTheDocument();
44+
});
45+
46+
it('(Mouse) Selects an option', async () => {
47+
const optionLabel = 'Option A';
48+
const user = userEvent.setup();
49+
50+
render(<Dropdown {...defaultProps} />);
51+
await act(async () => {
52+
await user.click(screen.getByText(label));
53+
});
54+
55+
expect(screen.getByText(optionLabel)).toBeInTheDocument();
56+
await act(async () => {
57+
await user.click(screen.getByText(optionLabel));
58+
});
59+
60+
const selectedOption = screen.getByText(optionLabel);
61+
expect(selectedOption).toBeInTheDocument();
62+
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);
63+
});
64+
65+
it('(Keyboard) Selects an option', async () => {
66+
const optionLabel = 'Option C';
67+
const user = userEvent.setup();
68+
69+
render(<Dropdown {...defaultProps} />);
70+
71+
expect(screen.queryByText(optionLabel)).not.toBeInTheDocument();
72+
await user.click(screen.getByText(label));
73+
await user.keyboard('{Tab}{Tab}{Enter}');
74+
75+
const selectedOption = screen.getByText(optionLabel);
76+
expect(selectedOption).toBeInTheDocument();
77+
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);
78+
});
79+
80+
it('Correctly displays a defaultValue', async () => {
81+
render(
82+
<Dropdown
83+
{...{
84+
id,
85+
label,
86+
options,
87+
onSelect,
88+
placeholder,
89+
defaultValue: options.at(-1)
90+
}}
91+
/>
92+
);
93+
94+
const selectedOption = screen.getByText('Option C');
95+
expect(selectedOption).toBeInTheDocument();
96+
expect(selectedOption.getAttribute('class')).toMatch(/singlevalue/gi);
97+
98+
expect(screen.queryByText('Option A')).not.toBeInTheDocument();
99+
});
100+
});
101+
102+
/**
103+
* Multi-select
104+
*/
105+
describe('Multi-select Dropdown', () => {
106+
const multiProperties = {
107+
id,
108+
label,
109+
options,
110+
onSelect,
111+
placeholder,
112+
isMulti: true
113+
};
114+
115+
it('(Mouse) Selects an option and displays pill', async () => {
116+
const optionLabel = 'Option A';
117+
const user = userEvent.setup();
118+
119+
render(<Dropdown {...multiProperties} />);
120+
await act(async () => {
121+
await user.click(screen.getByText(label));
122+
});
123+
124+
expect(screen.getByText(optionLabel)).toBeInTheDocument();
125+
expect(screen.getByText(optionLabel).getAttribute('class')).toMatch(
126+
/option/gi
127+
);
128+
await act(async () => {
129+
await user.click(screen.getByText(optionLabel));
130+
});
131+
132+
const pills = screen.queryAllByRole('listitem');
133+
expect(pills.length).toBe(1);
134+
135+
const selectedOption = pills[0];
136+
expect(selectedOption).toHaveClass('pill');
137+
expect(selectedOption).toHaveTextContent(optionLabel);
138+
});
139+
140+
it('(Keyboard) Navigation, selection, de-selection', async () => {
141+
const optionLabel = 'Option C';
142+
const user = userEvent.setup();
143+
144+
render(<Dropdown {...multiProperties} />);
145+
146+
const beforeSelection = screen.queryAllByRole('listitem');
147+
148+
expect(beforeSelection.length).toBe(0);
149+
expect(screen.queryByText(optionLabel)).not.toBeInTheDocument();
150+
151+
// Choose 'Option C' and close menu
152+
await user.click(screen.getByText(label));
153+
await user.keyboard(
154+
'{Tab}{Tab}{Shift>}{Tab}{/Shift}{Tab}{Enter}{Escape}{Tab}'
155+
);
156+
157+
// Verify other options are hidden
158+
expect(screen.queryByText('Option A')).not.toBeInTheDocument();
159+
expect(screen.queryByText('Option B')).not.toBeInTheDocument();
160+
161+
// Verify pill displayed
162+
expect(screen.getByText(optionLabel)).toBeInTheDocument();
163+
const afterSelection = screen.queryAllByRole('listitem');
164+
expect(afterSelection.length).toBe(1);
165+
expect(afterSelection[0]).toHaveClass('pill');
166+
expect(afterSelection[0]).toHaveTextContent(optionLabel);
167+
168+
// Delete selection
169+
await act(async () => {
170+
await user.click(screen.getByText(label));
171+
await user.keyboard('{Delete}');
172+
});
173+
174+
// No pills
175+
expect(screen.queryAllByRole('listitem').length).toBe(0);
176+
});
177+
178+
it('(Keyboard) Pills interaction', async () => {
179+
const optionLabel = 'Option C';
180+
const user = userEvent.setup();
181+
182+
// All options selected by default
183+
render(<Dropdown {...multiProperties} defaultValue={options} />);
184+
185+
// Verify pills displayed
186+
const afterSelection = screen.queryAllByRole('listitem');
187+
expect(afterSelection.length).toBe(3);
188+
expect(afterSelection[2]).toHaveClass('pill');
189+
expect(afterSelection[2]).toHaveTextContent(optionLabel);
190+
191+
// Focus on pill and delete selection
192+
await act(async () => {
193+
await user.click(screen.getByText(label));
194+
await user.keyboard('{Shift}{Tab}{Tab}{/Shift}{Enter}');
195+
});
196+
197+
// Verify correct option's pill was removed, while others remain
198+
const afterDelete = screen.queryAllByRole('listitem');
199+
expect(afterDelete.length).toBe(2);
200+
expect(afterDelete[0]).toHaveTextContent('Option B');
201+
expect(afterDelete[1]).toHaveTextContent('Option C');
202+
});
203+
204+
it('Correctly displays a default option', async () => {
205+
render(
206+
<Dropdown
207+
{...{
208+
...multiProperties,
209+
defaultValue: [options[1], options[2]]
210+
}}
211+
/>
212+
);
213+
214+
expect(screen.queryByText('Option A')).not.toBeInTheDocument();
215+
expect(screen.getByText('Option B')).toBeInTheDocument();
216+
expect(screen.getByText('Option C')).toBeInTheDocument();
217+
});
218+
});

src/components/Dropdown.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import type { KeyboardEvent } from 'react';
2-
import { useRef, useState } from 'react';
1+
import type { KeyboardEvent, Ref } from 'react';
2+
import { useCallback, useRef, useState } from 'react';
33
import type {
44
CSSObjectWithLabel,
55
ControlProps,
66
GroupBase,
77
OnChangeValue,
88
OptionsOrGroups,
9-
PropsValue
9+
PropsValue,
10+
SelectInstance
1011
} from 'react-select';
1112
import Select, { createFilter } from 'react-select';
1213
import { DropdownPills } from './DropdownPills';
@@ -56,16 +57,15 @@ const filterOptions = (
5657
return (options as SelectOption[]).filter(
5758
o => !(selected as SelectOption[]).map(s => s.value).includes(o.value)
5859
);
59-
6060
};
6161

6262
interface DropdownProperties {
6363
id: string;
6464
options: SelectOption[];
65+
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
6566
isMulti?: boolean;
6667
defaultValue?: PropsValue<SelectOption>;
6768
label?: string;
68-
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
6969
isDisabled?: boolean;
7070
}
7171

@@ -77,7 +77,7 @@ export function Dropdown({
7777
isMulti = false,
7878
options,
7979
defaultValue,
80-
id = 'dropdown',
80+
id,
8181
label = 'Dropdown w/ Multi-select',
8282
onSelect,
8383
...rest
@@ -86,33 +86,35 @@ export function Dropdown({
8686
defaultValue ?? []
8787
);
8888

89-
const selectReference = useRef(null);
89+
const selectReference = useRef<SelectInstance>(null);
9090

9191
// Store updated list of selected items
92-
function onChange(option: PropsValue<SelectOption>): void {
93-
onSelect(option);
94-
setSelected(option);
95-
}
92+
const onChange = useCallback(
93+
(option: PropsValue<SelectOption>) => {
94+
onSelect(option);
95+
setSelected(option);
96+
},
97+
[onSelect]
98+
);
9699

97-
function onKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
98-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
99-
if (event.key === 'Tab' && selectReference.current?.state?.focusedOption) {
100+
const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
101+
if (event.key === 'Tab' && selectReference.current?.state.focusedOption) {
100102
event.preventDefault();
101103
const direction = event.shiftKey ? 'up' : 'down';
102-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
103104
selectReference.current.focusOption(direction);
104105
}
105-
}
106+
}, []);
106107

107-
function onLabelClick(): void {
108-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
108+
const onLabelClick = useCallback(() => {
109109
selectReference.current?.focus();
110-
}
110+
}, []);
111+
112+
const labelID = `${id}-label`;
111113

112114
return (
113115
<div className='m-form-field m-form-field__select'>
114116
{!!label && (
115-
<Label htmlFor={id} onClick={onLabelClick}>
117+
<Label id={labelID} htmlFor={id} onClick={onLabelClick}>
116118
{label}
117119
</Label>
118120
)}
@@ -122,8 +124,10 @@ export function Dropdown({
122124
onChange={onChange}
123125
/>
124126
<Select
127+
inputId={id}
128+
aria-labelledby={labelID}
125129
openMenuOnFocus
126-
ref={selectReference}
130+
ref={selectReference as Ref<any>}
127131
tabSelectsValue={false}
128132
onKeyDown={onKeyDown}
129133
isMulti={isMulti}

0 commit comments

Comments
 (0)