@khanacademy/wonder-blocks-dropdown 2.7.0 → 2.7.3
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 +28 -0
- package/dist/es/index.js +50 -7
- package/dist/index.js +71 -7
- package/package.json +10 -10
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +494 -0
- package/src/components/__tests__/dropdown-core-virtualized.test.js +0 -9
- package/src/components/__tests__/dropdown-core.test.js +65 -42
- package/src/components/__tests__/multi-select.test.js +57 -0
- package/src/components/__tests__/single-select.test.js +30 -0
- package/src/components/dropdown-core.js +55 -1
- package/src/components/multi-select.js +2 -1
- package/src/components/search-text-input.js +22 -0
- package/src/components/single-select.stories.js +9 -2
- package/src/util/constants.js +3 -0
|
@@ -31,6 +31,19 @@ const items = [
|
|
|
31
31
|
},
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
+
const searchFieldItem = {
|
|
35
|
+
component: (
|
|
36
|
+
<SearchTextInput
|
|
37
|
+
testId="search-text-input"
|
|
38
|
+
key="search-text-input"
|
|
39
|
+
onChange={jest.fn()}
|
|
40
|
+
searchText={""}
|
|
41
|
+
/>
|
|
42
|
+
),
|
|
43
|
+
focusable: true,
|
|
44
|
+
populatedProps: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
34
47
|
describe("DropdownCore", () => {
|
|
35
48
|
it("should throw for invalid role", () => {
|
|
36
49
|
// Arrange
|
|
@@ -342,18 +355,7 @@ describe("DropdownCore", () => {
|
|
|
342
355
|
searchText=""
|
|
343
356
|
// mock the items
|
|
344
357
|
items={[
|
|
345
|
-
|
|
346
|
-
component: (
|
|
347
|
-
<SearchTextInput
|
|
348
|
-
testId="search"
|
|
349
|
-
key="search-text-input"
|
|
350
|
-
onChange={jest.fn()}
|
|
351
|
-
searchText={""}
|
|
352
|
-
/>
|
|
353
|
-
),
|
|
354
|
-
focusable: true,
|
|
355
|
-
populatedProps: {},
|
|
356
|
-
},
|
|
358
|
+
searchFieldItem,
|
|
357
359
|
{
|
|
358
360
|
component: (
|
|
359
361
|
<OptionItem
|
|
@@ -754,21 +756,7 @@ describe("DropdownCore", () => {
|
|
|
754
756
|
onSearchTextChanged={jest.fn()}
|
|
755
757
|
searchText=""
|
|
756
758
|
// mock the items
|
|
757
|
-
items={[
|
|
758
|
-
{
|
|
759
|
-
component: (
|
|
760
|
-
<SearchTextInput
|
|
761
|
-
testId="search-text-input"
|
|
762
|
-
key="search-text-input"
|
|
763
|
-
onChange={jest.fn()}
|
|
764
|
-
searchText={""}
|
|
765
|
-
/>
|
|
766
|
-
),
|
|
767
|
-
focusable: true,
|
|
768
|
-
populatedProps: {},
|
|
769
|
-
},
|
|
770
|
-
...optionItems,
|
|
771
|
-
]}
|
|
759
|
+
items={[searchFieldItem, ...optionItems]}
|
|
772
760
|
role="listbox"
|
|
773
761
|
open={true}
|
|
774
762
|
// mock the opener elements
|
|
@@ -797,21 +785,7 @@ describe("DropdownCore", () => {
|
|
|
797
785
|
onSearchTextChanged={jest.fn()}
|
|
798
786
|
searchText=""
|
|
799
787
|
// mock the items
|
|
800
|
-
items={[
|
|
801
|
-
{
|
|
802
|
-
component: (
|
|
803
|
-
<SearchTextInput
|
|
804
|
-
testId="search-text-input"
|
|
805
|
-
key="search-text-input"
|
|
806
|
-
onChange={jest.fn()}
|
|
807
|
-
searchText={""}
|
|
808
|
-
/>
|
|
809
|
-
),
|
|
810
|
-
focusable: true,
|
|
811
|
-
populatedProps: {},
|
|
812
|
-
},
|
|
813
|
-
...optionItems,
|
|
814
|
-
]}
|
|
788
|
+
items={[searchFieldItem, ...optionItems]}
|
|
815
789
|
role="listbox"
|
|
816
790
|
open={true}
|
|
817
791
|
// mock the opener elements
|
|
@@ -833,4 +807,53 @@ describe("DropdownCore", () => {
|
|
|
833
807
|
});
|
|
834
808
|
});
|
|
835
809
|
});
|
|
810
|
+
|
|
811
|
+
describe("a11y > Live region", () => {
|
|
812
|
+
it("should render a live region announcing the number of options", async () => {
|
|
813
|
+
// Arrange
|
|
814
|
+
|
|
815
|
+
// Act
|
|
816
|
+
const {container} = render(
|
|
817
|
+
<DropdownCore
|
|
818
|
+
initialFocusedIndex={undefined}
|
|
819
|
+
onSearchTextChanged={jest.fn()}
|
|
820
|
+
// mock the items (3 options)
|
|
821
|
+
items={items}
|
|
822
|
+
role="listbox"
|
|
823
|
+
open={true}
|
|
824
|
+
// mock the opener elements
|
|
825
|
+
opener={<button />}
|
|
826
|
+
openerElement={null}
|
|
827
|
+
onOpenChanged={jest.fn()}
|
|
828
|
+
/>,
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
// Assert
|
|
832
|
+
expect(container).toHaveTextContent("3 items");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("shouldn't include the search field as part of the options", async () => {
|
|
836
|
+
// Arrange
|
|
837
|
+
|
|
838
|
+
// Act
|
|
839
|
+
const {container} = render(
|
|
840
|
+
<DropdownCore
|
|
841
|
+
initialFocusedIndex={undefined}
|
|
842
|
+
onSearchTextChanged={jest.fn()}
|
|
843
|
+
searchText=""
|
|
844
|
+
// mock the items (3 options + search field)
|
|
845
|
+
items={[searchFieldItem, ...items]}
|
|
846
|
+
role="listbox"
|
|
847
|
+
open={true}
|
|
848
|
+
// mock the opener elements
|
|
849
|
+
opener={<button />}
|
|
850
|
+
openerElement={null}
|
|
851
|
+
onOpenChanged={jest.fn()}
|
|
852
|
+
/>,
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// Assert
|
|
856
|
+
expect(container).toHaveTextContent("3 items");
|
|
857
|
+
});
|
|
858
|
+
});
|
|
836
859
|
});
|
|
@@ -4,6 +4,8 @@ import * as React from "react";
|
|
|
4
4
|
import {render, screen} from "@testing-library/react";
|
|
5
5
|
import userEvent from "@testing-library/user-event";
|
|
6
6
|
|
|
7
|
+
import {ngettext} from "@khanacademy/wonder-blocks-i18n";
|
|
8
|
+
|
|
7
9
|
import OptionItem from "../option-item.js";
|
|
8
10
|
import MultiSelect from "../multi-select.js";
|
|
9
11
|
|
|
@@ -1187,4 +1189,59 @@ describe("MultiSelect", () => {
|
|
|
1187
1189
|
).toBeInTheDocument();
|
|
1188
1190
|
});
|
|
1189
1191
|
});
|
|
1192
|
+
|
|
1193
|
+
describe("a11y > Live region", () => {
|
|
1194
|
+
it("should announce the number of options when the listbox is open", async () => {
|
|
1195
|
+
// Arrange
|
|
1196
|
+
const labels: $Shape<Labels> = {
|
|
1197
|
+
someSelected: (numOptions: number): string =>
|
|
1198
|
+
ngettext("%(num)s school", "%(num)s schools", numOptions),
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
// Act
|
|
1202
|
+
const {container} = render(
|
|
1203
|
+
<MultiSelect
|
|
1204
|
+
onChange={jest.fn()}
|
|
1205
|
+
isFilterable={true}
|
|
1206
|
+
labels={labels}
|
|
1207
|
+
opened={true}
|
|
1208
|
+
>
|
|
1209
|
+
<OptionItem label="school 1" value="1" />
|
|
1210
|
+
<OptionItem label="school 2" value="2" />
|
|
1211
|
+
<OptionItem label="school 3" value="3" />
|
|
1212
|
+
</MultiSelect>,
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
// Assert
|
|
1216
|
+
expect(container).toHaveTextContent("3 schools");
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it("should change the number of options after using the search filter", async () => {
|
|
1220
|
+
// Arrange
|
|
1221
|
+
const labels: $Shape<Labels> = {
|
|
1222
|
+
someSelected: (numOptions: number): string =>
|
|
1223
|
+
ngettext("%(num)s planet", "%(num)s planets", numOptions),
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const {container} = render(
|
|
1227
|
+
<MultiSelect
|
|
1228
|
+
onChange={jest.fn()}
|
|
1229
|
+
isFilterable={true}
|
|
1230
|
+
shortcuts={true}
|
|
1231
|
+
labels={labels}
|
|
1232
|
+
opened={true}
|
|
1233
|
+
>
|
|
1234
|
+
<OptionItem label="Earth" value="earth" />
|
|
1235
|
+
<OptionItem label="Venus" value="venus" />
|
|
1236
|
+
<OptionItem label="Mars" value="mars" />
|
|
1237
|
+
</MultiSelect>,
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
// Act
|
|
1241
|
+
userEvent.paste(screen.getByRole("textbox"), "Ear");
|
|
1242
|
+
|
|
1243
|
+
// Assert
|
|
1244
|
+
expect(container).toHaveTextContent("1 planet");
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1190
1247
|
});
|
|
@@ -692,4 +692,34 @@ describe("SingleSelect", () => {
|
|
|
692
692
|
expect(dropdownMenu).toHaveStyle("max-height: 200px");
|
|
693
693
|
});
|
|
694
694
|
});
|
|
695
|
+
|
|
696
|
+
describe("a11y > Live region", () => {
|
|
697
|
+
it("should change the number of options after using the search filter", async () => {
|
|
698
|
+
// Arrange
|
|
699
|
+
render(
|
|
700
|
+
<SingleSelect
|
|
701
|
+
onChange={onChange}
|
|
702
|
+
placeholder="Choose"
|
|
703
|
+
isFilterable={true}
|
|
704
|
+
opened={true}
|
|
705
|
+
>
|
|
706
|
+
<OptionItem label="item 0" value="0" />
|
|
707
|
+
<OptionItem label="item 1" value="1" />
|
|
708
|
+
<OptionItem label="item 2" value="2" />
|
|
709
|
+
</SingleSelect>,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// Act
|
|
713
|
+
userEvent.paste(screen.getByRole("textbox"), "item 0");
|
|
714
|
+
|
|
715
|
+
// Assert
|
|
716
|
+
const liveRegionText = screen.getByTestId(
|
|
717
|
+
"dropdown-live-region",
|
|
718
|
+
).textContent;
|
|
719
|
+
|
|
720
|
+
// TODO(WB-1318): Change this assertion to `1 item` after adding the
|
|
721
|
+
// `labels` prop to the component.
|
|
722
|
+
expect(liveRegionText).toEqual("1 items");
|
|
723
|
+
});
|
|
724
|
+
});
|
|
695
725
|
});
|
|
@@ -10,7 +10,7 @@ import {VariableSizeList as List} from "react-window";
|
|
|
10
10
|
import Color, {fade} from "@khanacademy/wonder-blocks-color";
|
|
11
11
|
|
|
12
12
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
13
|
-
import {View} from "@khanacademy/wonder-blocks-core";
|
|
13
|
+
import {addStyle, View} from "@khanacademy/wonder-blocks-core";
|
|
14
14
|
import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
|
|
15
15
|
import {withActionScheduler} from "@khanacademy/wonder-blocks-timing";
|
|
16
16
|
|
|
@@ -42,8 +42,17 @@ import {
|
|
|
42
42
|
*/
|
|
43
43
|
const VIRTUALIZE_THRESHOLD = 125;
|
|
44
44
|
|
|
45
|
+
const StyledSpan = addStyle("span");
|
|
46
|
+
|
|
45
47
|
type Labels = {|
|
|
46
48
|
noResults: string,
|
|
49
|
+
/**
|
|
50
|
+
* The number total of items available.
|
|
51
|
+
*
|
|
52
|
+
* NOTE: We are reusing the same label for both the total number of items
|
|
53
|
+
* and the number of selected items.
|
|
54
|
+
*/
|
|
55
|
+
someSelected: (numOptions: number) => string,
|
|
47
56
|
|};
|
|
48
57
|
|
|
49
58
|
// we need to define a DefaultProps type to allow the HOC expose the default
|
|
@@ -212,6 +221,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
212
221
|
alignment: "left",
|
|
213
222
|
labels: {
|
|
214
223
|
noResults: defaultLabels.noResults,
|
|
224
|
+
someSelected: defaultLabels.someSelected,
|
|
215
225
|
},
|
|
216
226
|
light: false,
|
|
217
227
|
};
|
|
@@ -669,6 +679,12 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
669
679
|
},
|
|
670
680
|
// apply custom styles
|
|
671
681
|
style: searchInputStyle,
|
|
682
|
+
// TODO(WB-1310): Remove the autofocus prop after making
|
|
683
|
+
// the search field sticky.
|
|
684
|
+
// Currently autofocusing on the search field to work
|
|
685
|
+
// around it losing focus on mount when switching between
|
|
686
|
+
// virtualized and non-virtualized dropdown filter results.
|
|
687
|
+
autofocus: this.focusedIndex === 0,
|
|
672
688
|
});
|
|
673
689
|
}
|
|
674
690
|
|
|
@@ -719,6 +735,12 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
719
735
|
itemRef: this.state.itemRefs[focusIndex]
|
|
720
736
|
? this.state.itemRefs[focusIndex].ref
|
|
721
737
|
: null,
|
|
738
|
+
// TODO(WB-1310): Remove the autofocus prop after making
|
|
739
|
+
// the search field sticky.
|
|
740
|
+
// Currently autofocusing on the search field to work
|
|
741
|
+
// around it losing focus on mount when switching between
|
|
742
|
+
// virtualized and non-virtualized dropdown filter results.
|
|
743
|
+
autofocus: this.focusedIndex === 0,
|
|
722
744
|
},
|
|
723
745
|
};
|
|
724
746
|
}
|
|
@@ -825,6 +847,26 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
825
847
|
);
|
|
826
848
|
}
|
|
827
849
|
|
|
850
|
+
renderLiveRegion(): React.Node {
|
|
851
|
+
const {items, open} = this.props;
|
|
852
|
+
const {labels} = this.state;
|
|
853
|
+
const totalItems = this.hasSearchBox()
|
|
854
|
+
? items.length - 1
|
|
855
|
+
: items.length;
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<StyledSpan
|
|
859
|
+
aria-live="polite"
|
|
860
|
+
aria-atomic="true"
|
|
861
|
+
aria-relevant="additions text"
|
|
862
|
+
style={styles.srOnly}
|
|
863
|
+
data-test-id="dropdown-live-region"
|
|
864
|
+
>
|
|
865
|
+
{open && labels.someSelected(totalItems)}
|
|
866
|
+
</StyledSpan>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
828
870
|
render(): React.Node {
|
|
829
871
|
const {open, opener, style, className} = this.props;
|
|
830
872
|
|
|
@@ -835,6 +877,7 @@ class DropdownCore extends React.Component<Props, State> {
|
|
|
835
877
|
style={[styles.menuWrapper, style]}
|
|
836
878
|
className={className}
|
|
837
879
|
>
|
|
880
|
+
{this.renderLiveRegion()}
|
|
838
881
|
{opener}
|
|
839
882
|
{open && this.renderDropdown()}
|
|
840
883
|
</View>
|
|
@@ -872,6 +915,17 @@ const styles = StyleSheet.create({
|
|
|
872
915
|
alignSelf: "center",
|
|
873
916
|
marginTop: Spacing.xxSmall_6,
|
|
874
917
|
},
|
|
918
|
+
|
|
919
|
+
srOnly: {
|
|
920
|
+
border: 0,
|
|
921
|
+
clip: "rect(0,0,0,0)",
|
|
922
|
+
height: 1,
|
|
923
|
+
margin: -1,
|
|
924
|
+
overflow: "hidden",
|
|
925
|
+
padding: 0,
|
|
926
|
+
position: "absolute",
|
|
927
|
+
width: 1,
|
|
928
|
+
},
|
|
875
929
|
});
|
|
876
930
|
|
|
877
931
|
type ExportProps = WithoutActionScheduler<
|
|
@@ -556,7 +556,7 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
556
556
|
isFilterable,
|
|
557
557
|
} = this.props;
|
|
558
558
|
const {open, searchText} = this.state;
|
|
559
|
-
const {noResults} = this.state.labels;
|
|
559
|
+
const {noResults, someSelected} = this.state.labels;
|
|
560
560
|
|
|
561
561
|
const allChildren = React.Children.toArray(children).filter(Boolean);
|
|
562
562
|
const numOptions = allChildren.length;
|
|
@@ -590,6 +590,7 @@ export default class MultiSelect extends React.Component<Props, State> {
|
|
|
590
590
|
searchText={isFilterable ? searchText : ""}
|
|
591
591
|
labels={{
|
|
592
592
|
noResults,
|
|
593
|
+
someSelected,
|
|
593
594
|
}}
|
|
594
595
|
/>
|
|
595
596
|
);
|
|
@@ -51,6 +51,13 @@ type Props = {|
|
|
|
51
51
|
* Test ID used for e2e testing.
|
|
52
52
|
*/
|
|
53
53
|
testId?: string,
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Automatically focus on this search field on mount.
|
|
57
|
+
* TODO(WB-1310): Remove the autofocus prop after making
|
|
58
|
+
* the search field sticky in dropdowns.
|
|
59
|
+
*/
|
|
60
|
+
autofocus?: boolean,
|
|
54
61
|
|};
|
|
55
62
|
|
|
56
63
|
type DefaultProps = {|
|
|
@@ -71,6 +78,21 @@ export default class SearchTextInput extends React.Component<Props> {
|
|
|
71
78
|
},
|
|
72
79
|
};
|
|
73
80
|
|
|
81
|
+
// TODO(WB-1310): Remove `componentDidMount` autofocus on the search field
|
|
82
|
+
// after making the search field sticky.
|
|
83
|
+
componentDidMount() {
|
|
84
|
+
// We need to re-focus on the text input after it mounts because of
|
|
85
|
+
// the case in which the dropdown switches between virtualized and
|
|
86
|
+
// non-virtualized. It can rerender the search field as the user is
|
|
87
|
+
// typing based on the number of search results, which results
|
|
88
|
+
// in losing focus on the field so the user can't type anymore.
|
|
89
|
+
// To work around this issue, this temporary fix auto-focuses on the
|
|
90
|
+
// search field on mount.
|
|
91
|
+
if (this.props.autofocus) {
|
|
92
|
+
this.props.itemRef?.current.focus();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
static __IS_SEARCH_TEXT_INPUT__: boolean = true;
|
|
75
97
|
|
|
76
98
|
render(): React.Node {
|
|
@@ -79,25 +79,28 @@ const optionItems = new Array(1000)
|
|
|
79
79
|
));
|
|
80
80
|
|
|
81
81
|
type Props = {|
|
|
82
|
+
selectedValue?: ?string,
|
|
82
83
|
opened: boolean,
|
|
83
84
|
|};
|
|
84
85
|
|
|
85
86
|
type State = {|
|
|
86
|
-
selectedValue
|
|
87
|
+
selectedValue?: ?string,
|
|
87
88
|
opened: boolean,
|
|
88
89
|
|};
|
|
89
90
|
|
|
90
91
|
type DefaultProps = {|
|
|
92
|
+
selectedValue: $PropertyType<Props, "selectedValue">,
|
|
91
93
|
opened: $PropertyType<Props, "opened">,
|
|
92
94
|
|};
|
|
93
95
|
|
|
94
96
|
class SingleSelectWithFilter extends React.Component<Props, State> {
|
|
95
97
|
static defaultProps: DefaultProps = {
|
|
98
|
+
selectedValue: "2",
|
|
96
99
|
opened: false,
|
|
97
100
|
};
|
|
98
101
|
|
|
99
102
|
state: State = {
|
|
100
|
-
selectedValue:
|
|
103
|
+
selectedValue: this.props.selectedValue,
|
|
101
104
|
opened: this.props.opened,
|
|
102
105
|
};
|
|
103
106
|
|
|
@@ -167,6 +170,10 @@ export const WithFilterOpened: StoryComponentType = () => (
|
|
|
167
170
|
<SingleSelectWithFilter opened={true} />
|
|
168
171
|
);
|
|
169
172
|
|
|
173
|
+
export const WithFilterOpenedNoValueSelected: StoryComponentType = () => (
|
|
174
|
+
<SingleSelectWithFilter opened={true} selectedValue={null} />
|
|
175
|
+
);
|
|
176
|
+
|
|
170
177
|
export const DropdownInModal: StoryComponentType = () => {
|
|
171
178
|
const [value, setValue] = React.useState(null);
|
|
172
179
|
const [opened, setOpened] = React.useState(true);
|
package/src/util/constants.js
CHANGED
|
@@ -24,6 +24,9 @@ export const filterableDropdownStyle = {
|
|
|
24
24
|
export const searchInputStyle = {
|
|
25
25
|
margin: Spacing.xSmall_8,
|
|
26
26
|
marginTop: Spacing.xxxSmall_4,
|
|
27
|
+
// Set `minHeight` to "auto" to stop the search field from having
|
|
28
|
+
// a height of 0 and being cut off.
|
|
29
|
+
minHeight: "auto",
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
// The default item height
|