@khanacademy/wonder-blocks-dropdown 2.6.7 → 2.6.8

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,11 @@
1
1
  # @khanacademy/wonder-blocks-dropdown
2
2
 
3
+ ## 2.6.8
4
+
5
+ ### Patch Changes
6
+
7
+ - f36d2f21: Use `react-window` conditionally (80+ items)
8
+
3
9
  ## 2.6.7
4
10
 
5
11
  ### Patch Changes
package/dist/es/index.js CHANGED
@@ -11,11 +11,11 @@ import { addStyle, View } from '@khanacademy/wonder-blocks-core';
11
11
  import _objectWithoutPropertiesLoose from '@babel/runtime/helpers/objectWithoutPropertiesLoose';
12
12
  import Icon, { icons } from '@khanacademy/wonder-blocks-icon';
13
13
  import ReactDOM from 'react-dom';
14
- import { Popper } from 'react-popper';
15
14
  import { VariableSizeList } from 'react-window';
16
- import { maybeGetPortalMountedModalHostElement } from '@khanacademy/wonder-blocks-modal';
17
15
  import { withActionScheduler } from '@khanacademy/wonder-blocks-timing';
18
16
  import IconButton from '@khanacademy/wonder-blocks-icon-button';
17
+ import { Popper } from 'react-popper';
18
+ import { maybeGetPortalMountedModalHostElement } from '@khanacademy/wonder-blocks-modal';
19
19
  import { Strut } from '@khanacademy/wonder-blocks-layout';
20
20
 
21
21
  const keyCodes = {
@@ -299,7 +299,7 @@ const styles$7 = StyleSheet.create({
299
299
  }
300
300
  });
301
301
 
302
- const _excluded$6 = ["disabled", "label", "role", "selected", "testId", "style", "value", "onClick", "onToggle", "variant"];
302
+ const _excluded$5 = ["disabled", "label", "role", "selected", "testId", "style", "value", "onClick", "onToggle", "variant"];
303
303
 
304
304
  /**
305
305
  * For option items that can be selected in a dropdown, selection denoted either
@@ -346,7 +346,7 @@ class OptionItem extends React.Component {
346
346
  testId,
347
347
  style
348
348
  } = _this$props,
349
- sharedProps = _objectWithoutPropertiesLoose(_this$props, _excluded$6);
349
+ sharedProps = _objectWithoutPropertiesLoose(_this$props, _excluded$5);
350
350
 
351
351
  const ClickableBehavior = getClickableBehavior();
352
352
  const CheckComponent = this.getCheckComponent();
@@ -909,7 +909,76 @@ class DropdownCoreVirtualized extends React.Component {
909
909
 
910
910
  var DropdownCoreVirtualized$1 = withActionScheduler(DropdownCoreVirtualized);
911
911
 
912
- const _excluded$5 = ["pointerEvents"];
912
+ const modifiers = [{
913
+ name: "preventOverflow",
914
+ options: {
915
+ rootBoundary: "viewport",
916
+ // Allows to overlap the popper in case there's no more vertical
917
+ // room in the viewport.
918
+ altAxis: true,
919
+ // Also needed to make sure the Popper will be displayed correctly
920
+ // in different contexts (e.g inside a Modal)
921
+ tether: false
922
+ }
923
+ }];
924
+
925
+ /**
926
+ * A wrapper for PopperJS that renders the children inside a portal.
927
+ */
928
+ function DropdownPopper({
929
+ children,
930
+ alignment = "left",
931
+ onPopperElement,
932
+ referenceElement
933
+ }) {
934
+ // If we are in a modal, we find where we should be portalling the menu by
935
+ // using the helper function from the modal package on the opener element.
936
+ // If we are not in a modal, we use body as the location to portal to.
937
+ const modalHost = maybeGetPortalMountedModalHostElement(referenceElement) || document.querySelector("body");
938
+
939
+ if (!modalHost) {
940
+ return null;
941
+ }
942
+
943
+ return /*#__PURE__*/ReactDOM.createPortal( /*#__PURE__*/React.createElement(Popper, {
944
+ innerRef: node => {
945
+ if (node && onPopperElement) {
946
+ onPopperElement(node);
947
+ }
948
+ },
949
+ referenceElement: referenceElement,
950
+ strategy: "fixed",
951
+ placement: alignment === "left" ? "bottom-start" : "bottom-end",
952
+ modifiers: modifiers
953
+ }, ({
954
+ placement,
955
+ ref,
956
+ style,
957
+ hasPopperEscaped,
958
+ isReferenceHidden
959
+ }) => {
960
+ const shouldHidePopper = !!(hasPopperEscaped || isReferenceHidden);
961
+ return /*#__PURE__*/React.createElement("div", {
962
+ ref: ref,
963
+ style: style,
964
+ "data-test-id": "dropdown-popper",
965
+ "data-placement": placement
966
+ }, children(shouldHidePopper));
967
+ }), modalHost);
968
+ }
969
+
970
+ /**
971
+ * The number of options to apply the virtualized list to.
972
+ *
973
+ * NOTE: The threshold is defined taking into account performance
974
+ * implications (e.g. process input events for users should not be longer
975
+ * than 100ms).
976
+ * @see https://web.dev/rail/?utm_source=devtools#goals-and-guidelines
977
+ *
978
+ * TODO(juan, WB-1263): Improve performance by refactoring this component.
979
+ */
980
+
981
+ const VIRTUALIZE_THRESHOLD = 125;
913
982
 
914
983
  /**
915
984
  * A core dropdown component that takes an opener and children to display as
@@ -1092,6 +1161,18 @@ class DropdownCore extends React.Component {
1092
1161
  }
1093
1162
  };
1094
1163
 
1164
+ this.handleItemClick = (focusIndex, item) => {
1165
+ this.handleClickFocus(focusIndex);
1166
+
1167
+ if (item.component.props.onClick) {
1168
+ item.component.props.onClick();
1169
+ }
1170
+
1171
+ if (item.populatedProps.onClick) {
1172
+ item.populatedProps.onClick();
1173
+ }
1174
+ };
1175
+
1095
1176
  this.resetFocusedIndex();
1096
1177
  this.state = {
1097
1178
  prevItems: this.props.items,
@@ -1101,7 +1182,7 @@ class DropdownCore extends React.Component {
1101
1182
  noResults: defaultLabels.noResults
1102
1183
  }, props.labels)
1103
1184
  };
1104
- this.listRef = /*#__PURE__*/React.createRef();
1185
+ this.virtualizedListRef = /*#__PURE__*/React.createRef();
1105
1186
  }
1106
1187
 
1107
1188
  componentDidMount() {
@@ -1225,8 +1306,13 @@ class DropdownCore extends React.Component {
1225
1306
  }
1226
1307
 
1227
1308
  scheduleToFocusCurrentItem() {
1228
- // wait for windowed items to be recalculated
1229
- this.props.schedule.animationFrame(() => this.focusCurrentItem());
1309
+ if (this.shouldVirtualizeList()) {
1310
+ // wait for windowed items to be recalculated
1311
+ this.props.schedule.animationFrame(() => this.focusCurrentItem());
1312
+ } else {
1313
+ // immediately focus the current item if we're not virtualizing
1314
+ this.focusCurrentItem();
1315
+ }
1230
1316
  }
1231
1317
 
1232
1318
  focusCurrentItem() {
@@ -1234,12 +1320,12 @@ class DropdownCore extends React.Component {
1234
1320
 
1235
1321
  if (fousedItemRef) {
1236
1322
  // force react-window to scroll to ensure the focused item is visible
1237
- if (this.listRef.current) {
1323
+ if (this.virtualizedListRef.current) {
1238
1324
  // Our focused index does not include disabled items, but the
1239
1325
  // react-window index system does include the disabled items
1240
1326
  // in the count. So we need to use "originalIndex", which
1241
1327
  // does account for disabled items.
1242
- this.listRef.current.scrollToItem(fousedItemRef.originalIndex);
1328
+ this.virtualizedListRef.current.scrollToItem(fousedItemRef.originalIndex);
1243
1329
  }
1244
1330
 
1245
1331
  const node = ReactDOM.findDOMNode(fousedItemRef.ref.current);
@@ -1323,13 +1409,86 @@ class DropdownCore extends React.Component {
1323
1409
 
1324
1410
  return null;
1325
1411
  }
1412
+ /**
1413
+ * Handles click events for each item in the dropdown.
1414
+ */
1415
+
1416
+
1417
+ /**
1418
+ * Determines which rendering strategy we are going to apply to the options
1419
+ * list.
1420
+ */
1421
+ shouldVirtualizeList() {
1422
+ // Verify if the list is long enough to be virtualized (passes the
1423
+ // threshold).
1424
+ return this.props.items.length > VIRTUALIZE_THRESHOLD;
1425
+ }
1426
+ /**
1427
+ * Renders the non-virtualized list of items.
1428
+ */
1429
+
1430
+
1431
+ renderList() {
1432
+ let focusCounter = 0;
1433
+ const itemRole = this.getItemRole(); // if we don't need to virtualize, we can render the list directly
1434
+
1435
+ return this.props.items.map((item, index) => {
1436
+ if (SeparatorItem.isClassOf(item.component)) {
1437
+ return item.component;
1438
+ }
1439
+
1440
+ const {
1441
+ component,
1442
+ focusable,
1443
+ populatedProps
1444
+ } = item;
1445
+
1446
+ if (focusable) {
1447
+ focusCounter += 1;
1448
+ }
1449
+
1450
+ const focusIndex = focusCounter - 1; // The reference to the item is used to restore focus.
1451
+
1452
+ const currentRef = this.state.itemRefs[focusIndex] ? this.state.itemRefs[focusIndex].ref : null; // Render the SearchField component.
1453
+
1454
+ if (SearchTextInput.isClassOf(component)) {
1455
+ return /*#__PURE__*/React.cloneElement(component, _extends({}, populatedProps, {
1456
+ key: "search-text-input",
1457
+ // pass the current ref down to the input element
1458
+ itemRef: currentRef,
1459
+ // override to avoid losing focus when pressing a key
1460
+ onClick: () => {
1461
+ this.handleClickFocus(0);
1462
+ this.focusCurrentItem();
1463
+ },
1464
+ // apply custom styles
1465
+ style: searchInputStyle
1466
+ }));
1467
+ } // Render OptionItem and/or ActionItem elements.
1468
+
1469
+
1470
+ return /*#__PURE__*/React.cloneElement(component, _extends({}, populatedProps, {
1471
+ key: index,
1472
+ onClick: () => {
1473
+ this.handleItemClick(focusIndex, item);
1474
+ },
1475
+ // Only pass the ref if the item is focusable.
1476
+ ref: focusable ? currentRef : null,
1477
+ role: itemRole
1478
+ }));
1479
+ });
1480
+ }
1326
1481
  /**
1327
1482
  * Process the items and wrap them into an array that react-window can
1328
- * interpret
1483
+ * interpret.
1484
+ *
1485
+ * NOTE: The main difference with the collection in renderList() is that we
1486
+ * massage the items to be able to clone them later in
1487
+ * DropdownVirtualizedItem, where as renderList() clones the items directly.
1329
1488
  */
1330
1489
 
1331
1490
 
1332
- parseItemsList() {
1491
+ parseVirtualizedItems() {
1333
1492
  let focusCounter = 0;
1334
1493
  const itemRole = this.getItemRole();
1335
1494
  return this.props.items.map((item, index) => {
@@ -1358,21 +1517,26 @@ class DropdownCore extends React.Component {
1358
1517
  role: itemRole,
1359
1518
  ref: item.focusable ? this.state.itemRefs[focusIndex] ? this.state.itemRefs[focusIndex].ref : null : null,
1360
1519
  onClick: () => {
1361
- this.handleClickFocus(focusIndex);
1362
-
1363
- if (item.component.props.onClick) {
1364
- item.component.props.onClick();
1365
- }
1366
-
1367
- if (item.populatedProps.onClick) {
1368
- item.populatedProps.onClick();
1369
- }
1520
+ this.handleItemClick(focusIndex, item);
1370
1521
  }
1371
1522
  });
1372
1523
  });
1373
1524
  }
1525
+ /**
1526
+ * Render the items using a virtualized list
1527
+ */
1528
+
1529
+
1530
+ renderVirtualizedList() {
1531
+ // preprocess items data to pass it to the renderer
1532
+ const virtualizedItems = this.parseVirtualizedItems();
1533
+ return /*#__PURE__*/React.createElement(DropdownCoreVirtualized$1, {
1534
+ data: virtualizedItems,
1535
+ listRef: this.virtualizedListRef
1536
+ });
1537
+ }
1374
1538
 
1375
- renderItems(isReferenceHidden) {
1539
+ renderDropdownMenu(listRenderer, isReferenceHidden) {
1376
1540
  const {
1377
1541
  dropdownStyle,
1378
1542
  light,
@@ -1381,9 +1545,7 @@ class DropdownCore extends React.Component {
1381
1545
  // It's only used if the element exists in the DOM
1382
1546
 
1383
1547
  const openerStyle = openerElement && window.getComputedStyle(openerElement);
1384
- const minDropdownWidth = openerStyle ? openerStyle.getPropertyValue("width") : 0; // preprocess items data to pass it to the renderer
1385
-
1386
- const itemsList = this.parseItemsList();
1548
+ const minDropdownWidth = openerStyle ? openerStyle.getPropertyValue("width") : 0;
1387
1549
  return /*#__PURE__*/React.createElement(View // Stop propagation to prevent the mouseup listener on the
1388
1550
  // document from closing the menu.
1389
1551
  , {
@@ -1392,66 +1554,27 @@ class DropdownCore extends React.Component {
1392
1554
  style: [styles$3.dropdown, light && styles$3.light, isReferenceHidden && styles$3.hidden, {
1393
1555
  minWidth: minDropdownWidth
1394
1556
  }, dropdownStyle]
1395
- }, /*#__PURE__*/React.createElement(DropdownCoreVirtualized$1, {
1396
- data: itemsList,
1397
- listRef: this.listRef
1398
- }), this.maybeRenderNoResults());
1557
+ }, listRenderer, this.maybeRenderNoResults());
1399
1558
  }
1400
1559
 
1401
1560
  renderDropdown() {
1402
1561
  const {
1403
1562
  alignment,
1404
1563
  openerElement
1405
- } = this.props; // If we are in a modal, we find where we should be portalling the menu
1406
- // by using the helper function from the modal package on the opener
1407
- // element.
1408
- // If we are not in a modal, we use body as the location to portal to.
1409
-
1410
- const modalHost = maybeGetPortalMountedModalHostElement(openerElement) || document.querySelector("body");
1411
-
1412
- if (modalHost) {
1413
- return /*#__PURE__*/ReactDOM.createPortal( /*#__PURE__*/React.createElement(Popper, {
1414
- innerRef: node => {
1415
- if (node) {
1416
- this.popperElement = node;
1417
- }
1418
- },
1419
- referenceElement: this.props.openerElement,
1420
- strategy: "fixed",
1421
- placement: alignment === "left" ? "bottom-start" : "bottom-end",
1422
- modifiers: [{
1423
- name: "preventOverflow",
1424
- options: {
1425
- rootBoundary: "viewport",
1426
- // Allows to overlap the popper in case there's
1427
- // no more vertical room in the viewport.
1428
- altAxis: true,
1429
- // Also needed to make sure the Popper will be
1430
- // displayed correctly in different contexts
1431
- // (e.g inside a Modal)
1432
- tether: false
1433
- }
1434
- }]
1435
- }, ({
1436
- placement,
1437
- ref,
1438
- style,
1439
- hasPopperEscaped,
1440
- isReferenceHidden
1441
- }) => {
1442
- // For some reason react-popper includes `pointerEvents: "none"`
1443
- // in the `style` it passes to us, but only when running the tests.
1444
- const restStyle = _objectWithoutPropertiesLoose(style, _excluded$5);
1445
-
1446
- return /*#__PURE__*/React.createElement("div", {
1447
- ref: ref,
1448
- style: restStyle,
1449
- "data-placement": placement
1450
- }, this.renderItems(hasPopperEscaped || isReferenceHidden));
1451
- }), modalHost);
1452
- }
1453
-
1454
- return null;
1564
+ } = this.props; // Preprocess the items that are used inside the Popper instance. By
1565
+ // doing this, we optimize the list to be processed only one time
1566
+ // instead of every time popper changes.
1567
+ // NOTE: This improves the performance impact of the dropdown by
1568
+ // reducing the execution time up to 2.5X.
1569
+
1570
+ const listRenderer = this.shouldVirtualizeList() ? this.renderVirtualizedList() : this.renderList();
1571
+ return /*#__PURE__*/React.createElement(DropdownPopper, {
1572
+ alignment: alignment,
1573
+ onPopperElement: popperElement => {
1574
+ this.popperElement = popperElement;
1575
+ },
1576
+ referenceElement: openerElement
1577
+ }, isReferenceHidden => this.renderDropdownMenu(listRenderer, isReferenceHidden));
1455
1578
  }
1456
1579
 
1457
1580
  render() {