@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 +6 -0
- package/dist/es/index.js +202 -79
- package/dist/index.js +310 -174
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +1 -1
- package/src/__tests__/generated-snapshot.test.js +1 -1
- package/src/components/__tests__/action-menu.test.js +126 -166
- package/src/components/__tests__/dropdown-core-virtualized.test.js +20 -35
- package/src/components/__tests__/dropdown-core.test.js +724 -652
- package/src/components/__tests__/dropdown-popper.test.js +51 -0
- package/src/components/__tests__/multi-select.test.js +717 -518
- package/src/components/__tests__/single-select.test.js +52 -41
- package/src/components/action-item.js +2 -0
- package/src/components/dropdown-core.js +159 -90
- package/src/components/dropdown-popper.js +105 -0
- package/src/components/single-select.md +1 -1
- package/src/components/single-select.stories.js +49 -1
|
@@ -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
|
-
|
|
190
|
-
opened
|
|
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
|
-
|
|
200
|
-
opened: opened,
|
|
201
|
-
});
|
|
185
|
+
const handleToggleMenu = (opened) => {
|
|
186
|
+
setOpened(opened);
|
|
202
187
|
|
|
203
|
-
|
|
188
|
+
props.onToggle && props.onToggle(opened);
|
|
204
189
|
};
|
|
205
190
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
});
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import ReactDOM from "react-dom";
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
386
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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: "
|
|
144
|
+
width: "600px",
|
|
97
145
|
},
|
|
98
146
|
centered: {
|
|
99
147
|
alignItems: "center",
|