@dashfoo/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +315 -0
- package/dist/index.d.ts +1383 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Filho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# @dashfoo/core
|
|
2
|
+
|
|
3
|
+
The framework-free engine behind dashfoo's docking layout (FlexLayout / VS-Code-style
|
|
4
|
+
tiled, resizable, tabbed regions). Pure TypeScript: a zod schema for the model, a
|
|
5
|
+
pure reducer, self-healing invariants, drop geometry, an undo/redo history, JSON
|
|
6
|
+
serialization with validation, and two XState v5 machines.
|
|
7
|
+
|
|
8
|
+
This package has **no React**. It depends only on `zod` and `xstate`. The React
|
|
9
|
+
bindings, the react-resizable-panels and @dnd-kit adapters, and all rendering live
|
|
10
|
+
in `@dashfoo/react`. You can drive this engine from any runtime (a worker, a test,
|
|
11
|
+
a non-React UI) by dispatching actions and reading back the next model.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @dashfoo/core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## The model
|
|
20
|
+
|
|
21
|
+
A layout is one `Dashfoo` value. It is JSON-serializable by construction: per-tab
|
|
22
|
+
`config` is validated against a recursive JSON schema, so a function or symbol
|
|
23
|
+
slipped into `config` fails parsing instead of corrupting a saved layout.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
type Dashfoo = {
|
|
27
|
+
version: number;
|
|
28
|
+
global: GlobalAttributes;
|
|
29
|
+
layout: RowNode; // the root is always a row
|
|
30
|
+
activeTabsetId?: string;
|
|
31
|
+
maximizedTabsetId?: string;
|
|
32
|
+
};
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The tree has three node kinds, each discriminated by `type`:
|
|
36
|
+
|
|
37
|
+
| Node | `type` | Holds |
|
|
38
|
+
| ------------ | ---------- | ----------------------------------------------------------------------- |
|
|
39
|
+
| `RowNode` | `"row"` | `children` (rows or tabsets), `orientation` (`row`/`column`), `weight?` |
|
|
40
|
+
| `TabsetNode` | `"tabset"` | `children` (tabs), `selected` index, optional `min`/`max`/`weight` |
|
|
41
|
+
| `TabNode` | `"tab"` | `component`, `name`, `id`, optional `config` + `enable*` flags |
|
|
42
|
+
|
|
43
|
+
Rows nest (a row's child can be another row), which is how arbitrary tiled splits
|
|
44
|
+
are represented. `min`/`max` are `Dimension` values (`{ unit, value }`) where
|
|
45
|
+
`unit` is one of `px`, `%`, `em`, `rem`, `vh`, `vw`. `global.tabSetMinSize` is
|
|
46
|
+
the default tabset minimum size in pixels for renderers that honor it; the React
|
|
47
|
+
adapter falls back to `320px` when it is omitted.
|
|
48
|
+
|
|
49
|
+
Every schema is exported as a zod object plus an inferred type, so untrusted input
|
|
50
|
+
can be validated before it reaches the reducer:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { dashfooSchema, type Dashfoo } from "@dashfoo/core";
|
|
54
|
+
|
|
55
|
+
const model: Dashfoo = dashfooSchema.parse(untrustedJson);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Builders
|
|
59
|
+
|
|
60
|
+
`model` / `row` / `tabset` / `tab` construct a valid model without the
|
|
61
|
+
`type`/`version`/`selected` boilerplate:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { model, row, tabset, tab } from "@dashfoo/core";
|
|
65
|
+
|
|
66
|
+
const m = model(
|
|
67
|
+
row([
|
|
68
|
+
tabset([tab("chart", "Chart"), tab("depth", "Depth")], { id: "left", weight: 2 }),
|
|
69
|
+
tabset([tab("book", "Order Book")], { id: "right" }),
|
|
70
|
+
]),
|
|
71
|
+
{ activeTabsetId: "left" },
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A `tab`'s id defaults to its component name; `tabset`/`row` auto-generate an id
|
|
76
|
+
when omitted. Output is schema-valid by construction.
|
|
77
|
+
|
|
78
|
+
## The pure reducer
|
|
79
|
+
|
|
80
|
+
`reducer(model, action)` is the canonical engine. It deep-copies the model with
|
|
81
|
+
`structuredClone` (so the input is never mutated, no Immer), applies one action
|
|
82
|
+
to the copy, then runs `normalize` so the result is always valid and canonical.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const reducer: (model: Dashfoo, action: Action) => Dashfoo;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Every mutation is one immutable, discriminated `Action`. The reducer is exhaustive
|
|
89
|
+
over the union; an unhandled case throws at runtime via `assertNever`. Validate
|
|
90
|
+
untrusted payloads against `actionSchema` before dispatch.
|
|
91
|
+
|
|
92
|
+
| `action.type` | Effect |
|
|
93
|
+
| ------------------------ | ---------------------------------------------------------------- |
|
|
94
|
+
| `addNode` | Insert a tab at a `DockLocation` (center / split-\*) |
|
|
95
|
+
| `moveNode` | Remove a tab by `sourceId`, re-insert it at a dock target |
|
|
96
|
+
| `moveTabset` | Remove a tabset by `sourceId`, re-dock it whole at a dock target |
|
|
97
|
+
| `selectTab` | Set a tabset's `selected` index |
|
|
98
|
+
| `setActiveTabset` | Mark the focused tabset |
|
|
99
|
+
| `setMaximizedTabset` | Maximize one tabset (or clear with `null`) |
|
|
100
|
+
| `renameTab` | Change a tab's `name` |
|
|
101
|
+
| `deleteTab` | Remove a tab |
|
|
102
|
+
| `deleteTabset` | Remove a whole tabset |
|
|
103
|
+
| `adjustSplit` | Set the `weights` of a row's children (splitter drag) |
|
|
104
|
+
| `updateNodeAttributes` | Patch mutable attrs on a tab / tabset / row |
|
|
105
|
+
| `updateGlobalAttributes` | Patch the `global` block |
|
|
106
|
+
|
|
107
|
+
The `DockLocation` union is `center` and
|
|
108
|
+
`split-top`/`split-bottom`/`split-left`/`split-right`. A `center` drop
|
|
109
|
+
stacks the tab into the target tabset. A `split-*` drop creates a new tabset beside
|
|
110
|
+
the target, reusing the parent row when its orientation already matches, otherwise
|
|
111
|
+
wrapping both in a fresh row.
|
|
112
|
+
|
|
113
|
+
### Use the reducer directly
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { reducer, parseModel, type Action, type Dashfoo } from "@dashfoo/core";
|
|
117
|
+
|
|
118
|
+
const model: Dashfoo = parseModel({
|
|
119
|
+
version: 1,
|
|
120
|
+
global: {},
|
|
121
|
+
layout: {
|
|
122
|
+
type: "row",
|
|
123
|
+
id: "root",
|
|
124
|
+
orientation: "row",
|
|
125
|
+
children: [
|
|
126
|
+
{
|
|
127
|
+
type: "tabset",
|
|
128
|
+
id: "ts-1",
|
|
129
|
+
selected: 0,
|
|
130
|
+
children: [{ type: "tab", id: "tab-1", component: "editor", name: "README.md" }],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const stack: Action = {
|
|
137
|
+
type: "addNode",
|
|
138
|
+
targetId: "ts-1",
|
|
139
|
+
location: "center",
|
|
140
|
+
tab: { type: "tab", id: "tab-2", component: "editor", name: "index.ts" },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const next = reducer(model, stack);
|
|
144
|
+
// next.layout.children[0].children.length === 2; input `model` is untouched.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## normalize + invariants
|
|
148
|
+
|
|
149
|
+
`normalize(model)` is the self-healing pass run after every action. It keeps the
|
|
150
|
+
tree canonical so downstream code never has to defend against degenerate shapes:
|
|
151
|
+
|
|
152
|
+
- drops empty tabsets and empty rows
|
|
153
|
+
- simplifies a single-child row by lifting its lone child (which inherits the
|
|
154
|
+
lifted row's weight, so sizing is preserved)
|
|
155
|
+
- absorbs a root that reduces to a single nested row
|
|
156
|
+
- clamps every `selected` index into range
|
|
157
|
+
- forces `activeTabsetId` / `maximizedTabsetId` to point at a tabset that exists
|
|
158
|
+
(falling back to the first tabset, or clearing)
|
|
159
|
+
|
|
160
|
+
`normalize` is exported on its own if you build a model by hand and want it
|
|
161
|
+
canonicalized without dispatching an action.
|
|
162
|
+
|
|
163
|
+
## Tree helpers
|
|
164
|
+
|
|
165
|
+
Read-only lookups over a model, all exported:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
collectTabsets(model): Array<TabsetNode>; // depth-first, layout only
|
|
169
|
+
getFirstTabset(model): TabsetNode | undefined;
|
|
170
|
+
findTabset(model, tabsetId): TabsetNode | undefined;
|
|
171
|
+
findTab(model, tabId): TabLocation | undefined; // searches tabsets
|
|
172
|
+
findRow(row, rowId): RowNode | undefined; // pass model.layout as the root
|
|
173
|
+
findAttributedNode(model, id): AttributedNode | undefined; // row, tabset, or tab
|
|
174
|
+
findTabsetParent(row, tabsetId): { index: number; parent: RowNode } | undefined;
|
|
175
|
+
findDuplicateIds(model): Array<string>; // ids used more than once
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`findTab` returns `{ container, index, tab }` so a caller knows where the tab
|
|
179
|
+
lives (a tabset in the layout).
|
|
180
|
+
|
|
181
|
+
## Geometry
|
|
182
|
+
|
|
183
|
+
Pure functions translate a pointer position into a drop intent and back into an
|
|
184
|
+
indicator rect. The @dnd-kit adapter in `@dashfoo/react` feeds them rects; you can
|
|
185
|
+
call them directly for custom drag logic.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
resolveDockTarget(pointer, rect, opts?): DockTarget;
|
|
189
|
+
zoneRect(rect, location): Rect;
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`resolveDockTarget` decides where a drag over a tabset should land: `{ kind: "tab" }`
|
|
193
|
+
when the pointer is in the interior, or `{ kind: "split", edge }` when it is within
|
|
194
|
+
an outer band of one of the four edges (default 22%; the closer edge wins in
|
|
195
|
+
corners). It accepts `{ bandFraction }` to tune the band. `zoneRect` returns the
|
|
196
|
+
region the dock indicator highlights for a `DockLocation`: the whole tabset for a
|
|
197
|
+
`center` stack, the matching half for a split. `Point` and `Rect` are exported.
|
|
198
|
+
|
|
199
|
+
## History (undo / redo)
|
|
200
|
+
|
|
201
|
+
A small `past` / `present` / `future` structure that wraps the reducer. `present`
|
|
202
|
+
is the live model.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { createHistory, dispatch, undo, redo, canUndo, canRedo } from "@dashfoo/core";
|
|
206
|
+
|
|
207
|
+
let history = createHistory(model);
|
|
208
|
+
history = dispatch(history, { type: "deleteTab", tabId: "tab-2" });
|
|
209
|
+
|
|
210
|
+
if (canUndo(history)) history = undo(history);
|
|
211
|
+
if (canRedo(history)) history = redo(history);
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Every dispatched action is its own undo step; there is no coalescing. A splitter
|
|
215
|
+
drag still lands as one step because react-resizable-panels v4 commits a single
|
|
216
|
+
`adjustSplit` when the drag is released, not a per-frame stream. Any new dispatch
|
|
217
|
+
clears the redo `future`.
|
|
218
|
+
|
|
219
|
+
## Serialize
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
toJSON(model): string; // JSON.stringify
|
|
223
|
+
fromJSON(json): Dashfoo; // parse → validate → normalize
|
|
224
|
+
parseModel(value): Dashfoo; // same, from an already-parsed value
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`fromJSON` and `parseModel` validate an untrusted value against `dashfooSchema`
|
|
228
|
+
and return a normalized model. They throw on an invalid value. The payload's
|
|
229
|
+
`version` field is pinned to `1` by the schema itself, so a payload written in
|
|
230
|
+
any other format fails validation; a future format change bumps the literal.
|
|
231
|
+
|
|
232
|
+
## XState machines
|
|
233
|
+
|
|
234
|
+
Two XState v5 machines model the runtime. `@dashfoo/react` wires them to React, but
|
|
235
|
+
they are framework-free and usable on their own.
|
|
236
|
+
|
|
237
|
+
### dashfooMachine
|
|
238
|
+
|
|
239
|
+
The document actor. It owns the undo/redo `History` (whose `present` is the live
|
|
240
|
+
model) and processes mutations through the history helpers. There is no lifecycle,
|
|
241
|
+
the document is data, so it has a single `ready` state and handles four events:
|
|
242
|
+
|
|
243
|
+
| Event | Payload | Effect |
|
|
244
|
+
| ----------- | ------------ | ------------------------------ |
|
|
245
|
+
| `DISPATCH` | `{ action }` | run the reducer via `dispatch` |
|
|
246
|
+
| `UNDO` | — | step back |
|
|
247
|
+
| `REDO` | — | step forward |
|
|
248
|
+
| `SET_MODEL` | `{ model }` | replace with a fresh history |
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import { createActor } from "xstate";
|
|
252
|
+
import { dashfooMachine } from "@dashfoo/core";
|
|
253
|
+
|
|
254
|
+
const actor = createActor(dashfooMachine, { input: { model } }).start();
|
|
255
|
+
actor.send({ type: "DISPATCH", action: { type: "deleteTab", tabId: "tab-2" } });
|
|
256
|
+
const current = actor.getSnapshot().context.history.present;
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### dragDockMachine
|
|
260
|
+
|
|
261
|
+
The drag/dock interaction lifecycle (`idle` → `dragging` → `idle`), driven by
|
|
262
|
+
abstract events the dnd-kit adapter maps from pointer and keyboard input. It owns
|
|
263
|
+
transient drag state only and never touches the document. On a valid `DROP` it
|
|
264
|
+
**emits** a `COMMIT` carrying a `moveNode` action (the drag subject is a tab) or a
|
|
265
|
+
`moveTabset` action (the subject is a whole tabset, dragged by its grip), which
|
|
266
|
+
the React layer forwards to `dashfooMachine`.
|
|
267
|
+
|
|
268
|
+
| Event | Payload |
|
|
269
|
+
| -------- | -------------------- |
|
|
270
|
+
| `START` | `{ subject }` |
|
|
271
|
+
| `OVER` | `{ intent \| null }` |
|
|
272
|
+
| `DROP` | — |
|
|
273
|
+
| `CANCEL` | — |
|
|
274
|
+
|
|
275
|
+
## Public exports
|
|
276
|
+
|
|
277
|
+
`schema` — `dashfooSchema`, `rowNodeSchema`, `tabsetNodeSchema`, `tabNodeSchema`,
|
|
278
|
+
`dimensionSchema`, `edgeSchema`, `unitSchema`,
|
|
279
|
+
`orientationSchema`, `globalAttributesSchema`,
|
|
280
|
+
`jsonValueSchema`; types `Dashfoo`, `RowNode`, `TabsetNode`, `TabNode`,
|
|
281
|
+
`Dimension`, `Edge`, `Unit`, `Orientation`,
|
|
282
|
+
`GlobalAttributes`, `Node`, `Json`.
|
|
283
|
+
|
|
284
|
+
`builders` — `model`, `row`, `tabset`, `tab`; option types `ModelOptions`,
|
|
285
|
+
`RowOptions`, `TabsetOptions`, `TabOptions`.
|
|
286
|
+
|
|
287
|
+
`ids` — `createNodeId`, `createTabId`.
|
|
288
|
+
|
|
289
|
+
`actions` — `actionSchema`, `dockLocationSchema`, `mutableNodeAttrsSchema`; types
|
|
290
|
+
`Action`, `DockLocation`, `DropIntent`, `MutableNodeAttrs`.
|
|
291
|
+
|
|
292
|
+
`reducer` — `reducer`. `invariants` — `normalize`.
|
|
293
|
+
|
|
294
|
+
`tree` — `collectTabsets`, `getFirstTabset`, `findTabset`, `findTab`, `findRow`,
|
|
295
|
+
`findAttributedNode`, `findTabsetParent`, `findDuplicateIds`;
|
|
296
|
+
types `AttributedNode`, `TabContainer`, `TabLocation`.
|
|
297
|
+
|
|
298
|
+
`geometry` — `resolveDockTarget`, `zoneRect`; types `DockTarget`,
|
|
299
|
+
`BandOptions`, `Point`, `Rect`.
|
|
300
|
+
|
|
301
|
+
`history` — `createHistory`, `dispatch`, `undo`, `redo`, `canUndo`, `canRedo`;
|
|
302
|
+
type `History`.
|
|
303
|
+
|
|
304
|
+
`serialize` — `toJSON`, `fromJSON`, `parseModel`.
|
|
305
|
+
|
|
306
|
+
`stack` — `stackModel` (flatten any layout into one row or column of all its
|
|
307
|
+
tabsets, the building block for a narrow-screen breakpoint).
|
|
308
|
+
|
|
309
|
+
`machines` — `dashfooMachine`, `dragDockMachine`; types `DashfooContext`,
|
|
310
|
+
`DashfooEvent`, `DashfooInput`, `DragContext`, `DragEvent`, `DragSubject`,
|
|
311
|
+
`DragEmitted`.
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
MIT
|