@linzjs/windows 1.0.0 → 1.1.0
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/.storybook/main.ts +26 -6
- package/README.md +129 -6
- package/package.json +34 -12
- package/src/modal/Modal.tsx +9 -5
- package/src/modal/ModalContextProvider.tsx +35 -36
- package/src/modal/PreModal.tsx +45 -0
- package/src/panel/OpenPanelButton.tsx +18 -0
- package/src/panel/OpenPanelIcon.scss +73 -0
- package/src/panel/OpenPanelIcon.tsx +50 -0
- package/src/panel/Panel.scss +34 -0
- package/src/panel/Panel.tsx +150 -0
- package/src/panel/PanelContext.ts +17 -0
- package/src/panel/PanelInstanceContext.ts +41 -0
- package/src/panel/PanelInstanceContextProvider.tsx +47 -0
- package/src/panel/PanelsContext.tsx +36 -0
- package/src/panel/PanelsContextProvider.tsx +140 -0
- package/src/panel/PopoutWindow.tsx +183 -0
- package/src/panel/generateId.ts +23 -0
- package/src/panel/handleStyleSheetsChanges.ts +71 -0
- package/src/stories/Introduction.mdx +18 -0
- package/src/stories/Introduction.stories.tsx +8 -0
- package/src/stories/modal/Modal.mdx +9 -3
- package/src/stories/modal/Modal.stories.tsx +1 -1
- package/src/stories/modal/PreModal.mdx +26 -0
- package/src/stories/modal/PreModal.stories.tsx +27 -0
- package/src/stories/modal/PreModal.tsx +79 -0
- package/src/stories/modal/TestModal.scss +21 -0
- package/src/stories/panel/PanelButtons/ShowPanel.mdx +21 -0
- package/src/stories/panel/PanelButtons/ShowPanel.stories.tsx +27 -0
- package/src/stories/panel/PanelButtons/ShowPanel.tsx +86 -0
- package/src/stories/panel/ShowPanel/ShowPanel.mdx +20 -0
- package/src/stories/panel/ShowPanel/ShowPanel.stories.tsx +27 -0
- package/src/stories/panel/ShowPanel/ShowPanel.tsx +70 -0
- package/src/stories/panel/ShowPanelResizingAgGrid/ShowPanelResizingAgGrid.mdx +21 -0
- package/src/stories/panel/ShowPanelResizingAgGrid/ShowPanelResizingAgGrid.stories.tsx +27 -0
- package/src/stories/panel/ShowPanelResizingAgGrid/ShowPanelResizingStepAgGrid.tsx +164 -0
- package/src/stories/support.js +16 -0
- package/src/util/useInterval.ts +11 -19
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import "./Panel.scss";
|
|
2
|
+
import "@linzjs/lui/dist/scss/base.scss";
|
|
3
|
+
|
|
4
|
+
import { PanelContext } from "./PanelContext";
|
|
5
|
+
import { PanelInstanceContext, PanelSize } from "./PanelInstanceContext";
|
|
6
|
+
import { PanelPosition, PanelsContext } from "./PanelsContext";
|
|
7
|
+
import { PopoutWindow } from "./PopoutWindow";
|
|
8
|
+
import { ReactElement, ReactNode, useContext, useEffect, useState } from "react";
|
|
9
|
+
import { Rnd } from "react-rnd";
|
|
10
|
+
|
|
11
|
+
import { LuiButton, LuiIcon } from "@linzjs/lui";
|
|
12
|
+
import { LuiIconName } from "@linzjs/lui/dist/assets/svg-content";
|
|
13
|
+
|
|
14
|
+
export interface PanelProps {
|
|
15
|
+
title: string;
|
|
16
|
+
position?: PanelPosition;
|
|
17
|
+
size?: PanelSize;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const Panel = ({ title, position, size = { width: 320, height: 200 }, children }: PanelProps): ReactElement => {
|
|
22
|
+
const { nextStackPosition } = useContext(PanelsContext);
|
|
23
|
+
const { panelPoppedOut, bounds, zIndex, bringPanelToFront, uniqueId, setTitle } = useContext(PanelInstanceContext);
|
|
24
|
+
|
|
25
|
+
const [panelPosition, setPanelPosition] = useState(() => position ?? nextStackPosition());
|
|
26
|
+
const [panelSize, setPanelSize] = useState(size ?? { width: 320, height: 200 });
|
|
27
|
+
|
|
28
|
+
const resizePanel = (newPanelSize: Partial<PanelSize>) => {
|
|
29
|
+
if (panelPoppedOut) return;
|
|
30
|
+
const newSize = { ...panelSize, ...newPanelSize };
|
|
31
|
+
if (newSize.width !== panelSize.width || newSize.height !== panelSize.height) {
|
|
32
|
+
setPanelSize(newSize);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
setTitle(title);
|
|
38
|
+
}, [setTitle, title]);
|
|
39
|
+
|
|
40
|
+
//panelClose
|
|
41
|
+
return (
|
|
42
|
+
<PanelContext.Provider value={{ resizePanel }}>
|
|
43
|
+
{panelPoppedOut ? (
|
|
44
|
+
<PopoutWindow name={uniqueId} title={title} size={panelSize}>
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
display: "flex",
|
|
48
|
+
flexDirection: "column",
|
|
49
|
+
width: "100%",
|
|
50
|
+
height: "100%",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
</PopoutWindow>
|
|
56
|
+
) : (
|
|
57
|
+
<Rnd
|
|
58
|
+
className={"WindowPanel"}
|
|
59
|
+
dragHandleClassName={"draggable-handle"}
|
|
60
|
+
minWidth={100}
|
|
61
|
+
minHeight={100}
|
|
62
|
+
position={panelPosition}
|
|
63
|
+
size={panelSize}
|
|
64
|
+
style={{ zIndex }}
|
|
65
|
+
bounds={bounds ?? document.body}
|
|
66
|
+
onDragStop={(_evt, data) => {
|
|
67
|
+
setPanelPosition({ x: data.x, y: data.y });
|
|
68
|
+
}}
|
|
69
|
+
onResizeStop={(_evt, _dir, ref, _delta, position) => {
|
|
70
|
+
setPanelPosition(position);
|
|
71
|
+
setPanelSize({
|
|
72
|
+
width: parseInt(ref.style.width),
|
|
73
|
+
height: parseInt(ref.style.height),
|
|
74
|
+
});
|
|
75
|
+
}}
|
|
76
|
+
onMouseDown={bringPanelToFront}
|
|
77
|
+
>
|
|
78
|
+
<div
|
|
79
|
+
style={{
|
|
80
|
+
display: "flex",
|
|
81
|
+
flexDirection: "column",
|
|
82
|
+
width: "100%",
|
|
83
|
+
height: "100%",
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
</Rnd>
|
|
89
|
+
)}
|
|
90
|
+
</PanelContext.Provider>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export interface PanelHeaderButtonProps {
|
|
95
|
+
"aria-label": string;
|
|
96
|
+
icon: LuiIconName;
|
|
97
|
+
onClick: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const PanelHeaderButton = ({ "aria-label": ariaLabel, icon, onClick }: PanelHeaderButtonProps) => (
|
|
101
|
+
<LuiButton
|
|
102
|
+
level={"plain-text"}
|
|
103
|
+
className={"lui-button-icon-only"}
|
|
104
|
+
size={"sm"}
|
|
105
|
+
onClick={onClick}
|
|
106
|
+
buttonProps={{
|
|
107
|
+
onTouchStart: onClick,
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<LuiIcon name={icon} alt={ariaLabel} />
|
|
111
|
+
</LuiButton>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
export interface PanelHeaderProps {
|
|
115
|
+
extraButtons?: ReactNode;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const PanelHeader = ({ extraButtons }: PanelHeaderProps) => {
|
|
119
|
+
const { panelClose, panelTogglePopout, panelPoppedOut, title } = useContext(PanelInstanceContext);
|
|
120
|
+
const [cursor, setCursor] = useState<"grab" | "grabbing">("grab");
|
|
121
|
+
|
|
122
|
+
const headerMouseDown = () => {
|
|
123
|
+
!panelPoppedOut && setCursor("grabbing");
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={"WindowPanel-header draggable-handle"}
|
|
129
|
+
onMouseDown={headerMouseDown}
|
|
130
|
+
onMouseUp={() => !panelPoppedOut && setCursor("grab")}
|
|
131
|
+
style={{ cursor }}
|
|
132
|
+
>
|
|
133
|
+
<div className={"WindowPanel-header-title"}>{title}</div>
|
|
134
|
+
<div className={"LuiFloatingWindow-buttons"}>{extraButtons}</div>
|
|
135
|
+
<div className={"LuiFloatingWindow-extra-buttons-divider"}>|</div>
|
|
136
|
+
<div className={"LuiFloatingWindow-buttons"}>
|
|
137
|
+
<PanelHeaderButton
|
|
138
|
+
aria-label={panelPoppedOut ? "Pop-in" : "Pop-out"}
|
|
139
|
+
onClick={panelTogglePopout}
|
|
140
|
+
icon={panelPoppedOut ? "ic_pop_back" : "ic_launch_new window_open"}
|
|
141
|
+
/>
|
|
142
|
+
<PanelHeaderButton aria-label={"Close"} onClick={panelClose} icon={"ic_clear"} />
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const PanelContent = ({ children }: { children: ReactNode }) => {
|
|
149
|
+
return <div className={"WindowPanel-content"}>{children}</div>;
|
|
150
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { PanelSize } from "./PanelInstanceContext";
|
|
2
|
+
import { createContext } from "react";
|
|
3
|
+
|
|
4
|
+
export interface PanelContextType {
|
|
5
|
+
resizePanel: (size: Partial<PanelSize>) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const NoContextError = () => {
|
|
9
|
+
console.error("Missing PanelContext Provider");
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Provides access to closing/popping panels
|
|
14
|
+
*/
|
|
15
|
+
export const PanelContext = createContext<PanelContextType>({
|
|
16
|
+
resizePanel: NoContextError,
|
|
17
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
|
|
3
|
+
export interface PanelSize {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PanelInstanceContextType {
|
|
9
|
+
title: string;
|
|
10
|
+
setTitle: (title: string) => void;
|
|
11
|
+
uniqueId: string;
|
|
12
|
+
panelName: string;
|
|
13
|
+
bounds: string | Element | undefined;
|
|
14
|
+
panelTogglePopout: () => void;
|
|
15
|
+
panelClose: () => void;
|
|
16
|
+
setPanelWindow: (w: Window) => void;
|
|
17
|
+
bringPanelToFront: () => void;
|
|
18
|
+
panelPoppedOut: boolean;
|
|
19
|
+
zIndex: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const NoContextError = () => {
|
|
23
|
+
console.error("Missing PanelInstanceContext Provider");
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Provides access to closing/popping panels
|
|
28
|
+
*/
|
|
29
|
+
export const PanelInstanceContext = createContext<PanelInstanceContextType>({
|
|
30
|
+
title: "Missing PanelInstanceContext Provider: title",
|
|
31
|
+
uniqueId: "Missing PanelInstanceContext Provider: uniqueId",
|
|
32
|
+
panelName: "Missing PanelInstanceContext Provider: panelName",
|
|
33
|
+
bounds: document.body,
|
|
34
|
+
setTitle: NoContextError,
|
|
35
|
+
panelTogglePopout: NoContextError,
|
|
36
|
+
panelClose: NoContextError,
|
|
37
|
+
setPanelWindow: NoContextError,
|
|
38
|
+
bringPanelToFront: NoContextError,
|
|
39
|
+
panelPoppedOut: false,
|
|
40
|
+
zIndex: 0,
|
|
41
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { PanelInstanceContext } from "./PanelInstanceContext";
|
|
2
|
+
import { PanelInstance, PanelsContext } from "./PanelsContext";
|
|
3
|
+
import { ReactElement, ReactNode, useContext, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides access to closing/popping panels
|
|
7
|
+
*/
|
|
8
|
+
export const PanelInstanceContextProvider = ({
|
|
9
|
+
panelInstance,
|
|
10
|
+
bounds,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
bounds: string | Element | undefined;
|
|
14
|
+
panelInstance: PanelInstance;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): ReactElement => {
|
|
17
|
+
const { closePanel, togglePopOut, bringPanelToFront } = useContext(PanelsContext);
|
|
18
|
+
const [title, setTitle] = useState("");
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<PanelInstanceContext.Provider
|
|
22
|
+
value={{
|
|
23
|
+
title,
|
|
24
|
+
setTitle,
|
|
25
|
+
uniqueId: panelInstance.uniqueId,
|
|
26
|
+
bounds,
|
|
27
|
+
panelName: panelInstance.uniqueId,
|
|
28
|
+
panelClose: () => {
|
|
29
|
+
panelInstance.window?.close();
|
|
30
|
+
panelInstance.window = null;
|
|
31
|
+
closePanel(panelInstance);
|
|
32
|
+
},
|
|
33
|
+
panelTogglePopout: () => {
|
|
34
|
+
togglePopOut(panelInstance);
|
|
35
|
+
},
|
|
36
|
+
panelPoppedOut: panelInstance.poppedOut,
|
|
37
|
+
setPanelWindow: (w: Window) => {
|
|
38
|
+
panelInstance.window = w;
|
|
39
|
+
},
|
|
40
|
+
bringPanelToFront: () => bringPanelToFront(panelInstance),
|
|
41
|
+
zIndex: panelInstance.zIndex,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</PanelInstanceContext.Provider>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ReactElement, createContext } from "react";
|
|
2
|
+
|
|
3
|
+
export interface PanelPosition {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PanelInstance {
|
|
9
|
+
uniqueId: string;
|
|
10
|
+
componentInstance: ReactElement;
|
|
11
|
+
zIndex: number;
|
|
12
|
+
poppedOut: boolean;
|
|
13
|
+
window: Window | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PanelsContextType {
|
|
17
|
+
openPanels: Set<string>;
|
|
18
|
+
openPanel: (uniqueId: string, component: () => ReactElement) => void;
|
|
19
|
+
closePanel: (panelInstance: PanelInstance) => void;
|
|
20
|
+
togglePopOut: (panelInstance: PanelInstance) => void;
|
|
21
|
+
bringPanelToFront: (panelInstance: PanelInstance) => void;
|
|
22
|
+
nextStackPosition: () => PanelPosition;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NoContext = () => {
|
|
26
|
+
console.error("Missing PanelContext Provider");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const PanelsContext = createContext<PanelsContextType>({
|
|
30
|
+
openPanels: new Set(),
|
|
31
|
+
openPanel: NoContext,
|
|
32
|
+
closePanel: NoContext,
|
|
33
|
+
togglePopOut: NoContext,
|
|
34
|
+
bringPanelToFront: NoContext,
|
|
35
|
+
nextStackPosition: () => ({ x: 0, y: 0 }),
|
|
36
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useInterval } from "../util/useInterval";
|
|
2
|
+
import { PanelInstanceContextProvider } from "./PanelInstanceContextProvider";
|
|
3
|
+
import { PanelInstance, PanelPosition, PanelsContext } from "./PanelsContext";
|
|
4
|
+
import { castArray, maxBy, sortBy } from "lodash-es";
|
|
5
|
+
import { Fragment, PropsWithChildren, ReactElement, useCallback, useMemo, useRef, useState } from "react";
|
|
6
|
+
|
|
7
|
+
export interface PanelsContextProviderProps {
|
|
8
|
+
baseZIndex?: number;
|
|
9
|
+
bounds?: string | Element;
|
|
10
|
+
tilingStart?: PanelPosition;
|
|
11
|
+
tilingOffset?: PanelPosition;
|
|
12
|
+
tilingMax?: PanelPosition;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PanelsContextProvider = ({
|
|
16
|
+
bounds,
|
|
17
|
+
baseZIndex = 500,
|
|
18
|
+
children,
|
|
19
|
+
tilingStart = { x: 30, y: 30 },
|
|
20
|
+
tilingOffset = { x: 30, y: 50 },
|
|
21
|
+
tilingMax = { x: 200, y: 300 },
|
|
22
|
+
}: PropsWithChildren<PanelsContextProviderProps>): ReactElement => {
|
|
23
|
+
const stackPositionRef = useRef(tilingStart);
|
|
24
|
+
|
|
25
|
+
// Can't use a map here as we need to retain render order for performance
|
|
26
|
+
const [panelInstances, setPanelInstances] = useState<PanelInstance[]>([]);
|
|
27
|
+
|
|
28
|
+
const bringPanelToFront = useCallback(
|
|
29
|
+
(panelInstance: PanelInstance) => {
|
|
30
|
+
if (panelInstance.window) {
|
|
31
|
+
panelInstance.window.focus();
|
|
32
|
+
} else {
|
|
33
|
+
const maxZIndexPanelInstance = maxBy(panelInstances, "zIndex");
|
|
34
|
+
// Prevent unnecessary state updates
|
|
35
|
+
if (maxZIndexPanelInstance === panelInstance) return;
|
|
36
|
+
sortBy(panelInstances, (pi) => (panelInstance === pi ? 32768 : pi.zIndex)).forEach(
|
|
37
|
+
(pi, i) => (pi.zIndex = baseZIndex + i),
|
|
38
|
+
);
|
|
39
|
+
setPanelInstances([...panelInstances]);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[baseZIndex, panelInstances],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const openPanel = useCallback(
|
|
46
|
+
(uniqueId: string, componentFn: () => ReactElement): void => {
|
|
47
|
+
try {
|
|
48
|
+
const existingPanelInstance = panelInstances.find((pi) => pi.uniqueId === uniqueId);
|
|
49
|
+
if (existingPanelInstance) {
|
|
50
|
+
if (existingPanelInstance.window) {
|
|
51
|
+
existingPanelInstance.window?.focus();
|
|
52
|
+
} else {
|
|
53
|
+
bringPanelToFront(existingPanelInstance);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If there are any exceptions the modal won't show
|
|
59
|
+
setPanelInstances([
|
|
60
|
+
...panelInstances,
|
|
61
|
+
{
|
|
62
|
+
uniqueId,
|
|
63
|
+
componentInstance: componentFn(),
|
|
64
|
+
zIndex: baseZIndex + panelInstances.length,
|
|
65
|
+
poppedOut: false,
|
|
66
|
+
window: null,
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(e);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
[baseZIndex, bringPanelToFront, panelInstances],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const closePanel = useCallback(
|
|
77
|
+
(panelInstance: PanelInstance | PanelInstance[]) => {
|
|
78
|
+
const panelNames = castArray(panelInstance).map((pi) => pi.uniqueId);
|
|
79
|
+
const newPanelInstances = panelInstances.filter((pi) => !panelNames.includes(pi.uniqueId));
|
|
80
|
+
if (panelInstances.length !== newPanelInstances.length) setPanelInstances(newPanelInstances);
|
|
81
|
+
},
|
|
82
|
+
[panelInstances],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const togglePopOut = useCallback(
|
|
86
|
+
(panelInstance: PanelInstance) => {
|
|
87
|
+
panelInstance.poppedOut = !panelInstance.poppedOut;
|
|
88
|
+
if (!panelInstance.poppedOut) {
|
|
89
|
+
panelInstance.window = null;
|
|
90
|
+
}
|
|
91
|
+
setPanelInstances([...panelInstances]);
|
|
92
|
+
},
|
|
93
|
+
[panelInstances],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* It's not easy to tell the difference between a window closing and a window popping in via events,
|
|
98
|
+
* so we're periodically checking instead.
|
|
99
|
+
*/
|
|
100
|
+
useInterval(() => {
|
|
101
|
+
// close any panels that have a window that is closed
|
|
102
|
+
closePanel(panelInstances.filter((pi) => pi.window?.closed));
|
|
103
|
+
}, 500);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Tile the panel position relative to previous panel
|
|
107
|
+
*/
|
|
108
|
+
const nextStackPosition = useCallback(() => {
|
|
109
|
+
const p = { ...stackPositionRef.current };
|
|
110
|
+
const result = { ...p };
|
|
111
|
+
p.x += tilingOffset.x;
|
|
112
|
+
p.y += tilingOffset.y;
|
|
113
|
+
stackPositionRef.current = p.x > tilingMax.x || p.y > tilingMax.y ? tilingStart : p;
|
|
114
|
+
return result;
|
|
115
|
+
}, [tilingMax.x, tilingMax.y, tilingOffset.x, tilingOffset.y, tilingStart]);
|
|
116
|
+
|
|
117
|
+
const openPanels = useMemo(() => new Set(panelInstances.map((pi) => pi.uniqueId)), [panelInstances]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<PanelsContext.Provider
|
|
121
|
+
value={{
|
|
122
|
+
openPanels,
|
|
123
|
+
openPanel,
|
|
124
|
+
closePanel,
|
|
125
|
+
togglePopOut,
|
|
126
|
+
bringPanelToFront,
|
|
127
|
+
nextStackPosition,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<Fragment key={"panels"}>
|
|
131
|
+
{panelInstances.map((panelInstance) => (
|
|
132
|
+
<PanelInstanceContextProvider key={panelInstance.uniqueId} panelInstance={panelInstance} bounds={bounds}>
|
|
133
|
+
{panelInstance.componentInstance}
|
|
134
|
+
</PanelInstanceContextProvider>
|
|
135
|
+
))}
|
|
136
|
+
</Fragment>
|
|
137
|
+
<Fragment key={"children"}>{children}</Fragment>
|
|
138
|
+
</PanelsContext.Provider>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { PanelInstanceContext } from "./PanelInstanceContext";
|
|
2
|
+
import { makePopoutId, popoutWindowDivId } from "./generateId";
|
|
3
|
+
import { copyStyleSheets, observeStyleSheetChanges } from "./handleStyleSheetsChanges";
|
|
4
|
+
import createCache from "@emotion/cache";
|
|
5
|
+
import { CacheProvider } from "@emotion/react";
|
|
6
|
+
import { Dispatch, ReactElement, ReactNode, SetStateAction, useContext, useEffect, useRef, useState } from "react";
|
|
7
|
+
import ReactDOM from "react-dom";
|
|
8
|
+
|
|
9
|
+
export type FloatingWindowSize = { height: number; width: number };
|
|
10
|
+
|
|
11
|
+
interface PopoutWindowProps {
|
|
12
|
+
name: string;
|
|
13
|
+
title?: string; // The title of the popout window
|
|
14
|
+
children: ReactNode; // what to render inside the window
|
|
15
|
+
size: FloatingWindowSize;
|
|
16
|
+
|
|
17
|
+
//says if we want to watch for any dynamic style changes
|
|
18
|
+
//only needed when using UI libraries like MUI
|
|
19
|
+
observeStyleChanges?: boolean;
|
|
20
|
+
|
|
21
|
+
/** Testing seam to avoid extensively mocking the window object. */
|
|
22
|
+
openExternalWindowFn?: () => OpenExternalWindowResponse;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type OpenExternalWindowResponse = {
|
|
27
|
+
externalWindow: Window | null;
|
|
28
|
+
onCloseExternalWindow: (() => void) | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open and scaffold the new external window.
|
|
33
|
+
*
|
|
34
|
+
* The main motivation for extracting these procedures into a function is for testing ease. JSDOM does not mock out the
|
|
35
|
+
* entire window object, making the PopoutWindow component difficult to unit test without extensive mocking. Instead, we
|
|
36
|
+
* can avoid mocking most of the window methods entirely by conditionally calling an alternative to `openExternalWindow`
|
|
37
|
+
* during unit testing by providing a truthy `openExternalWindowFn` prop.
|
|
38
|
+
*/
|
|
39
|
+
const openExternalWindow = ({
|
|
40
|
+
title,
|
|
41
|
+
size,
|
|
42
|
+
setContainerElement,
|
|
43
|
+
setHeadElement,
|
|
44
|
+
observeStyleChanges = false,
|
|
45
|
+
className,
|
|
46
|
+
}: {
|
|
47
|
+
title: string;
|
|
48
|
+
size: { width: number; height: number };
|
|
49
|
+
setContainerElement: Dispatch<SetStateAction<HTMLElement | undefined>>;
|
|
50
|
+
setHeadElement: Dispatch<SetStateAction<HTMLElement | undefined>>;
|
|
51
|
+
observeStyleChanges?: boolean;
|
|
52
|
+
className?: string;
|
|
53
|
+
}): OpenExternalWindowResponse => {
|
|
54
|
+
const response: OpenExternalWindowResponse = { externalWindow: null, onCloseExternalWindow: null };
|
|
55
|
+
|
|
56
|
+
// NOTE:
|
|
57
|
+
// 1/ Edge ignores the left and top settings
|
|
58
|
+
// 2/ Chrome has a bug that screws up the window size by a factor dependent on your windows scaling settings
|
|
59
|
+
// if you open the browser in a window with a scaling and drag it to a window with a different scaling
|
|
60
|
+
const features =
|
|
61
|
+
`scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,` +
|
|
62
|
+
`width=${size.width},height=${size.height},left=300,top=200`;
|
|
63
|
+
response.externalWindow = window.open("", makePopoutId(title), features);
|
|
64
|
+
|
|
65
|
+
if (response.externalWindow) {
|
|
66
|
+
const { externalWindow } = response;
|
|
67
|
+
|
|
68
|
+
// Reset the default body style applied by the browser
|
|
69
|
+
externalWindow.document.body.style.margin = "0";
|
|
70
|
+
externalWindow.document.body.style.padding = "0";
|
|
71
|
+
|
|
72
|
+
// Remove any existing body HTML from this window
|
|
73
|
+
const existingBodyMountElement = externalWindow.document.body?.firstChild;
|
|
74
|
+
if (existingBodyMountElement) {
|
|
75
|
+
externalWindow.document.body.removeChild(existingBodyMountElement);
|
|
76
|
+
}
|
|
77
|
+
// Create a new HTML element to hang our rendering off
|
|
78
|
+
const newMountElement = externalWindow.document.createElement("div");
|
|
79
|
+
newMountElement.className = `PopoutWindowContainer ${className}`;
|
|
80
|
+
setContainerElement(newMountElement);
|
|
81
|
+
externalWindow.document.body.appendChild(newMountElement);
|
|
82
|
+
|
|
83
|
+
// Do the same for the head node (where all the styles are added)
|
|
84
|
+
const existingHeadMountElement = externalWindow.document.head?.firstChild;
|
|
85
|
+
if (existingHeadMountElement) {
|
|
86
|
+
externalWindow.document.head.removeChild(existingHeadMountElement);
|
|
87
|
+
}
|
|
88
|
+
// and an HTML element to hang our copied styles off
|
|
89
|
+
const newHeadMountElement = externalWindow.document.createElement("div");
|
|
90
|
+
externalWindow.document.head.appendChild(newHeadMountElement);
|
|
91
|
+
setHeadElement(newHeadMountElement);
|
|
92
|
+
|
|
93
|
+
// Set External window title
|
|
94
|
+
externalWindow.document.title = title;
|
|
95
|
+
|
|
96
|
+
// Set an id to the external window div to be queried and used by react-modal as parentSelector
|
|
97
|
+
// so the modal shows up in the popped out window instead the main window
|
|
98
|
+
newMountElement.id = popoutWindowDivId(title);
|
|
99
|
+
|
|
100
|
+
copyStyleSheets(newHeadMountElement);
|
|
101
|
+
|
|
102
|
+
let observer: MutationObserver | null = null;
|
|
103
|
+
if (observeStyleChanges) {
|
|
104
|
+
observer = observeStyleSheetChanges(newHeadMountElement);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
externalWindow.onbeforeunload = () => observer?.disconnect();
|
|
108
|
+
|
|
109
|
+
response.onCloseExternalWindow = () => {
|
|
110
|
+
// Make sure the window closes when the component unmounts
|
|
111
|
+
externalWindow.close();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return response;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const PopoutWindow = ({
|
|
119
|
+
name,
|
|
120
|
+
title = name,
|
|
121
|
+
size,
|
|
122
|
+
observeStyleChanges,
|
|
123
|
+
className,
|
|
124
|
+
openExternalWindowFn,
|
|
125
|
+
children,
|
|
126
|
+
}: PopoutWindowProps): ReactElement | null => {
|
|
127
|
+
const { setPanelWindow } = useContext(PanelInstanceContext);
|
|
128
|
+
|
|
129
|
+
const extWindow = useRef<Window | null>(null);
|
|
130
|
+
|
|
131
|
+
const [containerElement, setContainerElement] = useState<HTMLElement>();
|
|
132
|
+
const [headElement, setHeadElement] = useState<HTMLElement>();
|
|
133
|
+
|
|
134
|
+
// We only want to update the title when it changes
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (extWindow.current) {
|
|
137
|
+
extWindow.current.document.title = title;
|
|
138
|
+
}
|
|
139
|
+
}, [title]);
|
|
140
|
+
|
|
141
|
+
// When we create this component, open a new window
|
|
142
|
+
useEffect(
|
|
143
|
+
() => {
|
|
144
|
+
const oew = openExternalWindowFn ?? openExternalWindow;
|
|
145
|
+
const { externalWindow, onCloseExternalWindow } = oew({
|
|
146
|
+
title,
|
|
147
|
+
size,
|
|
148
|
+
setContainerElement,
|
|
149
|
+
setHeadElement,
|
|
150
|
+
observeStyleChanges,
|
|
151
|
+
className,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!externalWindow) return;
|
|
155
|
+
|
|
156
|
+
setPanelWindow(externalWindow);
|
|
157
|
+
|
|
158
|
+
const closeExternalWindow = () => {
|
|
159
|
+
externalWindow.close();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// When the main window closes or reloads, close the popout
|
|
163
|
+
window.addEventListener("beforeunload", closeExternalWindow, { once: true });
|
|
164
|
+
|
|
165
|
+
return () => {
|
|
166
|
+
onCloseExternalWindow?.();
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
// These deps MUST be empty
|
|
170
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
171
|
+
[],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Render this component's children into the root element of the popout window
|
|
175
|
+
// The `@emotion/react/CacheProvider`provides a cache derived from createCache for inline styles
|
|
176
|
+
// these will be generated into the `container` and prefixed with the `key` below, ensuring
|
|
177
|
+
// the styles are in the popout rather than the root window.
|
|
178
|
+
const myCache = createCache({ key: makePopoutId(name), container: headElement });
|
|
179
|
+
|
|
180
|
+
const wrappedChildren = <CacheProvider value={myCache}>{children}</CacheProvider>;
|
|
181
|
+
|
|
182
|
+
return containerElement ? ReactDOM.createPortal(wrappedChildren, containerElement) : null;
|
|
183
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only valid characters for an @emotion key are lower case chars or '-'.
|
|
3
|
+
* We need to replace any char that is not valid with a substitute.
|
|
4
|
+
*/
|
|
5
|
+
export function makePopoutId(str: string): string {
|
|
6
|
+
return str
|
|
7
|
+
.replace(/[/\s_]*/g, "-")
|
|
8
|
+
.replaceAll("0", "zero")
|
|
9
|
+
.replaceAll("1", "one")
|
|
10
|
+
.replaceAll("2", "two")
|
|
11
|
+
.replaceAll("3", "three")
|
|
12
|
+
.replaceAll("4", "four")
|
|
13
|
+
.replaceAll("5", "five")
|
|
14
|
+
.replaceAll("6", "six")
|
|
15
|
+
.replaceAll("7", "seven")
|
|
16
|
+
.replaceAll("8", "eight")
|
|
17
|
+
.replaceAll("9", "nine")
|
|
18
|
+
.toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function popoutWindowDivId(windowTitle: string): string {
|
|
22
|
+
return `popoutWindow-${makePopoutId(windowTitle)}`;
|
|
23
|
+
}
|