@khanacademy/wonder-blocks-dropdown 5.2.1 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ import * as React from "react";
2
+ import {render, screen} from "@testing-library/react";
3
+
4
+ import {userEvent} from "@testing-library/user-event";
5
+ import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
6
+ import Listbox from "../listbox";
7
+ import OptionItem from "../option-item";
8
+
9
+ describe("Listbox", () => {
10
+ it("should render the listbox", () => {
11
+ // Arrange
12
+
13
+ // Act
14
+ render(
15
+ <Listbox value={null} selectionType="single">
16
+ <OptionItem label="option 1" value="option1" />
17
+ <OptionItem label="option 2" value="option2" />
18
+ <OptionItem label="option 3" value="option3" />
19
+ </Listbox>,
20
+ {wrapper: RenderStateRoot},
21
+ );
22
+
23
+ // Assert
24
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
25
+ });
26
+
27
+ describe("focus", () => {
28
+ it("should focus on the listbox when tabbing", async () => {
29
+ // Arrange
30
+ render(
31
+ <Listbox selectionType="single" value="option2">
32
+ <OptionItem label="option 1" value="option1" />
33
+ <OptionItem label="option 2" value="option2" />
34
+ <OptionItem label="option 3" value="option3" />
35
+ </Listbox>,
36
+ {wrapper: RenderStateRoot},
37
+ );
38
+
39
+ // Act
40
+ await userEvent.tab();
41
+
42
+ // Assert
43
+ expect(screen.getByRole("listbox")).toHaveFocus();
44
+ });
45
+
46
+ it("should blur the listbox when tabbing out", async () => {
47
+ // Arrange
48
+ render(
49
+ <Listbox selectionType="single" value="option2">
50
+ <OptionItem label="option 1" value="option1" />
51
+ <OptionItem label="option 2" value="option2" />
52
+ <OptionItem label="option 3" value="option3" />
53
+ </Listbox>,
54
+ {wrapper: RenderStateRoot},
55
+ );
56
+
57
+ // Act
58
+ await userEvent.tab();
59
+ await userEvent.tab();
60
+
61
+ // Assert
62
+ expect(screen.getByRole("listbox")).not.toHaveFocus();
63
+ });
64
+
65
+ it("should give visual focus on the selected option item", async () => {
66
+ // Arrange
67
+ render(
68
+ <Listbox selectionType="single" value="option2" id="listbox">
69
+ <OptionItem label="option 1" value="option1" />
70
+ <OptionItem label="option 2" value="option2" />
71
+ <OptionItem label="option 3" value="option3" />
72
+ </Listbox>,
73
+ {wrapper: RenderStateRoot},
74
+ );
75
+
76
+ // Act
77
+ await userEvent.tab();
78
+
79
+ // Assert
80
+ expect(screen.getByRole("listbox")).toHaveAttribute(
81
+ "aria-activedescendant",
82
+ "listbox-option-1",
83
+ );
84
+ });
85
+ });
86
+
87
+ describe("keyboard navigation", () => {
88
+ it("should focus on the first option item", async () => {
89
+ // Arrange
90
+ render(
91
+ <Listbox selectionType="single" value="option2" id="listbox">
92
+ <OptionItem label="option 1" value="option1" />
93
+ <OptionItem label="option 2" value="option2" />
94
+ <OptionItem label="option 3" value="option3" />
95
+ </Listbox>,
96
+ {wrapper: RenderStateRoot},
97
+ );
98
+
99
+ // Focus on the listbox
100
+ await userEvent.tab();
101
+
102
+ // Act
103
+ await userEvent.keyboard("{home}");
104
+
105
+ // Assert
106
+ expect(screen.getByRole("listbox")).toHaveAttribute(
107
+ "aria-activedescendant",
108
+ "listbox-option-0",
109
+ );
110
+ });
111
+
112
+ it("should focus on the last option item", async () => {
113
+ // Arrange
114
+ render(
115
+ <Listbox selectionType="single" value="option1" id="listbox">
116
+ <OptionItem label="option 1" value="option1" />
117
+ <OptionItem label="option 2" value="option2" />
118
+ <OptionItem label="option 3" value="option3" />
119
+ </Listbox>,
120
+ {wrapper: RenderStateRoot},
121
+ );
122
+
123
+ // Focus on the listbox
124
+ await userEvent.tab();
125
+
126
+ // Act
127
+ await userEvent.keyboard("{end}");
128
+
129
+ // Assert
130
+ expect(screen.getByRole("listbox")).toHaveAttribute(
131
+ "aria-activedescendant",
132
+ "listbox-option-2",
133
+ );
134
+ });
135
+ it("should focus on the next option item", async () => {
136
+ // Arrange
137
+ render(
138
+ <Listbox selectionType="single" value="option2" id="listbox">
139
+ <OptionItem label="option 1" value="option1" />
140
+ <OptionItem label="option 2" value="option2" />
141
+ <OptionItem label="option 3" value="option3" />
142
+ </Listbox>,
143
+ {wrapper: RenderStateRoot},
144
+ );
145
+
146
+ // Focus on the listbox
147
+ await userEvent.tab();
148
+
149
+ // Act
150
+ await userEvent.keyboard("{arrowdown}");
151
+
152
+ // Assert
153
+ expect(screen.getByRole("listbox")).toHaveAttribute(
154
+ "aria-activedescendant",
155
+ "listbox-option-2",
156
+ );
157
+ });
158
+
159
+ it("should focus on the previous option item", async () => {
160
+ // Arrange
161
+ render(
162
+ <Listbox selectionType="single" value="option2" id="listbox">
163
+ <OptionItem label="option 1" value="option1" />
164
+ <OptionItem label="option 2" value="option2" />
165
+ <OptionItem label="option 3" value="option3" />
166
+ </Listbox>,
167
+ {wrapper: RenderStateRoot},
168
+ );
169
+
170
+ // Focus on the listbox
171
+ await userEvent.tab();
172
+
173
+ // Act
174
+ await userEvent.keyboard("{arrowup}");
175
+
176
+ // Assert
177
+ expect(screen.getByRole("listbox")).toHaveAttribute(
178
+ "aria-activedescendant",
179
+ "listbox-option-0",
180
+ );
181
+ });
182
+
183
+ it("should navigate to the first option item when pressing arrow down on the last item", async () => {
184
+ // Arrange
185
+ render(
186
+ <Listbox selectionType="single" value="option3" id="listbox">
187
+ <OptionItem label="option 1" value="option1" />
188
+ <OptionItem label="option 2" value="option2" />
189
+ <OptionItem label="option 3" value="option3" />
190
+ </Listbox>,
191
+ {wrapper: RenderStateRoot},
192
+ );
193
+
194
+ // Focus on the listbox
195
+ await userEvent.tab();
196
+
197
+ // Act
198
+ await userEvent.keyboard("{arrowdown}");
199
+
200
+ // Assert
201
+ expect(screen.getByRole("listbox")).toHaveAttribute(
202
+ "aria-activedescendant",
203
+ "listbox-option-0",
204
+ );
205
+ });
206
+
207
+ it("should navigate to the first option item when pressing arrow up on the first item", async () => {
208
+ // Arrange
209
+ render(
210
+ <Listbox selectionType="single" value="option1" id="listbox">
211
+ <OptionItem label="option 1" value="option1" />
212
+ <OptionItem label="option 2" value="option2" />
213
+ <OptionItem label="option 3" value="option3" />
214
+ </Listbox>,
215
+ {wrapper: RenderStateRoot},
216
+ );
217
+
218
+ // Focus on the listbox
219
+ await userEvent.tab();
220
+
221
+ // Act
222
+ await userEvent.keyboard("{arrowup}");
223
+
224
+ // Assert
225
+ expect(screen.getByRole("listbox")).toHaveAttribute(
226
+ "aria-activedescendant",
227
+ "listbox-option-2",
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("single selection", () => {
233
+ it("should select an option item when pressing Enter", async () => {
234
+ // Arrange
235
+ const onChange = jest.fn();
236
+ render(
237
+ <Listbox selectionType="single" value="" onChange={onChange}>
238
+ <OptionItem label="option 1" value="option1" />
239
+ <OptionItem label="option 2" value="option2" />
240
+ <OptionItem label="option 3" value="option3" />
241
+ </Listbox>,
242
+ {wrapper: RenderStateRoot},
243
+ );
244
+
245
+ // Focus on the listbox
246
+ await userEvent.tab();
247
+
248
+ // Act
249
+ await userEvent.keyboard("{enter}");
250
+
251
+ // Assert
252
+ expect(onChange).toHaveBeenCalledWith("option1");
253
+ });
254
+
255
+ it("should select an item when pressing space", async () => {
256
+ // Arrange
257
+ const onChange = jest.fn();
258
+ render(
259
+ <Listbox selectionType="single" value="" onChange={onChange}>
260
+ <OptionItem label="option 1" value="option1" />
261
+ <OptionItem label="option 2" value="option2" />
262
+ <OptionItem label="option 3" value="option3" />
263
+ </Listbox>,
264
+ {wrapper: RenderStateRoot},
265
+ );
266
+
267
+ // Focus on the listbox
268
+ await userEvent.tab();
269
+
270
+ // Act
271
+ await userEvent.keyboard(" ");
272
+
273
+ // Assert
274
+ expect(onChange).toHaveBeenCalledWith("option1");
275
+ });
276
+
277
+ it("should set the selected option item with aria-selected", async () => {
278
+ // Arrange
279
+ const onChange = jest.fn();
280
+ render(
281
+ <Listbox selectionType="single" value="" onChange={onChange}>
282
+ <OptionItem label="option 1" value="option1" />
283
+ <OptionItem label="option 2" value="option2" />
284
+ <OptionItem label="option 3" value="option3" />
285
+ </Listbox>,
286
+ {wrapper: RenderStateRoot},
287
+ );
288
+
289
+ // Focus on the listbox
290
+ await userEvent.tab();
291
+
292
+ // Act
293
+ await userEvent.keyboard("{enter}");
294
+
295
+ // Assert
296
+ expect(
297
+ screen.getByRole("option", {name: "option 1"}),
298
+ ).toHaveAttribute("aria-selected", "true");
299
+ });
300
+
301
+ it("should select an option item when clicking on it", async () => {
302
+ // Arrange
303
+ const onChange = jest.fn();
304
+ render(
305
+ <Listbox selectionType="single" value="" onChange={onChange}>
306
+ <OptionItem label="option 1" value="option1" />
307
+ <OptionItem label="option 2" value="option2" />
308
+ <OptionItem label="option 3" value="option3" />
309
+ </Listbox>,
310
+ {wrapper: RenderStateRoot},
311
+ );
312
+
313
+ // Act
314
+ await userEvent.click(
315
+ screen.getByRole("option", {name: "option 3"}),
316
+ );
317
+
318
+ // Assert
319
+ expect(onChange).toHaveBeenCalledWith("option3");
320
+ });
321
+
322
+ it("should not allow selecting an option item when disabled", async () => {
323
+ // Arrange
324
+ const onChange = jest.fn();
325
+ render(
326
+ <Listbox
327
+ selectionType="single"
328
+ value={null}
329
+ onChange={onChange}
330
+ disabled
331
+ >
332
+ <OptionItem label="option 1" value="option1" />
333
+ <OptionItem label="option 2" value="option2" />
334
+ <OptionItem label="option 3" value="option3" disabled />
335
+ </Listbox>,
336
+ {wrapper: RenderStateRoot},
337
+ );
338
+
339
+ // Act
340
+ await userEvent.click(
341
+ screen.getByRole("option", {name: "option 3"}),
342
+ );
343
+
344
+ // Assert
345
+ expect(onChange).not.toHaveBeenCalled();
346
+ });
347
+ });
348
+
349
+ describe("multiple selection", () => {
350
+ it("should select multiple option items when pressing Enter", async () => {
351
+ // Arrange
352
+ const onChange = jest.fn();
353
+ render(
354
+ <Listbox selectionType="multiple" onChange={onChange}>
355
+ <OptionItem label="option 1" value="option1" />
356
+ <OptionItem label="option 2" value="option2" />
357
+ <OptionItem label="option 3" value="option3" />
358
+ </Listbox>,
359
+ {wrapper: RenderStateRoot},
360
+ );
361
+
362
+ // Focus on the listbox
363
+ await userEvent.tab();
364
+
365
+ // Act
366
+ await userEvent.keyboard("{enter}");
367
+ await userEvent.keyboard("{arrowdown}");
368
+ await userEvent.keyboard("{enter}");
369
+
370
+ // Assert
371
+ expect(onChange).toHaveBeenCalledWith(["option1", "option2"]);
372
+ });
373
+ });
374
+
375
+ describe("aria", () => {
376
+ it("should announce aria-labelledby correctly", () => {
377
+ // Arrange
378
+ render(
379
+ <>
380
+ <Listbox
381
+ value={null}
382
+ selectionType="single"
383
+ aria-labelledby="label"
384
+ >
385
+ <OptionItem label="option 1" value="option1" />
386
+ <OptionItem label="option 2" value="option2" />
387
+ <OptionItem label="option 3" value="option3" />
388
+ </Listbox>
389
+ <div id="label">Accessible label</div>
390
+ </>,
391
+ {wrapper: RenderStateRoot},
392
+ );
393
+
394
+ // Assert
395
+ expect(
396
+ screen.getByRole("listbox", {
397
+ name: "Accessible label",
398
+ }),
399
+ ).toBeInTheDocument();
400
+ });
401
+
402
+ it("should announce aria-label correctly", () => {
403
+ // Arrange
404
+ render(
405
+ <Listbox
406
+ value={null}
407
+ selectionType="single"
408
+ aria-label="Accessible label"
409
+ >
410
+ <OptionItem label="option 1" value="option1" />
411
+ <OptionItem label="option 2" value="option2" />
412
+ <OptionItem label="option 3" value="option3" />
413
+ </Listbox>,
414
+ {wrapper: RenderStateRoot},
415
+ );
416
+
417
+ // Assert
418
+ expect(
419
+ screen.getByRole("listbox", {
420
+ name: "Accessible label",
421
+ }),
422
+ ).toBeInTheDocument();
423
+ });
424
+ });
425
+ });
@@ -0,0 +1,176 @@
1
+ import * as React from "react";
2
+ import {StyleSheet} from "aphrodite";
3
+ import {
4
+ StyleType,
5
+ useUniqueIdWithMock,
6
+ View,
7
+ } from "@khanacademy/wonder-blocks-core";
8
+ import {color} from "@khanacademy/wonder-blocks-tokens";
9
+
10
+ import {useListbox} from "../hooks/use-listbox";
11
+ import {MaybeValueOrValues, OptionItemComponent} from "../util/types";
12
+
13
+ type Props = {
14
+ /**
15
+ * The list of items to display in the listbox.
16
+ */
17
+ children: Array<OptionItemComponent>;
18
+
19
+ /**
20
+ * Whether the use can select more than one option item. Defaults to
21
+ * `single`.
22
+ *
23
+ * If `multiple` is selected, `aria-multiselectable={true}` is set
24
+ * internally in the listbox element.
25
+ */
26
+ selectionType: "single" | "multiple";
27
+
28
+ /**
29
+ * The value of the currently selected items.
30
+ */
31
+ value?: MaybeValueOrValues;
32
+
33
+ /**
34
+ * Callback for when the selection changes. The value passed as an argument
35
+ * is an updated array of the selected value(s).
36
+ */
37
+ onChange?: (value: MaybeValueOrValues) => void;
38
+
39
+ /**
40
+ * Provides a label for the listbox.
41
+ */
42
+ "aria-label"?: string;
43
+
44
+ /**
45
+ * A reference to the element that describes the listbox.
46
+ */
47
+ "aria-labelledby"?: string;
48
+
49
+ /**
50
+ * Whether the listbox is disabled.
51
+ *
52
+ * A disabled combobox does not support interaction, but it supports focus
53
+ * for a11y reasons. It internally maps to`aria-disabled`. Defaults to
54
+ * false.
55
+ */
56
+ disabled?: boolean;
57
+
58
+ /**
59
+ * The unique identifier of the listbox element.
60
+ */
61
+ id?: string;
62
+
63
+ /**
64
+ * TODO(WB-1678): Add async support to the listbox.
65
+ *
66
+ * Whether to display the loading state to let the user know that the
67
+ * results are being loaded asynchronously. Defaults to false.
68
+ */
69
+ loading?: boolean;
70
+
71
+ /**
72
+ * Optional custom styles applied to the listbox container.
73
+ */
74
+ style?: StyleType;
75
+
76
+ /**
77
+ * Includes the listbox in the page tab sequence.
78
+ */
79
+ tabIndex?: number;
80
+
81
+ /**
82
+ * Test ID used for e2e testing.
83
+ */
84
+ testId?: string;
85
+ };
86
+
87
+ /**
88
+ * A `Listbox` component presents a list of options and allows a user to select
89
+ * one or more of them. A listbox that allows a single option to be chosen is a
90
+ * single-select listbox; one that allows multiple options to be selected is a
91
+ * multi-select listbox.
92
+ *
93
+ * ### Usage
94
+ *
95
+ * ```tsx
96
+ * import {Listbox} from "@khanacademy/wonder-blocks-dropdown";
97
+ *
98
+ * <Listbox>
99
+ * <OptionItem label="Apple" value="apple" />
100
+ * <OptionItem disabled label="Strawberry" value="strawberry" />
101
+ * <OptionItem label="Pear" value="pear" />
102
+ * </Listbox>
103
+ * ```
104
+ */
105
+ export default function Listbox(props: Props) {
106
+ const {
107
+ children,
108
+ disabled,
109
+ id,
110
+ onChange,
111
+ selectionType = "single",
112
+ style,
113
+ tabIndex = 0,
114
+ testId,
115
+ value,
116
+ "aria-label": ariaLabel,
117
+ "aria-labelledby": ariaLabelledby,
118
+ } = props;
119
+
120
+ const ids = useUniqueIdWithMock("listbox");
121
+ const uniqueId = id ?? ids.get("id");
122
+
123
+ const {
124
+ focusedIndex,
125
+ isListboxFocused,
126
+ renderList,
127
+ selected,
128
+ // event handlers
129
+ handleKeyDown,
130
+ handleKeyUp,
131
+ handleFocus,
132
+ handleBlur,
133
+ } = useListbox({children, disabled, id: uniqueId, selectionType, value});
134
+ React.useEffect(() => {
135
+ // If the value changes, update the parent component.
136
+ if (selected && selected !== value) {
137
+ onChange?.(selected);
138
+ }
139
+ }, [onChange, selected, value]);
140
+
141
+ return (
142
+ <View
143
+ role="listbox"
144
+ aria-disabled={disabled}
145
+ id={uniqueId}
146
+ style={[styles.listbox, style, disabled && styles.disabled]}
147
+ tabIndex={tabIndex}
148
+ onKeyDown={handleKeyDown}
149
+ onKeyUp={handleKeyUp}
150
+ onFocus={handleFocus}
151
+ onBlur={handleBlur}
152
+ testId={testId}
153
+ // This is used to inform assistive technology users of the
154
+ // currently active element when focused.
155
+ // NOTE: This uses visual focus, not actual DOM focus.
156
+ // @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_vs_selection
157
+ aria-activedescendant={
158
+ isListboxFocused ? renderList[focusedIndex].props.id : undefined
159
+ }
160
+ aria-label={ariaLabel}
161
+ aria-labelledby={ariaLabelledby}
162
+ aria-multiselectable={selectionType === "multiple"}
163
+ >
164
+ {renderList}
165
+ </View>
166
+ );
167
+ }
168
+
169
+ const styles = StyleSheet.create({
170
+ listbox: {
171
+ outline: "none",
172
+ },
173
+ disabled: {
174
+ color: color.offBlack64,
175
+ },
176
+ });
@@ -18,6 +18,7 @@ import OptionItem from "./option-item";
18
18
  import type {
19
19
  DropdownItem,
20
20
  OpenerProps,
21
+ OptionItemComponent,
21
22
  OptionItemComponentArray,
22
23
  } from "../util/types";
23
24
  import {getLabel} from "../util/helpers";
@@ -406,12 +407,8 @@ export default class MultiSelect extends React.Component<Props, State> {
406
407
  -1,
407
408
  );
408
409
 
409
- const lastSelectedChildren: React.ReactElement<
410
- React.ComponentProps<typeof OptionItem>
411
- >[] = [];
412
- const restOfTheChildren: React.ReactElement<
413
- React.ComponentProps<typeof OptionItem>
414
- >[] = [];
410
+ const lastSelectedChildren: OptionItemComponentArray = [];
411
+ const restOfTheChildren: OptionItemComponentArray = [];
415
412
  for (const child of filteredChildren) {
416
413
  if (lastSelectedValues.includes(child.props.value)) {
417
414
  lastSelectedChildren.push(child);
@@ -440,23 +437,20 @@ export default class MultiSelect extends React.Component<Props, State> {
440
437
  ];
441
438
  }
442
439
 
443
- mapOptionItemToDropdownItem: (
444
- option: React.ReactElement<React.ComponentProps<typeof OptionItem>>,
445
- ) => DropdownItem = (
446
- option: React.ReactElement<React.ComponentProps<typeof OptionItem>>,
447
- ): DropdownItem => {
448
- const {selectedValues} = this.props;
449
- const {disabled, value} = option.props;
450
- return {
451
- component: option,
452
- focusable: !disabled,
453
- populatedProps: {
454
- onToggle: this.handleToggle,
455
- selected: selectedValues.includes(value),
456
- variant: "checkbox",
457
- },
440
+ mapOptionItemToDropdownItem: (option: OptionItemComponent) => DropdownItem =
441
+ (option: OptionItemComponent): DropdownItem => {
442
+ const {selectedValues} = this.props;
443
+ const {disabled, value} = option.props;
444
+ return {
445
+ component: option,
446
+ focusable: !disabled,
447
+ populatedProps: {
448
+ onToggle: this.handleToggle,
449
+ selected: selectedValues.includes(value),
450
+ variant: "checkbox",
451
+ },
452
+ };
458
453
  };
459
- };
460
454
 
461
455
  handleOpenerRef: (node?: any) => void = (node: any) => {
462
456
  const openerElement = ReactDOM.findDOMNode(node) as HTMLElement;