react-simple-dock 0.1.4 → 0.2.4
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/README.md +40 -1
- package/index.css +19 -11
- package/index.d.ts +5 -3
- package/index.js +215 -63
- package/index.test.d.ts +1 -0
- package/index.test.js +42 -0
- package/package.json +1 -1
- package/types.d.ts +22 -0
- package/utils.d.ts +4 -1
- package/utils.js +150 -3
- package/utils.test.d.ts +1 -0
- package/utils.test.js +65 -0
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# React-Simple-Dock
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/pret-simple-dock/)
|
|
4
|
+
[](https://www.npmjs.com/package/react-simple-dock)
|
|
5
|
+
[](https://github.com/percevalw/react-simple-dock/actions/workflows/test.yml)
|
|
6
|
+
|
|
3
7
|
A set of React components to create a dockable interface, allowing to arrange and resize tabs.
|
|
4
8
|
|
|
5
9
|
## Installation of the javascript package
|
|
@@ -65,6 +69,41 @@ const App = () => (
|
|
|
65
69
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
|
|
66
70
|
```
|
|
67
71
|
|
|
72
|
+
## Dynamic tab placement
|
|
73
|
+
|
|
74
|
+
When children are added/removed dynamically, `Layout` now preserves user-arranged layout and applies `place` only
|
|
75
|
+
when a panel appears for the first time in the current layout.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<Layout
|
|
79
|
+
defaultConfig={{
|
|
80
|
+
kind: "row",
|
|
81
|
+
children: [
|
|
82
|
+
{ kind: "leaf", tabs: ["Panel 1", "@anchor-1"] },
|
|
83
|
+
{ kind: "leaf", tabs: ["Panel 2"] },
|
|
84
|
+
],
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Panel key="Panel 1">...</Panel>
|
|
88
|
+
<Panel key="Panel 2">...</Panel>
|
|
89
|
+
|
|
90
|
+
{/* Insert into the same tab list */}
|
|
91
|
+
<Panel key="Panel 3" place={{ where: "after-tab", of: "Panel 1" }}>
|
|
92
|
+
...
|
|
93
|
+
</Panel>
|
|
94
|
+
|
|
95
|
+
{/* Insert as a sibling leaf around the target leaf */}
|
|
96
|
+
<Panel key="Panel 4" place={{ where: "right", of: "@anchor-1" }}>
|
|
97
|
+
...
|
|
98
|
+
</Panel>
|
|
99
|
+
</Layout>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- `where` supports: `before-tab`, `after-tab`, `first-tab`, `last-tab`, `left`, `right`, `top`, `bottom`.
|
|
103
|
+
- `of` references panel keys (or anchor tabs in `defaultConfig`, such as `@anchor-1`).
|
|
104
|
+
- If `of` is missing, placement falls back to the first tab currently present in the layout.
|
|
105
|
+
- Tabs prefixed with `@` are treated as virtual anchors: preserved for placement, but never rendered as visible tabs or leaves.
|
|
106
|
+
|
|
68
107
|
## Development
|
|
69
108
|
|
|
70
109
|
### Installation
|
|
@@ -107,7 +146,7 @@ pip install pret
|
|
|
107
146
|
If you have changed the signature of the components, you will need to update the python stubs.
|
|
108
147
|
|
|
109
148
|
```bash
|
|
110
|
-
pret stub . SimpleDock
|
|
149
|
+
pret stub . SimpleDock pret_simple_dock/__init__.py
|
|
111
150
|
```
|
|
112
151
|
|
|
113
152
|
To build the python library and make it available in your environment:
|
package/index.css
CHANGED
|
@@ -35,7 +35,6 @@ body[data-jp-theme-light] {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
.resize-border {
|
|
38
|
-
position: absolute;
|
|
39
38
|
background-color: var(--sd-background-color, #fff);
|
|
40
39
|
opacity: 0;
|
|
41
40
|
z-index: 10;
|
|
@@ -43,21 +42,19 @@ body[data-jp-theme-light] {
|
|
|
43
42
|
|
|
44
43
|
.resize-border:hover {
|
|
45
44
|
opacity: 0.8;
|
|
45
|
+
border: 1px solid var(--sd-background-color, #fff);
|
|
46
|
+
box-sizing: border-box;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
.resize-border.bottom {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
right: 0;
|
|
52
|
-
height: calc((var(--sd-grid-gap)));
|
|
50
|
+
height: 100%;
|
|
51
|
+
width: 100%;
|
|
53
52
|
cursor: row-resize;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
.resize-border.right {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
right: calc(0px - var(--sd-grid-gap));
|
|
60
|
-
width: calc((var(--sd-grid-gap)));
|
|
56
|
+
height: 100%;
|
|
57
|
+
width: 100%;
|
|
61
58
|
cursor: col-resize;
|
|
62
59
|
}
|
|
63
60
|
|
|
@@ -91,6 +88,7 @@ body[data-jp-theme-light] {
|
|
|
91
88
|
height: 100%;
|
|
92
89
|
width: 100%;
|
|
93
90
|
background: var(--sd-background-color);
|
|
91
|
+
overflow-x: hidden;
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
.panel.leaf {
|
|
@@ -100,7 +98,6 @@ body[data-jp-theme-light] {
|
|
|
100
98
|
.row > .panel-content,
|
|
101
99
|
.column > .panel-content {
|
|
102
100
|
display: grid;
|
|
103
|
-
grid-gap: var(--sd-grid-gap);
|
|
104
101
|
position: absolute;
|
|
105
102
|
left: 0;
|
|
106
103
|
top: 0;
|
|
@@ -122,7 +119,11 @@ body[data-jp-theme-light] {
|
|
|
122
119
|
left: 0;
|
|
123
120
|
width: 100%;
|
|
124
121
|
height: 100%;
|
|
125
|
-
overflow:
|
|
122
|
+
overflow: auto;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.leaf > div:nth-child(1) {
|
|
126
|
+
padding-right: 0px;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
/* TAB HEADER */
|
|
@@ -131,6 +132,13 @@ body[data-jp-theme-light] {
|
|
|
131
132
|
display: flex;
|
|
132
133
|
flex-direction: row;
|
|
133
134
|
height: var(--sd-header-height);
|
|
135
|
+
width: 100%;
|
|
136
|
+
overflow: auto;
|
|
137
|
+
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
|
138
|
+
scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */
|
|
139
|
+
}
|
|
140
|
+
.tab-header::-webkit-scrollbar {
|
|
141
|
+
display: none; /* Older Safari and Chromium */
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
.tab-header-border {
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import "./index.css";
|
|
3
|
-
import {
|
|
3
|
+
import { PanelProps, DefaultLayoutConfig } from "./types";
|
|
4
4
|
/**
|
|
5
5
|
* A Panel component.
|
|
6
6
|
*
|
|
@@ -21,10 +21,12 @@ export declare const Panel: (props: PanelProps) => any;
|
|
|
21
21
|
* @param children The children `Panel` components to render within the layout.
|
|
22
22
|
* @param defaultConfig The default layout configuration to use.
|
|
23
23
|
* @param wrapDnd A boolean flag to enable or disable drag and drop support (default: true).
|
|
24
|
+
* @param collapseTabsOnMobile If true, auto-detect mobile devices and collapse the whole layout into a single tabbed panel.
|
|
24
25
|
* @returns A React element representing the complete panel layout.
|
|
25
26
|
*/
|
|
26
|
-
export declare function Layout({ children, defaultConfig, wrapDnd, }: {
|
|
27
|
+
export declare function Layout({ children, defaultConfig, wrapDnd, collapseTabsOnMobile, }: {
|
|
27
28
|
children: React.ReactElement<PanelProps>[] | React.ReactElement<PanelProps>;
|
|
28
|
-
defaultConfig?:
|
|
29
|
+
defaultConfig?: DefaultLayoutConfig;
|
|
29
30
|
wrapDnd?: boolean;
|
|
31
|
+
collapseTabsOnMobile?: boolean | string[];
|
|
30
32
|
}): import("react/jsx-runtime").JSX.Element;
|
package/index.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { DndProvider, useDrag, useDrop, useDragDropManager } from "react-dnd";
|
|
3
4
|
import "./index.css";
|
|
4
|
-
import {
|
|
5
|
-
import { filterPanels, movePanel } from "./utils";
|
|
5
|
+
import { filterPanels, getLayoutTabs, insertPanel, isAnchorTab, movePanel } from "./utils";
|
|
6
6
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
|
7
7
|
function useForceUpdate() {
|
|
8
8
|
const [value, setValue] = useState(0); // integer state
|
|
9
9
|
return () => setValue((value) => value + 1); // update state to force render
|
|
10
10
|
}
|
|
11
|
+
const isMobileDevice = () => {
|
|
12
|
+
if (typeof window === "undefined" || typeof navigator === "undefined")
|
|
13
|
+
return false;
|
|
14
|
+
return /Mobi|Android|iPhone|iPad|iPod|Windows Phone|IEMobile|Opera Mini/i.test(navigator.userAgent || "");
|
|
15
|
+
};
|
|
11
16
|
const getPanelElementMaxHeaderHeight = (config, panelElements) => {
|
|
12
|
-
// If we have a leaf, then the header height is the
|
|
17
|
+
// If we have a leaf, then the header height is the tab header height.
|
|
13
18
|
if (config.kind === "leaf") {
|
|
14
|
-
|
|
19
|
+
const element = panelElements.get(config);
|
|
20
|
+
if (!element) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
return element.children[0]?.offsetHeight || 0;
|
|
15
24
|
}
|
|
16
25
|
// If we have a row, then the header height is the max of each child's max header height
|
|
17
26
|
else if (config.kind === "row") {
|
|
@@ -22,7 +31,62 @@ const getPanelElementMaxHeaderHeight = (config, panelElements) => {
|
|
|
22
31
|
return config.children.reduce((a, b) => a + getPanelElementMaxHeaderHeight(b, panelElements), 0);
|
|
23
32
|
}
|
|
24
33
|
};
|
|
25
|
-
|
|
34
|
+
function normalizeConfig(config, siblingsCount, depth = 0, default_kind = depth % 2 === 0 ? "row" : "column") {
|
|
35
|
+
if (typeof config === "string") {
|
|
36
|
+
config = { tabs: [config] };
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(config)) {
|
|
39
|
+
config = { children: config };
|
|
40
|
+
}
|
|
41
|
+
// Leaf panel if tabs provided
|
|
42
|
+
if (config.tabs) {
|
|
43
|
+
const leaf = config;
|
|
44
|
+
const tabIndex = typeof leaf.tabIndex === "number" ? leaf.tabIndex : 0;
|
|
45
|
+
const size = typeof leaf.size === "number" ? leaf.size : 100 / siblingsCount;
|
|
46
|
+
return { kind: "leaf", tabs: leaf.tabs, tabIndex, size, nesting: leaf.nesting };
|
|
47
|
+
}
|
|
48
|
+
const container = config;
|
|
49
|
+
const kind = container.kind || default_kind;
|
|
50
|
+
const size = typeof container.size === "number" ? container.size : 100 / siblingsCount;
|
|
51
|
+
const children = container.children.map((child) => normalizeConfig(child, container.children.length, depth + 1, kind === "row" ? "column" : "row"));
|
|
52
|
+
return { kind, children, size, nesting: container.nesting };
|
|
53
|
+
}
|
|
54
|
+
const getPanelName = (panel, index) => panel.props.name || (panel.key !== null ? panel.key.toString().slice(2) : `unnamed-${index}`);
|
|
55
|
+
const getRenderableTabs = (tabs) => tabs.filter((tab) => !isAnchorTab(tab));
|
|
56
|
+
const isRenderableConfig = (config) => {
|
|
57
|
+
if (config.kind === "leaf") {
|
|
58
|
+
return getRenderableTabs(config.tabs).length > 0;
|
|
59
|
+
}
|
|
60
|
+
return config.children.some((child) => isRenderableConfig(child));
|
|
61
|
+
};
|
|
62
|
+
const getFirstRenderableTab = (config) => {
|
|
63
|
+
if (config.kind === "leaf") {
|
|
64
|
+
return getRenderableTabs(config.tabs)[0] || null;
|
|
65
|
+
}
|
|
66
|
+
const firstRenderableChild = config.children.find((child) => isRenderableConfig(child));
|
|
67
|
+
if (!firstRenderableChild) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return getFirstRenderableTab(firstRenderableChild);
|
|
71
|
+
};
|
|
72
|
+
const getLastRenderableTab = (config) => {
|
|
73
|
+
if (config.kind === "leaf") {
|
|
74
|
+
const tabs = getRenderableTabs(config.tabs);
|
|
75
|
+
return tabs[tabs.length - 1] || null;
|
|
76
|
+
}
|
|
77
|
+
const children = config.children.filter((child) => isRenderableConfig(child));
|
|
78
|
+
if (!children.length) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return getLastRenderableTab(children[children.length - 1]);
|
|
82
|
+
};
|
|
83
|
+
const getRenderableTabCount = (config) => {
|
|
84
|
+
if (config.kind === "leaf") {
|
|
85
|
+
return getRenderableTabs(config.tabs).length;
|
|
86
|
+
}
|
|
87
|
+
return config.children.reduce((count, child) => count + getRenderableTabCount(child), 0);
|
|
88
|
+
};
|
|
89
|
+
const TabHandle = ({ name, visible, onClick, children, }) => {
|
|
26
90
|
const getItem = () => ({
|
|
27
91
|
name,
|
|
28
92
|
handleElement: targetRef.current,
|
|
@@ -37,13 +101,15 @@ const TabHandle = ({ name, index, visible, onClick, children, }) => {
|
|
|
37
101
|
return (_jsx("div", { className: `tab-handle tab-handle__${visible ? "visible" : "hidden"}`, ref: (element) => {
|
|
38
102
|
dragRef(element);
|
|
39
103
|
targetRef.current = element;
|
|
40
|
-
}, onClick: () => onClick(
|
|
104
|
+
}, onClick: () => onClick(name), children: children }));
|
|
41
105
|
};
|
|
42
106
|
const TabHeader = ({ config, onClick, leaves, }) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
107
|
+
const renderableTabs = getRenderableTabs(config.tabs);
|
|
108
|
+
const selectedTab = config.tabs[config.tabIndex];
|
|
109
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "tab-header", children: [renderableTabs
|
|
110
|
+
.map((tab) => [
|
|
111
|
+
_jsx("div", { className: "tab-placeholder", style: { width: "0px" }, "data-panel-name": tab }, `${tab}-placeholder`),
|
|
112
|
+
_jsx(TabHandle, { name: tab, visible: selectedTab === tab, onClick: onClick, children: leaves[tab]?.header || tab }, tab),
|
|
47
113
|
])
|
|
48
114
|
.flat(), _jsx("div", { className: "tab-placeholder" })] }), _jsx("div", { className: "tab-header-border" }), _jsx("div", { className: "tab-header-bottom" })] }));
|
|
49
115
|
};
|
|
@@ -51,6 +117,11 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
|
|
|
51
117
|
const startEvent = useRef(null);
|
|
52
118
|
const savedSizes = useRef([]);
|
|
53
119
|
const forceUpdate = useForceUpdate();
|
|
120
|
+
const renderableChildren = config.kind === "leaf"
|
|
121
|
+
? []
|
|
122
|
+
: config.children
|
|
123
|
+
.map((child, childIndex) => ({ child, childIndex }))
|
|
124
|
+
.filter(({ child }) => isRenderableConfig(child));
|
|
54
125
|
useEffect(() => {
|
|
55
126
|
const handleMouseUp = (e) => {
|
|
56
127
|
document.removeEventListener("mousemove", startEvent.current?.handleMouseMove);
|
|
@@ -80,17 +151,21 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
|
|
|
80
151
|
const { clientX, clientY } = e;
|
|
81
152
|
// get Panel top left corner
|
|
82
153
|
const { top, left } = (startEvent.current?.target).parentElement.getBoundingClientRect();
|
|
154
|
+
const parent = (startEvent.current?.target).parentElement;
|
|
155
|
+
const containerWidth = parent.offsetWidth;
|
|
156
|
+
const containerHeight = parent.offsetHeight;
|
|
83
157
|
let ratio;
|
|
84
158
|
if (startEvent.current.side === "bottom") {
|
|
85
|
-
ratio = (clientY - top) /
|
|
159
|
+
ratio = containerHeight > 0 ? (clientY - top) / containerHeight : 0;
|
|
86
160
|
}
|
|
87
161
|
else if (startEvent.current.side === "right") {
|
|
88
|
-
ratio = (clientX - left) /
|
|
162
|
+
ratio = containerWidth > 0 ? (clientX - left) / containerWidth : 0;
|
|
89
163
|
}
|
|
90
164
|
else {
|
|
91
165
|
return;
|
|
92
166
|
}
|
|
93
|
-
|
|
167
|
+
const boundedRatio = Math.max(0, Math.min(1, ratio));
|
|
168
|
+
onResize && onResize(boundedRatio, index, startEvent.current.target);
|
|
94
169
|
}, [onResize, index]);
|
|
95
170
|
const handleMouseDown = (e, side) => {
|
|
96
171
|
if (startEvent.current) {
|
|
@@ -101,8 +176,6 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
|
|
|
101
176
|
e.preventDefault();
|
|
102
177
|
saveSizes && saveSizes();
|
|
103
178
|
startEvent.current = {
|
|
104
|
-
width: e.clientX - left,
|
|
105
|
-
height: e.clientY - top,
|
|
106
179
|
target: e.currentTarget,
|
|
107
180
|
side: side,
|
|
108
181
|
handleMouseMove,
|
|
@@ -113,55 +186,85 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
|
|
|
113
186
|
if (config.kind === "row") {
|
|
114
187
|
// return {gridTemplateColumns: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
|
|
115
188
|
return {
|
|
116
|
-
gridTemplateColumns:
|
|
189
|
+
gridTemplateColumns: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
|
|
117
190
|
};
|
|
118
191
|
}
|
|
119
192
|
if (config.kind === "column") {
|
|
120
193
|
// return {gridTemplateRows: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
|
|
121
194
|
return {
|
|
122
|
-
gridTemplateRows:
|
|
195
|
+
gridTemplateRows: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
|
|
123
196
|
};
|
|
124
197
|
}
|
|
125
198
|
};
|
|
126
199
|
const handleResize = useCallback((ratio, idx, target) => {
|
|
127
200
|
if (config.kind === "leaf")
|
|
128
201
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
202
|
+
const current = renderableChildren[idx];
|
|
203
|
+
const next = renderableChildren[idx + 1];
|
|
204
|
+
if (!current || !next) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
131
207
|
const total = savedSizes.current.reduce((a, b) => a + b, 0);
|
|
208
|
+
if (total <= 0) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const sizeBefore = savedSizes.current.slice(0, idx).reduce((a, b) => a + b, 0);
|
|
212
|
+
const pairTotal = savedSizes.current[idx] + savedSizes.current[idx + 1];
|
|
213
|
+
let size = ratio * total - sizeBefore;
|
|
214
|
+
size = Math.max(0, Math.min(pairTotal, size));
|
|
215
|
+
let nextSize = pairTotal - size;
|
|
132
216
|
if (config.kind === "column") {
|
|
133
|
-
const headerHeightBefore = getPanelElementMaxHeaderHeight(
|
|
134
|
-
const headerHeightAfter = getPanelElementMaxHeaderHeight(
|
|
217
|
+
const headerHeightBefore = getPanelElementMaxHeaderHeight(current.child, panelElements);
|
|
218
|
+
const headerHeightAfter = getPanelElementMaxHeaderHeight(next.child, panelElements);
|
|
135
219
|
const parentHeight = panelContentRef.current.offsetHeight;
|
|
220
|
+
if (parentHeight <= 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
136
223
|
if ((size * parentHeight) / total < headerHeightBefore) {
|
|
137
224
|
size = (headerHeightBefore / parentHeight) * total;
|
|
138
|
-
|
|
225
|
+
size = Math.max(0, Math.min(pairTotal, size));
|
|
226
|
+
nextSize = pairTotal - size;
|
|
139
227
|
}
|
|
140
228
|
else if ((nextSize * parentHeight) / total < headerHeightAfter) {
|
|
141
|
-
nextSize = (headerHeightAfter / parentHeight) * total;
|
|
142
|
-
size =
|
|
229
|
+
nextSize = Math.max(0, Math.min(pairTotal, (headerHeightAfter / parentHeight) * total));
|
|
230
|
+
size = pairTotal - nextSize;
|
|
143
231
|
}
|
|
144
232
|
}
|
|
145
|
-
|
|
146
|
-
|
|
233
|
+
current.child.size = size;
|
|
234
|
+
next.child.size = nextSize;
|
|
147
235
|
Object.assign(panelContentRef.current.style, makeStyle());
|
|
148
|
-
}, [config]);
|
|
236
|
+
}, [config, renderableChildren]);
|
|
149
237
|
const handleSaveSizes = useCallback(() => {
|
|
150
238
|
if (config.kind === "leaf")
|
|
151
239
|
return;
|
|
152
|
-
savedSizes.current =
|
|
153
|
-
}, [config]);
|
|
154
|
-
const handleHeaderClick = (
|
|
240
|
+
savedSizes.current = renderableChildren.map(({ child }) => child.size);
|
|
241
|
+
}, [config, renderableChildren]);
|
|
242
|
+
const handleHeaderClick = (name) => {
|
|
155
243
|
if (config.kind === "leaf") {
|
|
244
|
+
const tabIndex = config.tabs.findIndex((tab) => tab === name);
|
|
245
|
+
if (tabIndex === -1) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
156
248
|
forceUpdate();
|
|
157
|
-
config.tabIndex =
|
|
249
|
+
config.tabIndex = tabIndex;
|
|
158
250
|
}
|
|
159
251
|
};
|
|
252
|
+
const activeTab = config.kind === "leaf"
|
|
253
|
+
? (() => {
|
|
254
|
+
const selected = config.tabs[config.tabIndex];
|
|
255
|
+
if (selected && !isAnchorTab(selected) && leaves[selected]) {
|
|
256
|
+
return selected;
|
|
257
|
+
}
|
|
258
|
+
return getRenderableTabs(config.tabs).find((tab) => leaves[tab]) || null;
|
|
259
|
+
})()
|
|
260
|
+
: null;
|
|
261
|
+
const leafContent = activeTab ? (_jsx("div", { children: leaves[activeTab].element }, activeTab)) : (_jsx("div", { style: { width: "100%", height: "100%" } }));
|
|
160
262
|
const panelContentRef = useRef(null);
|
|
161
263
|
const panelRef = useRef(null);
|
|
162
|
-
return (_jsxs("div", { className: `${config.kind} panel`, style: style, ref: panelRef, children: [config.kind === "leaf" ? (_jsx(TabHeader, { config: config, leaves: leaves, onClick: handleHeaderClick })) : null, _jsx("div", { className: "panel-content", ref: panelContentRef, style: makeStyle(), children: config.kind === "leaf" ? (
|
|
264
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${config.kind} panel`, style: style, ref: panelRef, children: [config.kind === "leaf" && getRenderableTabs(config.tabs).length > 0 ? (_jsx(TabHeader, { config: config, leaves: leaves, onClick: handleHeaderClick })) : null, _jsx("div", { className: "panel-content", ref: panelContentRef, style: makeStyle(), children: config.kind === "leaf" ? (leafContent) : (renderableChildren.map(({ child, childIndex }, i) => (_jsx(NestedPanel, { config: child, leaves: leaves, saveSizes: handleSaveSizes, index: i, onResize: handleResize, isLast: i === renderableChildren.length - 1, direction: config.kind, panelElements: panelElements }, childIndex)))) })] }), !isLast && direction === "column" && (_jsx("div", { className: "resize-border bottom", onMouseDown: (e) => handleMouseDown(e, "bottom") })), !isLast && direction === "row" && (_jsx("div", { className: "resize-border right", onMouseDown: (e) => handleMouseDown(e, "right") }))] }));
|
|
163
265
|
});
|
|
164
266
|
const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
|
|
267
|
+
const [, set] = useState();
|
|
165
268
|
const closestRef = useRef(null);
|
|
166
269
|
const lastItem = useRef(null);
|
|
167
270
|
let lastPlaceholder = useRef(null);
|
|
@@ -219,7 +322,8 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
|
|
|
219
322
|
zones.push({ rect, config, index, element });
|
|
220
323
|
};
|
|
221
324
|
if (config.kind === "leaf") {
|
|
222
|
-
|
|
325
|
+
const renderableTabs = getRenderableTabs(config.tabs);
|
|
326
|
+
if (!(renderableTabs.length === 1 && renderableTabs[0] === name)) {
|
|
223
327
|
pushZone("TOP", left, top, width, height / 2);
|
|
224
328
|
pushZone("BOTTOM", left, top + height / 2, width, height / 2);
|
|
225
329
|
pushZone("LEFT", left, top, width / 2, height);
|
|
@@ -234,24 +338,32 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
|
|
|
234
338
|
pushZone("TAB", left, top, width, element.children[0].offsetHeight);
|
|
235
339
|
}
|
|
236
340
|
else {
|
|
237
|
-
const
|
|
238
|
-
|
|
341
|
+
const renderableChildren = config.children.filter((child) => isRenderableConfig(child));
|
|
342
|
+
if (!renderableChildren.length) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const firstChild = renderableChildren[0];
|
|
346
|
+
const lastChild = renderableChildren[renderableChildren.length - 1];
|
|
347
|
+
const firstTab = getFirstRenderableTab(firstChild);
|
|
348
|
+
const lastTab = getLastRenderableTab(lastChild);
|
|
349
|
+
const firstTabCount = getRenderableTabCount(firstChild);
|
|
350
|
+
const lastTabCount = getRenderableTabCount(lastChild);
|
|
239
351
|
if (config.kind === "row" || config === rootConfig) {
|
|
240
|
-
const zoneWidth = width / (config.kind === "row" ?
|
|
352
|
+
const zoneWidth = width / (config.kind === "row" ? renderableChildren.length + 1 : 2);
|
|
241
353
|
// check that the dragged item is not the last item in the row and a single tab
|
|
242
|
-
if (config === rootConfig ||
|
|
354
|
+
if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
|
|
243
355
|
pushZone("RIGHT", left + width - zoneWidth, top, zoneWidth, height);
|
|
244
356
|
}
|
|
245
|
-
if (config === rootConfig ||
|
|
357
|
+
if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
|
|
246
358
|
pushZone("LEFT", left, top, zoneWidth, height);
|
|
247
359
|
}
|
|
248
360
|
}
|
|
249
361
|
if (config.kind === "column" || config === rootConfig) {
|
|
250
|
-
const zoneHeight = height / (config.kind === "column" ?
|
|
251
|
-
if (config === rootConfig ||
|
|
362
|
+
const zoneHeight = height / (config.kind === "column" ? renderableChildren.length + 1 : 2);
|
|
363
|
+
if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
|
|
252
364
|
pushZone("BOTTOM", left, top + height - zoneHeight, width, zoneHeight);
|
|
253
365
|
}
|
|
254
|
-
if (config === rootConfig ||
|
|
366
|
+
if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
|
|
255
367
|
pushZone("TOP", left, top, width, zoneHeight);
|
|
256
368
|
}
|
|
257
369
|
}
|
|
@@ -378,41 +490,81 @@ export const Panel = (props) => {
|
|
|
378
490
|
* @param children The children `Panel` components to render within the layout.
|
|
379
491
|
* @param defaultConfig The default layout configuration to use.
|
|
380
492
|
* @param wrapDnd A boolean flag to enable or disable drag and drop support (default: true).
|
|
493
|
+
* @param collapseTabsOnMobile If true, auto-detect mobile devices and collapse the whole layout into a single tabbed panel.
|
|
381
494
|
* @returns A React element representing the complete panel layout.
|
|
382
495
|
*/
|
|
383
|
-
export function Layout({ children, defaultConfig, wrapDnd = true, }) {
|
|
496
|
+
export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOnMobile = true, }) {
|
|
384
497
|
const children_array = React.Children.toArray(children);
|
|
385
|
-
const
|
|
386
|
-
|
|
498
|
+
const panelDefinitions = children_array.map((panel, i) => ({
|
|
499
|
+
name: getPanelName(panel, i),
|
|
500
|
+
header: panel.props.header,
|
|
501
|
+
element: panel.props.children,
|
|
502
|
+
place: panel.props.place,
|
|
503
|
+
}));
|
|
504
|
+
const panelNames = panelDefinitions.map((panel) => panel.name);
|
|
505
|
+
const placementByName = new Map(panelDefinitions.map((panel) => [panel.name, panel.place]));
|
|
506
|
+
const namedChildren = Object.fromEntries(panelDefinitions.map((panel) => [
|
|
507
|
+
panel.name,
|
|
387
508
|
{
|
|
388
|
-
element:
|
|
389
|
-
header:
|
|
509
|
+
element: panel.element,
|
|
510
|
+
header: panel.header,
|
|
390
511
|
},
|
|
391
512
|
]));
|
|
392
513
|
const panelElements = useRef(new Map());
|
|
393
|
-
const [rootConfig, setRootConfig] = useState(
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
514
|
+
const [rootConfig, setRootConfig] = useState(() => {
|
|
515
|
+
const base = defaultConfig
|
|
516
|
+
? normalizeConfig(defaultConfig, children_array.length)
|
|
517
|
+
: {
|
|
518
|
+
kind: "row",
|
|
519
|
+
size: 1,
|
|
520
|
+
children: panelDefinitions.map((panel) => ({
|
|
521
|
+
kind: "leaf",
|
|
522
|
+
tabs: [panel.name],
|
|
523
|
+
tabIndex: 0,
|
|
524
|
+
size: 100 / children_array.length,
|
|
525
|
+
})),
|
|
526
|
+
};
|
|
527
|
+
if (collapseTabsOnMobile && isMobileDevice()) {
|
|
528
|
+
// If collapseTabsOnMobile is a list, use the names in the list as the first tabs
|
|
529
|
+
// then complete with the rest of the named children
|
|
530
|
+
const actualTabs = panelNames;
|
|
531
|
+
const tabs = [
|
|
532
|
+
...(Array.isArray(collapseTabsOnMobile)
|
|
533
|
+
? collapseTabsOnMobile.filter((name) => actualTabs.includes(name))
|
|
534
|
+
: []),
|
|
535
|
+
...actualTabs.filter((name) => !Array.isArray(collapseTabsOnMobile) ||
|
|
536
|
+
!collapseTabsOnMobile.includes(name)),
|
|
537
|
+
];
|
|
538
|
+
return {
|
|
539
|
+
kind: "leaf",
|
|
540
|
+
tabs: tabs,
|
|
541
|
+
tabIndex: 0,
|
|
542
|
+
size: 100,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return base;
|
|
402
546
|
});
|
|
403
547
|
let config = rootConfig;
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
548
|
+
let nextConfig = rootConfig;
|
|
549
|
+
if (nextConfig.kind !== "leaf" || nextConfig.tabs.length > 0) {
|
|
550
|
+
nextConfig = filterPanels(panelNames, nextConfig) || { kind: "leaf", tabs: [], tabIndex: 0, size: 100 };
|
|
551
|
+
}
|
|
552
|
+
const tabs = getLayoutTabs(nextConfig);
|
|
553
|
+
const missingPanels = panelNames.filter((name) => !tabs.includes(name));
|
|
554
|
+
if (missingPanels.length > 0) {
|
|
555
|
+
missingPanels.forEach((name) => {
|
|
556
|
+
nextConfig = insertPanel(placementByName.get(name), name, nextConfig);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (nextConfig !== rootConfig) {
|
|
560
|
+
config = nextConfig;
|
|
561
|
+
setRootConfig(nextConfig);
|
|
410
562
|
}
|
|
411
563
|
const handleDrop = (zone, name) => {
|
|
412
564
|
const newConfig = movePanel(zone, name, rootConfig);
|
|
413
565
|
setRootConfig(newConfig);
|
|
414
566
|
};
|
|
415
|
-
const container = (_jsxs("div", { className: "container", children: [_jsx(NestedPanel, { leaves: namedChildren, config: config, panelElements: panelElements.current }), _jsx(Overlay, { panelElements: panelElements, onDrop: handleDrop, rootConfig: config })] }));
|
|
567
|
+
const container = (_jsxs("div", { className: "container", children: [isRenderableConfig(config) ? (_jsx(NestedPanel, { leaves: namedChildren, config: config, panelElements: panelElements.current })) : (_jsx("div", { style: { width: "100%", height: "100%" } })), _jsx(Overlay, { panelElements: panelElements, onDrop: handleDrop, rootConfig: config })] }));
|
|
416
568
|
if (wrapDnd) {
|
|
417
569
|
return _jsx(DndProvider, { backend: HTML5Backend, children: container });
|
|
418
570
|
}
|
package/index.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.test.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Layout, Panel } from "./index";
|
|
4
|
+
jest.mock("react-dnd", () => {
|
|
5
|
+
const React = require("react");
|
|
6
|
+
return {
|
|
7
|
+
DndProvider: ({ children }) => _jsx(_Fragment, { children: children }),
|
|
8
|
+
useDrag: () => [{}, () => null],
|
|
9
|
+
useDrop: () => [{}, () => null],
|
|
10
|
+
useDragDropManager: () => ({
|
|
11
|
+
getMonitor: () => ({
|
|
12
|
+
subscribeToStateChange: () => () => null,
|
|
13
|
+
subscribeToOffsetChange: () => () => null,
|
|
14
|
+
getItemType: () => null,
|
|
15
|
+
isDragging: () => false,
|
|
16
|
+
getItem: () => null,
|
|
17
|
+
getClientOffset: () => null,
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
jest.mock("react-dnd-html5-backend", () => ({
|
|
23
|
+
HTML5Backend: {},
|
|
24
|
+
}));
|
|
25
|
+
describe("Layout virtual anchors", () => {
|
|
26
|
+
it("does not render anchor tabs", () => {
|
|
27
|
+
render(_jsx(Layout, { wrapDnd: false, defaultConfig: { kind: "leaf", tabs: ["panel-1", "@anchor-1"] }, children: _jsx(Panel, { name: "panel-1", children: _jsx("div", { children: "Panel One" }) }) }));
|
|
28
|
+
expect(screen.getByText("panel-1")).not.toBeNull();
|
|
29
|
+
expect(screen.queryByText("@anchor-1")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it("does not render anchor-only leaves", () => {
|
|
32
|
+
const { container } = render(_jsx(Layout, { wrapDnd: false, defaultConfig: {
|
|
33
|
+
kind: "row",
|
|
34
|
+
children: [
|
|
35
|
+
{ kind: "leaf", tabs: ["@anchor-1"] },
|
|
36
|
+
{ kind: "leaf", tabs: ["panel-1"] },
|
|
37
|
+
],
|
|
38
|
+
}, children: _jsx(Panel, { name: "panel-1", children: _jsx("div", { children: "Panel One" }) }) }));
|
|
39
|
+
// eslint-disable-next-line testing-library/no-container,testing-library/no-node-access
|
|
40
|
+
expect(container.querySelectorAll(".panel.leaf")).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/package.json
CHANGED
package/types.d.ts
CHANGED
|
@@ -28,6 +28,27 @@ export type ContainerLayoutConfig = {
|
|
|
28
28
|
* A union type that represents either a leaf panel (with tabs) or a container panel (row/column with children).
|
|
29
29
|
*/
|
|
30
30
|
export type LayoutConfig = LeafLayoutConfig | ContainerLayoutConfig;
|
|
31
|
+
/**
|
|
32
|
+
* User-provided config can omit kind, size, and tabIndex for inference
|
|
33
|
+
*/
|
|
34
|
+
export type DefaultLeafConfig = {
|
|
35
|
+
kind?: "leaf";
|
|
36
|
+
tabs: string[];
|
|
37
|
+
tabIndex?: number;
|
|
38
|
+
size?: number;
|
|
39
|
+
nesting?: number;
|
|
40
|
+
};
|
|
41
|
+
export type DefaultContainerConfig = {
|
|
42
|
+
kind?: "row" | "column";
|
|
43
|
+
children: DefaultLayoutConfig[];
|
|
44
|
+
size?: number;
|
|
45
|
+
nesting?: number;
|
|
46
|
+
};
|
|
47
|
+
export type DefaultLayoutConfig = string | DefaultLeafConfig | DefaultContainerConfig | DefaultLayoutConfig[];
|
|
48
|
+
export type PanelPlacement = {
|
|
49
|
+
where: "before-tab" | "after-tab" | "first-tab" | "last-tab" | "left" | "right" | "top" | "bottom";
|
|
50
|
+
of: string;
|
|
51
|
+
};
|
|
31
52
|
/**
|
|
32
53
|
* Properties for a panel component.
|
|
33
54
|
*
|
|
@@ -37,6 +58,7 @@ export type PanelProps = {
|
|
|
37
58
|
children: React.ReactNode;
|
|
38
59
|
name?: string;
|
|
39
60
|
header?: React.ReactNode;
|
|
61
|
+
place?: PanelPlacement;
|
|
40
62
|
};
|
|
41
63
|
/**
|
|
42
64
|
* Represents a drop zone within the layout.
|
package/utils.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { Zone, LayoutConfig } from "./types";
|
|
1
|
+
import { Zone, LayoutConfig, PanelPlacement } from "./types";
|
|
2
|
+
export declare const isAnchorTab: (name: string) => boolean;
|
|
3
|
+
export declare const getLayoutTabs: (inside: LayoutConfig) => string[];
|
|
4
|
+
export declare const insertPanel: (place: PanelPlacement | undefined, name: string, inside: LayoutConfig) => LayoutConfig;
|
|
2
5
|
/**
|
|
3
6
|
* Moves a panel within the layout based on a drop zone and panel name.
|
|
4
7
|
*
|
package/utils.js
CHANGED
|
@@ -32,6 +32,151 @@ const simplifyLayout = (config) => {
|
|
|
32
32
|
}
|
|
33
33
|
return config;
|
|
34
34
|
};
|
|
35
|
+
export const isAnchorTab = (name) => name.startsWith("@");
|
|
36
|
+
export const getLayoutTabs = (inside) => {
|
|
37
|
+
if (inside.kind === "leaf") {
|
|
38
|
+
return [...inside.tabs];
|
|
39
|
+
}
|
|
40
|
+
return inside.children.flatMap((child) => getLayoutTabs(child));
|
|
41
|
+
};
|
|
42
|
+
const cloneLayout = (inside) => {
|
|
43
|
+
if (inside.kind === "leaf") {
|
|
44
|
+
return {
|
|
45
|
+
...inside,
|
|
46
|
+
tabs: [...inside.tabs],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...inside,
|
|
51
|
+
children: inside.children.map(cloneLayout),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const findLeafPathByTab = (inside, name, path = []) => {
|
|
55
|
+
if (inside.kind === "leaf") {
|
|
56
|
+
return inside.tabs.includes(name) ? path : null;
|
|
57
|
+
}
|
|
58
|
+
for (let i = 0; i < inside.children.length; i += 1) {
|
|
59
|
+
const childPath = findLeafPathByTab(inside.children[i], name, [...path, i]);
|
|
60
|
+
if (childPath) {
|
|
61
|
+
return childPath;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
const getNodeAtPath = (inside, path) => {
|
|
67
|
+
let current = inside;
|
|
68
|
+
path.forEach((index) => {
|
|
69
|
+
if (current.kind !== "leaf") {
|
|
70
|
+
current = current.children[index];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return current;
|
|
74
|
+
};
|
|
75
|
+
const insertTabRelative = (inside, name, target, where) => {
|
|
76
|
+
const targetPath = findLeafPathByTab(inside, target);
|
|
77
|
+
if (!targetPath) {
|
|
78
|
+
return inside;
|
|
79
|
+
}
|
|
80
|
+
const next = cloneLayout(inside);
|
|
81
|
+
const targetLeaf = getNodeAtPath(next, targetPath);
|
|
82
|
+
const targetTabIndex = targetLeaf.tabs.findIndex((tab) => tab === target);
|
|
83
|
+
if (targetTabIndex === -1) {
|
|
84
|
+
return inside;
|
|
85
|
+
}
|
|
86
|
+
const insertIndex = where === "before-tab"
|
|
87
|
+
? targetTabIndex
|
|
88
|
+
: where === "after-tab"
|
|
89
|
+
? targetTabIndex + 1
|
|
90
|
+
: where === "first-tab"
|
|
91
|
+
? 0
|
|
92
|
+
: targetLeaf.tabs.length;
|
|
93
|
+
targetLeaf.tabs.splice(insertIndex, 0, name);
|
|
94
|
+
targetLeaf.tabIndex = insertIndex;
|
|
95
|
+
return next;
|
|
96
|
+
};
|
|
97
|
+
const insertSplitRelative = (inside, name, target, where) => {
|
|
98
|
+
const targetPath = findLeafPathByTab(inside, target);
|
|
99
|
+
if (!targetPath) {
|
|
100
|
+
return inside;
|
|
101
|
+
}
|
|
102
|
+
const splitKind = where === "left" || where === "right" ? "row" : "column";
|
|
103
|
+
const insertBefore = where === "left" || where === "top";
|
|
104
|
+
const next = cloneLayout(inside);
|
|
105
|
+
const targetLeaf = getNodeAtPath(next, targetPath);
|
|
106
|
+
const newLeaf = {
|
|
107
|
+
kind: "leaf",
|
|
108
|
+
tabs: [name],
|
|
109
|
+
tabIndex: 0,
|
|
110
|
+
size: 50,
|
|
111
|
+
};
|
|
112
|
+
if (targetPath.length === 0) {
|
|
113
|
+
const originalRoot = next;
|
|
114
|
+
const existingRoot = {
|
|
115
|
+
...originalRoot,
|
|
116
|
+
tabs: [...originalRoot.tabs],
|
|
117
|
+
size: 50,
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
kind: splitKind,
|
|
121
|
+
children: insertBefore ? [newLeaf, existingRoot] : [existingRoot, newLeaf],
|
|
122
|
+
size: originalRoot.size,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const parentPath = targetPath.slice(0, -1);
|
|
126
|
+
const targetIndex = targetPath[targetPath.length - 1];
|
|
127
|
+
const parent = getNodeAtPath(next, parentPath);
|
|
128
|
+
if (parent.kind === splitKind) {
|
|
129
|
+
const currentTarget = parent.children[targetIndex];
|
|
130
|
+
const insertedSize = currentTarget.size / 2;
|
|
131
|
+
currentTarget.size -= insertedSize;
|
|
132
|
+
const insertedLeaf = {
|
|
133
|
+
...newLeaf,
|
|
134
|
+
size: insertedSize,
|
|
135
|
+
};
|
|
136
|
+
parent.children.splice(insertBefore ? targetIndex : targetIndex + 1, 0, insertedLeaf);
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
const wrappedTarget = targetLeaf;
|
|
140
|
+
const wrappedTargetLeaf = {
|
|
141
|
+
...wrappedTarget,
|
|
142
|
+
tabs: [...wrappedTarget.tabs],
|
|
143
|
+
size: 50,
|
|
144
|
+
};
|
|
145
|
+
const wrappedContainer = {
|
|
146
|
+
kind: splitKind,
|
|
147
|
+
children: insertBefore ? [newLeaf, wrappedTargetLeaf] : [wrappedTargetLeaf, newLeaf],
|
|
148
|
+
size: wrappedTarget.size,
|
|
149
|
+
};
|
|
150
|
+
parent.children[targetIndex] = wrappedContainer;
|
|
151
|
+
return next;
|
|
152
|
+
};
|
|
153
|
+
export const insertPanel = (place, name, inside) => {
|
|
154
|
+
const existingTabs = getLayoutTabs(inside);
|
|
155
|
+
if (existingTabs.includes(name)) {
|
|
156
|
+
return inside;
|
|
157
|
+
}
|
|
158
|
+
if (existingTabs.length === 0) {
|
|
159
|
+
if (inside.kind === "leaf") {
|
|
160
|
+
return {
|
|
161
|
+
...inside,
|
|
162
|
+
tabs: [name],
|
|
163
|
+
tabIndex: 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
kind: "leaf",
|
|
168
|
+
tabs: [name],
|
|
169
|
+
tabIndex: 0,
|
|
170
|
+
size: inside.size,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const target = place && existingTabs.includes(place.of) ? place.of : existingTabs[0];
|
|
174
|
+
const where = place?.where || "after-tab";
|
|
175
|
+
if (where === "before-tab" || where === "after-tab" || where === "first-tab" || where === "last-tab") {
|
|
176
|
+
return insertTabRelative(inside, name, target, where);
|
|
177
|
+
}
|
|
178
|
+
return insertSplitRelative(inside, name, target, where);
|
|
179
|
+
};
|
|
35
180
|
/**
|
|
36
181
|
* Moves a panel within the layout based on a drop zone and panel name.
|
|
37
182
|
*
|
|
@@ -201,13 +346,15 @@ export const movePanel = (zone, name, inside) => {
|
|
|
201
346
|
export const filterPanels = (names, inside) => {
|
|
202
347
|
const editLayout = (visitedConfig) => {
|
|
203
348
|
let config = visitedConfig;
|
|
204
|
-
if (config.kind === "leaf" &&
|
|
349
|
+
if (config.kind === "leaf" &&
|
|
350
|
+
!config.tabs.every((name) => names.includes(name) || isAnchorTab(name))) {
|
|
205
351
|
/* If it's a simple leaf, try to remove the matching tab if it was
|
|
206
352
|
* the tab that was picked by the user (since we're moving it) */
|
|
353
|
+
const tabs = config.tabs.filter((name) => names.includes(name) || isAnchorTab(name));
|
|
207
354
|
config = {
|
|
208
355
|
...config,
|
|
209
|
-
tabs
|
|
210
|
-
tabIndex: Math.min(
|
|
356
|
+
tabs,
|
|
357
|
+
tabIndex: tabs.length ? Math.min(tabs.length - 1, config.tabIndex) : 0,
|
|
211
358
|
};
|
|
212
359
|
if (config.tabs.length === 0) {
|
|
213
360
|
config = null;
|
package/utils.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/utils.test.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { filterPanels, insertPanel } from "./utils";
|
|
2
|
+
describe("filterPanels", () => {
|
|
3
|
+
it("keeps anchors even when they are not part of rendered Panel children", () => {
|
|
4
|
+
const config = {
|
|
5
|
+
kind: "leaf",
|
|
6
|
+
tabs: ["@anchor-1", "Panel 1", "Panel 2"],
|
|
7
|
+
tabIndex: 2,
|
|
8
|
+
size: 100,
|
|
9
|
+
};
|
|
10
|
+
const filtered = filterPanels(["Panel 1"], config);
|
|
11
|
+
expect(filtered.kind).toBe("leaf");
|
|
12
|
+
expect(filtered.tabs).toEqual(["@anchor-1", "Panel 1"]);
|
|
13
|
+
expect(filtered.tabIndex).toBe(1);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("insertPanel", () => {
|
|
17
|
+
it("falls back to first tab when place.of is missing", () => {
|
|
18
|
+
const config = {
|
|
19
|
+
kind: "leaf",
|
|
20
|
+
tabs: ["Panel 1", "Panel 2"],
|
|
21
|
+
tabIndex: 0,
|
|
22
|
+
size: 100,
|
|
23
|
+
};
|
|
24
|
+
const updated = insertPanel({ where: "after-tab", of: "Missing" }, "Panel 3", config);
|
|
25
|
+
expect(updated.kind).toBe("leaf");
|
|
26
|
+
expect(updated.tabs).toEqual(["Panel 1", "Panel 3", "Panel 2"]);
|
|
27
|
+
});
|
|
28
|
+
it("inserts a new sibling leaf when splitting in the same container axis", () => {
|
|
29
|
+
const config = {
|
|
30
|
+
kind: "row",
|
|
31
|
+
size: 100,
|
|
32
|
+
children: [
|
|
33
|
+
{ kind: "leaf", tabs: ["Panel 1"], tabIndex: 0, size: 60 },
|
|
34
|
+
{ kind: "leaf", tabs: ["Panel 2"], tabIndex: 0, size: 40 },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const updated = insertPanel({ where: "right", of: "Panel 1" }, "Panel 3", config);
|
|
38
|
+
expect(updated.kind).toBe("row");
|
|
39
|
+
const tabs = updated.children.map((child) => child.tabs[0]);
|
|
40
|
+
expect(tabs).toEqual(["Panel 1", "Panel 3", "Panel 2"]);
|
|
41
|
+
expect(updated.children.map((child) => child.size)).toEqual([30, 30, 40]);
|
|
42
|
+
});
|
|
43
|
+
it("inserts at the start of the target tab list with first-tab", () => {
|
|
44
|
+
const config = {
|
|
45
|
+
kind: "leaf",
|
|
46
|
+
tabs: ["Panel 1", "Panel 2", "Panel 3"],
|
|
47
|
+
tabIndex: 1,
|
|
48
|
+
size: 100,
|
|
49
|
+
};
|
|
50
|
+
const updated = insertPanel({ where: "first-tab", of: "Panel 2" }, "Panel 4", config);
|
|
51
|
+
expect(updated.kind).toBe("leaf");
|
|
52
|
+
expect(updated.tabs).toEqual(["Panel 4", "Panel 1", "Panel 2", "Panel 3"]);
|
|
53
|
+
});
|
|
54
|
+
it("inserts at the end of the target tab list with last-tab", () => {
|
|
55
|
+
const config = {
|
|
56
|
+
kind: "leaf",
|
|
57
|
+
tabs: ["Panel 1", "Panel 2", "Panel 3"],
|
|
58
|
+
tabIndex: 1,
|
|
59
|
+
size: 100,
|
|
60
|
+
};
|
|
61
|
+
const updated = insertPanel({ where: "last-tab", of: "Panel 2" }, "Panel 4", config);
|
|
62
|
+
expect(updated.kind).toBe("leaf");
|
|
63
|
+
expect(updated.tabs).toEqual(["Panel 1", "Panel 2", "Panel 3", "Panel 4"]);
|
|
64
|
+
});
|
|
65
|
+
});
|