@khanacademy/wonder-blocks-dropdown 5.3.8 → 5.4.0
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 +38 -0
- package/dist/components/action-menu.d.ts +15 -2
- package/dist/components/dropdown-core.d.ts +4 -0
- package/dist/components/dropdown-opener.d.ts +4 -0
- package/dist/components/multi-select.d.ts +9 -2
- package/dist/components/option-item.d.ts +1 -1
- package/dist/components/single-select.d.ts +9 -2
- package/dist/es/index.js +114 -61
- package/dist/hooks/use-listbox.d.ts +2 -2
- package/dist/index.js +113 -60
- package/package.json +6 -6
- package/src/components/__tests__/action-menu.test.tsx +630 -23
- package/src/components/__tests__/multi-select.test.tsx +293 -0
- package/src/components/__tests__/single-select.test.tsx +306 -0
- package/src/components/action-menu.tsx +85 -48
- package/src/components/dropdown-core.tsx +9 -2
- package/src/components/dropdown-opener.tsx +17 -1
- package/src/components/multi-select.tsx +94 -61
- package/src/components/option-item.tsx +1 -1
- package/src/components/select-opener.tsx +2 -2
- package/src/components/single-select.tsx +87 -57
- package/tsconfig-build.tsbuildinfo +1 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import * as ReactDOM from "react-dom";
|
|
3
3
|
import {StyleSheet} from "aphrodite";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
IDProvider,
|
|
6
|
+
type AriaProps,
|
|
7
|
+
type StyleType,
|
|
8
|
+
} from "@khanacademy/wonder-blocks-core";
|
|
5
9
|
import DropdownOpener from "./dropdown-opener";
|
|
6
10
|
import ActionItem from "./action-item";
|
|
7
11
|
import OptionItem from "./option-item";
|
|
@@ -72,6 +76,19 @@ type Props = AriaProps &
|
|
|
72
76
|
* element to access pointer event state.
|
|
73
77
|
*/
|
|
74
78
|
opener?: (openerProps: OpenerProps) => React.ReactElement<any>;
|
|
79
|
+
/**
|
|
80
|
+
* Unique identifier attached to the menu dropdown. If used, we need to
|
|
81
|
+
* guarantee that the ID is unique within everything rendered on a page.
|
|
82
|
+
* If one is not provided, one is auto-generated. It is used for the
|
|
83
|
+
* opener's `aria-controls` attribute for screenreaders.
|
|
84
|
+
*/
|
|
85
|
+
dropdownId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Unique identifier attached to the field control. If used, we need to
|
|
88
|
+
* guarantee that the ID is unique within everything rendered on a page.
|
|
89
|
+
* If one is not provided, one is auto-generated.
|
|
90
|
+
*/
|
|
91
|
+
id?: string;
|
|
75
92
|
}>;
|
|
76
93
|
|
|
77
94
|
type State = Readonly<{
|
|
@@ -203,14 +220,18 @@ export default class ActionMenu extends React.Component<Props, State> {
|
|
|
203
220
|
};
|
|
204
221
|
// @ts-expect-error [FEI-5019] - TS2345 - Argument of type 'ReactChild | ReactFragment | ReactPortal' is not assignable to parameter of type 'ReactElement<any, string | JSXElementConstructor<any>>'.
|
|
205
222
|
} else if (OptionItem.isClassOf(item)) {
|
|
223
|
+
const selected = selectedValues
|
|
224
|
+
? selectedValues.includes(value)
|
|
225
|
+
: false;
|
|
206
226
|
return {
|
|
207
227
|
...itemObject,
|
|
208
228
|
populatedProps: {
|
|
209
229
|
onToggle: this.handleOptionSelected,
|
|
210
|
-
selected
|
|
211
|
-
? selectedValues.includes(value)
|
|
212
|
-
: false,
|
|
230
|
+
selected,
|
|
213
231
|
variant: "check",
|
|
232
|
+
role: "menuitemcheckbox",
|
|
233
|
+
"aria-checked": selected,
|
|
234
|
+
"aria-selected": undefined,
|
|
214
235
|
},
|
|
215
236
|
};
|
|
216
237
|
} else {
|
|
@@ -229,61 +250,77 @@ export default class ActionMenu extends React.Component<Props, State> {
|
|
|
229
250
|
|
|
230
251
|
renderOpener(
|
|
231
252
|
numItems: number,
|
|
253
|
+
dropdownId: string,
|
|
232
254
|
): React.ReactElement<React.ComponentProps<typeof DropdownOpener>> {
|
|
233
|
-
const {disabled, menuText, opener, testId} = this.props;
|
|
255
|
+
const {disabled, menuText, opener, testId, id} = this.props;
|
|
234
256
|
|
|
235
257
|
return (
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
<IDProvider id={id} scope="action-menu-opener">
|
|
259
|
+
{(uniqueOpenerId) => (
|
|
260
|
+
<DropdownOpener
|
|
261
|
+
id={uniqueOpenerId}
|
|
262
|
+
aria-controls={dropdownId}
|
|
263
|
+
aria-haspopup="menu"
|
|
264
|
+
onClick={this.handleClick}
|
|
265
|
+
disabled={numItems === 0 || disabled}
|
|
266
|
+
text={menuText}
|
|
267
|
+
ref={this.handleOpenerRef}
|
|
268
|
+
testId={opener ? undefined : testId}
|
|
269
|
+
opened={this.state.opened}
|
|
270
|
+
>
|
|
271
|
+
{opener
|
|
272
|
+
? opener
|
|
273
|
+
: (openerProps) => {
|
|
274
|
+
const {
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
276
|
+
text,
|
|
277
|
+
opened,
|
|
278
|
+
...eventState
|
|
279
|
+
} = openerProps;
|
|
280
|
+
return (
|
|
281
|
+
<ActionMenuOpenerCore
|
|
282
|
+
{...eventState}
|
|
283
|
+
disabled={disabled}
|
|
284
|
+
opened={!!opened}
|
|
285
|
+
testId={testId}
|
|
286
|
+
>
|
|
287
|
+
{menuText}
|
|
288
|
+
</ActionMenuOpenerCore>
|
|
289
|
+
);
|
|
290
|
+
}}
|
|
291
|
+
</DropdownOpener>
|
|
292
|
+
)}
|
|
293
|
+
</IDProvider>
|
|
265
294
|
);
|
|
266
295
|
}
|
|
267
296
|
|
|
268
297
|
render(): React.ReactNode {
|
|
269
|
-
const {alignment, dropdownStyle, style, className} =
|
|
298
|
+
const {alignment, dropdownStyle, style, className, dropdownId} =
|
|
299
|
+
this.props;
|
|
270
300
|
|
|
271
301
|
const items = this.getMenuItems();
|
|
272
|
-
const dropdownOpener = this.renderOpener(items.length);
|
|
273
302
|
|
|
274
303
|
return (
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
<IDProvider id={dropdownId} scope="action-menu-dropdown">
|
|
305
|
+
{(uniqueDropdownId) => (
|
|
306
|
+
<DropdownCore
|
|
307
|
+
id={uniqueDropdownId}
|
|
308
|
+
role="menu"
|
|
309
|
+
style={style}
|
|
310
|
+
className={className}
|
|
311
|
+
opener={this.renderOpener(
|
|
312
|
+
items.length,
|
|
313
|
+
uniqueDropdownId,
|
|
314
|
+
)}
|
|
315
|
+
alignment={alignment}
|
|
316
|
+
open={this.state.opened}
|
|
317
|
+
items={items}
|
|
318
|
+
openerElement={this.openerElement}
|
|
319
|
+
onOpenChanged={this.handleOpenChanged}
|
|
320
|
+
dropdownStyle={[styles.menuTopSpace, dropdownStyle]}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</IDProvider>
|
|
287
324
|
);
|
|
288
325
|
}
|
|
289
326
|
}
|
|
@@ -173,6 +173,10 @@ type ExportProps = Readonly<{
|
|
|
173
173
|
* Whether the dropdown and it's interactions should be disabled.
|
|
174
174
|
*/
|
|
175
175
|
disabled?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Unique identifier attached to the dropdown.
|
|
178
|
+
*/
|
|
179
|
+
id?: string;
|
|
176
180
|
|
|
177
181
|
// Optional props with defaults
|
|
178
182
|
/**
|
|
@@ -861,7 +865,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
861
865
|
},
|
|
862
866
|
// Only pass the ref if the item is focusable.
|
|
863
867
|
ref: focusable ? currentRef : null,
|
|
864
|
-
role: itemRole,
|
|
868
|
+
role: populatedProps.role || itemRole,
|
|
865
869
|
});
|
|
866
870
|
});
|
|
867
871
|
}
|
|
@@ -879,6 +883,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
879
883
|
const itemRole = this.getItemRole();
|
|
880
884
|
|
|
881
885
|
return this.props.items.map((item, index) => {
|
|
886
|
+
const {populatedProps} = item;
|
|
882
887
|
if (!SeparatorItem.isClassOf(item.component) && item.focusable) {
|
|
883
888
|
focusCounter += 1;
|
|
884
889
|
}
|
|
@@ -887,7 +892,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
887
892
|
|
|
888
893
|
return {
|
|
889
894
|
...item,
|
|
890
|
-
role: itemRole,
|
|
895
|
+
role: populatedProps.role || itemRole,
|
|
891
896
|
ref: item.focusable
|
|
892
897
|
? this.state.itemRefs[focusIndex]
|
|
893
898
|
? this.state.itemRefs[focusIndex].ref
|
|
@@ -952,6 +957,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
952
957
|
light,
|
|
953
958
|
openerElement,
|
|
954
959
|
role,
|
|
960
|
+
id,
|
|
955
961
|
} = this.props;
|
|
956
962
|
|
|
957
963
|
// The dropdown width is at least the width of the opener.
|
|
@@ -977,6 +983,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
977
983
|
>
|
|
978
984
|
{isFilterable && this.renderSearchField()}
|
|
979
985
|
<View
|
|
986
|
+
id={id}
|
|
980
987
|
role={role}
|
|
981
988
|
style={[
|
|
982
989
|
styles.listboxOrMenu,
|
|
@@ -39,6 +39,10 @@ type Props = Partial<Omit<AriaProps, "aria-disabled">> & {
|
|
|
39
39
|
* Whether the dropdown is opened.
|
|
40
40
|
*/
|
|
41
41
|
opened: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The unique identifier for the opener.
|
|
44
|
+
*/
|
|
45
|
+
id?: string;
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
type DefaultProps = {
|
|
@@ -58,7 +62,15 @@ class DropdownOpener extends React.Component<Props> {
|
|
|
58
62
|
eventState: ClickableState,
|
|
59
63
|
clickableChildrenProps: ChildrenProps,
|
|
60
64
|
): React.ReactElement {
|
|
61
|
-
const {
|
|
65
|
+
const {
|
|
66
|
+
disabled,
|
|
67
|
+
testId,
|
|
68
|
+
text,
|
|
69
|
+
opened,
|
|
70
|
+
"aria-controls": ariaControls,
|
|
71
|
+
"aria-haspopup": ariaHasPopUp,
|
|
72
|
+
id,
|
|
73
|
+
} = this.props;
|
|
62
74
|
const renderedChildren = this.props.children({
|
|
63
75
|
...eventState,
|
|
64
76
|
text,
|
|
@@ -70,6 +82,10 @@ class DropdownOpener extends React.Component<Props> {
|
|
|
70
82
|
return React.cloneElement(renderedChildren, {
|
|
71
83
|
...clickableChildrenProps,
|
|
72
84
|
disabled,
|
|
85
|
+
"aria-controls": ariaControls,
|
|
86
|
+
id,
|
|
87
|
+
"aria-expanded": opened ? "true" : "false",
|
|
88
|
+
"aria-haspopup": ariaHasPopUp,
|
|
73
89
|
onClick: childrenProps.onClick
|
|
74
90
|
? (e: React.MouseEvent) => {
|
|
75
91
|
// This is done to avoid overriding a
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import * as ReactDOM from "react-dom";
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
IDProvider,
|
|
6
|
+
type AriaProps,
|
|
7
|
+
type StyleType,
|
|
8
|
+
} from "@khanacademy/wonder-blocks-core";
|
|
5
9
|
|
|
6
10
|
import ActionItem from "./action-item";
|
|
7
11
|
import DropdownCore from "./dropdown-core";
|
|
@@ -158,6 +162,13 @@ type Props = AriaProps &
|
|
|
158
162
|
* Test ID used for e2e testing.
|
|
159
163
|
*/
|
|
160
164
|
testId?: string;
|
|
165
|
+
/**
|
|
166
|
+
* Unique identifier attached to the listbox dropdown. If used, we need to
|
|
167
|
+
* guarantee that the ID is unique within everything rendered on a page.
|
|
168
|
+
* If one is not provided, one is auto-generated. It is used for the
|
|
169
|
+
* opener's `aria-controls` attribute for screenreaders.
|
|
170
|
+
*/
|
|
171
|
+
dropdownId?: string;
|
|
161
172
|
}>;
|
|
162
173
|
|
|
163
174
|
type State = Readonly<{
|
|
@@ -479,6 +490,7 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
479
490
|
React.ComponentProps<typeof OptionItem>
|
|
480
491
|
>[],
|
|
481
492
|
isDisabled: boolean,
|
|
493
|
+
dropdownId: string,
|
|
482
494
|
):
|
|
483
495
|
| React.ReactElement<React.ComponentProps<typeof DropdownOpener>>
|
|
484
496
|
| React.ReactElement<React.ComponentProps<typeof SelectOpener>> {
|
|
@@ -511,30 +523,40 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
511
523
|
|
|
512
524
|
const menuText = this.getMenuText(allChildren);
|
|
513
525
|
|
|
514
|
-
const dropdownOpener =
|
|
515
|
-
<
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
526
|
+
const dropdownOpener = (
|
|
527
|
+
<IDProvider id={id} scope="multi-select-opener">
|
|
528
|
+
{(uniqueOpenerId) => {
|
|
529
|
+
return opener ? (
|
|
530
|
+
<DropdownOpener
|
|
531
|
+
id={uniqueOpenerId}
|
|
532
|
+
aria-controls={dropdownId}
|
|
533
|
+
aria-haspopup="listbox"
|
|
534
|
+
onClick={this.handleClick}
|
|
535
|
+
disabled={isDisabled}
|
|
536
|
+
ref={this.handleOpenerRef}
|
|
537
|
+
text={menuText}
|
|
538
|
+
opened={this.state.open}
|
|
539
|
+
>
|
|
540
|
+
{opener}
|
|
541
|
+
</DropdownOpener>
|
|
542
|
+
) : (
|
|
543
|
+
<SelectOpener
|
|
544
|
+
{...sharedProps}
|
|
545
|
+
disabled={isDisabled}
|
|
546
|
+
id={uniqueOpenerId}
|
|
547
|
+
aria-controls={dropdownId}
|
|
548
|
+
isPlaceholder={menuText === noneSelected}
|
|
549
|
+
light={light}
|
|
550
|
+
onOpenChanged={this.handleOpenChanged}
|
|
551
|
+
open={this.state.open}
|
|
552
|
+
ref={this.handleOpenerRef}
|
|
553
|
+
testId={testId}
|
|
554
|
+
>
|
|
555
|
+
{menuText}
|
|
556
|
+
</SelectOpener>
|
|
557
|
+
);
|
|
558
|
+
}}
|
|
559
|
+
</IDProvider>
|
|
538
560
|
);
|
|
539
561
|
|
|
540
562
|
return dropdownOpener;
|
|
@@ -552,6 +574,7 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
552
574
|
"aria-invalid": ariaInvalid,
|
|
553
575
|
"aria-required": ariaRequired,
|
|
554
576
|
disabled,
|
|
577
|
+
dropdownId,
|
|
555
578
|
} = this.props;
|
|
556
579
|
const {open, searchText} = this.state;
|
|
557
580
|
const {clearSearch, filter, noResults, someSelected} =
|
|
@@ -567,44 +590,54 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
567
590
|
).length;
|
|
568
591
|
const filteredItems = this.getMenuItems(allChildren);
|
|
569
592
|
const isDisabled = numEnabledOptions === 0 || disabled;
|
|
570
|
-
const opener = this.renderOpener(allChildren, isDisabled);
|
|
571
593
|
|
|
572
594
|
return (
|
|
573
|
-
<
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
595
|
+
<IDProvider id={dropdownId} scope="multi-select-dropdown">
|
|
596
|
+
{(uniqueDropdownId) => (
|
|
597
|
+
<DropdownCore
|
|
598
|
+
id={uniqueDropdownId}
|
|
599
|
+
role="listbox"
|
|
600
|
+
alignment={alignment}
|
|
601
|
+
dropdownStyle={[
|
|
602
|
+
isFilterable && filterableDropdownStyle,
|
|
603
|
+
selectDropdownStyle,
|
|
604
|
+
dropdownStyle,
|
|
605
|
+
]}
|
|
606
|
+
isFilterable={isFilterable}
|
|
607
|
+
items={[
|
|
608
|
+
...this.getShortcuts(numEnabledOptions),
|
|
609
|
+
...filteredItems,
|
|
610
|
+
]}
|
|
611
|
+
light={light}
|
|
612
|
+
onOpenChanged={this.handleOpenChanged}
|
|
613
|
+
open={open}
|
|
614
|
+
opener={this.renderOpener(
|
|
615
|
+
allChildren,
|
|
616
|
+
isDisabled,
|
|
617
|
+
uniqueDropdownId,
|
|
618
|
+
)}
|
|
619
|
+
openerElement={this.state.openerElement}
|
|
620
|
+
selectionType="multi"
|
|
621
|
+
style={style}
|
|
622
|
+
className={className}
|
|
623
|
+
onSearchTextChanged={
|
|
624
|
+
isFilterable
|
|
625
|
+
? this.handleSearchTextChanged
|
|
626
|
+
: undefined
|
|
627
|
+
}
|
|
628
|
+
searchText={isFilterable ? searchText : ""}
|
|
629
|
+
labels={{
|
|
630
|
+
clearSearch,
|
|
631
|
+
filter,
|
|
632
|
+
noResults,
|
|
633
|
+
someResults: someSelected,
|
|
634
|
+
}}
|
|
635
|
+
aria-invalid={ariaInvalid}
|
|
636
|
+
aria-required={ariaRequired}
|
|
637
|
+
disabled={isDisabled}
|
|
638
|
+
/>
|
|
639
|
+
)}
|
|
640
|
+
</IDProvider>
|
|
608
641
|
);
|
|
609
642
|
}
|
|
610
643
|
}
|
|
@@ -322,7 +322,7 @@ const _generateStyles = (
|
|
|
322
322
|
newStyles = {
|
|
323
323
|
default: {
|
|
324
324
|
background: error ? tokens.color.fadedRed8 : tokens.color.white,
|
|
325
|
-
borderColor: error ? tokens.color.red : tokens.color.
|
|
325
|
+
borderColor: error ? tokens.color.red : tokens.color.offBlack50,
|
|
326
326
|
borderWidth: tokens.border.width.hairline,
|
|
327
327
|
color: placeholder
|
|
328
328
|
? tokens.color.offBlack64
|
|
@@ -334,7 +334,7 @@ const _generateStyles = (
|
|
|
334
334
|
":hover:not([aria-disabled=true])": {
|
|
335
335
|
borderColor: error
|
|
336
336
|
? tokens.color.red
|
|
337
|
-
: tokens.color.
|
|
337
|
+
: tokens.color.offBlack50,
|
|
338
338
|
borderWidth: tokens.border.width.hairline,
|
|
339
339
|
paddingLeft: tokens.spacing.medium_16,
|
|
340
340
|
paddingRight: tokens.spacing.small_12,
|