react-simple-dock 0.2.2 → 0.2.5

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![PyPI - pret-simple-dock](https://img.shields.io/pypi/v/pret-simple-dock?style=flat-square&color=blue)](https://pypi.org/project/pret-simple-dock/)
4
4
  [![npm - react-simple-dock](https://img.shields.io/npm/v/react-simple-dock?style=flat-square&color=blue)](https://www.npmjs.com/package/react-simple-dock)
5
- [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/percevalw/react-simple-dock/playwright.yml?style=flat-square)](https://github.com/percevalw/react-simple-dock/actions/workflows/playwright.yml)
5
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/percevalw/react-simple-dock/test.yml?style=flat-square)](https://github.com/percevalw/react-simple-dock/actions/workflows/test.yml)
6
6
 
7
7
  A set of React components to create a dockable interface, allowing to arrange and resize tabs.
8
8
 
@@ -69,6 +69,41 @@ const App = () => (
69
69
  ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
70
70
  ```
71
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
+
72
107
  ## Development
73
108
 
74
109
  ### Installation
@@ -111,7 +146,7 @@ pip install pret
111
146
  If you have changed the signature of the components, you will need to update the python stubs.
112
147
 
113
148
  ```bash
114
- pret stub . SimpleDock pret/ui/simple_dock/__init__.py
149
+ pret stub . SimpleDock pret_simple_dock/__init__.py
115
150
  ```
116
151
 
117
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
- bottom: calc(0px - var(--sd-grid-gap));
50
- left: 0;
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
- top: 0;
58
- bottom: 0;
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;
@@ -125,12 +122,23 @@ body[data-jp-theme-light] {
125
122
  overflow: auto;
126
123
  }
127
124
 
125
+ .leaf > div:nth-child(1) {
126
+ padding-right: 0px;
127
+ }
128
+
128
129
  /* TAB HEADER */
129
130
 
130
131
  .tab-header {
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.js CHANGED
@@ -1,8 +1,8 @@
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
3
  import { DndProvider, useDrag, useDrop, useDragDropManager } from "react-dnd";
4
4
  import "./index.css";
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
@@ -14,9 +14,13 @@ const isMobileDevice = () => {
14
14
  return /Mobi|Android|iPhone|iPad|iPod|Windows Phone|IEMobile|Opera Mini/i.test(navigator.userAgent || "");
15
15
  };
16
16
  const getPanelElementMaxHeaderHeight = (config, panelElements) => {
17
- // If we have a leaf, then the header height is the max of each tabs header height
17
+ // If we have a leaf, then the header height is the tab header height.
18
18
  if (config.kind === "leaf") {
19
- return Math.max(...config.tabs.map((tab) => panelElements.get(config).children[0].offsetHeight));
19
+ const element = panelElements.get(config);
20
+ if (!element) {
21
+ return 0;
22
+ }
23
+ return element.children[0]?.offsetHeight || 0;
20
24
  }
21
25
  // If we have a row, then the header height is the max of each child's max header height
22
26
  else if (config.kind === "row") {
@@ -47,7 +51,42 @@ function normalizeConfig(config, siblingsCount, depth = 0, default_kind = depth
47
51
  const children = container.children.map((child) => normalizeConfig(child, container.children.length, depth + 1, kind === "row" ? "column" : "row"));
48
52
  return { kind, children, size, nesting: container.nesting };
49
53
  }
50
- const TabHandle = ({ name, index, visible, onClick, children, }) => {
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, }) => {
51
90
  const getItem = () => ({
52
91
  name,
53
92
  handleElement: targetRef.current,
@@ -62,13 +101,15 @@ const TabHandle = ({ name, index, visible, onClick, children, }) => {
62
101
  return (_jsx("div", { className: `tab-handle tab-handle__${visible ? "visible" : "hidden"}`, ref: (element) => {
63
102
  dragRef(element);
64
103
  targetRef.current = element;
65
- }, onClick: () => onClick(index), children: children }));
104
+ }, onClick: () => onClick(name), children: children }));
66
105
  };
67
106
  const TabHeader = ({ config, onClick, leaves, }) => {
68
- return (_jsxs("div", { children: [_jsxs("div", { className: "tab-header", children: [config.tabs
69
- .map((tab, i) => [
70
- _jsx("div", { className: "tab-placeholder", style: { width: "0px" }, "data-panel-name": tab }, `${i}-placeholder`),
71
- _jsx(TabHandle, { name: tab, index: i, visible: config.tabIndex === i, onClick: onClick, children: leaves[tab].header || tab }, i),
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),
72
113
  ])
73
114
  .flat(), _jsx("div", { className: "tab-placeholder" })] }), _jsx("div", { className: "tab-header-border" }), _jsx("div", { className: "tab-header-bottom" })] }));
74
115
  };
@@ -76,6 +117,12 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
76
117
  const startEvent = useRef(null);
77
118
  const savedSizes = useRef([]);
78
119
  const forceUpdate = useForceUpdate();
120
+ const renderableChildren = config.kind === "leaf"
121
+ ? []
122
+ : config.children
123
+ .filter((child) => isRenderableConfig(child))
124
+ .map((child, childIndex) => ({ child, childIndex }));
125
+ console.log("RENDERABLE CHILDREN", renderableChildren);
79
126
  useEffect(() => {
80
127
  const handleMouseUp = (e) => {
81
128
  document.removeEventListener("mousemove", startEvent.current?.handleMouseMove);
@@ -105,17 +152,21 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
105
152
  const { clientX, clientY } = e;
106
153
  // get Panel top left corner
107
154
  const { top, left } = (startEvent.current?.target).parentElement.getBoundingClientRect();
155
+ const parent = (startEvent.current?.target).parentElement;
156
+ const containerWidth = parent.offsetWidth;
157
+ const containerHeight = parent.offsetHeight;
108
158
  let ratio;
109
159
  if (startEvent.current.side === "bottom") {
110
- ratio = (clientY - top) / startEvent.current.height;
160
+ ratio = containerHeight > 0 ? (clientY - top) / containerHeight : 0;
111
161
  }
112
162
  else if (startEvent.current.side === "right") {
113
- ratio = (clientX - left) / startEvent.current.width;
163
+ ratio = containerWidth > 0 ? (clientX - left) / containerWidth : 0;
114
164
  }
115
165
  else {
116
166
  return;
117
167
  }
118
- onResize && onResize(ratio, index, startEvent.current.target);
168
+ const boundedRatio = Math.max(0, Math.min(1, ratio));
169
+ onResize && onResize(boundedRatio, index, startEvent.current.target);
119
170
  }, [onResize, index]);
120
171
  const handleMouseDown = (e, side) => {
121
172
  if (startEvent.current) {
@@ -126,8 +177,6 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
126
177
  e.preventDefault();
127
178
  saveSizes && saveSizes();
128
179
  startEvent.current = {
129
- width: e.clientX - left,
130
- height: e.clientY - top,
131
180
  target: e.currentTarget,
132
181
  side: side,
133
182
  handleMouseMove,
@@ -138,53 +187,82 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
138
187
  if (config.kind === "row") {
139
188
  // return {gridTemplateColumns: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
140
189
  return {
141
- gridTemplateColumns: config.children.map((c) => `${c.size}fr`).join(" "),
190
+ gridTemplateColumns: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
142
191
  };
143
192
  }
144
193
  if (config.kind === "column") {
145
194
  // return {gridTemplateRows: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
146
195
  return {
147
- gridTemplateRows: config.children.map((c) => `${c.size}fr`).join(" "),
196
+ gridTemplateRows: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
148
197
  };
149
198
  }
150
199
  };
151
200
  const handleResize = useCallback((ratio, idx, target) => {
152
201
  if (config.kind === "leaf")
153
202
  return;
154
- let size = savedSizes.current[idx] * ratio;
155
- let nextSize = savedSizes.current[idx + 1] + (savedSizes.current[idx] - size);
203
+ const current = renderableChildren[idx];
204
+ const next = renderableChildren[idx + 1];
205
+ if (!current || !next) {
206
+ return;
207
+ }
156
208
  const total = savedSizes.current.reduce((a, b) => a + b, 0);
209
+ if (total <= 0) {
210
+ return;
211
+ }
212
+ const sizeBefore = savedSizes.current.slice(0, idx).reduce((a, b) => a + b, 0);
213
+ const pairTotal = savedSizes.current[idx] + savedSizes.current[idx + 1];
214
+ let size = ratio * total - sizeBefore;
215
+ size = Math.max(0, Math.min(pairTotal, size));
216
+ let nextSize = pairTotal - size;
157
217
  if (config.kind === "column") {
158
- const headerHeightBefore = getPanelElementMaxHeaderHeight(config.children[idx], panelElements);
159
- const headerHeightAfter = getPanelElementMaxHeaderHeight(config.children[idx + 1], panelElements);
218
+ const headerHeightBefore = getPanelElementMaxHeaderHeight(current.child, panelElements);
219
+ const headerHeightAfter = getPanelElementMaxHeaderHeight(next.child, panelElements);
160
220
  const parentHeight = panelContentRef.current.offsetHeight;
221
+ if (parentHeight <= 0) {
222
+ return;
223
+ }
161
224
  if ((size * parentHeight) / total < headerHeightBefore) {
162
225
  size = (headerHeightBefore / parentHeight) * total;
163
- nextSize = savedSizes.current[idx + 1] + (savedSizes.current[idx] - size);
226
+ size = Math.max(0, Math.min(pairTotal, size));
227
+ nextSize = pairTotal - size;
164
228
  }
165
229
  else if ((nextSize * parentHeight) / total < headerHeightAfter) {
166
- nextSize = (headerHeightAfter / parentHeight) * total;
167
- size = savedSizes.current[idx] + (savedSizes.current[idx + 1] - nextSize);
230
+ nextSize = Math.max(0, Math.min(pairTotal, (headerHeightAfter / parentHeight) * total));
231
+ size = pairTotal - nextSize;
168
232
  }
169
233
  }
170
- config.children[idx].size = size;
171
- config.children[idx + 1].size = nextSize;
234
+ current.child.size = size;
235
+ next.child.size = nextSize;
172
236
  Object.assign(panelContentRef.current.style, makeStyle());
173
- }, [config]);
237
+ }, [config, renderableChildren]);
174
238
  const handleSaveSizes = useCallback(() => {
175
239
  if (config.kind === "leaf")
176
240
  return;
177
- savedSizes.current = config.children.map((child) => child.size);
178
- }, [config]);
179
- const handleHeaderClick = (i) => {
241
+ savedSizes.current = renderableChildren.map(({ child }) => child.size);
242
+ }, [config, renderableChildren]);
243
+ const handleHeaderClick = (name) => {
180
244
  if (config.kind === "leaf") {
245
+ const tabIndex = config.tabs.findIndex((tab) => tab === name);
246
+ if (tabIndex === -1) {
247
+ return;
248
+ }
181
249
  forceUpdate();
182
- config.tabIndex = i;
250
+ config.tabIndex = tabIndex;
183
251
  }
184
252
  };
253
+ const activeTab = config.kind === "leaf"
254
+ ? (() => {
255
+ const selected = config.tabs[config.tabIndex];
256
+ if (selected && !isAnchorTab(selected) && leaves[selected]) {
257
+ return selected;
258
+ }
259
+ return getRenderableTabs(config.tabs).find((tab) => leaves[tab]) || null;
260
+ })()
261
+ : null;
262
+ const leafContent = activeTab ? (_jsx("div", { children: leaves[activeTab].element }, activeTab)) : (_jsx("div", { style: { width: "100%", height: "100%" } }));
185
263
  const panelContentRef = useRef(null);
186
264
  const panelRef = useRef(null);
187
- 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" ? (config.tabIndex < config.tabs.length ? (_jsx("div", { children: leaves[config.tabs[config.tabIndex]].element }, config.tabs[config.tabIndex])) : (_jsx("div", { style: { width: "100%", height: "100%" } }))) : (config.children.map((c, i) => (_jsx(NestedPanel, { config: c, leaves: leaves, saveSizes: handleSaveSizes, index: i, onResize: handleResize, isLast: i === config.children.length - 1, direction: config.kind, panelElements: panelElements }, i)))) }), !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") }))] }));
265
+ 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") }))] }));
188
266
  });
189
267
  const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
190
268
  const [, set] = useState();
@@ -245,7 +323,8 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
245
323
  zones.push({ rect, config, index, element });
246
324
  };
247
325
  if (config.kind === "leaf") {
248
- if (!(config.tabs.length == 1 && config.tabs[0] == name)) {
326
+ const renderableTabs = getRenderableTabs(config.tabs);
327
+ if (!(renderableTabs.length === 1 && renderableTabs[0] === name)) {
249
328
  pushZone("TOP", left, top, width, height / 2);
250
329
  pushZone("BOTTOM", left, top + height / 2, width, height / 2);
251
330
  pushZone("LEFT", left, top, width / 2, height);
@@ -260,24 +339,32 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
260
339
  pushZone("TAB", left, top, width, element.children[0].offsetHeight);
261
340
  }
262
341
  else {
263
- const firstTabs = config.children?.[0]?.tabs || [null];
264
- const lastTabs = config.children?.[config.children.length - 1]?.tabs || [null];
342
+ const renderableChildren = config.children.filter((child) => isRenderableConfig(child));
343
+ if (!renderableChildren.length) {
344
+ continue;
345
+ }
346
+ const firstChild = renderableChildren[0];
347
+ const lastChild = renderableChildren[renderableChildren.length - 1];
348
+ const firstTab = getFirstRenderableTab(firstChild);
349
+ const lastTab = getLastRenderableTab(lastChild);
350
+ const firstTabCount = getRenderableTabCount(firstChild);
351
+ const lastTabCount = getRenderableTabCount(lastChild);
265
352
  if (config.kind === "row" || config === rootConfig) {
266
- const zoneWidth = width / (config.kind === "row" ? config.children.length + 1 : 2);
353
+ const zoneWidth = width / (config.kind === "row" ? renderableChildren.length + 1 : 2);
267
354
  // check that the dragged item is not the last item in the row and a single tab
268
- if (config === rootConfig || lastTabs.length > 1 || lastTabs[0] !== name) {
355
+ if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
269
356
  pushZone("RIGHT", left + width - zoneWidth, top, zoneWidth, height);
270
357
  }
271
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
358
+ if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
272
359
  pushZone("LEFT", left, top, zoneWidth, height);
273
360
  }
274
361
  }
275
362
  if (config.kind === "column" || config === rootConfig) {
276
- const zoneHeight = height / (config.kind === "column" ? config.children.length + 1 : 2);
277
- if (config === rootConfig || lastTabs.length > 1 || lastTabs[0] !== name) {
363
+ const zoneHeight = height / (config.kind === "column" ? renderableChildren.length + 1 : 2);
364
+ if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
278
365
  pushZone("BOTTOM", left, top + height - zoneHeight, width, zoneHeight);
279
366
  }
280
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
367
+ if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
281
368
  pushZone("TOP", left, top, width, zoneHeight);
282
369
  }
283
370
  }
@@ -409,11 +496,19 @@ export const Panel = (props) => {
409
496
  */
410
497
  export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOnMobile = true, }) {
411
498
  const children_array = React.Children.toArray(children);
412
- const namedChildren = Object.fromEntries(children_array.map((c, i) => [
413
- c.props.name || (c.key !== null ? c.key.toString().slice(2) : `unnamed-${i}`),
499
+ const panelDefinitions = children_array.map((panel, i) => ({
500
+ name: getPanelName(panel, i),
501
+ header: panel.props.header,
502
+ element: panel.props.children,
503
+ place: panel.props.place,
504
+ }));
505
+ const panelNames = panelDefinitions.map((panel) => panel.name);
506
+ const placementByName = new Map(panelDefinitions.map((panel) => [panel.name, panel.place]));
507
+ const namedChildren = Object.fromEntries(panelDefinitions.map((panel) => [
508
+ panel.name,
414
509
  {
415
- element: c.props.children,
416
- header: c.props.header,
510
+ element: panel.element,
511
+ header: panel.header,
417
512
  },
418
513
  ]));
419
514
  const panelElements = useRef(new Map());
@@ -423,9 +518,9 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
423
518
  : {
424
519
  kind: "row",
425
520
  size: 1,
426
- children: children_array.map((c, i) => ({
521
+ children: panelDefinitions.map((panel) => ({
427
522
  kind: "leaf",
428
- tabs: [c.props.name || (c.key !== null ? c.key.toString().slice(2) : `unnamed-${i}`)],
523
+ tabs: [panel.name],
429
524
  tabIndex: 0,
430
525
  size: 100 / children_array.length,
431
526
  })),
@@ -433,7 +528,7 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
433
528
  if (collapseTabsOnMobile && isMobileDevice()) {
434
529
  // If collapseTabsOnMobile is a list, use the names in the list as the first tabs
435
530
  // then complete with the rest of the named children
436
- const actualTabs = Object.keys(namedChildren);
531
+ const actualTabs = panelNames;
437
532
  const tabs = [
438
533
  ...(Array.isArray(collapseTabsOnMobile)
439
534
  ? collapseTabsOnMobile.filter((name) => actualTabs.includes(name))
@@ -451,18 +546,26 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
451
546
  return base;
452
547
  });
453
548
  let config = rootConfig;
454
- if (rootConfig.kind !== "leaf" || rootConfig.tabs.length > 0) {
455
- const newConfig = filterPanels(Object.keys(namedChildren), rootConfig);
456
- if (newConfig !== rootConfig) {
457
- config = newConfig || { kind: "leaf", tabs: [], tabIndex: 0, size: 100 };
458
- setRootConfig(config);
459
- }
549
+ let nextConfig = rootConfig;
550
+ if (nextConfig.kind !== "leaf" || nextConfig.tabs.length > 0) {
551
+ nextConfig = filterPanels(panelNames, nextConfig) || { kind: "leaf", tabs: [], tabIndex: 0, size: 100 };
552
+ }
553
+ const tabs = getLayoutTabs(nextConfig);
554
+ const missingPanels = panelNames.filter((name) => !tabs.includes(name));
555
+ if (missingPanels.length > 0) {
556
+ missingPanels.forEach((name) => {
557
+ nextConfig = insertPanel(placementByName.get(name), name, nextConfig);
558
+ });
559
+ }
560
+ if (nextConfig !== rootConfig) {
561
+ config = nextConfig;
562
+ setRootConfig(nextConfig);
460
563
  }
461
564
  const handleDrop = (zone, name) => {
462
565
  const newConfig = movePanel(zone, name, rootConfig);
463
566
  setRootConfig(newConfig);
464
567
  };
465
- const container = (_jsxs("div", { className: "container", children: [_jsx(NestedPanel, { leaves: namedChildren, config: config, panelElements: panelElements.current }), _jsx(Overlay, { panelElements: panelElements, onDrop: handleDrop, rootConfig: config })] }));
568
+ 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 })] }));
466
569
  if (wrapDnd) {
467
570
  return _jsx(DndProvider, { backend: HTML5Backend, children: container });
468
571
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-simple-dock",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "main": "index.js",
5
5
  "description": "Simple dock component for React",
6
6
  "repository": "https://github.com/percevalw/react-simple-dock",
package/types.d.ts CHANGED
@@ -45,6 +45,10 @@ export type DefaultContainerConfig = {
45
45
  nesting?: number;
46
46
  };
47
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
+ };
48
52
  /**
49
53
  * Properties for a panel component.
50
54
  *
@@ -54,6 +58,7 @@ export type PanelProps = {
54
58
  children: React.ReactNode;
55
59
  name?: string;
56
60
  header?: React.ReactNode;
61
+ place?: PanelPlacement;
57
62
  };
58
63
  /**
59
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" && !config.tabs.every((name) => names.includes(name))) {
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: config.tabs.filter((name) => names.includes(name)),
210
- tabIndex: Math.min(config.tabs.length - 1, config.tabIndex),
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;
@@ -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
+ });