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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # React-Simple-Dock
2
2
 
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
+ [![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/test.yml?style=flat-square)](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 pret/ui/simple_dock/__init__.py
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
- 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;
@@ -122,7 +119,11 @@ body[data-jp-theme-light] {
122
119
  left: 0;
123
120
  width: 100%;
124
121
  height: 100%;
125
- overflow: scroll;
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 { LayoutConfig, PanelProps } from "./types";
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?: LayoutConfig;
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 { DndProvider, useDrag, useDragDropManager, useDrop } from "react-dnd";
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 max of each tabs header height
17
+ // If we have a leaf, then the header height is the tab header height.
13
18
  if (config.kind === "leaf") {
14
- 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;
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
- const TabHandle = ({ name, index, visible, onClick, children, }) => {
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(index), children: children }));
104
+ }, onClick: () => onClick(name), children: children }));
41
105
  };
42
106
  const TabHeader = ({ config, onClick, leaves, }) => {
43
- return (_jsxs("div", { children: [_jsxs("div", { className: "tab-header", children: [config.tabs
44
- .map((tab, i) => [
45
- _jsx("div", { className: "tab-placeholder", style: { width: "0px" }, "data-panel-name": tab }, `${i}-placeholder`),
46
- _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),
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) / startEvent.current.height;
159
+ ratio = containerHeight > 0 ? (clientY - top) / containerHeight : 0;
86
160
  }
87
161
  else if (startEvent.current.side === "right") {
88
- ratio = (clientX - left) / startEvent.current.width;
162
+ ratio = containerWidth > 0 ? (clientX - left) / containerWidth : 0;
89
163
  }
90
164
  else {
91
165
  return;
92
166
  }
93
- 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);
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: config.children.map((c) => `${c.size}fr`).join(" "),
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: config.children.map((c) => `${c.size}fr`).join(" "),
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
- let size = savedSizes.current[idx] * ratio;
130
- 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
+ }
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(config.children[idx], panelElements);
134
- const headerHeightAfter = getPanelElementMaxHeaderHeight(config.children[idx + 1], panelElements);
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
- nextSize = savedSizes.current[idx + 1] + (savedSizes.current[idx] - size);
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 = savedSizes.current[idx] + (savedSizes.current[idx + 1] - nextSize);
229
+ nextSize = Math.max(0, Math.min(pairTotal, (headerHeightAfter / parentHeight) * total));
230
+ size = pairTotal - nextSize;
143
231
  }
144
232
  }
145
- config.children[idx].size = size;
146
- config.children[idx + 1].size = nextSize;
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 = config.children.map((child) => child.size);
153
- }, [config]);
154
- const handleHeaderClick = (i) => {
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 = i;
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" ? (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") }))] }));
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
- if (!(config.tabs.length == 1 && config.tabs[0] == name)) {
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 firstTabs = config.children?.[0]?.tabs || [null];
238
- 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);
239
351
  if (config.kind === "row" || config === rootConfig) {
240
- const zoneWidth = width / (config.kind === "row" ? config.children.length + 1 : 2);
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 || lastTabs.length > 1 || lastTabs[0] !== name) {
354
+ if (config === rootConfig || lastTabCount > 1 || lastTab !== name) {
243
355
  pushZone("RIGHT", left + width - zoneWidth, top, zoneWidth, height);
244
356
  }
245
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
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" ? config.children.length + 1 : 2);
251
- 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) {
252
364
  pushZone("BOTTOM", left, top + height - zoneHeight, width, zoneHeight);
253
365
  }
254
- if (config === rootConfig || firstTabs.length > 1 || firstTabs[0] !== name) {
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 namedChildren = Object.fromEntries(children_array.map((c, i) => [
386
- 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,
387
508
  {
388
- element: c.props.children,
389
- header: c.props.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(defaultConfig || {
394
- kind: "row",
395
- size: 1,
396
- children: children_array.map((c, i) => ({
397
- kind: "leaf",
398
- tabs: [c.props.name || (c.key !== null ? c.key.toString().slice(2) : `unnamed-${i}`)],
399
- tabIndex: 0,
400
- size: 100 / children_array.length,
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
- if (rootConfig.kind !== "leaf" || rootConfig.tabs.length > 0) {
405
- const newConfig = filterPanels(Object.keys(namedChildren), rootConfig);
406
- if (newConfig !== rootConfig) {
407
- config = newConfig || { kind: "leaf", tabs: [], tabIndex: 0, size: 100 };
408
- setRootConfig(config);
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
  }
@@ -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.1.4",
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
@@ -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" && !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
+ });