@tensor_1/react 2.43.1 → 2.44.1
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/dist/components/DashboardCanvas.d.ts +78 -0
- package/dist/components/DashboardCanvas.js +227 -0
- package/dist/dashboard-canvas-model.d.ts +104 -0
- package/dist/dashboard-canvas-model.js +113 -0
- package/dist/index.cjs +2218 -1869
- package/dist/index.d.cts +177 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { HTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
import { DashboardCanvasItem } from '../dashboard-canvas-model.js';
|
|
4
|
+
|
|
5
|
+
/** Every word the canvas renders. `{label}` is the item's name; the others are grid units. */
|
|
6
|
+
interface DashboardCanvasLabels {
|
|
7
|
+
/** The region's accessible name. */
|
|
8
|
+
region: string;
|
|
9
|
+
/** The move handle's name. */
|
|
10
|
+
move: string;
|
|
11
|
+
/** The resize handle's name. */
|
|
12
|
+
resize: string;
|
|
13
|
+
/** How the handles work, read with each handle (`aria-describedby`). */
|
|
14
|
+
instructions: string;
|
|
15
|
+
/** Announced after a move. `{col}` and `{row}` count from 1. */
|
|
16
|
+
moved: string;
|
|
17
|
+
/** Announced after a resize. */
|
|
18
|
+
resized: string;
|
|
19
|
+
/** Announced when a step would leave the canvas or pass the item's size limit. */
|
|
20
|
+
atEdge: string;
|
|
21
|
+
/** Announced when Escape drops a drag. */
|
|
22
|
+
cancelled: string;
|
|
23
|
+
}
|
|
24
|
+
declare const DEFAULT_DASHBOARD_CANVAS_LABELS: DashboardCanvasLabels;
|
|
25
|
+
/** Space between items: `sm` --uix-space-3, `md` --uix-space-4, `lg` --uix-space-6. */
|
|
26
|
+
type DashboardCanvasGap = 'sm' | 'md' | 'lg';
|
|
27
|
+
/** What changed, for the product's save and its journey event. */
|
|
28
|
+
interface DashboardCanvasChange {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly kind: 'move' | 'resize';
|
|
31
|
+
readonly source: 'pointer' | 'keyboard';
|
|
32
|
+
}
|
|
33
|
+
interface DashboardCanvasProps<T extends DashboardCanvasItem> extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children'> {
|
|
34
|
+
/** The layout. Overlaps and out-of-range values are settled before rendering. */
|
|
35
|
+
items: readonly T[];
|
|
36
|
+
/** One cell's content: a widget, a card. Its last child takes the spare height. */
|
|
37
|
+
renderItem: (item: T) => ReactNode;
|
|
38
|
+
/** The item's name in labels and announcements. Default: its id. */
|
|
39
|
+
itemLabel?: (item: T) => string;
|
|
40
|
+
/**
|
|
41
|
+
* Edit mode: each item gets a move handle and a resize handle, both draggable and both
|
|
42
|
+
* operable with the arrow keys. Off, the canvas is a plain layout and server-renderable.
|
|
43
|
+
*/
|
|
44
|
+
editable?: boolean;
|
|
45
|
+
/** The next layout, once per drop or key press. The canvas never keeps its own copy. */
|
|
46
|
+
onLayoutChange?: (items: T[], change: DashboardCanvasChange) => void;
|
|
47
|
+
/** Height of one row, any CSS length. Default `--uix-dashboard-canvas-row` (5rem). */
|
|
48
|
+
rowHeight?: string;
|
|
49
|
+
/** Default `md`. */
|
|
50
|
+
gap?: DashboardCanvasGap;
|
|
51
|
+
labels?: Partial<DashboardCanvasLabels>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A free dashboard layout (TENSOR HAR-1823): twelve columns, rows of one fixed height, and each
|
|
55
|
+
* item at its own `{ col, row, w, h }` in grid units. In edit mode a person drags an item by its
|
|
56
|
+
* move handle and resizes it by the corner handle; both snap to the grid, push what they land
|
|
57
|
+
* on downwards, and report one new layout per drop. Both handles also take the arrow keys, and
|
|
58
|
+
* the pure functions behind them (`moveCanvasItem`, `resizeCanvasItem`) are exported for the
|
|
59
|
+
* product's own item menu, so dragging is never the only way (WCAG 2.5.7).
|
|
60
|
+
*
|
|
61
|
+
* Below 36rem of its own width the canvas is one column in reading order (row by row, left to
|
|
62
|
+
* right), each item keeping its height, and the handles step aside: the item menu remains.
|
|
63
|
+
*
|
|
64
|
+
* Owns interaction, never persistence: the product stores `items` and passes them back.
|
|
65
|
+
*
|
|
66
|
+
* ```tsx
|
|
67
|
+
* <DashboardCanvas
|
|
68
|
+
* items={layout}
|
|
69
|
+
* editable={editing}
|
|
70
|
+
* itemLabel={(item) => titles[item.id]}
|
|
71
|
+
* renderItem={(item) => <Widget id={item.id} />}
|
|
72
|
+
* onLayoutChange={(next) => save(next)}
|
|
73
|
+
* />
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
declare function DashboardCanvas<T extends DashboardCanvasItem>({ items, renderItem, itemLabel, editable, onLayoutChange, rowHeight, gap, labels: labelOverrides, className, style, ...props }: DashboardCanvasProps<T>): react.JSX.Element;
|
|
77
|
+
|
|
78
|
+
export { DEFAULT_DASHBOARD_CANVAS_LABELS, DashboardCanvas, type DashboardCanvasChange, type DashboardCanvasGap, type DashboardCanvasLabels, type DashboardCanvasProps };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { cx } from "../cx.js";
|
|
5
|
+
import { fillLabel } from "../fill-label.js";
|
|
6
|
+
import { GripVerticalIcon, Maximize2Icon } from "../icons/components.js";
|
|
7
|
+
import {
|
|
8
|
+
canvasMoveTarget,
|
|
9
|
+
canvasReadingOrder,
|
|
10
|
+
canvasResizeTarget,
|
|
11
|
+
DASHBOARD_CANVAS_COLUMNS,
|
|
12
|
+
normalizeCanvas,
|
|
13
|
+
placeCanvasItem
|
|
14
|
+
} from "../dashboard-canvas-model.js";
|
|
15
|
+
const DEFAULT_DASHBOARD_CANVAS_LABELS = {
|
|
16
|
+
region: "Dashboard layout",
|
|
17
|
+
move: "Move {label}",
|
|
18
|
+
resize: "Resize {label}",
|
|
19
|
+
instructions: "Arrow keys move or resize one step. Escape cancels a drag.",
|
|
20
|
+
moved: "{label}: column {col}, row {row}",
|
|
21
|
+
resized: "{label}: {w} columns wide, {h} rows tall",
|
|
22
|
+
atEdge: "{label} cannot go further that way",
|
|
23
|
+
cancelled: "Move cancelled"
|
|
24
|
+
};
|
|
25
|
+
const DRAG_THRESHOLD = 4;
|
|
26
|
+
function measure(grid) {
|
|
27
|
+
const style = getComputedStyle(grid);
|
|
28
|
+
const width = grid.clientWidth - (Number.parseFloat(style.paddingLeft) || 0) - (Number.parseFloat(style.paddingRight) || 0);
|
|
29
|
+
const tracks = style.gridTemplateColumns.split(" ").map((track) => Number.parseFloat(track)).filter(Number.isFinite);
|
|
30
|
+
const trackSum = tracks.reduce((sum, track) => sum + track, 0);
|
|
31
|
+
const columnGap = tracks.length > 1 ? Math.max(0, (width - trackSum) / (tracks.length - 1)) : 0;
|
|
32
|
+
const colPitch = (width + columnGap) / DASHBOARD_CANVAS_COLUMNS;
|
|
33
|
+
const rowGap = Number.parseFloat(style.rowGap) || 0;
|
|
34
|
+
const rowTrack = Number.parseFloat(style.gridAutoRows) || 80;
|
|
35
|
+
return { colPitch, rowPitch: rowTrack + rowGap };
|
|
36
|
+
}
|
|
37
|
+
function DashboardCanvas({
|
|
38
|
+
items,
|
|
39
|
+
renderItem,
|
|
40
|
+
itemLabel = (item) => item.id,
|
|
41
|
+
editable = false,
|
|
42
|
+
onLayoutChange,
|
|
43
|
+
rowHeight,
|
|
44
|
+
gap = "md",
|
|
45
|
+
labels: labelOverrides,
|
|
46
|
+
className,
|
|
47
|
+
style,
|
|
48
|
+
...props
|
|
49
|
+
}) {
|
|
50
|
+
const labels = { ...DEFAULT_DASHBOARD_CANVAS_LABELS, ...labelOverrides };
|
|
51
|
+
const instructionsId = useId();
|
|
52
|
+
const gridRef = useRef(null);
|
|
53
|
+
const dragRef = useRef(null);
|
|
54
|
+
const [preview, setPreview] = useState(null);
|
|
55
|
+
const [announcement, setAnnouncement] = useState("");
|
|
56
|
+
const layout = useMemo(() => normalizeCanvas(items), [items]);
|
|
57
|
+
const order = useMemo(() => new Map(canvasReadingOrder(layout).map((item, index) => [item.id, index])), [layout]);
|
|
58
|
+
const live = useRef({ layout, onLayoutChange, labels, itemLabel });
|
|
59
|
+
live.current = { layout, onLayoutChange, labels, itemLabel };
|
|
60
|
+
useEffect(() => () => dragRef.current?.stop(), []);
|
|
61
|
+
const describe = (item, kind) => kind === "move" ? fillLabel(labels.moved, { label: itemLabel(item), col: item.col + 1, row: item.row + 1 }) : fillLabel(labels.resized, { label: itemLabel(item), w: item.w, h: item.h });
|
|
62
|
+
const commit = (id, placement, kind, source) => {
|
|
63
|
+
const state = live.current;
|
|
64
|
+
const next = placeCanvasItem(state.layout, id, placement);
|
|
65
|
+
const placed = next.find((item) => item.id === id);
|
|
66
|
+
if (!placed) return;
|
|
67
|
+
state.onLayoutChange?.(next, { id, kind, source });
|
|
68
|
+
setAnnouncement(describe(placed, kind));
|
|
69
|
+
};
|
|
70
|
+
const onHandleKeyDown = (event, item, kind) => {
|
|
71
|
+
const keys = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" };
|
|
72
|
+
const resizes = { ArrowRight: "wider", ArrowLeft: "narrower", ArrowDown: "taller", ArrowUp: "shorter" };
|
|
73
|
+
if (event.key === "Escape" && dragRef.current) {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
dragRef.current.stop();
|
|
76
|
+
dragRef.current = null;
|
|
77
|
+
setPreview(null);
|
|
78
|
+
setAnnouncement(labels.cancelled);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!(event.key in keys)) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
const target = kind === "move" ? canvasMoveTarget(item, keys[event.key]) : canvasResizeTarget(item, resizes[event.key]);
|
|
84
|
+
if (!target) {
|
|
85
|
+
setAnnouncement(fillLabel(labels.atEdge, { label: itemLabel(item) }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
commit(item.id, target, kind, "keyboard");
|
|
89
|
+
};
|
|
90
|
+
const onHandlePointerDown = (event, item, kind) => {
|
|
91
|
+
if (event.button !== 0 || dragRef.current || !gridRef.current) return;
|
|
92
|
+
const { colPitch, rowPitch } = measure(gridRef.current);
|
|
93
|
+
const origin = { col: item.col, row: item.row, w: item.w, h: item.h };
|
|
94
|
+
let moved = false;
|
|
95
|
+
let last = origin;
|
|
96
|
+
const targetFor = (move) => {
|
|
97
|
+
const dc = Math.round((move.clientX - drag.startX) / colPitch);
|
|
98
|
+
const dr = Math.round((move.clientY - drag.startY) / rowPitch);
|
|
99
|
+
return kind === "move" ? { ...origin, col: origin.col + dc, row: origin.row + dr } : { ...origin, w: origin.w + dc, h: origin.h + dr };
|
|
100
|
+
};
|
|
101
|
+
const onMove = (move) => {
|
|
102
|
+
if (move.pointerId !== drag.pointerId) return;
|
|
103
|
+
if (!moved && Math.hypot(move.clientX - drag.startX, move.clientY - drag.startY) < DRAG_THRESHOLD) return;
|
|
104
|
+
moved = true;
|
|
105
|
+
const placed = placeCanvasItem(live.current.layout, item.id, targetFor(move)).find((candidate) => candidate.id === item.id);
|
|
106
|
+
if (!placed) return;
|
|
107
|
+
last = { col: placed.col, row: placed.row, w: placed.w, h: placed.h };
|
|
108
|
+
setPreview({ id: item.id, placement: last });
|
|
109
|
+
};
|
|
110
|
+
const finish = (drop) => {
|
|
111
|
+
drag.stop();
|
|
112
|
+
dragRef.current = null;
|
|
113
|
+
setPreview(null);
|
|
114
|
+
const changed = last.col !== origin.col || last.row !== origin.row || last.w !== origin.w || last.h !== origin.h;
|
|
115
|
+
if (drop && moved && changed) commit(item.id, last, kind, "pointer");
|
|
116
|
+
};
|
|
117
|
+
const onUp = (up) => {
|
|
118
|
+
if (up.pointerId === drag.pointerId) finish(true);
|
|
119
|
+
};
|
|
120
|
+
const onCancel = (cancel) => {
|
|
121
|
+
if (cancel.pointerId === drag.pointerId) finish(false);
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener("pointermove", onMove);
|
|
124
|
+
window.addEventListener("pointerup", onUp);
|
|
125
|
+
window.addEventListener("pointercancel", onCancel);
|
|
126
|
+
const drag = {
|
|
127
|
+
id: item.id,
|
|
128
|
+
kind,
|
|
129
|
+
pointerId: event.pointerId,
|
|
130
|
+
startX: event.clientX,
|
|
131
|
+
startY: event.clientY,
|
|
132
|
+
origin,
|
|
133
|
+
colPitch,
|
|
134
|
+
rowPitch,
|
|
135
|
+
stop: () => {
|
|
136
|
+
window.removeEventListener("pointermove", onMove);
|
|
137
|
+
window.removeEventListener("pointerup", onUp);
|
|
138
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
dragRef.current = drag;
|
|
142
|
+
};
|
|
143
|
+
const placementStyle = (placement, readingIndex) => ({
|
|
144
|
+
"--uix-dashboard-canvas-col": placement.col + 1,
|
|
145
|
+
"--uix-dashboard-canvas-row-start": placement.row + 1,
|
|
146
|
+
"--uix-dashboard-canvas-w": placement.w,
|
|
147
|
+
"--uix-dashboard-canvas-h": placement.h,
|
|
148
|
+
"--uix-dashboard-canvas-order": readingIndex
|
|
149
|
+
});
|
|
150
|
+
const rootStyle = rowHeight ? { ...style, "--uix-dashboard-canvas-row": rowHeight } : style;
|
|
151
|
+
return /* @__PURE__ */ jsxs(
|
|
152
|
+
"div",
|
|
153
|
+
{
|
|
154
|
+
...props,
|
|
155
|
+
ref: gridRef,
|
|
156
|
+
role: "region",
|
|
157
|
+
"aria-label": labels.region,
|
|
158
|
+
className: cx(
|
|
159
|
+
"uix-dashboard-canvas",
|
|
160
|
+
gap === "sm" && "uix-dashboard-canvas--gap-sm",
|
|
161
|
+
gap === "lg" && "uix-dashboard-canvas--gap-lg",
|
|
162
|
+
editable && "uix-dashboard-canvas--editing",
|
|
163
|
+
preview && "uix-dashboard-canvas--dragging",
|
|
164
|
+
className
|
|
165
|
+
),
|
|
166
|
+
style: rootStyle,
|
|
167
|
+
children: [
|
|
168
|
+
layout.map((item) => {
|
|
169
|
+
const name = itemLabel(item);
|
|
170
|
+
const dragging = preview?.id === item.id;
|
|
171
|
+
return /* @__PURE__ */ jsxs(
|
|
172
|
+
"div",
|
|
173
|
+
{
|
|
174
|
+
className: cx("uix-dashboard-canvas__item", dragging && "uix-dashboard-canvas__item--dragging"),
|
|
175
|
+
style: placementStyle(item, order.get(item.id) ?? 0),
|
|
176
|
+
"data-canvas-id": item.id,
|
|
177
|
+
children: [
|
|
178
|
+
renderItem(item),
|
|
179
|
+
editable ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
180
|
+
/* @__PURE__ */ jsx(
|
|
181
|
+
"button",
|
|
182
|
+
{
|
|
183
|
+
type: "button",
|
|
184
|
+
className: "uix-dashboard-canvas__handle uix-dashboard-canvas__move",
|
|
185
|
+
"aria-label": fillLabel(labels.move, { label: name }),
|
|
186
|
+
"aria-describedby": instructionsId,
|
|
187
|
+
onKeyDown: (event) => onHandleKeyDown(event, item, "move"),
|
|
188
|
+
onPointerDown: (event) => onHandlePointerDown(event, item, "move"),
|
|
189
|
+
children: /* @__PURE__ */ jsx(GripVerticalIcon, { "aria-hidden": "true" })
|
|
190
|
+
}
|
|
191
|
+
),
|
|
192
|
+
/* @__PURE__ */ jsx(
|
|
193
|
+
"button",
|
|
194
|
+
{
|
|
195
|
+
type: "button",
|
|
196
|
+
className: "uix-dashboard-canvas__handle uix-dashboard-canvas__resize",
|
|
197
|
+
"aria-label": fillLabel(labels.resize, { label: name }),
|
|
198
|
+
"aria-describedby": instructionsId,
|
|
199
|
+
onKeyDown: (event) => onHandleKeyDown(event, item, "resize"),
|
|
200
|
+
onPointerDown: (event) => onHandlePointerDown(event, item, "resize"),
|
|
201
|
+
children: /* @__PURE__ */ jsx(Maximize2Icon, { "aria-hidden": "true" })
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
] }) : null
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
item.id
|
|
208
|
+
);
|
|
209
|
+
}),
|
|
210
|
+
preview ? /* @__PURE__ */ jsx(
|
|
211
|
+
"div",
|
|
212
|
+
{
|
|
213
|
+
className: "uix-dashboard-canvas__preview",
|
|
214
|
+
style: placementStyle(preview.placement, order.get(preview.id) ?? 0),
|
|
215
|
+
"aria-hidden": "true"
|
|
216
|
+
}
|
|
217
|
+
) : null,
|
|
218
|
+
editable ? /* @__PURE__ */ jsx("span", { id: instructionsId, className: "uix-visually-hidden", children: labels.instructions }) : null,
|
|
219
|
+
/* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: announcement })
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
DEFAULT_DASHBOARD_CANVAS_LABELS,
|
|
226
|
+
DashboardCanvas
|
|
227
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure model for `DashboardCanvas` (TENSOR HAR-1823): a free dashboard layout on a 12-column
|
|
3
|
+
* grid. Every item has a place and a size in grid units — `col` and `row` (0-based), `w`
|
|
4
|
+
* columns wide and `h` rows tall — and may declare the smallest and largest size it accepts.
|
|
5
|
+
*
|
|
6
|
+
* The canvas is free: an item stays where it is put, and nothing floats up into a gap. When a
|
|
7
|
+
* move or a resize lands on other items, they are pushed down below it, in reading order, and
|
|
8
|
+
* whatever they then land on is pushed in turn. `compactCanvas` is the separate, explicit
|
|
9
|
+
* "close the gaps" step for a product that wants one (after removing a widget, say).
|
|
10
|
+
*
|
|
11
|
+
* The same functions back the pointer drag in the component and the keyboard alternative a
|
|
12
|
+
* product puts in each item's menu (Move up / down / left / right, Wider / Narrower, Taller /
|
|
13
|
+
* Shorter), so dragging is never the only way to arrange the canvas (WCAG 2.5.7).
|
|
14
|
+
*/
|
|
15
|
+
/** The canvas has twelve columns: 12 divides into halves, thirds, quarters and sixths. */
|
|
16
|
+
declare const DASHBOARD_CANVAS_COLUMNS = 12;
|
|
17
|
+
interface DashboardCanvasItem {
|
|
18
|
+
/** Stable id: the key of the cell and of every change reported for it. */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** First column, 0-based (0 – 11). */
|
|
21
|
+
readonly col: number;
|
|
22
|
+
/** First row, 0-based. */
|
|
23
|
+
readonly row: number;
|
|
24
|
+
/** Width in columns. */
|
|
25
|
+
readonly w: number;
|
|
26
|
+
/** Height in rows. */
|
|
27
|
+
readonly h: number;
|
|
28
|
+
/** Narrowest the item may be, in columns. Default 1. */
|
|
29
|
+
readonly minW?: number;
|
|
30
|
+
/** Widest the item may be, in columns. Default 12. */
|
|
31
|
+
readonly maxW?: number;
|
|
32
|
+
/** Shortest the item may be, in rows. Default 1. */
|
|
33
|
+
readonly minH?: number;
|
|
34
|
+
/** Tallest the item may be, in rows. Default: no limit. */
|
|
35
|
+
readonly maxH?: number;
|
|
36
|
+
}
|
|
37
|
+
/** A keyboard move: one column or one row at a time. */
|
|
38
|
+
type DashboardCanvasDirection = 'up' | 'down' | 'left' | 'right';
|
|
39
|
+
/** A keyboard resize: one column or one row at a time. */
|
|
40
|
+
type DashboardCanvasResize = 'wider' | 'narrower' | 'taller' | 'shorter';
|
|
41
|
+
/** Where and how big an item is, in grid units. */
|
|
42
|
+
interface DashboardCanvasPlacement {
|
|
43
|
+
readonly col: number;
|
|
44
|
+
readonly row: number;
|
|
45
|
+
readonly w: number;
|
|
46
|
+
readonly h: number;
|
|
47
|
+
}
|
|
48
|
+
/** An item's size limits, made sane: whole numbers, inside the canvas, minimum never above maximum. */
|
|
49
|
+
declare function canvasItemLimits(item: DashboardCanvasItem): {
|
|
50
|
+
readonly minW: number;
|
|
51
|
+
readonly maxW: number;
|
|
52
|
+
readonly minH: number;
|
|
53
|
+
readonly maxH: number;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The item made valid: whole numbers, its size inside its own limits and the canvas, and its
|
|
57
|
+
* place inside the twelve columns (a too-wide item at the right edge moves left). Limits from
|
|
58
|
+
* an untyped caller are made sane too: a maximum below the minimum is raised to it.
|
|
59
|
+
*/
|
|
60
|
+
declare function clampCanvasItem<T extends DashboardCanvasItem>(item: T): T;
|
|
61
|
+
/** True when two different items share at least one cell. */
|
|
62
|
+
declare function canvasItemsOverlap(a: DashboardCanvasPlacement & {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
}, b: DashboardCanvasPlacement & {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
}): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Reading order: row by row from the top, left to right in a row. Ties keep the order they
|
|
69
|
+
* came in. This is the order the canvas collapses to on a narrow screen and the order a
|
|
70
|
+
* screen reader meets the items in.
|
|
71
|
+
*/
|
|
72
|
+
declare function canvasReadingOrder<T extends DashboardCanvasItem>(items: readonly T[]): T[];
|
|
73
|
+
/** The number of rows the canvas uses: one past the lowest item's last row. */
|
|
74
|
+
declare function canvasRowCount(items: readonly DashboardCanvasItem[]): number;
|
|
75
|
+
/**
|
|
76
|
+
* A layout with every item valid and no two sharing a cell. Use it on a stored layout before
|
|
77
|
+
* showing it: an overlap someone saved elsewhere is settled the same way a move settles one.
|
|
78
|
+
*/
|
|
79
|
+
declare function normalizeCanvas<T extends DashboardCanvasItem>(items: readonly T[]): T[];
|
|
80
|
+
/**
|
|
81
|
+
* Puts one item at a new place and size (clamped to its limits and the canvas) and pushes
|
|
82
|
+
* what it lands on down. Every other item keeps its place unless it had to move. Returns the
|
|
83
|
+
* items in the order they came in; an unknown id returns them unchanged.
|
|
84
|
+
*/
|
|
85
|
+
declare function placeCanvasItem<T extends DashboardCanvasItem>(items: readonly T[], id: string, placement: Partial<DashboardCanvasPlacement>): T[];
|
|
86
|
+
/** One step of a keyboard move, as a new place. Null when the item is already at that edge. */
|
|
87
|
+
declare function canvasMoveTarget(item: DashboardCanvasItem, direction: DashboardCanvasDirection): DashboardCanvasPlacement | null;
|
|
88
|
+
/**
|
|
89
|
+
* One step of a keyboard resize, as a new place and size. `wider` grows to the right, or to the
|
|
90
|
+
* left when the item already touches the right edge. Null when the item is at its limit.
|
|
91
|
+
*/
|
|
92
|
+
declare function canvasResizeTarget(item: DashboardCanvasItem, change: DashboardCanvasResize): DashboardCanvasPlacement | null;
|
|
93
|
+
/** Moves an item one column or row. At an edge the items come back unchanged. */
|
|
94
|
+
declare function moveCanvasItem<T extends DashboardCanvasItem>(items: readonly T[], id: string, direction: DashboardCanvasDirection): T[];
|
|
95
|
+
/** Resizes an item by one column or row. At a limit the items come back unchanged. */
|
|
96
|
+
declare function resizeCanvasItem<T extends DashboardCanvasItem>(items: readonly T[], id: string, change: DashboardCanvasResize): T[];
|
|
97
|
+
/**
|
|
98
|
+
* Closes the gaps: in reading order, each item rises as far as it can without landing on one
|
|
99
|
+
* above it. Columns and sizes never change. Not called by the canvas itself — a free canvas
|
|
100
|
+
* keeps the gaps a person leaves — but by a product that wants a tidy layout.
|
|
101
|
+
*/
|
|
102
|
+
declare function compactCanvas<T extends DashboardCanvasItem>(items: readonly T[]): T[];
|
|
103
|
+
|
|
104
|
+
export { DASHBOARD_CANVAS_COLUMNS, type DashboardCanvasDirection, type DashboardCanvasItem, type DashboardCanvasPlacement, type DashboardCanvasResize, canvasItemLimits, canvasItemsOverlap, canvasMoveTarget, canvasReadingOrder, canvasResizeTarget, canvasRowCount, clampCanvasItem, compactCanvas, moveCanvasItem, normalizeCanvas, placeCanvasItem, resizeCanvasItem };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const DASHBOARD_CANVAS_COLUMNS = 12;
|
|
2
|
+
const whole = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.round(value) : fallback;
|
|
3
|
+
const clampTo = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
4
|
+
function canvasItemLimits(item) {
|
|
5
|
+
const minW = clampTo(whole(item.minW, 1), 1, DASHBOARD_CANVAS_COLUMNS);
|
|
6
|
+
const maxW = clampTo(whole(item.maxW, DASHBOARD_CANVAS_COLUMNS), minW, DASHBOARD_CANVAS_COLUMNS);
|
|
7
|
+
const minH = Math.max(1, whole(item.minH, 1));
|
|
8
|
+
const maxH = Math.max(minH, whole(item.maxH, Number.POSITIVE_INFINITY));
|
|
9
|
+
return { minW, maxW, minH, maxH };
|
|
10
|
+
}
|
|
11
|
+
function clampCanvasItem(item) {
|
|
12
|
+
const { minW, maxW, minH, maxH } = canvasItemLimits(item);
|
|
13
|
+
const w = clampTo(whole(item.w, minW), minW, maxW);
|
|
14
|
+
const h = clampTo(whole(item.h, minH), minH, maxH);
|
|
15
|
+
const col = clampTo(whole(item.col, 0), 0, DASHBOARD_CANVAS_COLUMNS - w);
|
|
16
|
+
const row = Math.max(0, whole(item.row, 0));
|
|
17
|
+
if (col === item.col && row === item.row && w === item.w && h === item.h) return item;
|
|
18
|
+
return { ...item, col, row, w, h };
|
|
19
|
+
}
|
|
20
|
+
function canvasItemsOverlap(a, b) {
|
|
21
|
+
return a.id !== b.id && a.col < b.col + b.w && b.col < a.col + a.w && a.row < b.row + b.h && b.row < a.row + a.h;
|
|
22
|
+
}
|
|
23
|
+
function canvasReadingOrder(items) {
|
|
24
|
+
return items.map((item, index) => ({ item, index })).sort((a, b) => a.item.row - b.item.row || a.item.col - b.item.col || a.index - b.index).map(({ item }) => item);
|
|
25
|
+
}
|
|
26
|
+
function canvasRowCount(items) {
|
|
27
|
+
return items.reduce((rows, item) => Math.max(rows, item.row + item.h), 0);
|
|
28
|
+
}
|
|
29
|
+
function settle(fixed, rest) {
|
|
30
|
+
const settled = [...fixed];
|
|
31
|
+
for (const item of canvasReadingOrder(rest)) {
|
|
32
|
+
let candidate = item;
|
|
33
|
+
for (; ; ) {
|
|
34
|
+
const below = settled.filter((other) => canvasItemsOverlap(candidate, other)).reduce((lowest, other) => Math.max(lowest, other.row + other.h), -1);
|
|
35
|
+
if (below < 0) break;
|
|
36
|
+
candidate = { ...candidate, row: below };
|
|
37
|
+
}
|
|
38
|
+
settled.push(candidate);
|
|
39
|
+
}
|
|
40
|
+
return settled;
|
|
41
|
+
}
|
|
42
|
+
const inInputOrder = (items, settled) => {
|
|
43
|
+
const byId = new Map(settled.map((item) => [item.id, item]));
|
|
44
|
+
return items.map((item) => byId.get(item.id) ?? item);
|
|
45
|
+
};
|
|
46
|
+
function normalizeCanvas(items) {
|
|
47
|
+
const clamped = items.map((item) => clampCanvasItem(item));
|
|
48
|
+
return inInputOrder(clamped, settle([], clamped));
|
|
49
|
+
}
|
|
50
|
+
function placeCanvasItem(items, id, placement) {
|
|
51
|
+
const target = items.find((item) => item.id === id);
|
|
52
|
+
if (!target) return [...items];
|
|
53
|
+
const moved = clampCanvasItem({ ...target, ...placement });
|
|
54
|
+
const others = items.filter((item) => item.id !== id).map((item) => clampCanvasItem(item));
|
|
55
|
+
return inInputOrder(items, settle([moved], others));
|
|
56
|
+
}
|
|
57
|
+
function canvasMoveTarget(item, direction) {
|
|
58
|
+
const current = clampCanvasItem(item);
|
|
59
|
+
const col = current.col + (direction === "left" ? -1 : direction === "right" ? 1 : 0);
|
|
60
|
+
const row = current.row + (direction === "up" ? -1 : direction === "down" ? 1 : 0);
|
|
61
|
+
if (col < 0 || col + current.w > DASHBOARD_CANVAS_COLUMNS || row < 0) return null;
|
|
62
|
+
return { col, row, w: current.w, h: current.h };
|
|
63
|
+
}
|
|
64
|
+
function canvasResizeTarget(item, change) {
|
|
65
|
+
const current = clampCanvasItem(item);
|
|
66
|
+
const limits = canvasItemLimits(current);
|
|
67
|
+
if (change === "wider") {
|
|
68
|
+
if (current.w >= limits.maxW) return null;
|
|
69
|
+
const col = current.col + current.w < DASHBOARD_CANVAS_COLUMNS ? current.col : current.col - 1;
|
|
70
|
+
return { col, row: current.row, w: current.w + 1, h: current.h };
|
|
71
|
+
}
|
|
72
|
+
if (change === "narrower") {
|
|
73
|
+
return current.w <= limits.minW ? null : { col: current.col, row: current.row, w: current.w - 1, h: current.h };
|
|
74
|
+
}
|
|
75
|
+
if (change === "taller") {
|
|
76
|
+
return current.h >= limits.maxH ? null : { col: current.col, row: current.row, w: current.w, h: current.h + 1 };
|
|
77
|
+
}
|
|
78
|
+
return current.h <= limits.minH ? null : { col: current.col, row: current.row, w: current.w, h: current.h - 1 };
|
|
79
|
+
}
|
|
80
|
+
function moveCanvasItem(items, id, direction) {
|
|
81
|
+
const item = items.find((candidate) => candidate.id === id);
|
|
82
|
+
const target = item ? canvasMoveTarget(item, direction) : null;
|
|
83
|
+
return target ? placeCanvasItem(items, id, target) : [...items];
|
|
84
|
+
}
|
|
85
|
+
function resizeCanvasItem(items, id, change) {
|
|
86
|
+
const item = items.find((candidate) => candidate.id === id);
|
|
87
|
+
const target = item ? canvasResizeTarget(item, change) : null;
|
|
88
|
+
return target ? placeCanvasItem(items, id, target) : [...items];
|
|
89
|
+
}
|
|
90
|
+
function compactCanvas(items) {
|
|
91
|
+
const settled = [];
|
|
92
|
+
for (const item of canvasReadingOrder(normalizeCanvas(items))) {
|
|
93
|
+
let row = item.row;
|
|
94
|
+
while (row > 0 && !settled.some((other) => canvasItemsOverlap({ ...item, row: row - 1 }, other))) row -= 1;
|
|
95
|
+
settled.push(row === item.row ? item : { ...item, row });
|
|
96
|
+
}
|
|
97
|
+
return inInputOrder(items, settled);
|
|
98
|
+
}
|
|
99
|
+
export {
|
|
100
|
+
DASHBOARD_CANVAS_COLUMNS,
|
|
101
|
+
canvasItemLimits,
|
|
102
|
+
canvasItemsOverlap,
|
|
103
|
+
canvasMoveTarget,
|
|
104
|
+
canvasReadingOrder,
|
|
105
|
+
canvasResizeTarget,
|
|
106
|
+
canvasRowCount,
|
|
107
|
+
clampCanvasItem,
|
|
108
|
+
compactCanvas,
|
|
109
|
+
moveCanvasItem,
|
|
110
|
+
normalizeCanvas,
|
|
111
|
+
placeCanvasItem,
|
|
112
|
+
resizeCanvasItem
|
|
113
|
+
};
|