@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.
- package/CHANGELOG.md +26 -0
- package/dist/components/listbox.d.ts +85 -0
- package/dist/components/multi-select.d.ts +2 -2
- package/dist/components/option-item.d.ts +22 -0
- package/dist/es/index.js +313 -50
- package/dist/hooks/use-listbox.d.ts +73 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +312 -48
- package/dist/util/selection.d.ts +2 -0
- package/dist/util/types.d.ts +7 -1
- package/package.json +11 -11
- package/src/components/__tests__/listbox.test.tsx +425 -0
- package/src/components/listbox.tsx +176 -0
- package/src/components/multi-select.tsx +16 -22
- package/src/components/option-item.tsx +127 -15
- package/src/hooks/use-listbox.tsx +224 -0
- package/src/index.ts +2 -0
- package/src/util/__tests__/selection.test.ts +50 -0
- package/src/util/selection.ts +16 -0
- package/src/util/types.ts +12 -3
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -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:
|
|
410
|
-
|
|
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:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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;
|