Skip to content

Commit 0b388f9

Browse files
committed
improve placeholder replacement for click events on context overlays
1 parent 703e665 commit 0b388f9

3 files changed

Lines changed: 179 additions & 20 deletions

File tree

src/components/ContextOverlay/ContextOverlay.tsx

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import {
33
Classes as BlueprintClasses,
44
Popover as BlueprintPopover,
5+
PopoverInteractionKind as InteractionKind,
56
PopoverProps as BlueprintPopoverProps,
67
Utils as BlueprintUtils,
78
} from "@blueprintjs/core";
@@ -38,8 +39,11 @@ export const ContextOverlay = ({
3839
...otherPopoverProps
3940
}: ContextOverlayProps) => {
4041
const placeholderRef = React.useRef(null);
41-
const eventMemory = React.useRef<undefined | "afterhover" | "afterfocus">(undefined);
42+
const eventMemory = React.useRef<undefined | "mouseenter" | "focusin" | "click">(undefined);
4243
const swapDelay = React.useRef<null | NodeJS.Timeout>(null);
44+
const interactionKind = React.useRef<InteractionKind>(
45+
otherPopoverProps["interactionKind"] ?? InteractionKind.CLICK
46+
);
4347
const swapDelayTime = 15;
4448
const [placeholder, setPlaceholder] = React.useState<boolean>(
4549
// use placeholder only for "simple" overlays without special states
@@ -50,36 +54,79 @@ export const ContextOverlay = ({
5054
usePlaceholder
5155
);
5256

57+
const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
58+
const waitForClick =
59+
interactionKind.current === InteractionKind.CLICK ||
60+
interactionKind.current === InteractionKind.CLICK_TARGET_ONLY
61+
? true
62+
: false;
63+
64+
if (swapDelay.current) {
65+
clearTimeout(swapDelay.current);
66+
}
67+
swapDelay.current = setTimeout(
68+
() => {
69+
eventMemory.current = ev.type as "mouseenter" | "focusin" | "click";
70+
setPlaceholder(false);
71+
},
72+
// we delay the swap for hover/focus to prevent unwanted effects
73+
// (e.g. event hickup after replacing elements when it is not really necessary)
74+
waitForClick ? 0 : swapDelayTime
75+
);
76+
};
77+
5378
React.useEffect(() => {
79+
const waitForClick =
80+
interactionKind.current === InteractionKind.CLICK ||
81+
interactionKind.current === InteractionKind.CLICK_TARGET_ONLY
82+
? true
83+
: false;
84+
const removeEvents = () => {
85+
if (placeholderRef.current) {
86+
(placeholderRef.current as HTMLElement).removeEventListener("click", swap);
87+
(placeholderRef.current as HTMLElement).removeEventListener("mouseenter", swap);
88+
(placeholderRef.current as HTMLElement).removeEventListener("focusin", swap);
89+
}
90+
return;
91+
};
5492
if (placeholderRef.current) {
55-
const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
56-
if (swapDelay.current) {
57-
clearTimeout(swapDelay.current);
58-
}
59-
swapDelay.current = setTimeout(() => {
60-
// we delay the swap to prevent unwanted effects
61-
// (e.g. event hickup after replacing elements when it is not really necessary)
62-
eventMemory.current = ev.type === "focusin" ? "afterfocus" : "afterhover";
63-
setPlaceholder(false);
64-
}, swapDelayTime);
65-
};
66-
(placeholderRef.current as HTMLElement).addEventListener("mouseenter", swap);
67-
(placeholderRef.current as HTMLElement).addEventListener("focusin", swap);
93+
removeEvents(); // remove events in case of interaction kind changed during existence
94+
if (waitForClick) {
95+
(placeholderRef.current as HTMLElement).addEventListener("click", swap);
96+
} else {
97+
(placeholderRef.current as HTMLElement).addEventListener("mouseenter", swap);
98+
(placeholderRef.current as HTMLElement).addEventListener("focusin", swap);
99+
}
68100
return () => {
69-
if (placeholderRef.current) {
70-
(placeholderRef.current as HTMLElement).removeEventListener("mouseenter", swap);
71-
(placeholderRef.current as HTMLElement).removeEventListener("focusin", swap);
72-
}
101+
removeEvents();
73102
};
74103
}
75104
return () => {};
76105
}, [!!placeholderRef.current]);
77106

78107
const refocus = React.useCallback((node) => {
79-
if (eventMemory.current === "afterfocus" && node) {
108+
if (eventMemory.current && node) {
80109
const target = node.targetRef.current.children[0];
81110
if (target) {
82-
target.focus();
111+
switch (eventMemory.current) {
112+
case "focusin":
113+
target.focus();
114+
break;
115+
case "click":
116+
target.click();
117+
break;
118+
case "mouseenter":
119+
// re-check if the cursor is still over the element after swapping the placeholder before triggering the event to bubble up
120+
(target as HTMLElement).addEventListener(
121+
"mouseover",
122+
() => (target as HTMLElement).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })),
123+
{
124+
capture: true,
125+
once: true,
126+
}
127+
);
128+
break;
129+
}
83130
}
84131
}
85132
}, []);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
4+
import "@testing-library/jest-dom";
5+
6+
import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
7+
8+
import ContextMenu from "./../ContextMenu";
9+
import { Default as ContextMenuStory } from "./../ContextMenu.stories";
10+
11+
const overlayWrapper = `${eccgui}-contextoverlay`;
12+
const placeholderClass = `${overlayWrapper}__wrapper--placeholder`;
13+
14+
const checkForPlaceholderClass = (container: HTMLElement, tobe: number) => {
15+
expect(container.getElementsByClassName(placeholderClass).length).toBe(tobe);
16+
};
17+
18+
describe("ContextMenu", () => {
19+
it("should render placeholder automatically", () => {
20+
const { container } = render(<ContextMenu {...ContextMenuStory.args} />);
21+
checkForPlaceholderClass(container, 1);
22+
});
23+
it("should not render placeholder when `preventPlaceholder===true`", () => {
24+
const { container } = render(<ContextMenu {...ContextMenuStory.args} preventPlaceholder={true} />);
25+
checkForPlaceholderClass(container, 0);
26+
});
27+
it("should render placeholder when `preventPlaceholder===false`", () => {
28+
const { container } = render(<ContextMenu {...ContextMenuStory.args} preventPlaceholder={false} />);
29+
checkForPlaceholderClass(container, 1);
30+
});
31+
it("if no placeholder is used the menu should be displayed on click", async () => {
32+
const { container } = render(<ContextMenu {...ContextMenuStory.args} preventPlaceholder={true} />);
33+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
34+
expect(await screen.findByText("First option")).toBeVisible();
35+
});
36+
it("if placeholder is used the overlay should be displayed on click", async () => {
37+
const { container } = render(<ContextMenu {...ContextMenuStory.args} usePlaceholder={false} />);
38+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
39+
expect(await screen.findByText("First option")).toBeVisible();
40+
});
41+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from "react";
2+
import { PopoverInteractionKind } from "@blueprintjs/core";
3+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4+
5+
import "@testing-library/jest-dom";
6+
7+
import { CLASSPREFIX as eccgui } from "../../../configuration/constants";
8+
9+
import ContextOverlay from "./../ContextOverlay";
10+
import { Default as ContextOverlayStory } from "./../ContextOverlay.stories";
11+
12+
const overlayWrapper = `${eccgui}-contextoverlay`;
13+
const placeholderClass = `${overlayWrapper}__wrapper--placeholder`;
14+
15+
const checkForPlaceholderClass = (container: HTMLElement, tobe: number) => {
16+
expect(container.getElementsByClassName(placeholderClass).length).toBe(tobe);
17+
};
18+
19+
describe("ContextOverlay", () => {
20+
it("should not render placeholder automatically", () => {
21+
const { container } = render(<ContextOverlay {...ContextOverlayStory.args} />);
22+
checkForPlaceholderClass(container, 0);
23+
});
24+
it("should render placeholder when `usePlaceholder===true`", () => {
25+
const { container } = render(<ContextOverlay {...ContextOverlayStory.args} usePlaceholder={true} />);
26+
checkForPlaceholderClass(container, 1);
27+
});
28+
it("should render no placeholder when `usePlaceholder===false`", () => {
29+
const { container } = render(<ContextOverlay {...ContextOverlayStory.args} usePlaceholder={false} />);
30+
checkForPlaceholderClass(container, 0);
31+
});
32+
it("if no placeholder is used the overlay should be displayed on click", async () => {
33+
const { container } = render(<ContextOverlay {...ContextOverlayStory.args} usePlaceholder={false} />);
34+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
35+
expect(await screen.findByText("Overlay:")).toBeVisible();
36+
});
37+
it("if no placeholder is used the overlay should be displayed on hover (hover interactionKind)", async () => {
38+
const { container } = render(
39+
<ContextOverlay
40+
{...ContextOverlayStory.args}
41+
usePlaceholder={false}
42+
interactionKind={PopoverInteractionKind.HOVER}
43+
/>
44+
);
45+
fireEvent.mouseEnter(container.getElementsByClassName(overlayWrapper)[0]);
46+
expect(await screen.findByText("Overlay:")).toBeVisible();
47+
});
48+
it("if placeholder is used the overlay should be displayed on click", async () => {
49+
const { container } = render(<ContextOverlay {...ContextOverlayStory.args} usePlaceholder={true} />);
50+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
51+
expect(await screen.findByText("Overlay:")).toBeVisible();
52+
});
53+
it("if placeholder is used the overlay should be displayed on hover (hover interactionKind)", async () => {
54+
const { container } = render(
55+
<ContextOverlay
56+
{...ContextOverlayStory.args}
57+
usePlaceholder={true}
58+
interactionKind={PopoverInteractionKind.HOVER}
59+
/>
60+
);
61+
checkForPlaceholderClass(container, 1);
62+
fireEvent.mouseEnter(container.getElementsByClassName(overlayWrapper)[0]);
63+
await waitFor(async () => {
64+
expect(screen.queryByDisplayValue("Overlay:")).toBeNull();
65+
checkForPlaceholderClass(container, 0);
66+
// we need to emulate another mouseover to simulate real user behaviour
67+
fireEvent.mouseOver(container.getElementsByClassName(overlayWrapper)[0]);
68+
expect(await screen.findByText("Overlay:")).toBeVisible();
69+
});
70+
});
71+
});

0 commit comments

Comments
 (0)