Skip to content

Commit 4125ac2

Browse files
authored
Merge pull request #355 from eccenca/feature/contextOverlayImprovePlaceholderProcess-CMEM-7032
Improve placeholder replacement for ContextOverlay/ContextMenu (CMEM-7032)
2 parents 14eb384 + 9e02e77 commit 4125ac2

File tree

3 files changed

+188
-24
lines changed

3 files changed

+188
-24
lines changed

src/components/ContextOverlay/ContextOverlay.tsx

Lines changed: 74 additions & 24 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";
@@ -37,9 +38,10 @@ export const ContextOverlay = ({
3738
usePlaceholder = false,
3839
...otherPopoverProps
3940
}: ContextOverlayProps) => {
40-
const placeholderRef = React.useRef(null);
41-
const eventMemory = React.useRef<undefined | "afterhover" | "afterfocus">(undefined);
41+
const placeholderRef = React.useRef<HTMLElement>(null);
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>(otherPopoverProps.interactionKind ?? InteractionKind.CLICK);
4345
const swapDelayTime = 15;
4446
const [placeholder, setPlaceholder] = React.useState<boolean>(
4547
// use placeholder only for "simple" overlays without special states
@@ -50,37 +52,85 @@ export const ContextOverlay = ({
5052
usePlaceholder
5153
);
5254

55+
const swap = (ev: MouseEvent | globalThis.FocusEvent) => {
56+
const waitForClick =
57+
interactionKind.current === InteractionKind.CLICK ||
58+
interactionKind.current === InteractionKind.CLICK_TARGET_ONLY;
59+
60+
if (swapDelay.current) {
61+
clearTimeout(swapDelay.current);
62+
}
63+
64+
const replacePlaceholder = () => {
65+
eventMemory.current = ev.type as "mouseenter" | "focusin" | "click";
66+
setPlaceholder(false);
67+
};
68+
69+
if (waitForClick) {
70+
ev.stopImmediatePropagation();
71+
replacePlaceholder();
72+
return;
73+
}
74+
75+
swapDelay.current = setTimeout(
76+
replacePlaceholder,
77+
// we delay the swap for hover/focus to prevent unwanted effects
78+
// (e.g. event hickup after replacing elements when it is not really necessary)
79+
swapDelayTime
80+
);
81+
};
82+
5383
React.useEffect(() => {
84+
interactionKind.current = otherPopoverProps.interactionKind ?? InteractionKind.CLICK;
85+
const waitForClick =
86+
interactionKind.current === InteractionKind.CLICK ||
87+
interactionKind.current === InteractionKind.CLICK_TARGET_ONLY;
88+
const removeEvents = () => {
89+
if (placeholderRef.current) {
90+
placeholderRef.current.removeEventListener("click", swap);
91+
placeholderRef.current.removeEventListener("mouseenter", swap);
92+
placeholderRef.current.removeEventListener("focusin", swap);
93+
}
94+
return;
95+
};
5496
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);
97+
removeEvents(); // remove events in case of interaction kind changed during existence
98+
if (waitForClick) {
99+
placeholderRef.current.addEventListener("click", swap);
100+
} else {
101+
placeholderRef.current.addEventListener("mouseenter", swap);
102+
placeholderRef.current.addEventListener("focusin", swap);
103+
}
68104
return () => {
69-
if (placeholderRef.current) {
70-
(placeholderRef.current as HTMLElement).removeEventListener("mouseenter", swap);
71-
(placeholderRef.current as HTMLElement).removeEventListener("focusin", swap);
72-
}
105+
removeEvents();
73106
};
74107
}
75108
return () => {};
76-
}, [!!placeholderRef.current]);
109+
}, [!!placeholderRef.current, otherPopoverProps.interactionKind]);
77110

78111
const refocus = React.useCallback((node) => {
79-
if (eventMemory.current === "afterfocus" && node) {
80-
const target = node.targetRef.current.children[0];
81-
if (target) {
112+
const target = node?.targetRef.current.children[0];
113+
if (!eventMemory.current || !target) {
114+
return;
115+
}
116+
switch (eventMemory.current) {
117+
case "focusin":
82118
target.focus();
83-
}
119+
break;
120+
case "click":
121+
target.click();
122+
break;
123+
case "mouseenter":
124+
// re-check if the cursor is still over the element after swapping the placeholder before triggering the event to bubble up
125+
(target as HTMLElement).addEventListener(
126+
"mouseover",
127+
() => (target as HTMLElement).dispatchEvent(new MouseEvent("mouseover", { bubbles: true })),
128+
{
129+
capture: true,
130+
once: true,
131+
}
132+
);
133+
break;
84134
}
85135
}, []);
86136

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
checkForPlaceholderClass(container, 0);
34+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
35+
expect(await screen.findByText("First option")).toBeVisible();
36+
});
37+
it("if placeholder is used the menu should be displayed on click", async () => {
38+
const { container } = render(<ContextMenu {...ContextMenuStory.args} preventPlaceholder={false} />);
39+
checkForPlaceholderClass(container, 1);
40+
fireEvent.click(container.getElementsByClassName(overlayWrapper)[0]);
41+
expect(await screen.findByText("First option")).toBeVisible();
42+
});
43+
});
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)