Skip to content

Commit de80598

Browse files
Added WithPortals and Portal components
1 parent f4e3af4 commit de80598

File tree

6 files changed

+621
-0
lines changed

6 files changed

+621
-0
lines changed

src/portal/Portal.mjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React, { useContext, useLayoutEffect, useState } from "react";
2+
import WithPortals from "./WithPortals.mjs";
3+
4+
/**
5+
* @typedef {{readonly isActive: boolean}} PortalContext
6+
*/
7+
8+
/**
9+
* @param {React.PropsWithChildren<{}>} props
10+
*/
11+
const Portal = (props) => {
12+
const [portalId] = useState(() => getNextPortalId());
13+
14+
const ctx = useContext(WithPortals.Context);
15+
if (!ctx) {
16+
throw Error(
17+
"WithPortals.Context is not found." +
18+
"\nPlease, make sure you use WithPortals.Context.Provider in parent component."
19+
);
20+
}
21+
22+
useLayoutEffect(() => {
23+
return () => {
24+
ctx.onRemove(portalId);
25+
};
26+
}, []);
27+
28+
useLayoutEffect(() => {
29+
ctx.onRender(portalId, props.children);
30+
}, [props.children]);
31+
32+
return null;
33+
};
34+
35+
Portal.displayName = "Portal";
36+
Portal.Context = React.createContext(
37+
/** @type {PortalContext} */ ({ isActive: false })
38+
);
39+
Portal._nextPortalId = 0;
40+
41+
function getNextPortalId() {
42+
Portal._nextPortalId += 1;
43+
return Portal._nextPortalId;
44+
}
45+
46+
export default Portal;

src/portal/WithPortals.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from "react";
2+
3+
export interface WithPortalsContext {
4+
onRender(portalId: number, content: React.ReactNode): void;
5+
onRemove(portalId: number): void;
6+
}

src/portal/WithPortals.mjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @typedef {import("./WithPortals").WithPortalsContext} WithPortalsContext
3+
* @typedef {import("./Portal.mjs").PortalContext} PortalContext
4+
* @typedef {import("@farjs/blessed").Widgets.Screen} BlessedScreen
5+
* @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement
6+
*/
7+
import React, { useMemo, useState } from "react";
8+
import Portal from "./Portal.mjs";
9+
10+
const h = React.createElement;
11+
12+
/**
13+
* @typedef {{id: number, content: React.ReactNode, focused: BlessedElement}} PortalItem
14+
*/
15+
16+
const WithPortals = {
17+
Context: React.createContext(/** @type {WithPortalsContext | null} */ (null)),
18+
19+
/**
20+
* @param {BlessedScreen} screen
21+
*/
22+
create: (screen) => {
23+
/**
24+
* @param {React.PropsWithChildren<{}>} props
25+
*/
26+
const WithPortalsComp = (props) => {
27+
const [portals, setPortals] = useState(
28+
/** @type {Array<PortalItem>} */ ([])
29+
);
30+
31+
/** @type {WithPortalsContext} */
32+
const context = useMemo(() => {
33+
return {
34+
onRender: (id, content) => {
35+
setPortals((portals) => {
36+
const index = portals.findIndex((p) => p.id === id);
37+
if (index >= 0) {
38+
return portals.map((p) => {
39+
return p.id === id ? { ...p, content } : p;
40+
});
41+
}
42+
43+
return [...portals, { id, content, focused: screen.focused }];
44+
});
45+
},
46+
onRemove: (id) => {
47+
setPortals((portals) => {
48+
const index = portals.findIndex((p) => p.id === id);
49+
if (index >= 0) {
50+
const { focused } = portals[index];
51+
let updated;
52+
if (index === portals.length - 1) {
53+
if (focused) {
54+
focused.focus();
55+
}
56+
updated = portals.slice(0, index);
57+
} else {
58+
const prefix = portals.slice(0, index);
59+
const suffix = portals.slice(index + 1);
60+
const p = suffix[0];
61+
suffix[0] = { ...p, focused };
62+
updated = [...prefix, ...suffix];
63+
}
64+
65+
Promise.resolve().then(() => screen.render()); //trigger re-render on the next tick
66+
return updated;
67+
}
68+
69+
return portals;
70+
});
71+
},
72+
};
73+
}, []);
74+
75+
const lastPortalIndex = portals.length - 1;
76+
77+
return h(
78+
React.Fragment,
79+
null,
80+
h(WithPortals.Context.Provider, { value: context }, props.children),
81+
h(
82+
WithPortals.Context.Provider,
83+
{ value: context },
84+
...portals.map(({ id, content }, index) => {
85+
return renderPortal(id, content, index === lastPortalIndex);
86+
})
87+
)
88+
);
89+
};
90+
91+
WithPortalsComp.displayName = "WithPortals";
92+
return WithPortalsComp;
93+
},
94+
};
95+
96+
/**
97+
* @param {number} id
98+
* @param {React.ReactNode} content
99+
* @param {boolean} isActive
100+
* @returns {React.ReactElement}
101+
*/
102+
function renderPortal(id, content, isActive) {
103+
return h(
104+
React.Fragment,
105+
{ key: `${id}` },
106+
h(Portal.Context.Provider, { value: { isActive } }, content)
107+
);
108+
}
109+
110+
export default WithPortals;

test/all.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ await import("./Button.test.mjs");
22
await import("./ButtonsPanel.test.mjs");
33
await import("./UI.test.mjs");
44

5+
await import("./portal/Portal.test.mjs");
6+
await import("./portal/WithPortals.test.mjs");
7+
58
await import("./theme/Theme.test.mjs");
69

710
await import("./tool/ColorPanel.test.mjs");

test/portal/Portal.test.mjs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import React from "react";
2+
import TestRenderer from "react-test-renderer";
3+
import assert from "node:assert/strict";
4+
import mockFunction from "mock-fn";
5+
import { assertComponents, TestErrorBoundary } from "react-assert";
6+
import Portal from "../../src/portal/Portal.mjs";
7+
import WithPortals from "../../src/portal/WithPortals.mjs";
8+
9+
const h = React.createElement;
10+
11+
const { describe, it } = await (async () => {
12+
// @ts-ignore
13+
return process.isBun // @ts-ignore
14+
? Promise.resolve({ describe: (_, fn) => fn(), it: test })
15+
: import("node:test");
16+
})();
17+
18+
describe("Portal.test.mjs", () => {
19+
it("should fail if no WithPortals.Context when render", () => {
20+
//given
21+
// suppress intended error
22+
// see: https://github.com/facebook/react/issues/11098#issuecomment-412682721
23+
const savedConsoleError = console.error;
24+
const consoleErrorMock = mockFunction(() => {
25+
console.error = savedConsoleError;
26+
});
27+
console.error = consoleErrorMock;
28+
29+
const portal = h(Portal, null, h(React.Fragment));
30+
31+
//when
32+
const result = TestRenderer.create(h(TestErrorBoundary, null, portal)).root;
33+
34+
//then
35+
assert.deepEqual(consoleErrorMock.times, 1);
36+
assert.deepEqual(console.error, savedConsoleError);
37+
assertComponents(
38+
result.children,
39+
h(
40+
"div",
41+
null,
42+
"Error: WithPortals.Context is not found." +
43+
"\nPlease, make sure you use WithPortals.Context.Provider in parent component."
44+
)
45+
);
46+
});
47+
48+
it("should call onRender/onRemove when mount/un-mount", () => {
49+
//given
50+
const portalId = Portal._nextPortalId + 1;
51+
const content = h(React.Fragment);
52+
const onRender = mockFunction((resPortalId, resContent) => {
53+
assert.deepEqual(resPortalId, portalId);
54+
assert.deepEqual(resContent, content);
55+
});
56+
const onRemove = mockFunction((resPortalId) => {
57+
assert.deepEqual(resPortalId, portalId);
58+
});
59+
60+
//when & then
61+
const renderer = TestRenderer.create(
62+
h(
63+
WithPortals.Context.Provider,
64+
{ value: { onRender, onRemove } },
65+
h(Portal, null, content)
66+
)
67+
);
68+
assert.deepEqual(onRender.times, 1);
69+
assert.deepEqual(onRemove.times, 0);
70+
71+
//when & then
72+
renderer.unmount();
73+
assert.deepEqual(onRender.times, 1);
74+
assert.deepEqual(onRemove.times, 1);
75+
});
76+
77+
it("should call onRender if different content when update", () => {
78+
//given
79+
const portalId = Portal._nextPortalId + 1;
80+
const content1 = h(React.Fragment);
81+
const content2 = h(React.Fragment);
82+
const onRender = mockFunction((resPortalId, resContent) => {
83+
assert.deepEqual(resPortalId, portalId);
84+
85+
if (onRender.times === 1) assert.deepEqual(resContent, content1);
86+
else assert.deepEqual(resContent, content2);
87+
});
88+
const onRemove = mockFunction((resPortalId) => {
89+
assert.deepEqual(resPortalId, portalId);
90+
});
91+
92+
//when & then
93+
const renderer = TestRenderer.create(
94+
h(
95+
WithPortals.Context.Provider,
96+
{ value: { onRender, onRemove } },
97+
h(Portal, null, content1)
98+
)
99+
);
100+
assert.deepEqual(onRender.times, 1);
101+
assert.deepEqual(onRemove.times, 0);
102+
103+
//when & then
104+
renderer.update(
105+
h(
106+
WithPortals.Context.Provider,
107+
{ value: { onRender, onRemove } },
108+
h(Portal, null, content2)
109+
)
110+
);
111+
assert.deepEqual(onRender.times, 2);
112+
assert.deepEqual(onRemove.times, 0);
113+
114+
//cleanup
115+
renderer.unmount();
116+
assert.deepEqual(onRender.times, 2);
117+
assert.deepEqual(onRemove.times, 1);
118+
});
119+
120+
it("should not call onRender if the same content when update", () => {
121+
//given
122+
const portalId = Portal._nextPortalId + 1;
123+
const content = h(React.Fragment);
124+
const onRender = mockFunction((resPortalId, resContent) => {
125+
assert.deepEqual(resPortalId, portalId);
126+
assert.deepEqual(resContent, content);
127+
});
128+
const onRemove = mockFunction((resPortalId) => {
129+
assert.deepEqual(resPortalId, portalId);
130+
});
131+
132+
//when & then
133+
const renderer = TestRenderer.create(
134+
h(
135+
WithPortals.Context.Provider,
136+
{ value: { onRender, onRemove } },
137+
h(Portal, null, content)
138+
)
139+
);
140+
assert.deepEqual(onRender.times, 1);
141+
assert.deepEqual(onRemove.times, 0);
142+
143+
//when & then
144+
renderer.update(
145+
h(
146+
WithPortals.Context.Provider,
147+
{ value: { onRender, onRemove } },
148+
h(Portal, null, content)
149+
)
150+
);
151+
assert.deepEqual(onRender.times, 1);
152+
assert.deepEqual(onRemove.times, 0);
153+
154+
//cleanup
155+
renderer.unmount();
156+
assert.deepEqual(onRender.times, 1);
157+
assert.deepEqual(onRemove.times, 1);
158+
});
159+
});

0 commit comments

Comments
 (0)