@khanacademy/wonder-blocks-dropdown 2.7.6 → 2.8.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # @khanacademy/wonder-blocks-dropdown
2
2
 
3
+ ## 2.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ee6fc773: Added keyboard support to search items when the dropdown is focused, included "Enter" as a key to trigger actions with the "option" role
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [ee6fc773]
12
+ - @khanacademy/wonder-blocks-clickable@2.3.0
13
+ - @khanacademy/wonder-blocks-button@3.0.1
14
+ - @khanacademy/wonder-blocks-icon-button@3.4.9
15
+ - @khanacademy/wonder-blocks-modal@2.3.4
16
+ - @khanacademy/wonder-blocks-search-field@1.0.7
17
+
3
18
  ## 2.7.6
4
19
 
5
20
  ### Patch Changes
package/dist/es/index.js CHANGED
@@ -664,6 +664,26 @@ function DropdownPopper({
664
664
  }), modalHost);
665
665
  }
666
666
 
667
+ function getStringForKey(key) {
668
+ if (key.length === 1 || !/^[A-Z]/i.test(key)) {
669
+ return key;
670
+ }
671
+
672
+ return "";
673
+ }
674
+ function debounce(callback, wait) {
675
+ let timeout;
676
+ return function executedFunction(...args) {
677
+ const later = () => {
678
+ clearTimeout(timeout);
679
+ callback.apply(void 0, args);
680
+ };
681
+
682
+ clearTimeout(timeout);
683
+ timeout = setTimeout(later, wait);
684
+ };
685
+ }
686
+
667
687
  const VIRTUALIZE_THRESHOLD = 125;
668
688
  const StyledSpan = addStyle("span");
669
689
 
@@ -734,6 +754,12 @@ class DropdownCore extends React.Component {
734
754
  } = this.props;
735
755
  const keyCode = event.which || event.keyCode;
736
756
 
757
+ if (getStringForKey(event.key)) {
758
+ event.stopPropagation();
759
+ this.textSuggestion += event.key;
760
+ this.handleKeyDownDebounced(this.textSuggestion);
761
+ }
762
+
737
763
  if (!open) {
738
764
  if (keyCode === keyCodes.down) {
739
765
  event.preventDefault();
@@ -801,6 +827,39 @@ class DropdownCore extends React.Component {
801
827
  }
802
828
  };
803
829
 
830
+ this.handleKeyDownDebounceResult = key => {
831
+ const foundIndex = this.props.items.filter(item => item.focusable).findIndex(({
832
+ component
833
+ }) => {
834
+ var _component$props;
835
+
836
+ if (SeparatorItem.isClassOf(component)) {
837
+ return false;
838
+ }
839
+
840
+ const label = (_component$props = component.props) == null ? void 0 : _component$props.label.toLowerCase();
841
+ return label.startsWith(key.toLowerCase());
842
+ });
843
+
844
+ if (foundIndex >= 0) {
845
+ const isClosed = !this.props.open;
846
+
847
+ if (isClosed) {
848
+ this.props.onOpenChanged(true);
849
+ }
850
+
851
+ this.focusedIndex = foundIndex;
852
+ this.scheduleToFocusCurrentItem(node => {
853
+ if (this.props.selectionType === "single" && isClosed && node) {
854
+ node.click();
855
+ this.props.onOpenChanged(false);
856
+ }
857
+ });
858
+ }
859
+
860
+ this.textSuggestion = "";
861
+ };
862
+
804
863
  this.handleClickFocus = index => {
805
864
  this.itemsClicked = true;
806
865
  this.focusedIndex = index;
@@ -847,6 +906,8 @@ class DropdownCore extends React.Component {
847
906
  }, props.labels)
848
907
  };
849
908
  this.virtualizedListRef = React.createRef();
909
+ this.handleKeyDownDebounced = debounce(this.handleKeyDownDebounceResult, 500);
910
+ this.textSuggestion = "";
850
911
  }
851
912
 
852
913
  componentDidMount() {
@@ -941,15 +1002,17 @@ class DropdownCore extends React.Component {
941
1002
  document.removeEventListener("touchend", this.handleInteract);
942
1003
  }
943
1004
 
944
- scheduleToFocusCurrentItem() {
1005
+ scheduleToFocusCurrentItem(onFocus) {
945
1006
  if (this.shouldVirtualizeList()) {
946
- this.props.schedule.animationFrame(() => this.focusCurrentItem());
1007
+ this.props.schedule.animationFrame(() => {
1008
+ this.focusCurrentItem(onFocus);
1009
+ });
947
1010
  } else {
948
- this.focusCurrentItem();
1011
+ this.focusCurrentItem(onFocus);
949
1012
  }
950
1013
  }
951
1014
 
952
- focusCurrentItem() {
1015
+ focusCurrentItem(onFocus) {
953
1016
  const focusedItemRef = this.state.itemRefs[this.focusedIndex];
954
1017
 
955
1018
  if (focusedItemRef) {
@@ -962,6 +1025,10 @@ class DropdownCore extends React.Component {
962
1025
  if (node) {
963
1026
  node.focus();
964
1027
  this.focusedOriginalIndex = focusedItemRef.originalIndex;
1028
+
1029
+ if (onFocus) {
1030
+ onFocus(node);
1031
+ }
965
1032
  }
966
1033
  }
967
1034
  }
@@ -1206,7 +1273,8 @@ DropdownCore.defaultProps = {
1206
1273
  noResults: defaultLabels.noResults,
1207
1274
  someSelected: defaultLabels.someSelected
1208
1275
  },
1209
- light: false
1276
+ light: false,
1277
+ selectionType: "single"
1210
1278
  };
1211
1279
  const styles$3 = StyleSheet.create({
1212
1280
  menuWrapper: {
@@ -1909,6 +1977,7 @@ class SingleSelect extends React.Component {
1909
1977
  const opener = this.renderOpener(allChildren.length);
1910
1978
  return React.createElement(DropdownCore$1, {
1911
1979
  role: "listbox",
1980
+ selectionType: "single",
1912
1981
  alignment: alignment,
1913
1982
  dropdownStyle: [isFilterable && filterableDropdownStyle, selectDropdownStyle, dropdownStyle],
1914
1983
  initialFocusedIndex: this.selectedIndex,
@@ -2227,6 +2296,7 @@ class MultiSelect extends React.Component {
2227
2296
  open: open,
2228
2297
  opener: opener,
2229
2298
  openerElement: this.state.openerElement,
2299
+ selectionType: "multi",
2230
2300
  style: style,
2231
2301
  className: className,
2232
2302
  onSearchTextChanged: isFilterable ? this.handleSearchTextChanged : null,
package/dist/index.js CHANGED
@@ -82,7 +82,7 @@ module.exports =
82
82
  /******/
83
83
  /******/
84
84
  /******/ // Load entry module and return exports
85
- /******/ return __webpack_require__(__webpack_require__.s = 34);
85
+ /******/ return __webpack_require__(__webpack_require__.s = 35);
86
86
  /******/ })
87
87
  /************************************************************************/
88
88
  /******/ ([
@@ -258,7 +258,7 @@ module.exports = require("@khanacademy/wonder-blocks-clickable");
258
258
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
259
259
  /* harmony import */ var aphrodite__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(4);
260
260
  /* harmony import */ var aphrodite__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(aphrodite__WEBPACK_IMPORTED_MODULE_1__);
261
- /* harmony import */ var react_router_dom__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(23);
261
+ /* harmony import */ var react_router_dom__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(24);
262
262
  /* harmony import */ var react_router_dom__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_router_dom__WEBPACK_IMPORTED_MODULE_2__);
263
263
  /* harmony import */ var react_router__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(16);
264
264
  /* harmony import */ var react_router__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react_router__WEBPACK_IMPORTED_MODULE_3__);
@@ -437,8 +437,8 @@ const styles = aphrodite__WEBPACK_IMPORTED_MODULE_1__["StyleSheet"].create({
437
437
  /* harmony import */ var _khanacademy_wonder_blocks_clickable__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(10);
438
438
  /* harmony import */ var _khanacademy_wonder_blocks_clickable__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_clickable__WEBPACK_IMPORTED_MODULE_6__);
439
439
  /* harmony import */ var _util_constants_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(2);
440
- /* harmony import */ var _check_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(24);
441
- /* harmony import */ var _checkbox_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(25);
440
+ /* harmony import */ var _check_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(25);
441
+ /* harmony import */ var _checkbox_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(26);
442
442
  function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
443
443
 
444
444
 
@@ -671,17 +671,19 @@ DropdownOpener.defaultProps = {
671
671
  /* harmony import */ var _khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_5__);
672
672
  /* harmony import */ var _khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(5);
673
673
  /* harmony import */ var _khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_6__);
674
- /* harmony import */ var _khanacademy_wonder_blocks_search_field__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(26);
674
+ /* harmony import */ var _khanacademy_wonder_blocks_search_field__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(27);
675
675
  /* harmony import */ var _khanacademy_wonder_blocks_search_field__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_search_field__WEBPACK_IMPORTED_MODULE_7__);
676
676
  /* harmony import */ var _khanacademy_wonder_blocks_typography__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(9);
677
677
  /* harmony import */ var _khanacademy_wonder_blocks_typography__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_typography__WEBPACK_IMPORTED_MODULE_8__);
678
678
  /* harmony import */ var _khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(17);
679
679
  /* harmony import */ var _khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_9__);
680
- /* harmony import */ var _dropdown_core_virtualized_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(27);
680
+ /* harmony import */ var _dropdown_core_virtualized_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(28);
681
681
  /* harmony import */ var _separator_item_js__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(6);
682
682
  /* harmony import */ var _util_constants_js__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(2);
683
- /* harmony import */ var _dropdown_popper_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(29);
684
- /* harmony import */ var _util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(15);
683
+ /* harmony import */ var _dropdown_popper_js__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(30);
684
+ /* harmony import */ var _util_helpers_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(20);
685
+ /* harmony import */ var _util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(15);
686
+ /* eslint-disable max-lines */
685
687
  // A menu that consists of action items
686
688
 
687
689
 
@@ -698,6 +700,7 @@ DropdownOpener.defaultProps = {
698
700
 
699
701
 
700
702
 
703
+
701
704
  /**
702
705
  * The number of options to apply the virtualized list to.
703
706
  *
@@ -795,7 +798,15 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
795
798
  open,
796
799
  searchText
797
800
  } = this.props;
798
- const keyCode = event.which || event.keyCode; // If menu isn't open and user presses down, open the menu
801
+ const keyCode = event.which || event.keyCode; // Listen for the keydown events if we are using ASCII characters.
802
+
803
+ if (Object(_util_helpers_js__WEBPACK_IMPORTED_MODULE_14__[/* getStringForKey */ "b"])(event.key)) {
804
+ event.stopPropagation();
805
+ this.textSuggestion += event.key; // Trigger the filter logic only after the debounce is resolved.
806
+
807
+ this.handleKeyDownDebounced(this.textSuggestion);
808
+ } // If menu isn't open and user presses down, open the menu
809
+
799
810
 
800
811
  if (!open) {
801
812
  if (keyCode === _util_constants_js__WEBPACK_IMPORTED_MODULE_12__[/* keyCodes */ "f"].down) {
@@ -877,6 +888,47 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
877
888
  }
878
889
  };
879
890
 
891
+ this.handleKeyDownDebounceResult = key => {
892
+ const foundIndex = this.props.items.filter(item => item.focusable).findIndex(({
893
+ component
894
+ }) => {
895
+ var _component$props;
896
+
897
+ if (_separator_item_js__WEBPACK_IMPORTED_MODULE_11__[/* default */ "a"].isClassOf(component)) {
898
+ return false;
899
+ } // Flow doesn't know that the component is an OptionItem
900
+ // $FlowIgnore[incompatible-use]
901
+
902
+
903
+ const label = (_component$props = component.props) == null ? void 0 : _component$props.label.toLowerCase();
904
+ return label.startsWith(key.toLowerCase());
905
+ });
906
+
907
+ if (foundIndex >= 0) {
908
+ const isClosed = !this.props.open;
909
+
910
+ if (isClosed) {
911
+ // Open the menu to be able to focus on the item that matches
912
+ // the text suggested.
913
+ this.props.onOpenChanged(true);
914
+ } // Update the focus reference.
915
+
916
+
917
+ this.focusedIndex = foundIndex;
918
+ this.scheduleToFocusCurrentItem(node => {
919
+ // Force click only if the dropdown is closed and we are using
920
+ // the SingleSelect component.
921
+ if (this.props.selectionType === "single" && isClosed && node) {
922
+ node.click();
923
+ this.props.onOpenChanged(false);
924
+ }
925
+ });
926
+ } // Otherwise, reset current text
927
+
928
+
929
+ this.textSuggestion = "";
930
+ };
931
+
880
932
  this.handleClickFocus = index => {
881
933
  // Turn itemsClicked on so pressing up or down would focus the
882
934
  // appropriate item in handleKeyDown
@@ -927,7 +979,12 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
927
979
  ...props.labels
928
980
  }
929
981
  };
930
- this.virtualizedListRef = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createRef"]();
982
+ this.virtualizedListRef = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createRef"](); // We debounce the keydown handler to get the ASCII chars because it's
983
+ // called on every keydown
984
+
985
+ this.handleKeyDownDebounced = Object(_util_helpers_js__WEBPACK_IMPORTED_MODULE_14__[/* debounce */ "a"])(this.handleKeyDownDebounceResult, // Leaving enough time for the user to type a valid query (e.g. jul)
986
+ 500);
987
+ this.textSuggestion = "";
931
988
  }
932
989
 
933
990
  componentDidMount() {
@@ -1043,17 +1100,24 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
1043
1100
  document.removeEventListener("touchend", this.handleInteract);
1044
1101
  }
1045
1102
 
1046
- scheduleToFocusCurrentItem() {
1103
+ scheduleToFocusCurrentItem(onFocus) {
1047
1104
  if (this.shouldVirtualizeList()) {
1048
1105
  // wait for windowed items to be recalculated
1049
- this.props.schedule.animationFrame(() => this.focusCurrentItem());
1106
+ this.props.schedule.animationFrame(() => {
1107
+ this.focusCurrentItem(onFocus);
1108
+ });
1050
1109
  } else {
1051
1110
  // immediately focus the current item if we're not virtualizing
1052
- this.focusCurrentItem();
1111
+ this.focusCurrentItem(onFocus);
1053
1112
  }
1054
1113
  }
1114
+ /**
1115
+ * Focus on the current item.
1116
+ * @param [onFocus] - Callback to be called when the item is focused.
1117
+ */
1118
+
1055
1119
 
1056
- focusCurrentItem() {
1120
+ focusCurrentItem(onFocus) {
1057
1121
  const focusedItemRef = this.state.itemRefs[this.focusedIndex];
1058
1122
 
1059
1123
  if (focusedItemRef) {
@@ -1073,6 +1137,11 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
1073
1137
  // To be used if the set of focusable items in the menu changes
1074
1138
 
1075
1139
  this.focusedOriginalIndex = focusedItemRef.originalIndex;
1140
+
1141
+ if (onFocus) {
1142
+ // Call the callback with the node that was focused.
1143
+ onFocus(node);
1144
+ }
1076
1145
  }
1077
1146
  }
1078
1147
  }
@@ -1289,7 +1358,7 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
1289
1358
 
1290
1359
  const openerStyle = openerElement && window.getComputedStyle(openerElement);
1291
1360
  const minDropdownWidth = openerStyle ? openerStyle.getPropertyValue("width") : 0;
1292
- const maxDropdownHeight = Object(_util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_14__[/* getDropdownMenuHeight */ "b"])(this.props.items);
1361
+ const maxDropdownHeight = Object(_util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_15__[/* getDropdownMenuHeight */ "b"])(this.props.items);
1293
1362
  return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_6__["View"] // Stop propagation to prevent the mouseup listener on the
1294
1363
  // document from closing the menu.
1295
1364
  , {
@@ -1298,7 +1367,7 @@ class DropdownCore extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
1298
1367
  testId: "dropdown-core-container"
1299
1368
  }, this.props.isFilterable && this.renderSearchField(), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_khanacademy_wonder_blocks_core__WEBPACK_IMPORTED_MODULE_6__["View"], {
1300
1369
  role: this.props.role,
1301
- style: [styles.listboxOrMenu, Object(_util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_14__[/* generateDropdownMenuStyles */ "a"])(minDropdownWidth, maxDropdownHeight)]
1370
+ style: [styles.listboxOrMenu, Object(_util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_15__[/* generateDropdownMenuStyles */ "a"])(minDropdownWidth, maxDropdownHeight)]
1302
1371
  }, listRenderer), this.maybeRenderNoResults());
1303
1372
  }
1304
1373
 
@@ -1365,7 +1434,8 @@ DropdownCore.defaultProps = {
1365
1434
  noResults: _util_constants_js__WEBPACK_IMPORTED_MODULE_12__[/* defaultLabels */ "d"].noResults,
1366
1435
  someSelected: _util_constants_js__WEBPACK_IMPORTED_MODULE_12__[/* defaultLabels */ "d"].someSelected
1367
1436
  },
1368
- light: false
1437
+ light: false,
1438
+ selectionType: "single"
1369
1439
  };
1370
1440
  const styles = aphrodite__WEBPACK_IMPORTED_MODULE_2__["StyleSheet"].create({
1371
1441
  menuWrapper: {
@@ -1729,6 +1799,52 @@ module.exports = require("react-window");
1729
1799
  /* 20 */
1730
1800
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
1731
1801
 
1802
+ "use strict";
1803
+ /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { return getStringForKey; });
1804
+ /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return debounce; });
1805
+ /**
1806
+ * Checks if a given key is a valid ASCII value.
1807
+ *
1808
+ * @param {string} key The key that is being typed in.
1809
+ * @returns A valid string representation of the given key.
1810
+ */
1811
+ function getStringForKey(key) {
1812
+ // If the key is of length 1, it is an ASCII value.
1813
+ // Otherwise, if there are no ASCII characters in the key name,
1814
+ // it is a Unicode character.
1815
+ // See https://www.w3.org/TR/uievents-key/
1816
+ if (key.length === 1 || !/^[A-Z]/i.test(key)) {
1817
+ return key;
1818
+ }
1819
+
1820
+ return "";
1821
+ }
1822
+ /**
1823
+ *
1824
+ * @param {fn} callback The function that will be executed after the debounce is resolved.
1825
+ * @param {number} wait The period of time that will be executed the debounced
1826
+ * function.
1827
+ * @returns The function that will be executed after the wait period is
1828
+ * fulfilled.
1829
+ */
1830
+
1831
+ function debounce(callback, wait) {
1832
+ let timeout;
1833
+ return function executedFunction(...args) {
1834
+ const later = () => {
1835
+ clearTimeout(timeout);
1836
+ callback.apply(void 0, args);
1837
+ };
1838
+
1839
+ clearTimeout(timeout);
1840
+ timeout = setTimeout(later, wait);
1841
+ };
1842
+ }
1843
+
1844
+ /***/ }),
1845
+ /* 21 */
1846
+ /***/ (function(module, __webpack_exports__, __webpack_require__) {
1847
+
1732
1848
  "use strict";
1733
1849
  /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return ActionMenu; });
1734
1850
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(0);
@@ -1741,7 +1857,7 @@ module.exports = require("react-window");
1741
1857
  /* harmony import */ var _action_item_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(11);
1742
1858
  /* harmony import */ var _option_item_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(12);
1743
1859
  /* harmony import */ var _dropdown_core_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(14);
1744
- /* harmony import */ var _action_menu_opener_core_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(32);
1860
+ /* harmony import */ var _action_menu_opener_core_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(33);
1745
1861
  function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
1746
1862
 
1747
1863
 
@@ -1951,7 +2067,7 @@ const styles = aphrodite__WEBPACK_IMPORTED_MODULE_2__["StyleSheet"].create({
1951
2067
  });
1952
2068
 
1953
2069
  /***/ }),
1954
- /* 21 */
2070
+ /* 22 */
1955
2071
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
1956
2072
 
1957
2073
  "use strict";
@@ -2191,6 +2307,7 @@ class SingleSelect extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
2191
2307
  const opener = this.renderOpener(allChildren.length);
2192
2308
  return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__["createElement"](_dropdown_core_js__WEBPACK_IMPORTED_MODULE_2__[/* default */ "a"], {
2193
2309
  role: "listbox",
2310
+ selectionType: "single",
2194
2311
  alignment: alignment,
2195
2312
  dropdownStyle: [isFilterable && _util_constants_js__WEBPACK_IMPORTED_MODULE_5__[/* filterableDropdownStyle */ "e"], _util_constants_js__WEBPACK_IMPORTED_MODULE_5__[/* selectDropdownStyle */ "g"], dropdownStyle],
2196
2313
  initialFocusedIndex: this.selectedIndex,
@@ -2216,7 +2333,7 @@ SingleSelect.defaultProps = {
2216
2333
  };
2217
2334
 
2218
2335
  /***/ }),
2219
- /* 22 */
2336
+ /* 23 */
2220
2337
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
2221
2338
 
2222
2339
  "use strict";
@@ -2593,6 +2710,7 @@ class MultiSelect extends react__WEBPACK_IMPORTED_MODULE_0__["Component"] {
2593
2710
  open: open,
2594
2711
  opener: opener,
2595
2712
  openerElement: this.state.openerElement,
2713
+ selectionType: "multi",
2596
2714
  style: style,
2597
2715
  className: className,
2598
2716
  onSearchTextChanged: isFilterable ? this.handleSearchTextChanged : null,
@@ -2616,13 +2734,13 @@ MultiSelect.defaultProps = {
2616
2734
  };
2617
2735
 
2618
2736
  /***/ }),
2619
- /* 23 */
2737
+ /* 24 */
2620
2738
  /***/ (function(module, exports) {
2621
2739
 
2622
2740
  module.exports = require("react-router-dom");
2623
2741
 
2624
2742
  /***/ }),
2625
- /* 24 */
2743
+ /* 25 */
2626
2744
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
2627
2745
 
2628
2746
  "use strict";
@@ -2679,7 +2797,7 @@ const styles = aphrodite__WEBPACK_IMPORTED_MODULE_1__["StyleSheet"].create({
2679
2797
  });
2680
2798
 
2681
2799
  /***/ }),
2682
- /* 25 */
2800
+ /* 26 */
2683
2801
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
2684
2802
 
2685
2803
  "use strict";
@@ -2772,13 +2890,13 @@ const styles = aphrodite__WEBPACK_IMPORTED_MODULE_1__["StyleSheet"].create({
2772
2890
  });
2773
2891
 
2774
2892
  /***/ }),
2775
- /* 26 */
2893
+ /* 27 */
2776
2894
  /***/ (function(module, exports) {
2777
2895
 
2778
2896
  module.exports = require("@khanacademy/wonder-blocks-search-field");
2779
2897
 
2780
2898
  /***/ }),
2781
- /* 27 */
2899
+ /* 28 */
2782
2900
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
2783
2901
 
2784
2902
  "use strict";
@@ -2790,7 +2908,7 @@ module.exports = require("@khanacademy/wonder-blocks-search-field");
2790
2908
  /* harmony import */ var react_window__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_window__WEBPACK_IMPORTED_MODULE_2__);
2791
2909
  /* harmony import */ var _khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(17);
2792
2910
  /* harmony import */ var _khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_3__);
2793
- /* harmony import */ var _dropdown_core_virtualized_item_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(28);
2911
+ /* harmony import */ var _dropdown_core_virtualized_item_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(29);
2794
2912
  /* harmony import */ var _separator_item_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(6);
2795
2913
  /* harmony import */ var _util_constants_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(2);
2796
2914
  /* harmony import */ var _util_dropdown_menu_styles_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(15);
@@ -2971,7 +3089,7 @@ class DropdownCoreVirtualized extends react__WEBPACK_IMPORTED_MODULE_0__["Compon
2971
3089
  /* harmony default export */ __webpack_exports__["a"] = (Object(_khanacademy_wonder_blocks_timing__WEBPACK_IMPORTED_MODULE_3__["withActionScheduler"])(DropdownCoreVirtualized));
2972
3090
 
2973
3091
  /***/ }),
2974
- /* 28 */
3092
+ /* 29 */
2975
3093
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
2976
3094
 
2977
3095
  "use strict";
@@ -3025,7 +3143,7 @@ class DropdownVirtualizedItem extends react__WEBPACK_IMPORTED_MODULE_0__["Compon
3025
3143
  /* harmony default export */ __webpack_exports__["a"] = (DropdownVirtualizedItem);
3026
3144
 
3027
3145
  /***/ }),
3028
- /* 29 */
3146
+ /* 30 */
3029
3147
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
3030
3148
 
3031
3149
  "use strict";
@@ -3034,9 +3152,9 @@ class DropdownVirtualizedItem extends react__WEBPACK_IMPORTED_MODULE_0__["Compon
3034
3152
  /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
3035
3153
  /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(7);
3036
3154
  /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_1__);
3037
- /* harmony import */ var react_popper__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(30);
3155
+ /* harmony import */ var react_popper__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(31);
3038
3156
  /* harmony import */ var react_popper__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_popper__WEBPACK_IMPORTED_MODULE_2__);
3039
- /* harmony import */ var _khanacademy_wonder_blocks_modal__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(31);
3157
+ /* harmony import */ var _khanacademy_wonder_blocks_modal__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(32);
3040
3158
  /* harmony import */ var _khanacademy_wonder_blocks_modal__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_modal__WEBPACK_IMPORTED_MODULE_3__);
3041
3159
 
3042
3160
 
@@ -3101,19 +3219,19 @@ function DropdownPopper({
3101
3219
  }
3102
3220
 
3103
3221
  /***/ }),
3104
- /* 30 */
3222
+ /* 31 */
3105
3223
  /***/ (function(module, exports) {
3106
3224
 
3107
3225
  module.exports = require("react-popper");
3108
3226
 
3109
3227
  /***/ }),
3110
- /* 31 */
3228
+ /* 32 */
3111
3229
  /***/ (function(module, exports) {
3112
3230
 
3113
3231
  module.exports = require("@khanacademy/wonder-blocks-modal");
3114
3232
 
3115
3233
  /***/ }),
3116
- /* 32 */
3234
+ /* 33 */
3117
3235
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
3118
3236
 
3119
3237
  "use strict";
@@ -3132,7 +3250,7 @@ module.exports = require("@khanacademy/wonder-blocks-modal");
3132
3250
  /* harmony import */ var _khanacademy_wonder_blocks_icon__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_icon__WEBPACK_IMPORTED_MODULE_5__);
3133
3251
  /* harmony import */ var _khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(3);
3134
3252
  /* harmony import */ var _khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_spacing__WEBPACK_IMPORTED_MODULE_6__);
3135
- /* harmony import */ var _khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(33);
3253
+ /* harmony import */ var _khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(34);
3136
3254
  /* harmony import */ var _khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(_khanacademy_wonder_blocks_layout__WEBPACK_IMPORTED_MODULE_7__);
3137
3255
  /* harmony import */ var _util_constants_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(2);
3138
3256
  function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
@@ -3288,13 +3406,13 @@ const _generateStyles = color => {
3288
3406
  };
3289
3407
 
3290
3408
  /***/ }),
3291
- /* 33 */
3409
+ /* 34 */
3292
3410
  /***/ (function(module, exports) {
3293
3411
 
3294
3412
  module.exports = require("@khanacademy/wonder-blocks-layout");
3295
3413
 
3296
3414
  /***/ }),
3297
- /* 34 */
3415
+ /* 35 */
3298
3416
  /***/ (function(module, __webpack_exports__, __webpack_require__) {
3299
3417
 
3300
3418
  "use strict";
@@ -3308,13 +3426,13 @@ __webpack_require__.r(__webpack_exports__);
3308
3426
  /* harmony import */ var _components_separator_item_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6);
3309
3427
  /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SeparatorItem", function() { return _components_separator_item_js__WEBPACK_IMPORTED_MODULE_2__["a"]; });
3310
3428
 
3311
- /* harmony import */ var _components_action_menu_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(20);
3429
+ /* harmony import */ var _components_action_menu_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(21);
3312
3430
  /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ActionMenu", function() { return _components_action_menu_js__WEBPACK_IMPORTED_MODULE_3__["a"]; });
3313
3431
 
3314
- /* harmony import */ var _components_single_select_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(21);
3432
+ /* harmony import */ var _components_single_select_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(22);
3315
3433
  /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SingleSelect", function() { return _components_single_select_js__WEBPACK_IMPORTED_MODULE_4__["a"]; });
3316
3434
 
3317
- /* harmony import */ var _components_multi_select_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(22);
3435
+ /* harmony import */ var _components_multi_select_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(23);
3318
3436
  /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MultiSelect", function() { return _components_multi_select_js__WEBPACK_IMPORTED_MODULE_5__["a"]; });
3319
3437
 
3320
3438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-dropdown",
3
- "version": "2.7.6",
3
+ "version": "2.8.0",
4
4
  "design": "v1",
5
5
  "description": "Dropdown variants for Wonder Blocks.",
6
6
  "main": "dist/index.js",
@@ -15,16 +15,16 @@
15
15
  "access": "public"
16
16
  },
17
17
  "dependencies": {
18
- "@babel/runtime": "^7.16.3",
19
- "@khanacademy/wonder-blocks-button": "^3.0.0",
20
- "@khanacademy/wonder-blocks-clickable": "^2.2.7",
18
+ "@babel/runtime": "^7.18.6",
19
+ "@khanacademy/wonder-blocks-button": "^3.0.1",
20
+ "@khanacademy/wonder-blocks-clickable": "^2.3.0",
21
21
  "@khanacademy/wonder-blocks-color": "^1.1.20",
22
22
  "@khanacademy/wonder-blocks-core": "^4.3.2",
23
23
  "@khanacademy/wonder-blocks-icon": "^1.2.29",
24
- "@khanacademy/wonder-blocks-icon-button": "^3.4.8",
24
+ "@khanacademy/wonder-blocks-icon-button": "^3.4.9",
25
25
  "@khanacademy/wonder-blocks-layout": "^1.4.10",
26
- "@khanacademy/wonder-blocks-modal": "^2.3.3",
27
- "@khanacademy/wonder-blocks-search-field": "^1.0.6",
26
+ "@khanacademy/wonder-blocks-modal": "^2.3.4",
27
+ "@khanacademy/wonder-blocks-search-field": "^1.0.7",
28
28
  "@khanacademy/wonder-blocks-spacing": "^3.0.5",
29
29
  "@khanacademy/wonder-blocks-timing": "^2.1.0",
30
30
  "@khanacademy/wonder-blocks-typography": "^1.1.32"
@@ -541,7 +541,7 @@ describe("DropdownCore", () => {
541
541
  expect(screen.getByText("No results")).toBeInTheDocument();
542
542
  });
543
543
 
544
- it("SearchField should be focused when opened and there's no selection", async () => {
544
+ it("SearchField should be focused when opened and there's no selection", () => {
545
545
  // Arrange
546
546
 
547
547
  // Act
@@ -562,9 +562,7 @@ describe("DropdownCore", () => {
562
562
  );
563
563
 
564
564
  // Assert
565
- waitFor(() => {
566
- expect(screen.getByRole("textbox")).toHaveFocus();
567
- });
565
+ expect(screen.getByRole("textbox")).toHaveFocus();
568
566
  });
569
567
 
570
568
  it("SearchField should trigger change when the user types in", () => {
@@ -739,7 +737,7 @@ describe("DropdownCore", () => {
739
737
  });
740
738
 
741
739
  describe("a11y > Live region", () => {
742
- it("should render a live region announcing the number of options", async () => {
740
+ it("should render a live region announcing the number of options", () => {
743
741
  // Arrange
744
742
 
745
743
  // Act
@@ -19,6 +19,19 @@ const labels: $Shape<Labels> = {
19
19
  };
20
20
 
21
21
  describe("MultiSelect", () => {
22
+ beforeEach(() => {
23
+ window.scrollTo = jest.fn();
24
+
25
+ // We mock console.error() because React logs a bunch of errors pertaining
26
+ // to the use href="javascript:void(0);".
27
+ jest.spyOn(console, "error").mockImplementation(() => {});
28
+ });
29
+
30
+ afterEach(() => {
31
+ window.scrollTo.mockClear();
32
+ jest.spyOn(console, "error").mockReset();
33
+ });
34
+
22
35
  describe("uncontrolled", () => {
23
36
  const onChange = jest.fn();
24
37
  const uncontrolledSingleSelect = (
@@ -1186,6 +1199,41 @@ describe("MultiSelect", () => {
1186
1199
  screen.getByText(updatedLabels.selectNoneLabel),
1187
1200
  ).toBeInTheDocument();
1188
1201
  });
1202
+
1203
+ describe("keyboard", () => {
1204
+ beforeEach(() => {
1205
+ // Required due to the `debounce` call.
1206
+ jest.useFakeTimers();
1207
+ });
1208
+
1209
+ it("should find and focus an item using the keyboard", () => {
1210
+ // Arrange
1211
+ const onChangeMock = jest.fn();
1212
+
1213
+ render(
1214
+ <MultiSelect onChange={onChangeMock}>
1215
+ <OptionItem label="Mercury" value="mercury" />
1216
+ <OptionItem label="Venus" value="venus" />
1217
+ <OptionItem label="Mars" value="mars" />
1218
+ </MultiSelect>,
1219
+ );
1220
+ userEvent.tab();
1221
+
1222
+ // Act
1223
+ // find first occurrence
1224
+ userEvent.keyboard("v");
1225
+ jest.advanceTimersByTime(501);
1226
+
1227
+ // Assert
1228
+ const filteredOption = screen.getByRole("option", {
1229
+ name: /Venus/i,
1230
+ });
1231
+ // Verify that the element found is focused.
1232
+ expect(filteredOption).toHaveFocus();
1233
+ // And also verify that the listbox is opened.
1234
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
1235
+ });
1236
+ });
1189
1237
  });
1190
1238
 
1191
1239
  describe("a11y > Live region", () => {
@@ -99,6 +99,10 @@ describe("SingleSelect", () => {
99
99
  });
100
100
 
101
101
  describe("keyboard", () => {
102
+ beforeEach(() => {
103
+ jest.useFakeTimers();
104
+ });
105
+
102
106
  describe.each([{key: "{enter}"}, {key: "{space}"}])(
103
107
  "$key",
104
108
  ({key}) => {
@@ -129,7 +133,7 @@ describe("SingleSelect", () => {
129
133
  },
130
134
  );
131
135
 
132
- it("should not select an item when pressing {enter}", () => {
136
+ it("should select an item when pressing {enter}", () => {
133
137
  // Arrange
134
138
  render(uncontrolledSingleSelect);
135
139
  userEvent.tab();
@@ -139,8 +143,8 @@ describe("SingleSelect", () => {
139
143
  userEvent.keyboard("{enter}");
140
144
 
141
145
  // Assert
142
- expect(onChange).not.toHaveBeenCalled();
143
- expect(screen.getByRole("listbox")).toBeInTheDocument();
146
+ expect(onChange).toHaveBeenCalledWith("1");
147
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
144
148
  });
145
149
 
146
150
  it("should select an item when pressing {space}", () => {
@@ -157,6 +161,27 @@ describe("SingleSelect", () => {
157
161
  expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
158
162
  });
159
163
 
164
+ it("should find and select an item using the keyboard", () => {
165
+ // Arrange
166
+ render(
167
+ <SingleSelect onChange={onChange} placeholder="Choose">
168
+ <OptionItem label="apple" value="apple" />
169
+ <OptionItem label="orange" value="orange" />
170
+ <OptionItem label="pear" value="pear" />
171
+ </SingleSelect>,
172
+ );
173
+ userEvent.tab();
174
+
175
+ // Act
176
+ // find first occurrence
177
+ userEvent.keyboard("or");
178
+ jest.advanceTimersByTime(501);
179
+
180
+ // Assert
181
+ expect(onChange).toHaveBeenCalledWith("orange");
182
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
183
+ });
184
+
160
185
  it("should dismiss the dropdown when pressing {escape}", () => {
161
186
  // Arrange
162
187
  render(uncontrolledSingleSelect);
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  // @flow
2
3
  // A menu that consists of action items
3
4
 
@@ -25,6 +26,7 @@ import SeparatorItem from "./separator-item.js";
25
26
  import {defaultLabels, keyCodes} from "../util/constants.js";
26
27
  import type {DropdownItem} from "../util/types.js";
27
28
  import DropdownPopper from "./dropdown-popper.js";
29
+ import {debounce, getStringForKey} from "../util/helpers.js";
28
30
  import {
29
31
  generateDropdownMenuStyles,
30
32
  getDropdownMenuHeight,
@@ -94,6 +96,11 @@ type DefaultProps = {|
94
96
  * use when the item is used on a dark background.
95
97
  */
96
98
  light: boolean,
99
+
100
+ /**
101
+ * Used to determine if we can automatically select an item using the keyboard.
102
+ */
103
+ selectionType: "single" | "multi",
97
104
  |};
98
105
 
99
106
  type DropdownAriaRole = "listbox" | "menu";
@@ -219,6 +226,10 @@ class DropdownCore extends React.Component<Props, State> {
219
226
  current: null | React.ElementRef<typeof List>,
220
227
  |};
221
228
 
229
+ handleKeyDownDebounced: (key: string) => void;
230
+
231
+ textSuggestion: string;
232
+
222
233
  // Figure out if the same items are focusable. If an item has been added or
223
234
  // removed, this method will return false.
224
235
  static sameItemsFocusable(
@@ -245,6 +256,7 @@ class DropdownCore extends React.Component<Props, State> {
245
256
  someSelected: defaultLabels.someSelected,
246
257
  },
247
258
  light: false,
259
+ selectionType: "single",
248
260
  };
249
261
 
250
262
  // This is here to avoid calling React.createRef on each rerender. Instead,
@@ -295,6 +307,15 @@ class DropdownCore extends React.Component<Props, State> {
295
307
  };
296
308
 
297
309
  this.virtualizedListRef = React.createRef();
310
+
311
+ // We debounce the keydown handler to get the ASCII chars because it's
312
+ // called on every keydown
313
+ this.handleKeyDownDebounced = debounce(
314
+ this.handleKeyDownDebounceResult,
315
+ // Leaving enough time for the user to type a valid query (e.g. jul)
316
+ 500,
317
+ );
318
+ this.textSuggestion = "";
298
319
  }
299
320
 
300
321
  componentDidMount() {
@@ -419,17 +440,23 @@ class DropdownCore extends React.Component<Props, State> {
419
440
  }
420
441
  };
421
442
 
422
- scheduleToFocusCurrentItem() {
443
+ scheduleToFocusCurrentItem(onFocus?: (node: void | HTMLElement) => void) {
423
444
  if (this.shouldVirtualizeList()) {
424
445
  // wait for windowed items to be recalculated
425
- this.props.schedule.animationFrame(() => this.focusCurrentItem());
446
+ this.props.schedule.animationFrame(() => {
447
+ this.focusCurrentItem(onFocus);
448
+ });
426
449
  } else {
427
450
  // immediately focus the current item if we're not virtualizing
428
- this.focusCurrentItem();
451
+ this.focusCurrentItem(onFocus);
429
452
  }
430
453
  }
431
454
 
432
- focusCurrentItem() {
455
+ /**
456
+ * Focus on the current item.
457
+ * @param [onFocus] - Callback to be called when the item is focused.
458
+ */
459
+ focusCurrentItem(onFocus?: (node: HTMLElement) => void) {
433
460
  const focusedItemRef = this.state.itemRefs[this.focusedIndex];
434
461
 
435
462
  if (focusedItemRef) {
@@ -452,6 +479,11 @@ class DropdownCore extends React.Component<Props, State> {
452
479
  // Keep track of the original index of the newly focused item.
453
480
  // To be used if the set of focusable items in the menu changes
454
481
  this.focusedOriginalIndex = focusedItemRef.originalIndex;
482
+
483
+ if (onFocus) {
484
+ // Call the callback with the node that was focused.
485
+ onFocus(node);
486
+ }
455
487
  }
456
488
  }
457
489
  }
@@ -514,6 +546,15 @@ class DropdownCore extends React.Component<Props, State> {
514
546
  handleKeyDown: (event: SyntheticKeyboardEvent<>) => void = (event) => {
515
547
  const {onOpenChanged, open, searchText} = this.props;
516
548
  const keyCode = event.which || event.keyCode;
549
+
550
+ // Listen for the keydown events if we are using ASCII characters.
551
+ if (getStringForKey(event.key)) {
552
+ event.stopPropagation();
553
+ this.textSuggestion += event.key;
554
+ // Trigger the filter logic only after the debounce is resolved.
555
+ this.handleKeyDownDebounced(this.textSuggestion);
556
+ }
557
+
517
558
  // If menu isn't open and user presses down, open the menu
518
559
  if (!open) {
519
560
  if (keyCode === keyCodes.down) {
@@ -583,6 +624,45 @@ class DropdownCore extends React.Component<Props, State> {
583
624
  }
584
625
  };
585
626
 
627
+ handleKeyDownDebounceResult: (key: string) => void = (key) => {
628
+ const foundIndex = this.props.items
629
+ .filter((item) => item.focusable)
630
+ .findIndex(({component}) => {
631
+ if (SeparatorItem.isClassOf(component)) {
632
+ return false;
633
+ }
634
+
635
+ // Flow doesn't know that the component is an OptionItem
636
+ // $FlowIgnore[incompatible-use]
637
+ const label = component.props?.label.toLowerCase();
638
+
639
+ return label.startsWith(key.toLowerCase());
640
+ });
641
+
642
+ if (foundIndex >= 0) {
643
+ const isClosed = !this.props.open;
644
+ if (isClosed) {
645
+ // Open the menu to be able to focus on the item that matches
646
+ // the text suggested.
647
+ this.props.onOpenChanged(true);
648
+ }
649
+ // Update the focus reference.
650
+ this.focusedIndex = foundIndex;
651
+
652
+ this.scheduleToFocusCurrentItem((node) => {
653
+ // Force click only if the dropdown is closed and we are using
654
+ // the SingleSelect component.
655
+ if (this.props.selectionType === "single" && isClosed && node) {
656
+ node.click();
657
+ this.props.onOpenChanged(false);
658
+ }
659
+ });
660
+ }
661
+
662
+ // Otherwise, reset current text
663
+ this.textSuggestion = "";
664
+ };
665
+
586
666
  handleClickFocus: (index: number) => void = (index) => {
587
667
  // Turn itemsClicked on so pressing up or down would focus the
588
668
  // appropriate item in handleKeyDown
@@ -564,6 +564,7 @@ export default class MultiSelect extends React.Component<Props, State> {
564
564
  open={open}
565
565
  opener={opener}
566
566
  openerElement={this.state.openerElement}
567
+ selectionType="multi"
567
568
  style={style}
568
569
  className={className}
569
570
  onSearchTextChanged={
@@ -379,6 +379,7 @@ export default class SingleSelect extends React.Component<Props, State> {
379
379
  return (
380
380
  <DropdownCore
381
381
  role="listbox"
382
+ selectionType="single"
382
383
  alignment={alignment}
383
384
  dropdownStyle={[
384
385
  isFilterable && filterableDropdownStyle,
@@ -0,0 +1,73 @@
1
+ // @flow
2
+ import {debounce, getStringForKey} from "../helpers.js";
3
+
4
+ describe("getStringForKey", () => {
5
+ it("should get a valid string", () => {
6
+ // Arrange
7
+
8
+ // Act
9
+ const key = getStringForKey("a");
10
+
11
+ // Assert
12
+ expect(key).toBe("a");
13
+ });
14
+
15
+ it("should return empty if we use a glyph modifier key (e.g. Shift)", () => {
16
+ // Arrange
17
+
18
+ // Act
19
+ const key = getStringForKey("Shift");
20
+
21
+ // Assert
22
+ expect(key).toBe("");
23
+ });
24
+ });
25
+
26
+ describe("debounce", () => {
27
+ beforeEach(() => {
28
+ jest.useFakeTimers();
29
+ });
30
+
31
+ it("should call the debounced function", () => {
32
+ // Arrange
33
+ const callbackFnMock = jest.fn();
34
+ const debounced = debounce(callbackFnMock, 500);
35
+
36
+ // Act
37
+ debounced();
38
+ jest.advanceTimersByTime(501);
39
+
40
+ // Assert
41
+ expect(callbackFnMock).toHaveBeenCalled();
42
+ });
43
+
44
+ it("should call the debounced function only once", () => {
45
+ // Arrange
46
+ const callbackFnMock = jest.fn();
47
+ const debounced = debounce(callbackFnMock, 500);
48
+
49
+ // Act
50
+ debounced();
51
+ debounced();
52
+ debounced();
53
+ jest.advanceTimersByTime(501);
54
+
55
+ // Assert
56
+ expect(callbackFnMock).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it("should execute the last call with the exact args", () => {
60
+ // Arrange
61
+ const callbackFnMock = jest.fn();
62
+ const debounced = debounce(callbackFnMock, 500);
63
+
64
+ // Act
65
+ debounced("a");
66
+ debounced("ab");
67
+ debounced("abc");
68
+ jest.advanceTimersByTime(501);
69
+
70
+ // Assert
71
+ expect(callbackFnMock).toHaveBeenCalledWith("abc");
72
+ });
73
+ });
@@ -0,0 +1,44 @@
1
+ // @flow
2
+
3
+ /**
4
+ * Checks if a given key is a valid ASCII value.
5
+ *
6
+ * @param {string} key The key that is being typed in.
7
+ * @returns A valid string representation of the given key.
8
+ */
9
+ export function getStringForKey(key: string): string {
10
+ // If the key is of length 1, it is an ASCII value.
11
+ // Otherwise, if there are no ASCII characters in the key name,
12
+ // it is a Unicode character.
13
+ // See https://www.w3.org/TR/uievents-key/
14
+ if (key.length === 1 || !/^[A-Z]/i.test(key)) {
15
+ return key;
16
+ }
17
+
18
+ return "";
19
+ }
20
+
21
+ /**
22
+ *
23
+ * @param {fn} callback The function that will be executed after the debounce is resolved.
24
+ * @param {number} wait The period of time that will be executed the debounced
25
+ * function.
26
+ * @returns The function that will be executed after the wait period is
27
+ * fulfilled.
28
+ */
29
+ export function debounce(
30
+ callback: (...args: any) => void,
31
+ wait: number,
32
+ ): (...args: any) => void {
33
+ let timeout;
34
+
35
+ return function executedFunction(...args) {
36
+ const later = () => {
37
+ clearTimeout(timeout);
38
+ callback(...args);
39
+ };
40
+
41
+ clearTimeout(timeout);
42
+ timeout = setTimeout(later, wait);
43
+ };
44
+ }