@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.
@@ -1,18 +1,15 @@
1
1
  //@flow
2
2
  import * as React from "react";
3
3
  import {render, screen} from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
4
5
 
5
6
  import OptionItem from "../option-item.js";
6
7
  import SingleSelect from "../single-select.js";
7
8
 
8
- import userEvent from "../../../../../utils/testing/user-event.js";
9
-
10
9
  describe("SingleSelect", () => {
11
10
  const onChange = jest.fn();
12
11
 
13
12
  beforeEach(() => {
14
- jest.useFakeTimers();
15
-
16
13
  window.scrollTo = jest.fn();
17
14
 
18
15
  // We mock console.error() because React logs a bunch of errors pertaining
@@ -26,10 +23,6 @@ describe("SingleSelect", () => {
26
23
  jest.spyOn(console, "error").mockReset();
27
24
  });
28
25
 
29
- afterEach(() => {
30
- window.scrollTo.mockClear();
31
- });
32
-
33
26
  describe("uncontrolled", () => {
34
27
  const uncontrolledSingleSelect = (
35
28
  <SingleSelect onChange={onChange} placeholder="Choose">
@@ -186,43 +179,33 @@ describe("SingleSelect", () => {
186
179
  onToggle?: (opened: boolean) => mixed,
187
180
  |};
188
181
 
189
- type State = {|
190
- opened?: boolean,
191
- |};
192
-
193
- class ControlledComponent extends React.Component<Props, State> {
194
- state: State = {
195
- opened: this.props.opened,
196
- };
182
+ function ControlledComponent(props: Props) {
183
+ const [opened, setOpened] = React.useState(props.opened);
197
184
 
198
- handleToggleMenu = (opened) => {
199
- this.setState({
200
- opened: opened,
201
- });
185
+ const handleToggleMenu = (opened) => {
186
+ setOpened(opened);
202
187
 
203
- this.props.onToggle && this.props.onToggle(opened);
188
+ props.onToggle && props.onToggle(opened);
204
189
  };
205
190
 
206
- render(): React.Node {
207
- return (
208
- <React.Fragment>
209
- <SingleSelect
210
- opened={this.state.opened}
211
- onToggle={this.handleToggleMenu}
212
- onChange={onChange}
213
- placeholder="Choose"
214
- >
215
- <OptionItem label="item 1" value="1" />
216
- <OptionItem label="item 2" value="2" />
217
- <OptionItem label="item 3" value="3" />
218
- </SingleSelect>
219
- <button
220
- data-test-id="parent-button"
221
- onClick={() => this.handleToggleMenu(true)}
222
- />
223
- </React.Fragment>
224
- );
225
- }
191
+ return (
192
+ <React.Fragment>
193
+ <SingleSelect
194
+ opened={opened}
195
+ onToggle={handleToggleMenu}
196
+ onChange={onChange}
197
+ placeholder="Choose"
198
+ >
199
+ <OptionItem label="item 1" value="1" />
200
+ <OptionItem label="item 2" value="2" />
201
+ <OptionItem label="item 3" value="3" />
202
+ </SingleSelect>
203
+ <button
204
+ data-test-id="parent-button"
205
+ onClick={() => handleToggleMenu(true)}
206
+ />
207
+ </React.Fragment>
208
+ );
226
209
  }
227
210
 
228
211
  it("opens the menu when the parent updates its state", () => {
@@ -579,5 +562,33 @@ describe("SingleSelect", () => {
579
562
  // Assert
580
563
  expect(searchInput.textContent).toEqual("");
581
564
  });
565
+
566
+ it("should move focus to the dismiss button after pressing {tab} on the text input", () => {
567
+ // Arrange
568
+ render(
569
+ <SingleSelect
570
+ onChange={onChange}
571
+ isFilterable={true}
572
+ placeholder="Choose"
573
+ selectedValue="2"
574
+ >
575
+ <OptionItem label="item 1" value="1" />
576
+ <OptionItem label="item 2" value="2" />
577
+ <OptionItem label="item 3" value="3" />
578
+ </SingleSelect>,
579
+ );
580
+ // open the dropdown menu
581
+ userEvent.click(screen.getByRole("button"));
582
+
583
+ const searchInput = screen.getByPlaceholderText("Filter");
584
+ userEvent.paste(searchInput, "some text");
585
+
586
+ // Act
587
+ userEvent.tab();
588
+
589
+ // Assert
590
+ const dismissBtn = screen.getByLabelText("Clear search");
591
+ expect(dismissBtn).toHaveFocus();
592
+ });
582
593
  });
583
594
  });
@@ -46,6 +46,8 @@ type ActionProps = {|
46
46
 
47
47
  /**
48
48
  * A target destination window for a link to open in.
49
+ *
50
+ * TODO(WB-1262): only allow this prop when `href` is also set.t
49
51
  */
50
52
  target?: "_blank",
51
53
 
@@ -3,12 +3,12 @@
3
3
 
4
4
  import * as React from "react";
5
5
  import ReactDOM from "react-dom";
6
- import {Popper} from "react-popper";
6
+
7
7
  import {StyleSheet} from "aphrodite";
8
8
  import {VariableSizeList as List} from "react-window";
9
9
 
10
10
  import Color, {fade} from "@khanacademy/wonder-blocks-color";
11
- import {maybeGetPortalMountedModalHostElement} from "@khanacademy/wonder-blocks-modal";
11
+
12
12
  import Spacing from "@khanacademy/wonder-blocks-spacing";
13
13
  import {View} from "@khanacademy/wonder-blocks-core";
14
14
  import {LabelMedium} from "@khanacademy/wonder-blocks-typography";
@@ -24,6 +24,19 @@ import SeparatorItem from "./separator-item.js";
24
24
  import SearchTextInput from "./search-text-input.js";
25
25
  import {defaultLabels, keyCodes, searchInputStyle} from "../util/constants.js";
26
26
  import type {DropdownItem} from "../util/types.js";
27
+ import DropdownPopper from "./dropdown-popper.js";
28
+
29
+ /**
30
+ * The number of options to apply the virtualized list to.
31
+ *
32
+ * NOTE: The threshold is defined taking into account performance
33
+ * implications (e.g. process input events for users should not be longer
34
+ * than 100ms).
35
+ * @see https://web.dev/rail/?utm_source=devtools#goals-and-guidelines
36
+ *
37
+ * TODO(juan, WB-1263): Improve performance by refactoring this component.
38
+ */
39
+ const VIRTUALIZE_THRESHOLD = 125;
27
40
 
28
41
  type Labels = {|
29
42
  noResults: string,
@@ -170,7 +183,7 @@ class DropdownCore extends React.Component<Props, State> {
170
183
  itemsClicked: boolean;
171
184
  popperElement: ?HTMLElement;
172
185
  // Keeps a reference of the virtualized list instance
173
- listRef: {|
186
+ virtualizedListRef: {|
174
187
  current: null | React.ElementRef<typeof List>,
175
188
  |};
176
189
 
@@ -246,7 +259,7 @@ class DropdownCore extends React.Component<Props, State> {
246
259
  },
247
260
  };
248
261
 
249
- this.listRef = React.createRef();
262
+ this.virtualizedListRef = React.createRef();
250
263
  }
251
264
 
252
265
  componentDidMount() {
@@ -382,8 +395,13 @@ class DropdownCore extends React.Component<Props, State> {
382
395
  };
383
396
 
384
397
  scheduleToFocusCurrentItem() {
385
- // wait for windowed items to be recalculated
386
- this.props.schedule.animationFrame(() => this.focusCurrentItem());
398
+ if (this.shouldVirtualizeList()) {
399
+ // wait for windowed items to be recalculated
400
+ this.props.schedule.animationFrame(() => this.focusCurrentItem());
401
+ } else {
402
+ // immediately focus the current item if we're not virtualizing
403
+ this.focusCurrentItem();
404
+ }
387
405
  }
388
406
 
389
407
  focusCurrentItem() {
@@ -391,12 +409,14 @@ class DropdownCore extends React.Component<Props, State> {
391
409
 
392
410
  if (fousedItemRef) {
393
411
  // force react-window to scroll to ensure the focused item is visible
394
- if (this.listRef.current) {
412
+ if (this.virtualizedListRef.current) {
395
413
  // Our focused index does not include disabled items, but the
396
414
  // react-window index system does include the disabled items
397
415
  // in the count. So we need to use "originalIndex", which
398
416
  // does account for disabled items.
399
- this.listRef.current.scrollToItem(fousedItemRef.originalIndex);
417
+ this.virtualizedListRef.current.scrollToItem(
418
+ fousedItemRef.originalIndex,
419
+ );
400
420
  }
401
421
 
402
422
  const node = ((ReactDOM.findDOMNode(
@@ -579,11 +599,98 @@ class DropdownCore extends React.Component<Props, State> {
579
599
  return null;
580
600
  }
581
601
 
602
+ /**
603
+ * Handles click events for each item in the dropdown.
604
+ */
605
+ handleItemClick: (focusIndex: number, item: DropdownItem) => void = (
606
+ focusIndex: number,
607
+ item: DropdownItem,
608
+ ) => {
609
+ this.handleClickFocus(focusIndex);
610
+ if (item.component.props.onClick) {
611
+ item.component.props.onClick();
612
+ }
613
+ if (item.populatedProps.onClick) {
614
+ item.populatedProps.onClick();
615
+ }
616
+ };
617
+
618
+ /**
619
+ * Determines which rendering strategy we are going to apply to the options
620
+ * list.
621
+ */
622
+ shouldVirtualizeList(): boolean {
623
+ // Verify if the list is long enough to be virtualized (passes the
624
+ // threshold).
625
+ return this.props.items.length > VIRTUALIZE_THRESHOLD;
626
+ }
627
+
628
+ /**
629
+ * Renders the non-virtualized list of items.
630
+ */
631
+ renderList(): React.Node {
632
+ let focusCounter = 0;
633
+ const itemRole = this.getItemRole();
634
+
635
+ // if we don't need to virtualize, we can render the list directly
636
+ return this.props.items.map((item, index) => {
637
+ if (SeparatorItem.isClassOf(item.component)) {
638
+ return item.component;
639
+ }
640
+
641
+ const {component, focusable, populatedProps} = item;
642
+
643
+ if (focusable) {
644
+ focusCounter += 1;
645
+ }
646
+
647
+ const focusIndex = focusCounter - 1;
648
+ // The reference to the item is used to restore focus.
649
+ const currentRef = this.state.itemRefs[focusIndex]
650
+ ? this.state.itemRefs[focusIndex].ref
651
+ : null;
652
+
653
+ // Render the SearchField component.
654
+ if (SearchTextInput.isClassOf(component)) {
655
+ return React.cloneElement(component, {
656
+ ...populatedProps,
657
+
658
+ key: "search-text-input",
659
+ // pass the current ref down to the input element
660
+ itemRef: currentRef,
661
+ // override to avoid losing focus when pressing a key
662
+ onClick: () => {
663
+ this.handleClickFocus(0);
664
+ this.focusCurrentItem();
665
+ },
666
+ // apply custom styles
667
+ style: searchInputStyle,
668
+ });
669
+ }
670
+
671
+ // Render OptionItem and/or ActionItem elements.
672
+ return React.cloneElement(component, {
673
+ ...populatedProps,
674
+ key: index,
675
+ onClick: () => {
676
+ this.handleItemClick(focusIndex, item);
677
+ },
678
+ // Only pass the ref if the item is focusable.
679
+ ref: focusable ? currentRef : null,
680
+ role: itemRole,
681
+ });
682
+ });
683
+ }
684
+
582
685
  /**
583
686
  * Process the items and wrap them into an array that react-window can
584
- * interpret
687
+ * interpret.
688
+ *
689
+ * NOTE: The main difference with the collection in renderList() is that we
690
+ * massage the items to be able to clone them later in
691
+ * DropdownVirtualizedItem, where as renderList() clones the items directly.
585
692
  */
586
- parseItemsList(): Array<DropdownItem> {
693
+ parseVirtualizedItems(): Array<DropdownItem> {
587
694
  let focusCounter = 0;
588
695
  const itemRole = this.getItemRole();
589
696
 
@@ -621,19 +728,30 @@ class DropdownCore extends React.Component<Props, State> {
621
728
  : null
622
729
  : null,
623
730
  onClick: () => {
624
- this.handleClickFocus(focusIndex);
625
- if (item.component.props.onClick) {
626
- item.component.props.onClick();
627
- }
628
- if (item.populatedProps.onClick) {
629
- item.populatedProps.onClick();
630
- }
731
+ this.handleItemClick(focusIndex, item);
631
732
  },
632
733
  };
633
734
  });
634
735
  }
635
736
 
636
- renderItems(isReferenceHidden: ?boolean): React.Node {
737
+ /**
738
+ * Render the items using a virtualized list
739
+ */
740
+ renderVirtualizedList(): React.Node {
741
+ // preprocess items data to pass it to the renderer
742
+ const virtualizedItems = this.parseVirtualizedItems();
743
+ return (
744
+ <DropdownCoreVirtualized
745
+ data={virtualizedItems}
746
+ listRef={this.virtualizedListRef}
747
+ />
748
+ );
749
+ }
750
+
751
+ renderDropdownMenu(
752
+ listRenderer: React.Node,
753
+ isReferenceHidden: ?boolean,
754
+ ): React.Node {
637
755
  const {dropdownStyle, light, openerElement} = this.props;
638
756
 
639
757
  // The dropdown width is at least the width of the opener.
@@ -644,9 +762,6 @@ class DropdownCore extends React.Component<Props, State> {
644
762
  ? openerStyle.getPropertyValue("width")
645
763
  : 0;
646
764
 
647
- // preprocess items data to pass it to the renderer
648
- const itemsList = this.parseItemsList();
649
-
650
765
  return (
651
766
  <View
652
767
  // Stop propagation to prevent the mouseup listener on the
@@ -661,11 +776,7 @@ class DropdownCore extends React.Component<Props, State> {
661
776
  dropdownStyle,
662
777
  ]}
663
778
  >
664
- <DropdownCoreVirtualized
665
- data={itemsList}
666
- listRef={this.listRef}
667
- />
668
-
779
+ {listRenderer}
669
780
  {this.maybeRenderNoResults()}
670
781
  </View>
671
782
  );
@@ -673,71 +784,29 @@ class DropdownCore extends React.Component<Props, State> {
673
784
 
674
785
  renderDropdown(): React.Node {
675
786
  const {alignment, openerElement} = this.props;
676
- // If we are in a modal, we find where we should be portalling the menu
677
- // by using the helper function from the modal package on the opener
678
- // element.
679
- // If we are not in a modal, we use body as the location to portal to.
680
- const modalHost =
681
- maybeGetPortalMountedModalHostElement(openerElement) ||
682
- document.querySelector("body");
683
-
684
- if (modalHost) {
685
- return ReactDOM.createPortal(
686
- <Popper
687
- innerRef={(node: ?HTMLElement) => {
688
- if (node) {
689
- this.popperElement = node;
690
- }
691
- }}
692
- referenceElement={this.props.openerElement}
693
- strategy="fixed"
694
- placement={
695
- alignment === "left" ? "bottom-start" : "bottom-end"
696
- }
697
- modifiers={[
698
- {
699
- name: "preventOverflow",
700
- options: {
701
- rootBoundary: "viewport",
702
- // Allows to overlap the popper in case there's
703
- // no more vertical room in the viewport.
704
- altAxis: true,
705
- // Also needed to make sure the Popper will be
706
- // displayed correctly in different contexts
707
- // (e.g inside a Modal)
708
- tether: false,
709
- },
710
- },
711
- ]}
712
- >
713
- {({
714
- placement,
715
- ref,
716
- style,
717
- hasPopperEscaped,
718
- isReferenceHidden,
719
- }) => {
720
- // For some reason react-popper includes `pointerEvents: "none"`
721
- // in the `style` it passes to us, but only when running the tests.
722
- const {pointerEvents: _, ...restStyle} = style;
723
- return (
724
- <div
725
- ref={ref}
726
- style={restStyle}
727
- data-placement={placement}
728
- >
729
- {this.renderItems(
730
- hasPopperEscaped || isReferenceHidden,
731
- )}
732
- </div>
733
- );
734
- }}
735
- </Popper>,
736
- modalHost,
737
- );
738
- }
739
787
 
740
- return null;
788
+ // Preprocess the items that are used inside the Popper instance. By
789
+ // doing this, we optimize the list to be processed only one time
790
+ // instead of every time popper changes.
791
+ // NOTE: This improves the performance impact of the dropdown by
792
+ // reducing the execution time up to 2.5X.
793
+ const listRenderer = this.shouldVirtualizeList()
794
+ ? this.renderVirtualizedList()
795
+ : this.renderList();
796
+
797
+ return (
798
+ <DropdownPopper
799
+ alignment={alignment}
800
+ onPopperElement={(popperElement) => {
801
+ this.popperElement = popperElement;
802
+ }}
803
+ referenceElement={openerElement}
804
+ >
805
+ {(isReferenceHidden) =>
806
+ this.renderDropdownMenu(listRenderer, isReferenceHidden)
807
+ }
808
+ </DropdownPopper>
809
+ );
741
810
  }
742
811
 
743
812
  render(): React.Node {
@@ -0,0 +1,105 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import ReactDOM from "react-dom";
4
+ import {Popper} from "react-popper";
5
+
6
+ import {maybeGetPortalMountedModalHostElement} from "@khanacademy/wonder-blocks-modal";
7
+
8
+ import type {StyleType} from "@khanacademy/wonder-blocks-core";
9
+
10
+ const modifiers = [
11
+ {
12
+ name: "preventOverflow",
13
+ options: {
14
+ rootBoundary: "viewport",
15
+ // Allows to overlap the popper in case there's no more vertical
16
+ // room in the viewport.
17
+ altAxis: true,
18
+ // Also needed to make sure the Popper will be displayed correctly
19
+ // in different contexts (e.g inside a Modal)
20
+ tether: false,
21
+ },
22
+ },
23
+ ];
24
+
25
+ type Props = {|
26
+ /**
27
+ * The children that will be wrapped by PopperJS.
28
+ */
29
+ children: (isReferenceHidden: boolean) => React.Node,
30
+
31
+ /**
32
+ * The reference element used to position the popper.
33
+ */
34
+ referenceElement: ?HTMLElement,
35
+
36
+ /**
37
+ * Whether this menu should be left-aligned or right-aligned with the
38
+ * reference component. Defaults to left-aligned.
39
+ */
40
+ alignment?: "left" | "right",
41
+
42
+ /**
43
+ * The popper's reference.
44
+ * @see https://popper.js.org/react-popper/v2/render-props/#innerref
45
+ */
46
+ onPopperElement?: (popperElement: ?HTMLElement) => mixed,
47
+
48
+ /**
49
+ * Styles that will be applied to the children.
50
+ */
51
+ style?: StyleType,
52
+ |};
53
+
54
+ /**
55
+ * A wrapper for PopperJS that renders the children inside a portal.
56
+ */
57
+ export default function DropdownPopper({
58
+ children,
59
+ alignment = "left",
60
+ onPopperElement,
61
+ referenceElement,
62
+ }: Props): React.Node {
63
+ // If we are in a modal, we find where we should be portalling the menu by
64
+ // using the helper function from the modal package on the opener element.
65
+ // If we are not in a modal, we use body as the location to portal to.
66
+ const modalHost =
67
+ maybeGetPortalMountedModalHostElement(referenceElement) ||
68
+ document.querySelector("body");
69
+
70
+ if (!modalHost) {
71
+ return null;
72
+ }
73
+
74
+ return ReactDOM.createPortal(
75
+ <Popper
76
+ innerRef={(node: ?HTMLElement) => {
77
+ if (node && onPopperElement) {
78
+ onPopperElement(node);
79
+ }
80
+ }}
81
+ referenceElement={referenceElement}
82
+ strategy="fixed"
83
+ placement={alignment === "left" ? "bottom-start" : "bottom-end"}
84
+ modifiers={modifiers}
85
+ >
86
+ {({placement, ref, style, hasPopperEscaped, isReferenceHidden}) => {
87
+ const shouldHidePopper = !!(
88
+ hasPopperEscaped || isReferenceHidden
89
+ );
90
+
91
+ return (
92
+ <div
93
+ ref={ref}
94
+ style={style}
95
+ data-test-id="dropdown-popper"
96
+ data-placement={placement}
97
+ >
98
+ {children(shouldHidePopper)}
99
+ </div>
100
+ );
101
+ }}
102
+ </Popper>,
103
+ modalHost,
104
+ );
105
+ }
@@ -477,7 +477,7 @@ class ExampleWithFilter extends React.Component {
477
477
  constructor() {
478
478
  super();
479
479
  this.state = {
480
- opened: true,
480
+ opened: false,
481
481
  selectedValue: null,
482
482
  };
483
483
  this.handleChange = this.handleChange.bind(this);
@@ -15,6 +15,54 @@ import {SingleSelect, OptionItem} from "../index.js";
15
15
 
16
16
  export default {
17
17
  title: "Dropdown / SingleSelect",
18
+ component: SingleSelect,
19
+ args: {
20
+ isFilterable: true,
21
+ opened: true,
22
+ disabled: false,
23
+ light: false,
24
+ placeholder: "Choose a fruit",
25
+ },
26
+ };
27
+
28
+ const SingleSelectTemplate = (args) => <SingleSelect {...args} />;
29
+
30
+ export const DefaultSingleSelectOpened: StoryComponentType = (args) => {
31
+ const [selectedValue, setSelectedValue] = React.useState("pear");
32
+ const [opened, setOpened] = React.useState(args.opened);
33
+ React.useEffect(() => {
34
+ // Only update opened if the args.opened prop changes (using the
35
+ // controls panel).
36
+ setOpened(args.opened);
37
+ }, [args.opened]);
38
+
39
+ return (
40
+ <SingleSelectTemplate
41
+ {...args}
42
+ onChange={setSelectedValue}
43
+ selectedValue={selectedValue}
44
+ opened={opened}
45
+ onToggle={setOpened}
46
+ >
47
+ <OptionItem label="Banana" value="banana" />
48
+ <OptionItem label="Strawberry" value="strawberry" disabled />
49
+ <OptionItem label="Pear" value="pear" />
50
+ <OptionItem label="Orange" value="orange" />
51
+ <OptionItem label="Watermelon" value="watermelon" />
52
+ <OptionItem label="Apple" value="apple" />
53
+ <OptionItem label="Grape" value="grape" />
54
+ <OptionItem label="Lemon" value="lemon" />
55
+ </SingleSelectTemplate>
56
+ );
57
+ };
58
+
59
+ DefaultSingleSelectOpened.parameters = {
60
+ docs: {
61
+ storyDescription:
62
+ "This select starts with a starting selected item. One of the items is disabled and thus cannot be selected.",
63
+ },
64
+ // Added to ensure that the dropdown menu is rendered using PopperJS.
65
+ chromatic: {delay: 400},
18
66
  };
19
67
 
20
68
  const fruits = ["banana", "strawberry", "pear", "orange"];
@@ -93,7 +141,7 @@ const styles = StyleSheet.create({
93
141
  },
94
142
  wrapper: {
95
143
  height: "800px",
96
- width: "1200px",
144
+ width: "600px",
97
145
  },
98
146
  centered: {
99
147
  alignItems: "center",