@dash-builder/core 0.0.0-stage → 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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +163 -2
- package/dist/dom/autoscroll.d.ts +16 -0
- package/dist/dom/autoscroll.js +83 -0
- package/dist/dom/controller.d.ts +28 -0
- package/dist/dom/controller.js +308 -0
- package/dist/dom/drag.d.ts +42 -0
- package/dist/dom/drag.js +412 -0
- package/dist/dom/gesture.d.ts +20 -0
- package/dist/dom/gesture.js +94 -0
- package/dist/dom/resize.d.ts +31 -0
- package/dist/dom/resize.js +180 -0
- package/dist/engine/drop.d.ts +28 -0
- package/dist/engine/drop.js +213 -0
- package/dist/engine/flow.d.ts +13 -0
- package/dist/engine/flow.js +51 -0
- package/dist/engine/hit.d.ts +40 -0
- package/dist/engine/hit.js +71 -0
- package/dist/engine/mutate.d.ts +10 -0
- package/dist/engine/mutate.js +24 -0
- package/dist/engine/normalize.d.ts +11 -0
- package/dist/engine/normalize.js +43 -0
- package/dist/engine/resize.d.ts +14 -0
- package/dist/engine/resize.js +75 -0
- package/dist/engine/rows.d.ts +11 -0
- package/dist/engine/rows.js +22 -0
- package/dist/engine/shrink.d.ts +7 -0
- package/dist/engine/shrink.js +30 -0
- package/dist/engine/types.d.ts +39 -0
- package/dist/engine/types.js +1 -0
- package/dist/grid.css +95 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/package.json +58 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @dash-builder/core
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 37f3acf: First public release: a dashboard grid where widgets flow into a fixed number of columns and can be dragged and resized, with four drop modes (insert, block, shrink, swap), drag handles, locked widgets, a row limit and a narrow (stacked) view. `@dash-builder/core` works on its own; `@dash-builder/angular` and `@dash-builder/react` wrap it.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dash-builder contributors
|
|
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
CHANGED
|
@@ -1,3 +1,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dash-builder/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A framework-free TypeScript dashboard grid. Widgets flow left to right into a fixed number of columns, wrap into rows, and can be dragged and resized. There are no free x/y coordinates: the layout is an ordered list, and the grid positions the widgets from it.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @dash-builder/core
|
|
9
|
+
# or: npm install @dash-builder/core
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The package is ESM-only (`"type": "module"`, no CommonJS build): use `import`, not `require`.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
The grid reads widgets from the DOM. Each direct child of the container that has a `data-grid-id` attribute is a widget, and its id must match an entry in `layout`.
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<div id="grid">
|
|
20
|
+
<div data-grid-id="a">Widget A</div>
|
|
21
|
+
<div data-grid-id="b"><span data-grid-handle>drag here</span> Widget B</div>
|
|
22
|
+
<div data-grid-id="c">Widget C</div>
|
|
23
|
+
</div>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createGrid } from '@dash-builder/core';
|
|
28
|
+
import '@dash-builder/core/grid.css';
|
|
29
|
+
import type { LayoutItem } from '@dash-builder/core';
|
|
30
|
+
|
|
31
|
+
const save = (layout: LayoutItem[]) => localStorage.setItem('dashboard-layout', JSON.stringify(layout));
|
|
32
|
+
|
|
33
|
+
const grid = createGrid(document.getElementById('grid')!, {
|
|
34
|
+
columns: 4,
|
|
35
|
+
layout: [
|
|
36
|
+
{ id: 'a', w: 2, h: 2 },
|
|
37
|
+
{ id: 'b', w: 1, h: 1 },
|
|
38
|
+
{ id: 'c', w: 1, h: 1, locked: true },
|
|
39
|
+
],
|
|
40
|
+
dropMode: 'insert',
|
|
41
|
+
onLayoutChange: (layout) => save(layout),
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### DOM contract
|
|
46
|
+
|
|
47
|
+
- Widgets are direct children of the container with a `data-grid-id` attribute. The first element with a given id wins; later duplicates are hidden. Widgets without a layout entry are hidden.
|
|
48
|
+
- A widget containing a `[data-grid-handle]` element can only be dragged by that element. Without one, the whole widget is the drag surface.
|
|
49
|
+
- The controller scans the container on every render. After you add or remove widget elements yourself, call `grid.refresh()` so it binds new elements and releases removed ones.
|
|
50
|
+
- The container gets the `dash-grid` class and `display: grid`; widgets get `dash-item`. Both are removed again by `destroy()`.
|
|
51
|
+
|
|
52
|
+
### Options
|
|
53
|
+
|
|
54
|
+
| Option | Default | Description |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `columns` | required | Number of columns. |
|
|
57
|
+
| `layout` | required | Ordered list of `LayoutItem`s. |
|
|
58
|
+
| `dropMode` | `'insert'` | `'insert'`, `'block'`, `'shrink'` or `'swap'`. |
|
|
59
|
+
| `maxRows` | none | Refuse a drop, resize or `addItem` whose result needs more rows than both `maxRows` and the current layout (see below). |
|
|
60
|
+
| `rowHeight` | `80` | Row height in px. |
|
|
61
|
+
| `gap` | `8` | Gap between widgets in px. |
|
|
62
|
+
| `breakpoint` | `640` | Container width in px below which the narrow view is used. |
|
|
63
|
+
| `onLayoutChange` | none | Called with a copy of the layout after a drop, a resize, `addItem`, `removeItem`, or when `setOptions({ columns })` changes widths. |
|
|
64
|
+
| `onReject` | none | Called with a `RejectReason` when `addItem` is refused. |
|
|
65
|
+
| `ghost` | sanitized clone | `(el: HTMLElement) => HTMLElement`: builds the element that follows the pointer during a drag. The grid adds the `dash-ghost` class, sizes and positions it, appends it to `body` and removes it on drop. |
|
|
66
|
+
|
|
67
|
+
The default ghost is a deep clone of the widget with every `id` attribute removed, `name` removed from radio inputs (so the clone can't steal the original's checked state), each `iframe`, `video` and `audio` replaced by an empty box of the same rendered size (no reload, no second playback), and each `canvas` given a copy of the original's pixels (left blank if the canvas can't be read, for example when it is tainted). Pass `ghost` when you want something lighter, such as a plain box with a title.
|
|
68
|
+
|
|
69
|
+
### Controller
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
grid.setLayout(layout); // replace the layout (no onLayoutChange); a no-op if it equals the current one
|
|
73
|
+
grid.setOptions({ dropMode: 'swap' }); // change options at runtime (any option except layout, including callbacks and ghost)
|
|
74
|
+
grid.addItem({ id: 'd', w: 1, h: 1 }, index?); // returns { ok: true, layout } or { ok: false, reason }
|
|
75
|
+
grid.removeItem('d');
|
|
76
|
+
grid.getLayout(); // a copy of the current layout
|
|
77
|
+
grid.refresh(); // re-scan the DOM and re-render
|
|
78
|
+
grid.destroy(); // remove listeners, classes and inline styles
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Notes:
|
|
82
|
+
|
|
83
|
+
- `addItem` only inserts into the layout. Create the element with its `data-grid-id` and append it to the container before calling `addItem`; remove it again if the result is not `ok`. For `removeItem`, call it first, then remove the element and call `refresh()`.
|
|
84
|
+
- `addItem` and `removeItem` cancel an active drag or resize. So does `setLayout`, but only when the new layout differs from the current one after normalizing (an equal layout changes nothing, and a temporary narrow-view order is kept). `setOptions` cancels only when `columns`, `dropMode`, `maxRows`, `breakpoint`, `rowHeight` or `gap` actually changes value; changing callbacks or `ghost`, or passing equal values, never interrupts the user.
|
|
85
|
+
- `addItem(item, index)` may shift a locked widget's index. Locks constrain the user's drags, not your own calls; the same goes for `removeItem` and `setLayout`.
|
|
86
|
+
- `setLayout` (and `createGrid`) normalize the layout silently: widths are clamped to `[minW, columns]`, heights to at least `minH`, sizes are truncated to integers, non-finite values become their minimum, and `minW` and `minH` below 1 are raised to 1. The normalized layout is not emitted; you see it in `getLayout()` and in the next `onLayoutChange`.
|
|
87
|
+
- `setOptions({ columns })` re-normalizes widths to the new column count and calls `onLayoutChange` if that changed any width.
|
|
88
|
+
- After `destroy()`, `setLayout`, `setOptions`, `addItem` (returns `{ ok: false, reason: 'invalid' }`), `removeItem` and `refresh` do nothing; `getLayout()` still returns a copy of the last layout and `destroy()` can be called again safely.
|
|
89
|
+
- `onLayoutChange` is the only notification of change. Treat it as the source of truth for saving state.
|
|
90
|
+
|
|
91
|
+
## Layout JSON
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
interface LayoutItem {
|
|
95
|
+
id: string; // matches data-grid-id
|
|
96
|
+
w: number; // width in columns, clamped to [minW ?? 1, columns]
|
|
97
|
+
h: number; // height in rows, at least minH ?? 1
|
|
98
|
+
minW?: number;
|
|
99
|
+
minH?: number;
|
|
100
|
+
locked?: boolean; // cannot be dragged or resized, and stays pinned to its index in the order
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
[
|
|
106
|
+
{ "id": "a", "w": 2, "h": 2 },
|
|
107
|
+
{ "id": "b", "w": 1, "h": 1, "minW": 1 },
|
|
108
|
+
{ "id": "c", "w": 1, "h": 1, "locked": true }
|
|
109
|
+
]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The array order is the flow order: items fill each row left to right and wrap when the next one does not fit.
|
|
113
|
+
|
|
114
|
+
## Drop modes
|
|
115
|
+
|
|
116
|
+
- `insert`: the dragged widget is inserted at the target position and everything after it moves on.
|
|
117
|
+
- `block`: the drop is refused if it would change which widgets share a row. The rows are compared two ways, and either one is enough: as you see them now (the dragged widget still in place), and as they would be with the dragged widget's old spot closed up. So moving a widget within its own row is always allowed, and so is a move whose gap the following widgets refill without changing rows.
|
|
118
|
+
- `shrink`: the widgets of the row the marker is on are narrowed (nearest first, down to their `minW`) to make room. The drop is refused if they cannot fit, or if shrinking would move any widget into a different row (for example a narrowed neighbour jumping onto the row above). Rows are judged the same two ways as in block, the rows as you see them first, so moving a widget within its own row never narrows anything when it fits. Dropping a widget that starts a row onto the end of the previous row narrows that row to take it. Resizing in this mode: narrowing a widget behaves like insert (later widgets may move up); only a resize that narrows neighbours checks that the other rows keep their members.
|
|
119
|
+
- `swap`: the dragged widget trades places with the widget it is dropped on.
|
|
120
|
+
|
|
121
|
+
Locked widgets are pinned to their index in the list; unlocked widgets reorder freely around them. In insert, block and shrink mode you can drop a widget on the other side of a locked one: the dragged widget takes its place among the unlocked widgets, and the unlocked widgets fill the slots the locked ones leave free. For example, with `[a, L, b]` and `L` locked, dragging `a` to the end gives `[b, L, a]`. Marker: next to a locked widget it shows where the dragged widget will actually land (right after the widget that ends up before it), not where the pointer is. Pointing between a locked widget and the widget being dragged can map back to the dragged widget's own slot; that drop changes nothing. Block and shrink still apply their row rules to the result (block: every other widget stays in its row, though it may change sides of a locked widget within the row; shrink: the widget must land in the marker's row). In shrink mode this means a drop next to a locked widget can be refused: when pinning the locked widget pushes the dragged one onto the next row, it no longer lands in the marker's row. A locked widget can't be dragged, and in swap mode it can't be swapped with. With `maxRows`, a drop, resize or `addItem` is refused when the result needs more rows than both `maxRows` and the current layout. A layout that is already taller than `maxRows` (for example after lowering it) can still be rearranged as long as it doesn't grow. Refused drops show the marker in the blocked colour and change nothing. Nothing else moves during a drag: only a marker (or, in swap mode, an outline on the target) shows where the widget will land, and `onLayoutChange` fires once on drop.
|
|
122
|
+
|
|
123
|
+
The engine is also exported for use without the DOM: `planDrop`, `planResize`, `addItem`, `removeItem`, `normalizeLayout`, `layoutsEqual`, `computeFlow`, `rowsOf`, `totalRows`, `stackForNarrow`, and the types `LayoutItem`, `PlacedItem`, `DropMode`, `EngineConfig`, `DropTarget`, `RejectReason` and `DropResult`.
|
|
124
|
+
|
|
125
|
+
`planDrop` takes a `DropTarget`: `{ swapWith: id }` in swap mode, otherwise `{ index, after? }`, where `index` is the position in the list without the dragged widget. Set `after: true` when the marker is drawn after a widget (its right half, the empty space beside it, or the end of the list): shrink mode then makes room in the row of the widget before `index` rather than the one at `index`. Insert and block ignore `after`.
|
|
126
|
+
|
|
127
|
+
## Interaction
|
|
128
|
+
|
|
129
|
+
- Mouse: press on the widget (or its handle) and move to drag. Resize with the corner handle (`.dash-resize-handle`) at the bottom right of unlocked widgets.
|
|
130
|
+
- Touch: a widget with a `[data-grid-handle]` drags immediately from the handle. A widget without one needs a 400 ms long-press before it drags, so normal scrolling still works. Resize handles start at once.
|
|
131
|
+
- Form controls (`input`, `textarea`, `select`, `button`, `label`, links with `href`, editable text) inside a widget without a `[data-grid-handle]` never start a drag, so they stay usable. Add `data-grid-no-drag` to any other element to opt it out the same way. A `[data-grid-handle]` that is itself a `button` still drags. The widget root itself being a link, `label` or `button` doesn't count (it still drags; put `data-grid-no-drag` on the root to opt the whole widget out). For a widget whose body is a form control or a label, add a `[data-grid-handle]`: that is the way to drag it.
|
|
132
|
+
- Dragging or resizing near the top or bottom edge of the scrolling area scrolls it.
|
|
133
|
+
- Escape cancels an active drag or resize.
|
|
134
|
+
|
|
135
|
+
## Narrow view
|
|
136
|
+
|
|
137
|
+
When the container is narrower than `breakpoint` (default 640 px) the grid switches to a single column: widgets are stacked, one per row, and resizing is disabled (the resize handles are hidden). Dragging still works, but reordering in the narrow view is temporary. It is a temporary display order that is cleared by `addItem`, `removeItem` or a `setLayout` that changes the layout; it never calls `onLayoutChange` or changes `getLayout()`, so the saved layout is untouched.
|
|
138
|
+
|
|
139
|
+
## Styling
|
|
140
|
+
|
|
141
|
+
Import `@dash-builder/core/grid.css` and override these CSS custom properties on the container. The exception is `--dash-ghost-opacity`: the ghost is appended to `body`, outside the container, so set that one on `:root` or `body`.
|
|
142
|
+
|
|
143
|
+
| Variable | Default | Purpose |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `--dash-marker-color` | `#3b82f6` | Drop marker, swap outline and resize preview. |
|
|
146
|
+
| `--dash-marker-blocked-color` | `#ef4444` | Same, when the drop or resize is refused. |
|
|
147
|
+
| `--dash-placeholder-border` | `2px dashed #94a3b8` | Border of the placeholder left at the origin during a drag. |
|
|
148
|
+
| `--dash-ghost-opacity` | `0.85` | Opacity of the widget following the pointer. Set on `:root` or `body`. |
|
|
149
|
+
|
|
150
|
+
The controller sets `--dash-columns`, `--dash-row-height` and `--dash-gap` on the container from its options; do not set these yourself.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
pnpm build # tsc + copy grid.css into dist/
|
|
156
|
+
pnpm test # unit tests
|
|
157
|
+
pnpm test:smoke # builds, then imports the built package in Node and checks its exports
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`examples/vanilla.html` is a manual smoke page. Serve the repository root (for example `npx serve .`) and open `/packages/core/examples/vanilla.html` after building.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Px to scroll per frame for a pointer at `pointerY` in a scroll area spanning
|
|
3
|
+
* `top`..`bottom`: negative near the top, positive near the bottom, 0 elsewhere,
|
|
4
|
+
* growing linearly to ±max at the edge or beyond it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scrollStep(pointerY: number, top: number, bottom: number, zone?: number, max?: number): number;
|
|
7
|
+
export interface AutoScroll {
|
|
8
|
+
/** Pointer position changed: start, retarget or end the scroll loop. */
|
|
9
|
+
move(clientY: number): void;
|
|
10
|
+
stop(): void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* While the pointer is within the edge zone of the scroll target, scrolls it once
|
|
14
|
+
* per animation frame and reports each step that actually moved (`onScroll(delta)`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createAutoScroll(container: HTMLElement, onScroll: (dy: number) => void): AutoScroll;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Edge autoscroll shared by the drag and resize interactions.
|
|
2
|
+
const ZONE = 48; // px from the scroll area's top/bottom edge in which scrolling starts
|
|
3
|
+
const MAX_STEP = 16; // px per frame at (or beyond) the edge
|
|
4
|
+
/**
|
|
5
|
+
* Px to scroll per frame for a pointer at `pointerY` in a scroll area spanning
|
|
6
|
+
* `top`..`bottom`: negative near the top, positive near the bottom, 0 elsewhere,
|
|
7
|
+
* growing linearly to ±max at the edge or beyond it.
|
|
8
|
+
*/
|
|
9
|
+
export function scrollStep(pointerY, top, bottom, zone = ZONE, max = MAX_STEP) {
|
|
10
|
+
if (pointerY < top + zone)
|
|
11
|
+
return -max * Math.min(1, (top + zone - pointerY) / zone);
|
|
12
|
+
if (pointerY > bottom - zone)
|
|
13
|
+
return max * Math.min(1, (pointerY - (bottom - zone)) / zone);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
/** Nearest ancestor that scrolls vertically, else the document's scrolling element. */
|
|
17
|
+
function findScroller(container) {
|
|
18
|
+
const view = container.ownerDocument.defaultView;
|
|
19
|
+
for (let node = container.parentElement; node; node = node.parentElement) {
|
|
20
|
+
const overflow = view?.getComputedStyle(node).overflowY;
|
|
21
|
+
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight)
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
return container.ownerDocument.scrollingElement;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* While the pointer is within the edge zone of the scroll target, scrolls it once
|
|
28
|
+
* per animation frame and reports each step that actually moved (`onScroll(delta)`).
|
|
29
|
+
*/
|
|
30
|
+
export function createAutoScroll(container, onScroll) {
|
|
31
|
+
const doc = container.ownerDocument;
|
|
32
|
+
const target = findScroller(container);
|
|
33
|
+
let y = 0;
|
|
34
|
+
let frame = null;
|
|
35
|
+
function edges() {
|
|
36
|
+
if (!target || target === doc.scrollingElement)
|
|
37
|
+
return [0, doc.defaultView?.innerHeight ?? 0];
|
|
38
|
+
const r = target.getBoundingClientRect();
|
|
39
|
+
return [r.top, r.bottom];
|
|
40
|
+
}
|
|
41
|
+
/** The step for the current pointer, 0 when outside the zone or already at that end of the range. */
|
|
42
|
+
function pending() {
|
|
43
|
+
if (!target)
|
|
44
|
+
return 0;
|
|
45
|
+
const [top, bottom] = edges();
|
|
46
|
+
const step = scrollStep(y, top, bottom);
|
|
47
|
+
const max = Math.max(0, target.scrollHeight - target.clientHeight);
|
|
48
|
+
return (step < 0 && target.scrollTop <= 0) || (step > 0 && target.scrollTop >= max) ? 0 : step;
|
|
49
|
+
}
|
|
50
|
+
function tick() {
|
|
51
|
+
frame = null;
|
|
52
|
+
const step = pending();
|
|
53
|
+
if (!target || step === 0)
|
|
54
|
+
return; // the next move() restarts the loop
|
|
55
|
+
const before = target.scrollTop;
|
|
56
|
+
const max = Math.max(0, target.scrollHeight - target.clientHeight);
|
|
57
|
+
target.scrollTop = Math.min(max, Math.max(0, before + step));
|
|
58
|
+
const delta = target.scrollTop - before;
|
|
59
|
+
if (delta === 0)
|
|
60
|
+
return;
|
|
61
|
+
onScroll(delta);
|
|
62
|
+
request();
|
|
63
|
+
}
|
|
64
|
+
function request() {
|
|
65
|
+
const raf = globalThis.requestAnimationFrame;
|
|
66
|
+
if (frame === null && typeof raf === 'function')
|
|
67
|
+
frame = raf(tick);
|
|
68
|
+
}
|
|
69
|
+
function move(clientY) {
|
|
70
|
+
y = clientY;
|
|
71
|
+
if (pending() !== 0)
|
|
72
|
+
request();
|
|
73
|
+
}
|
|
74
|
+
function stop() {
|
|
75
|
+
if (frame === null)
|
|
76
|
+
return;
|
|
77
|
+
const caf = globalThis.cancelAnimationFrame;
|
|
78
|
+
if (typeof caf === 'function')
|
|
79
|
+
caf(frame);
|
|
80
|
+
frame = null;
|
|
81
|
+
}
|
|
82
|
+
return { move, stop };
|
|
83
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DropMode, DropResult, LayoutItem, RejectReason } from '../engine/types.js';
|
|
2
|
+
export interface GridOptions {
|
|
3
|
+
columns: number;
|
|
4
|
+
layout: LayoutItem[];
|
|
5
|
+
dropMode?: DropMode;
|
|
6
|
+
maxRows?: number;
|
|
7
|
+
rowHeight?: number;
|
|
8
|
+
gap?: number;
|
|
9
|
+
breakpoint?: number;
|
|
10
|
+
onLayoutChange?: (layout: LayoutItem[]) => void;
|
|
11
|
+
onReject?: (reason: RejectReason) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Builds the drag ghost from the widget element. The grid adds `dash-ghost`,
|
|
14
|
+
* positions and sizes it, appends it to body and removes it on drop. Default:
|
|
15
|
+
* a sanitized deep clone (see README).
|
|
16
|
+
*/
|
|
17
|
+
ghost?: (el: HTMLElement) => HTMLElement;
|
|
18
|
+
}
|
|
19
|
+
export interface GridController {
|
|
20
|
+
setLayout(layout: LayoutItem[]): void;
|
|
21
|
+
setOptions(opts: Partial<Omit<GridOptions, 'layout'>>): void;
|
|
22
|
+
addItem(item: LayoutItem, index?: number): DropResult;
|
|
23
|
+
removeItem(id: string): void;
|
|
24
|
+
getLayout(): LayoutItem[];
|
|
25
|
+
refresh(): void;
|
|
26
|
+
destroy(): void;
|
|
27
|
+
}
|
|
28
|
+
export declare function createGrid(container: HTMLElement, options: GridOptions): GridController;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { computeFlow, stackForNarrow } from '../engine/flow.js';
|
|
2
|
+
import { addItem as engineAddItem, removeItem as engineRemoveItem } from '../engine/mutate.js';
|
|
3
|
+
import { layoutsEqual, normalizeLayout } from '../engine/normalize.js';
|
|
4
|
+
import { createDrag } from './drag.js';
|
|
5
|
+
import { createResize } from './resize.js';
|
|
6
|
+
const ID_ATTR = 'data-grid-id';
|
|
7
|
+
/** Options the geometry or the drop rules depend on; changing one cancels an interaction. */
|
|
8
|
+
const GEOMETRY_KEYS = ['columns', 'dropMode', 'maxRows', 'breakpoint', 'rowHeight', 'gap'];
|
|
9
|
+
const DEFAULTS = { dropMode: 'insert', rowHeight: 80, gap: 8, breakpoint: 640 };
|
|
10
|
+
const effective = (o, k) => o[k] ?? DEFAULTS[k];
|
|
11
|
+
export function createGrid(container, options) {
|
|
12
|
+
const opts = { ...options };
|
|
13
|
+
let layout = normalizeLayout(opts.layout, opts.columns);
|
|
14
|
+
let narrow = false;
|
|
15
|
+
// Temporary narrow-view order (ids) from a narrow drag; never emitted, cleared by a setLayout that changes the layout.
|
|
16
|
+
let narrowOrder = null;
|
|
17
|
+
let destroyed = false;
|
|
18
|
+
const items = new Map(); // widgets currently managed, by id
|
|
19
|
+
const bound = new Map(); // element -> id it is bound under
|
|
20
|
+
const hiddenByUs = new Set();
|
|
21
|
+
const prevInline = {
|
|
22
|
+
display: container.style.display,
|
|
23
|
+
};
|
|
24
|
+
const marker = document.createElement('div');
|
|
25
|
+
marker.className = 'dash-marker';
|
|
26
|
+
marker.hidden = true;
|
|
27
|
+
container.classList.add('dash-grid');
|
|
28
|
+
container.style.display = 'grid';
|
|
29
|
+
container.appendChild(marker);
|
|
30
|
+
const config = () => ({ columns: opts.columns, maxRows: opts.maxRows, mode: opts.dropMode ?? 'insert' });
|
|
31
|
+
const emit = () => opts.onLayoutChange?.(layout.map((i) => ({ ...i })));
|
|
32
|
+
/** The layout in narrow-view order: the temporary order first, then items it doesn't know. */
|
|
33
|
+
function narrowList() {
|
|
34
|
+
if (!narrowOrder)
|
|
35
|
+
return layout;
|
|
36
|
+
const byId = new Map(layout.map((i) => [i.id, i]));
|
|
37
|
+
const ordered = narrowOrder.flatMap((id) => byId.get(id) ?? []);
|
|
38
|
+
const seen = new Set(ordered.map((i) => i.id));
|
|
39
|
+
return [...ordered, ...layout.filter((i) => !seen.has(i.id))];
|
|
40
|
+
}
|
|
41
|
+
// ---- interactions --------------------------------------------------------
|
|
42
|
+
function commit(next) {
|
|
43
|
+
layout = next;
|
|
44
|
+
render();
|
|
45
|
+
emit();
|
|
46
|
+
}
|
|
47
|
+
// Only one interaction at a time: each refuses to start while the other is active.
|
|
48
|
+
const drag = createDrag({
|
|
49
|
+
container,
|
|
50
|
+
marker,
|
|
51
|
+
items,
|
|
52
|
+
isNarrow: () => narrow,
|
|
53
|
+
getList: () => (narrow ? narrowList() : layout),
|
|
54
|
+
config,
|
|
55
|
+
commit,
|
|
56
|
+
setNarrowOrder(ids) {
|
|
57
|
+
narrowOrder = ids;
|
|
58
|
+
render();
|
|
59
|
+
},
|
|
60
|
+
isBusy: () => resize.isActive(),
|
|
61
|
+
ghost: () => opts.ghost,
|
|
62
|
+
});
|
|
63
|
+
const resize = createResize({
|
|
64
|
+
container,
|
|
65
|
+
items,
|
|
66
|
+
isNarrow: () => narrow,
|
|
67
|
+
getLayout: () => layout,
|
|
68
|
+
config,
|
|
69
|
+
metrics: () => ({ rowHeight: opts.rowHeight ?? 80, gap: opts.gap ?? 8 }),
|
|
70
|
+
commit,
|
|
71
|
+
isBusy: () => drag.isActive(),
|
|
72
|
+
});
|
|
73
|
+
// Cancel any active drag or resize. Called from setLayout (when the layout changes),
|
|
74
|
+
// setOptions (when a geometry option changes), addItem, removeItem and from render when the view flips between wide and narrow
|
|
75
|
+
// (destroy uses drag.destroy() / resize.destroy(), which also drop any pending click blocker).
|
|
76
|
+
function cancelInteraction() {
|
|
77
|
+
drag.cancel();
|
|
78
|
+
resize.cancel();
|
|
79
|
+
}
|
|
80
|
+
// Called once per element when it becomes managed (or is rebound under a new id).
|
|
81
|
+
function bindItem(el, id) {
|
|
82
|
+
drag.bind(el, id);
|
|
83
|
+
}
|
|
84
|
+
// Called when an element leaves the managed set or before it is rebound.
|
|
85
|
+
function unbindItem(el) {
|
|
86
|
+
drag.unbind(el);
|
|
87
|
+
resize.unbind(el);
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
function scan() {
|
|
91
|
+
const winners = new Map();
|
|
92
|
+
const dupes = [];
|
|
93
|
+
for (const child of Array.from(container.children)) {
|
|
94
|
+
const id = child.getAttribute(ID_ATTR);
|
|
95
|
+
if (id === null || !(child instanceof HTMLElement))
|
|
96
|
+
continue;
|
|
97
|
+
if (winners.has(id))
|
|
98
|
+
dupes.push(child); // first in DOM order wins
|
|
99
|
+
else
|
|
100
|
+
winners.set(id, child);
|
|
101
|
+
}
|
|
102
|
+
const winnerEls = new Set(winners.values());
|
|
103
|
+
// release elements no longer managed (removed, now a duplicate, or id attribute dropped)
|
|
104
|
+
for (const [node, id] of Array.from(bound)) {
|
|
105
|
+
if (!winnerEls.has(node)) {
|
|
106
|
+
unbindItem(node);
|
|
107
|
+
bound.delete(node);
|
|
108
|
+
clearItem(node);
|
|
109
|
+
}
|
|
110
|
+
else if (winners.get(id) !== node) {
|
|
111
|
+
unbindItem(node); // id attribute changed: rebound below under the new id
|
|
112
|
+
bound.delete(node);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const [id, node] of winners) {
|
|
116
|
+
if (bound.get(node) !== id) {
|
|
117
|
+
bound.set(node, id);
|
|
118
|
+
node.classList.add('dash-item');
|
|
119
|
+
bindItem(node, id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
items.clear();
|
|
123
|
+
for (const [id, node] of winners)
|
|
124
|
+
items.set(id, node);
|
|
125
|
+
for (const node of dupes)
|
|
126
|
+
hide(node);
|
|
127
|
+
// forget hidden-by-us elements that left the container
|
|
128
|
+
for (const node of Array.from(hiddenByUs))
|
|
129
|
+
if (node.parentElement !== container)
|
|
130
|
+
unhide(node);
|
|
131
|
+
}
|
|
132
|
+
function hide(node) {
|
|
133
|
+
if (!node.hidden) {
|
|
134
|
+
node.hidden = true;
|
|
135
|
+
hiddenByUs.add(node);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Only un-hides what the controller hid; consumer-hidden elements stay hidden.
|
|
139
|
+
function unhide(node) {
|
|
140
|
+
if (hiddenByUs.delete(node))
|
|
141
|
+
node.hidden = false;
|
|
142
|
+
}
|
|
143
|
+
function clearItem(node) {
|
|
144
|
+
node.classList.remove('dash-item');
|
|
145
|
+
node.style.removeProperty('--dash-col');
|
|
146
|
+
node.style.removeProperty('--dash-row');
|
|
147
|
+
node.querySelector(':scope > .dash-resize-handle')?.remove();
|
|
148
|
+
unhide(node);
|
|
149
|
+
}
|
|
150
|
+
function measureNarrow() {
|
|
151
|
+
return container.clientWidth < (opts.breakpoint ?? 640);
|
|
152
|
+
}
|
|
153
|
+
function render() {
|
|
154
|
+
scan();
|
|
155
|
+
const nextNarrow = measureNarrow();
|
|
156
|
+
if (nextNarrow !== narrow)
|
|
157
|
+
cancelInteraction(); // axis and drop config differ between views
|
|
158
|
+
narrow = nextNarrow;
|
|
159
|
+
container.classList.toggle('dash-grid--narrow', narrow);
|
|
160
|
+
container.style.setProperty('--dash-columns', String(narrow ? 1 : opts.columns));
|
|
161
|
+
container.style.setProperty('--dash-row-height', `${opts.rowHeight ?? 80}px`);
|
|
162
|
+
container.style.setProperty('--dash-gap', `${opts.gap ?? 8}px`);
|
|
163
|
+
const placed = narrow ? stackForNarrow(narrowList()) : computeFlow(layout, opts.columns);
|
|
164
|
+
const byId = new Map(placed.map((p) => [p.id, p]));
|
|
165
|
+
for (const [id, node] of items) {
|
|
166
|
+
const p = byId.get(id);
|
|
167
|
+
if (!p) {
|
|
168
|
+
hide(node);
|
|
169
|
+
node.style.removeProperty('--dash-col');
|
|
170
|
+
node.style.removeProperty('--dash-row');
|
|
171
|
+
node.querySelector(':scope > .dash-resize-handle')?.remove();
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
unhide(node);
|
|
175
|
+
node.style.setProperty('--dash-col', `${p.x + 1} / span ${p.w}`);
|
|
176
|
+
node.style.setProperty('--dash-row', `${p.y + 1} / span ${p.h}`);
|
|
177
|
+
const handle = node.querySelector(':scope > .dash-resize-handle');
|
|
178
|
+
if (p.locked) {
|
|
179
|
+
handle?.remove();
|
|
180
|
+
}
|
|
181
|
+
else if (!handle) {
|
|
182
|
+
const h = document.createElement('div');
|
|
183
|
+
h.className = 'dash-resize-handle';
|
|
184
|
+
h.style.touchAction = 'none'; // touch resize starts at once; no long-press, no page scroll
|
|
185
|
+
node.appendChild(h);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
drag.sync(); // touch-action follows handles and lock state
|
|
189
|
+
// keep the marker last so it never interleaves with widgets
|
|
190
|
+
if (container.lastElementChild !== marker)
|
|
191
|
+
container.appendChild(marker);
|
|
192
|
+
}
|
|
193
|
+
let observer;
|
|
194
|
+
let frame = null; // pending narrow re-check
|
|
195
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
196
|
+
observer = new ResizeObserver(() => {
|
|
197
|
+
if (destroyed)
|
|
198
|
+
return;
|
|
199
|
+
// Rendering here changes the container size inside the observer callback, which the
|
|
200
|
+
// browser reports as "ResizeObserver loop completed with undelivered notifications".
|
|
201
|
+
// Defer to a frame instead (one at a time); render at once only without rAF.
|
|
202
|
+
if (typeof requestAnimationFrame === 'undefined') {
|
|
203
|
+
if (measureNarrow() !== narrow)
|
|
204
|
+
render();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (frame !== null)
|
|
208
|
+
return;
|
|
209
|
+
frame = requestAnimationFrame(() => {
|
|
210
|
+
frame = null;
|
|
211
|
+
if (!destroyed && measureNarrow() !== narrow)
|
|
212
|
+
render();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
observer.observe(container);
|
|
216
|
+
}
|
|
217
|
+
render();
|
|
218
|
+
return {
|
|
219
|
+
setLayout(next) {
|
|
220
|
+
if (destroyed)
|
|
221
|
+
return;
|
|
222
|
+
const normalized = normalizeLayout(next, opts.columns);
|
|
223
|
+
if (layoutsEqual(normalized, layout))
|
|
224
|
+
return; // nothing changes: keep any interaction and the narrow order
|
|
225
|
+
cancelInteraction();
|
|
226
|
+
narrowOrder = null;
|
|
227
|
+
layout = normalized;
|
|
228
|
+
render();
|
|
229
|
+
},
|
|
230
|
+
setOptions(patch) {
|
|
231
|
+
if (destroyed)
|
|
232
|
+
return;
|
|
233
|
+
// Only a change to something the geometry or an in-flight plan depends on cancels.
|
|
234
|
+
const changed = GEOMETRY_KEYS.some((k) => k in patch && effective(patch, k) !== effective(opts, k));
|
|
235
|
+
Object.assign(opts, patch);
|
|
236
|
+
if (!changed)
|
|
237
|
+
return; // callbacks / equal values: nothing to re-render
|
|
238
|
+
cancelInteraction();
|
|
239
|
+
let layoutChanged = false;
|
|
240
|
+
if (patch.columns !== undefined) {
|
|
241
|
+
const next = normalizeLayout(layout, opts.columns);
|
|
242
|
+
layoutChanged = !layoutsEqual(layout, next);
|
|
243
|
+
layout = next;
|
|
244
|
+
}
|
|
245
|
+
render();
|
|
246
|
+
if (layoutChanged)
|
|
247
|
+
emit();
|
|
248
|
+
},
|
|
249
|
+
addItem(item, index) {
|
|
250
|
+
if (destroyed)
|
|
251
|
+
return { ok: false, reason: 'invalid' };
|
|
252
|
+
const result = engineAddItem(layout, config(), item, index);
|
|
253
|
+
if (result.ok) {
|
|
254
|
+
cancelInteraction(); // an in-flight plan was computed against the old list
|
|
255
|
+
narrowOrder = null;
|
|
256
|
+
layout = result.layout;
|
|
257
|
+
render();
|
|
258
|
+
emit();
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
opts.onReject?.(result.reason);
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
},
|
|
265
|
+
removeItem(id) {
|
|
266
|
+
if (destroyed)
|
|
267
|
+
return;
|
|
268
|
+
const next = engineRemoveItem(layout, id);
|
|
269
|
+
if (next === layout)
|
|
270
|
+
return;
|
|
271
|
+
cancelInteraction(); // an in-flight plan was computed against the old list
|
|
272
|
+
narrowOrder = null;
|
|
273
|
+
layout = next;
|
|
274
|
+
render();
|
|
275
|
+
emit();
|
|
276
|
+
},
|
|
277
|
+
getLayout: () => layout.map((i) => ({ ...i })),
|
|
278
|
+
refresh() {
|
|
279
|
+
if (!destroyed)
|
|
280
|
+
render();
|
|
281
|
+
},
|
|
282
|
+
destroy() {
|
|
283
|
+
if (destroyed)
|
|
284
|
+
return;
|
|
285
|
+
destroyed = true;
|
|
286
|
+
drag.destroy(); // like cancelInteraction, but leaves no click blocker behind
|
|
287
|
+
resize.destroy(); // also removes the delegated pointerdown listener
|
|
288
|
+
observer?.disconnect();
|
|
289
|
+
if (frame !== null) {
|
|
290
|
+
cancelAnimationFrame(frame);
|
|
291
|
+
frame = null;
|
|
292
|
+
}
|
|
293
|
+
for (const node of Array.from(bound.keys())) {
|
|
294
|
+
unbindItem(node);
|
|
295
|
+
clearItem(node);
|
|
296
|
+
}
|
|
297
|
+
for (const node of Array.from(hiddenByUs))
|
|
298
|
+
unhide(node);
|
|
299
|
+
bound.clear();
|
|
300
|
+
items.clear();
|
|
301
|
+
marker.remove();
|
|
302
|
+
container.classList.remove('dash-grid', 'dash-grid--narrow');
|
|
303
|
+
container.style.display = prevInline.display;
|
|
304
|
+
for (const p of ['--dash-columns', '--dash-row-height', '--dash-gap'])
|
|
305
|
+
container.style.removeProperty(p);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|