Skip to content

Commit 6940600

Browse files
authored
feat: implement usePortal and useSSR hooks from upstream library (#1178)
Signed-off-by: Mason Hu <mason.hu@canonical.com>
1 parent 9717f07 commit 6940600

File tree

13 files changed

+490
-37
lines changed

13 files changed

+490
-37
lines changed

.storybook/main.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const config = {
1616
use: ["style-loader", "css-loader", "sass-loader"],
1717
include: path.resolve(__dirname, "../"),
1818
});
19+
config.resolve.alias = {
20+
...config.resolve.alias,
21+
external: path.resolve(__dirname, "../src/external"),
22+
};
1923
return config;
2024
},
2125
docs: {

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@
108108
"jest-environment-jsdom": "29.7.0",
109109
"lodash.isequal": "4.5.0",
110110
"prop-types": "15.8.1",
111-
"react-table": "7.8.0",
112-
"react-useportal": "1.0.19"
111+
"react-table": "7.8.0"
113112
},
114113
"peerDependencies": {
115114
"@types/react": "^18.0.0 || ^19.0.0",

src/components/ConfirmationButton/ConfirmationButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ActionButton, { ActionButtonProps } from "../ActionButton";
44
import ConfirmationModal, {
55
ConfirmationModalProps,
66
} from "../ConfirmationModal";
7-
import usePortal from "react-useportal";
7+
import { usePortal } from "external";
88

99
const generateTitle = (title: ReactNode) => {
1010
if (typeof title === "string") {

src/components/ContextualMenu/ContextualMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import classNames from "classnames";
22
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
33
import type { HTMLProps, ReactNode } from "react";
4-
import usePortal from "react-useportal";
54
import { useListener, usePrevious } from "hooks";
65
import Button from "../Button";
76
import type { ButtonProps } from "../Button";
@@ -14,6 +13,7 @@ import {
1413
PropsWithSpread,
1514
SubComponentProps,
1615
} from "types";
16+
import { usePortal } from "external";
1717

1818
export enum Label {
1919
Toggle = "Toggle menu",
@@ -300,7 +300,7 @@ const ContextualMenu = <L,>({
300300
className={classNames("p-contextual-menu__toggle", toggleClassName)}
301301
disabled={toggleDisabled}
302302
hasIcon={hasToggleIcon}
303-
onClick={(evt: React.MouseEvent) => {
303+
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
304304
if (!isOpen) {
305305
openPortal(evt);
306306
} else {

src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type VerticalPosition = "top" | "bottom";
2727
export type Props<L = null> = {
2828
adjustedPosition?: Position;
2929
autoAdjust?: boolean;
30-
handleClose?: (evt?: MouseEvent) => void;
30+
handleClose?: (evt?: React.MouseEvent<HTMLButtonElement>) => void;
3131
constrainPanelWidth?: boolean;
3232
dropdownClassName?: string;
3333
dropdownContent?: ReactNode | ((close: () => void) => React.JSX.Element);
@@ -148,7 +148,9 @@ const generateLink = <L,>(
148148
onClick={
149149
onClick
150150
? (evt) => {
151-
handleClose(evt.nativeEvent);
151+
handleClose(
152+
evt.nativeEvent as unknown as React.MouseEvent<HTMLButtonElement>,
153+
);
152154
onClick(evt);
153155
}
154156
: null

src/components/Tooltip/Tooltip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import classNames from "classnames";
22
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
33
import type { MouseEventHandler, ReactNode } from "react";
4-
import usePortal from "react-useportal";
54

65
import { useWindowFitment, useListener } from "hooks";
76
import type { WindowFitment } from "hooks";
87

98
import type { ClassName, ValueOf } from "types";
109

10+
import { usePortal } from "external";
11+
1112
export type CSSPosition =
1213
| "static"
1314
| "absolute"

src/external/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { usePortal } from "./usePortal";
2+
export type { UsePortalOptions } from "./usePortal";

src/external/usePortal.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* This is a reference implementation of the usePortal hook from react-useportal: https://github.com/iamthesiz/react-useportal/blob/master/usePortal.test.ts
3+
* The license for the content in this file is goverened by the original project's license: https://github.com/iamthesiz/react-useportal/blob/master/license.md
4+
*/
5+
6+
import { renderHook, act } from "@testing-library/react";
7+
import { usePortal, errorMessage1 } from "./usePortal";
8+
9+
jest.mock("./useSSR", () => ({
10+
useSSR: jest.fn(() => ({ isServer: false, isBrowser: true })),
11+
}));
12+
13+
describe("usePortal", () => {
14+
it("should not be open", () => {
15+
const { result } = renderHook(() => usePortal());
16+
const { isOpen } = result.current;
17+
expect(isOpen).toBe(false);
18+
});
19+
20+
it("should error if no event is passed and no ref is set", () => {
21+
const { result } = renderHook(() => usePortal());
22+
try {
23+
result.current.openPortal();
24+
} catch (err) {
25+
expect(err.message).toBe(errorMessage1);
26+
}
27+
});
28+
29+
it("does not error if programmatically opening the portal", () => {
30+
const { result } = renderHook(() =>
31+
usePortal({ programmaticallyOpen: true }),
32+
);
33+
act(() => {
34+
expect(result.current.openPortal).not.toThrow();
35+
});
36+
});
37+
38+
it("should open portal when openPortal is called", () => {
39+
const { result } = renderHook(() =>
40+
usePortal({ programmaticallyOpen: true }),
41+
);
42+
act(() => {
43+
result.current.openPortal();
44+
});
45+
expect(result.current.isOpen).toBe(true);
46+
});
47+
48+
it("should close portal when closePortal is called", () => {
49+
const { result } = renderHook(() => usePortal({ isOpen: true }));
50+
act(() => {
51+
result.current.closePortal();
52+
});
53+
expect(result.current.isOpen).toBe(false);
54+
});
55+
56+
it("should toggle portal state when togglePortal is called", () => {
57+
const { result } = renderHook(() =>
58+
usePortal({ programmaticallyOpen: true }),
59+
);
60+
act(() => {
61+
result.current.togglePortal();
62+
});
63+
expect(result.current.isOpen).toBe(true);
64+
act(() => {
65+
result.current.togglePortal();
66+
});
67+
expect(result.current.isOpen).toBe(false);
68+
});
69+
70+
it("should throw error when openPortal is called without event and programmaticallyOpen is false", () => {
71+
const { result } = renderHook(() =>
72+
usePortal({ programmaticallyOpen: false }),
73+
);
74+
expect(() => {
75+
act(() => {
76+
result.current.openPortal();
77+
});
78+
}).toThrow(errorMessage1);
79+
});
80+
81+
it("should call onOpen callback when portal is opened", () => {
82+
const onOpen = jest.fn();
83+
const { result } = renderHook(() =>
84+
usePortal({ onOpen, programmaticallyOpen: true }),
85+
);
86+
act(() => {
87+
result.current.openPortal();
88+
});
89+
expect(onOpen).toHaveBeenCalled();
90+
});
91+
92+
it("should call onClose callback when portal is closed", () => {
93+
const onClose = jest.fn();
94+
const { result } = renderHook(() => usePortal({ isOpen: true, onClose }));
95+
act(() => {
96+
result.current.closePortal();
97+
});
98+
expect(onClose).toHaveBeenCalled();
99+
});
100+
101+
it("should close portal when Escape key is pressed", () => {
102+
const { result } = renderHook(() => usePortal({ isOpen: true }));
103+
act(() => {
104+
const event = new KeyboardEvent("keydown", { key: "Escape" });
105+
document.dispatchEvent(event);
106+
});
107+
expect(result.current.isOpen).toBe(false);
108+
});
109+
110+
it("should close portal when clicking outside", () => {
111+
const { result } = renderHook(() => usePortal({ isOpen: true }));
112+
act(() => {
113+
const event = new MouseEvent("mousedown", { bubbles: true });
114+
document.body.dispatchEvent(event);
115+
});
116+
expect(result.current.isOpen).toBe(false);
117+
});
118+
});

0 commit comments

Comments
 (0)