@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.
@@ -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: string,
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: "2",
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);
@@ -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