Skip to content

Commit 7a33b34

Browse files
feat(Select): add checkbox variant of the simple select template (#10159)
* feat(Select): add checkbox variant of the simple select template * chore(Select): rename template * fix(Select): mock generated id in CheckboxSelect snapshot tests
1 parent 51ee95e commit 7a33b34

File tree

7 files changed

+634
-2
lines changed

7 files changed

+634
-2
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import * as React from 'react';
2+
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { CheckboxSelect } from './CheckboxSelect';
5+
import styles from '@patternfly/react-styles/css/components/Badge/badge';
6+
7+
test('renders checkbox select with options', async () => {
8+
const initialOptions = [
9+
{ content: 'Option 1', value: 'option1' },
10+
{ content: 'Option 2', value: 'option2' },
11+
{ content: 'Option 3', value: 'option3' }
12+
];
13+
14+
const user = userEvent.setup();
15+
16+
render(<CheckboxSelect initialOptions={initialOptions} />);
17+
18+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
19+
20+
await user.click(toggle);
21+
22+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
23+
const option2 = screen.getByRole('checkbox', { name: 'Option 2' });
24+
const option3 = screen.getByRole('checkbox', { name: 'Option 3' });
25+
26+
expect(option1).toBeInTheDocument();
27+
expect(option2).toBeInTheDocument();
28+
expect(option3).toBeInTheDocument();
29+
});
30+
31+
test('selects options when clicked', async () => {
32+
const initialOptions = [
33+
{ content: 'Option 1', value: 'option1' },
34+
{ content: 'Option 2', value: 'option2' },
35+
{ content: 'Option 3', value: 'option3' }
36+
];
37+
38+
const user = userEvent.setup();
39+
40+
render(<CheckboxSelect initialOptions={initialOptions} />);
41+
42+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
43+
44+
await user.click(toggle);
45+
46+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
47+
48+
expect(option1).not.toBeChecked();
49+
50+
await user.click(option1);
51+
52+
expect(option1).toBeChecked();
53+
});
54+
55+
test('deselects options when an already selected option is clicked', async () => {
56+
const initialOptions = [
57+
{ content: 'Option 1', value: 'option1' },
58+
{ content: 'Option 2', value: 'option2' },
59+
{ content: 'Option 3', value: 'option3' }
60+
];
61+
62+
const user = userEvent.setup();
63+
64+
render(<CheckboxSelect initialOptions={initialOptions} />);
65+
66+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
67+
68+
await user.click(toggle);
69+
70+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
71+
72+
await user.click(option1);
73+
await user.click(option1);
74+
75+
expect(option1).not.toBeChecked();
76+
});
77+
78+
test('calls the onSelect callback with the selected value when an option is selected', async () => {
79+
const initialOptions = [
80+
{ content: 'Option 1', value: 'option1' },
81+
{ content: 'Option 2', value: 'option2' },
82+
{ content: 'Option 3', value: 'option3' }
83+
];
84+
85+
const user = userEvent.setup();
86+
const onSelectMock = jest.fn();
87+
88+
render(<CheckboxSelect initialOptions={initialOptions} onSelect={onSelectMock} />);
89+
90+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
91+
92+
await user.click(toggle);
93+
94+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
95+
96+
await user.click(option1);
97+
98+
expect(onSelectMock).toHaveBeenCalledTimes(1);
99+
expect(onSelectMock).toHaveBeenCalledWith(expect.anything(), 'option1');
100+
});
101+
102+
test('does not call the onSelect callback when no options are selected', async () => {
103+
const initialOptions = [
104+
{ content: 'Option 1', value: 'option1' },
105+
{ content: 'Option 2', value: 'option2' },
106+
{ content: 'Option 3', value: 'option3' }
107+
];
108+
109+
const user = userEvent.setup();
110+
const onSelectMock = jest.fn();
111+
112+
render(<CheckboxSelect initialOptions={initialOptions} onSelect={onSelectMock} />);
113+
114+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
115+
116+
await user.click(toggle);
117+
118+
expect(onSelectMock).not.toHaveBeenCalled();
119+
});
120+
121+
test('toggles the select menu when the toggle button is clicked', async () => {
122+
const initialOptions = [
123+
{ content: 'Option 1', value: 'option1' },
124+
{ content: 'Option 2', value: 'option2' },
125+
{ content: 'Option 3', value: 'option3' }
126+
];
127+
128+
const user = userEvent.setup();
129+
130+
render(<CheckboxSelect initialOptions={initialOptions} />);
131+
132+
const toggleButton = screen.getByRole('button', { name: 'Filter by status' });
133+
134+
await user.click(toggleButton);
135+
136+
expect(screen.getByRole('menu')).toBeInTheDocument();
137+
138+
await user.click(toggleButton);
139+
140+
await waitForElementToBeRemoved(() => screen.queryByRole('menu'));
141+
142+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
143+
});
144+
145+
test('displays custom toggle content', async () => {
146+
const initialOptions = [
147+
{ content: 'Option 1', value: 'option1' },
148+
{ content: 'Option 2', value: 'option2' },
149+
{ content: 'Option 3', value: 'option3' }
150+
];
151+
152+
render(<CheckboxSelect initialOptions={initialOptions} toggleContent="Custom Toggle" />);
153+
154+
const toggleButton = screen.getByRole('button', { name: 'Custom Toggle' });
155+
156+
expect(toggleButton).toBeInTheDocument();
157+
});
158+
159+
test('calls the onToggle callback when the select opens or closes', async () => {
160+
const initialOptions = [
161+
{ content: 'Option 1', value: 'option1' },
162+
{ content: 'Option 2', value: 'option2' },
163+
{ content: 'Option 3', value: 'option3' }
164+
];
165+
166+
const user = userEvent.setup();
167+
const onToggleMock = jest.fn();
168+
169+
render(<CheckboxSelect initialOptions={initialOptions} onToggle={onToggleMock} />);
170+
171+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
172+
173+
await user.click(toggle);
174+
175+
expect(onToggleMock).toHaveBeenCalledTimes(1);
176+
expect(onToggleMock).toHaveBeenCalledWith(true);
177+
178+
await user.click(toggle);
179+
180+
expect(onToggleMock).toHaveBeenCalledTimes(2);
181+
expect(onToggleMock).toHaveBeenCalledWith(false);
182+
});
183+
184+
test('does not call the onToggle callback when the toggle is not clicked', async () => {
185+
const initialOptions = [
186+
{ content: 'Option 1', value: 'option1' },
187+
{ content: 'Option 2', value: 'option2' },
188+
{ content: 'Option 3', value: 'option3' }
189+
];
190+
191+
const onToggleMock = jest.fn();
192+
193+
render(<CheckboxSelect initialOptions={initialOptions} onToggle={onToggleMock} />);
194+
195+
expect(onToggleMock).not.toHaveBeenCalled();
196+
});
197+
198+
test('disables the select when isDisabled prop is true', async () => {
199+
const initialOptions = [
200+
{ content: 'Option 1', value: 'option1' },
201+
{ content: 'Option 2', value: 'option2' },
202+
{ content: 'Option 3', value: 'option3' }
203+
];
204+
205+
const user = userEvent.setup();
206+
207+
render(<CheckboxSelect initialOptions={initialOptions} isDisabled={true} />);
208+
209+
const toggleButton = screen.getByRole('button', { name: 'Filter by status' });
210+
211+
expect(toggleButton).toBeDisabled();
212+
213+
await user.click(toggleButton);
214+
215+
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
216+
});
217+
218+
test('passes other SelectOption props to the SelectOption component', async () => {
219+
const initialOptions = [{ content: 'Option 1', value: 'option1', isDisabled: true }];
220+
221+
const user = userEvent.setup();
222+
223+
render(<CheckboxSelect initialOptions={initialOptions} />);
224+
225+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
226+
227+
await user.click(toggle);
228+
229+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
230+
231+
expect(option1).toBeDisabled();
232+
});
233+
234+
test('displays the badge count when options are selected', async () => {
235+
const initialOptions = [
236+
{ content: 'Option 1', value: 'option1' },
237+
{ content: 'Option 2', value: 'option2' },
238+
{ content: 'Option 3', value: 'option3' }
239+
];
240+
241+
const user = userEvent.setup();
242+
243+
render(<CheckboxSelect initialOptions={initialOptions} />);
244+
245+
const toggle = screen.getByRole('button', { name: 'Filter by status' });
246+
247+
await user.click(toggle);
248+
249+
const option1 = screen.getByRole('checkbox', { name: 'Option 1' });
250+
251+
expect(screen.queryByText('1')).not.toBeInTheDocument();
252+
253+
await user.click(option1);
254+
255+
expect(screen.getByText('1')).toHaveClass(styles.badge, 'pf-m-read');
256+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import {
3+
Badge,
4+
MenuToggle,
5+
MenuToggleElement,
6+
Select,
7+
SelectList,
8+
SelectOption,
9+
SelectOptionProps
10+
} from '@patternfly/react-core';
11+
12+
export interface CheckboxSelectOption extends Omit<SelectOptionProps, 'content'> {
13+
/** Content of the select option. */
14+
content: React.ReactNode;
15+
/** Value of the select option. */
16+
value: string | number;
17+
}
18+
19+
export interface CheckboxSelectProps {
20+
/** @hide Forwarded ref */
21+
innerRef?: React.Ref<any>;
22+
/** Initial options of the select. */
23+
initialOptions?: CheckboxSelectOption[];
24+
/** Callback triggered on selection. */
25+
onSelect?: (_event: React.MouseEvent<Element, MouseEvent>, value?: string | number) => void;
26+
/** Callback triggered when the select opens or closes. */
27+
onToggle?: (nextIsOpen: boolean) => void;
28+
/** Flag indicating the select should be disabled. */
29+
isDisabled?: boolean;
30+
/** Content of the toggle. Defaults to the selected option. */
31+
toggleContent?: React.ReactNode;
32+
}
33+
34+
const CheckboxSelectBase: React.FunctionComponent<CheckboxSelectProps> = ({
35+
innerRef,
36+
initialOptions,
37+
isDisabled,
38+
onSelect: passedOnSelect,
39+
onToggle,
40+
toggleContent,
41+
...props
42+
}: CheckboxSelectProps) => {
43+
const [isOpen, setIsOpen] = React.useState(false);
44+
const [selected, setSelected] = React.useState<string[]>([]);
45+
46+
const checkboxSelectOptions = initialOptions?.map((option) => {
47+
const { content, value, ...props } = option;
48+
const isSelected = selected.includes(`${value}`);
49+
return (
50+
<SelectOption {...props} value={value} key={value} hasCheckbox isSelected={isSelected}>
51+
{content}
52+
</SelectOption>
53+
);
54+
});
55+
56+
const onToggleClick = () => {
57+
onToggle && onToggle(!isOpen);
58+
setIsOpen(!isOpen);
59+
};
60+
61+
const onSelect = (event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
62+
const valueString = `${value}`;
63+
if (selected.includes(valueString)) {
64+
setSelected((prevSelected) => prevSelected.filter((item) => item !== valueString));
65+
} else {
66+
setSelected((prevSelected) => [...prevSelected, valueString]);
67+
}
68+
passedOnSelect && passedOnSelect(event, value);
69+
};
70+
71+
const defaultToggleContent = (
72+
<>
73+
Filter by status
74+
{selected.length > 0 && <Badge isRead>{selected.length}</Badge>}
75+
</>
76+
);
77+
78+
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
79+
<MenuToggle
80+
ref={toggleRef}
81+
onClick={onToggleClick}
82+
isExpanded={isOpen}
83+
isDisabled={isDisabled}
84+
style={
85+
{
86+
width: '200px'
87+
} as React.CSSProperties
88+
}
89+
>
90+
{toggleContent || defaultToggleContent}
91+
</MenuToggle>
92+
);
93+
94+
return (
95+
<Select
96+
id="checkbox-select"
97+
isOpen={isOpen}
98+
selected={selected}
99+
onSelect={onSelect}
100+
onOpenChange={(isOpen) => setIsOpen(isOpen)}
101+
toggle={toggle}
102+
ref={innerRef}
103+
role="menu"
104+
{...props}
105+
>
106+
<SelectList>{checkboxSelectOptions}</SelectList>
107+
</Select>
108+
);
109+
};
110+
111+
export const CheckboxSelect = React.forwardRef((props: CheckboxSelectProps, ref: React.Ref<any>) => (
112+
<CheckboxSelectBase {...props} innerRef={ref} />
113+
));

0 commit comments

Comments
 (0)