react-simple-dock 0.2.2 → 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 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,11 @@ 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
+ .map((child, childIndex) => ({ child, childIndex }))
124
+ .filter(({ child }) => isRenderableConfig(child));
79
125
  useEffect(() => {
80
126
  const handleMouseUp = (e) => {
81
127
  document.removeEventListener("mousemove", startEvent.current?.handleMouseMove);
@@ -105,17 +151,21 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
105
151
  const { clientX, clientY } = e;
106
152
  // get Panel top left corner
107
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;
108
157
  let ratio;
109
158
  if (startEvent.current.side === "bottom") {
110
- ratio = (clientY - top) / startEvent.current.height;
159
+ ratio = containerHeight > 0 ? (clientY - top) / containerHeight : 0;
111
160
  }
112
161
  else if (startEvent.current.side === "right") {
113
- ratio = (clientX - left) / startEvent.current.width;
162
+ ratio = containerWidth > 0 ? (clientX - left) / containerWidth : 0;
114
163
  }
115
164
  else {
116
165
  return;
117
166
  }
118
- onResize && onResize(ratio, index, startEvent.current.target);
167
+ const boundedRatio = Math.max(0, Math.min(1, ratio));
168
+ onResize && onResize(boundedRatio, index, startEvent.current.target);
119
169
  }, [onResize, index]);
120
170
  const handleMouseDown = (e, side) => {
121
171
  if (startEvent.current) {
@@ -126,8 +176,6 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
126
176
  e.preventDefault();
127
177
  saveSizes && saveSizes();
128
178
  startEvent.current = {
129
- width: e.clientX - left,
130
- height: e.clientY - top,
131
179
  target: e.currentTarget,
132
180
  side: side,
133
181
  handleMouseMove,
@@ -138,53 +186,82 @@ const NestedPanel = React.memo(({ leaves, config, index, onResize, saveSizes, is
138
186
  if (config.kind === "row") {
139
187
  // return {gridTemplateColumns: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
140
188
  return {
141
- gridTemplateColumns: config.children.map((c) => `${c.size}fr`).join(" "),
189
+ gridTemplateColumns: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
142
190
  };
143
191
  }
144
192
  if (config.kind === "column") {
145
193
  // return {gridTemplateRows: config.children.map(c => `calc((${100}% - var(--grid-gap) * ${config.children.length - 1}) * ${c.size / 100})`).join(" ")};
146
194
  return {
147
- gridTemplateRows: config.children.map((c) => `${c.size}fr`).join(" "),
195
+ gridTemplateRows: renderableChildren.map(({ child, childIndex }) => (childIndex == 0 ? `${child.size}fr` : `7px ${child.size}fr`)).join(" "),
148
196
  };
149
197
  }
150
198
  };
151
199
  const handleResize = useCallback((ratio, idx, target) => {
152
200
  if (config.kind === "leaf")
153
201
  return;
154
- let size = savedSizes.current[idx] * ratio;
155
- let nextSize = savedSizes.current[idx + 1] + (savedSizes.current[idx] - size);
202
+ const current = renderableChildren[idx];
203
+ const next = renderableChildren[idx + 1];
204
+ if (!current || !next) {
205
+ return;
206
+ }
156
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;
157
216
  if (config.kind === "column") {
158
- const headerHeightBefore = getPanelElementMaxHeaderHeight(config.children[idx], panelElements);
159
- const headerHeightAfter = getPanelElementMaxHeaderHeight(config.children[idx + 1], panelElements);
217
+ const headerHeightBefore = getPanelElementMaxHeaderHeight(current.child, panelElements);
218
+ const headerHeightAfter = getPanelElementMaxHeaderHeight(next.child, panelElements);
160
219
  const parentHeight = panelContentRef.current.offsetHeight;
220
+ if (parentHeight <= 0) {
221
+ return;
222
+ }
161
223
  if ((size * parentHeight) / total < headerHeightBefore) {
162
224
  size = (headerHeightBefore / parentHeight) * total;
163
- nextSize = savedSizes.current[idx + 1] + (savedSizes.current[idx] - size);
225
+ size = Math.max(0, Math.min(pairTotal, size));
226
+ nextSize = pairTotal - size;
164
227
  }
165
228
  else if ((nextSize * parentHeight) / total < headerHeightAfter) {
166
- nextSize = (headerHeightAfter / parentHeight) * total;
167
- size = savedSizes.current[idx] + (savedSizes.current[idx + 1] - nextSize);
229
+ nextSize = Math.max(0, Math.min(pairTotal, (headerHeightAfter / parentHeight) * total));
230
+ size = pairTotal - nextSize;
168
231
  }
169
232
  }
170
- config.children[idx].size = size;
171
- config.children[idx + 1].size = nextSize;
233
+ current.child.size = size;
234
+ next.child.size = nextSize;
172
235
  Object.assign(panelContentRef.current.style, makeStyle());
173
- }, [config]);
236
+ }, [config, renderableChildren]);
174
237
  const handleSaveSizes = useCallback(() => {
175
238
  if (config.kind === "leaf")
176
239
  return;
177
- savedSizes.current = config.children.map((child) => child.size);
178
- }, [config]);
179
- const handleHeaderClick = (i) => {
240
+ savedSizes.current = renderableChildren.map(({ child }) => child.size);
241
+ }, [config, renderableChildren]);
242
+ const handleHeaderClick = (name) => {
180
243
  if (config.kind === "leaf") {
244
+ const tabIndex = config.tabs.findIndex((tab) => tab === name);
245
+ if (tabIndex === -1) {
246
+ return;
247
+ }
181
248
  forceUpdate();
182
- config.tabIndex = i;
249
+ config.tabIndex = tabIndex;
183
250
  }
184
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%" } }));
185
262
  const panelContentRef = useRef(null);
186
263
  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") }))] }));
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") }))] }));
188
265
  });
189
266
  const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
190
267
  const [, set] = useState();
@@ -245,7 +322,8 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
245
322
  zones.push({ rect, config, index, element });
246
323
  };
247
324
  if (config.kind === "leaf") {
248
- if (!(config.tabs.length == 1 && config.tabs[0] == name)) {
325
+ const renderableTabs = getRenderableTabs(config.tabs);
326
+ if (!(renderableTabs.length === 1 && renderableTabs[0] === name)) {
249
327
  pushZone("TOP", left, top, width, height / 2);
250
328
  pushZone("BOTTOM", left, top + height / 2, width, height / 2);
251
329
  pushZone("LEFT", left, top, width / 2, height);
@@ -260,24 +338,32 @@ const Overlay = ({ panelElements, onDrop, rootConfig, }) => {
260
338
  pushZone("TAB", left, top, width, element.children[0].offsetHeight);
261
339
  }
262
340
  else {
263
- const firstTabs = config.children?.[0]?.tabs || [null];
264
- const lastTabs = config.children?.[config.children.length - 1]?.tabs || [null];
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);
265
351
  if (config.kind === "row" || config === rootConfig) {
266
- const zoneWidth = width / (config.kind === "row" ? config.children.length + 1 : 2);
352
+ const zoneWidth = width / (config.kind === "row" ? renderableChildren.length + 1 : 2);
267
353
  // 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) {
354
+ if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
269
355
  pushZone("RIGHT", left + width - zoneWidth, top, zoneWidth, height);
270
356
  }
271
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
357
+ if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
272
358
  pushZone("LEFT", left, top, zoneWidth, height);
273
359
  }
274
360
  }
275
361
  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) {
362
+ const zoneHeight = height / (config.kind === "column" ? renderableChildren.length + 1 : 2);
363
+ if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
278
364
  pushZone("BOTTOM", left, top + height - zoneHeight, width, zoneHeight);
279
365
  }
280
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
366
+ if (config === rootConfig || firstTabCount > 1 || firstTab !== name) {
281
367
  pushZone("TOP", left, top, width, zoneHeight);
282
368
  }
283
369
  }
@@ -409,11 +495,19 @@ export const Panel = (props) => {
409
495
  */
410
496
  export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOnMobile = true, }) {
411
497
  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}`),
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,
414
508
  {
415
- element: c.props.children,
416
- header: c.props.header,
509
+ element: panel.element,
510
+ header: panel.header,
417
511
  },
418
512
  ]));
419
513
  const panelElements = useRef(new Map());
@@ -423,9 +517,9 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
423
517
  : {
424
518
  kind: "row",
425
519
  size: 1,
426
- children: children_array.map((c, i) => ({
520
+ children: panelDefinitions.map((panel) => ({
427
521
  kind: "leaf",
428
- tabs: [c.props.name || (c.key !== null ? c.key.toString().slice(2) : `unnamed-${i}`)],
522
+ tabs: [panel.name],
429
523
  tabIndex: 0,
430
524
  size: 100 / children_array.length,
431
525
  })),
@@ -433,7 +527,7 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
433
527
  if (collapseTabsOnMobile && isMobileDevice()) {
434
528
  // If collapseTabsOnMobile is a list, use the names in the list as the first tabs
435
529
  // then complete with the rest of the named children
436
- const actualTabs = Object.keys(namedChildren);
530
+ const actualTabs = panelNames;
437
531
  const tabs = [
438
532
  ...(Array.isArray(collapseTabsOnMobile)
439
533
  ? collapseTabsOnMobile.filter((name) => actualTabs.includes(name))
@@ -451,18 +545,26 @@ export function Layout({ children, defaultConfig, wrapDnd = true, collapseTabsOn
451
545
  return base;
452
546
  });
453
547
  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
- }
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);
460
562
  }
461
563
  const handleDrop = (zone, name) => {
462
564
  const newConfig = movePanel(zone, name, rootConfig);
463
565
  setRootConfig(newConfig);
464
566
  };
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 })] }));
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 })] }));
466
568
  if (wrapDnd) {
467
569
  return _jsx(DndProvider, { backend: HTML5Backend, children: container });
468
570
  }
@@ -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.4",
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
+ });