@tensor_1/react 2.5.0 → 2.7.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/dist/components/AppShell.d.ts +16 -2
- package/dist/components/AppShell.js +39 -6
- package/dist/components/Composer.d.ts +21 -0
- package/dist/components/Composer.js +12 -0
- package/dist/components/Field.js +10 -4
- package/dist/components/Popover.d.ts +18 -4
- package/dist/components/Popover.js +31 -2
- package/dist/components/Segmented.d.ts +29 -0
- package/dist/components/Segmented.js +36 -0
- package/dist/components/Table.d.ts +45 -4
- package/dist/components/Table.js +63 -3
- package/dist/components/Timeline.d.ts +7 -1
- package/dist/components/Timeline.js +2 -2
- package/dist/components/Toast.js +5 -4
- package/dist/components/Tooltip.d.ts +9 -5
- package/dist/components/Tooltip.js +61 -3
- package/dist/components/Tree.d.ts +15 -1
- package/dist/components/Tree.js +247 -35
- package/dist/hooks/useAnchoredPosition.d.ts +22 -0
- package/dist/hooks/useAnchoredPosition.js +44 -0
- package/dist/hooks/useDialog.js +36 -5
- package/dist/hooks/useTable.d.ts +50 -0
- package/dist/hooks/useTable.js +69 -0
- package/dist/index.cjs +1107 -141
- package/dist/index.d.cts +406 -15
- package/dist/index.d.ts +9 -2
- package/dist/index.js +40 -2
- package/dist/overlay-position.d.ts +59 -0
- package/dist/overlay-position.js +62 -0
- package/dist/table-engine.d.ts +89 -0
- package/dist/table-engine.js +251 -0
- package/dist/tree-model.d.ts +48 -0
- package/dist/tree-model.js +49 -0
- package/package.json +3 -2
package/dist/components/Tree.js
CHANGED
|
@@ -1,47 +1,76 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { cx } from "../cx.js";
|
|
5
|
+
import { flattenTree, treeNav } from "../tree-model.js";
|
|
6
|
+
import { virtualWindow, shouldVirtualize } from "../table-engine.js";
|
|
5
7
|
const ChevronIcon = () => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 4l4 4 4-4" }) });
|
|
6
|
-
function TreeNode({ node, expanded, selected, onToggle, onSelect, level =
|
|
8
|
+
function TreeNode({ node, expanded, selected, selectable, tabbableId, onToggle, onSelect, onFocusNode, level = 1 }) {
|
|
7
9
|
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
|
8
10
|
const isExpanded = expanded.has(node.id);
|
|
9
|
-
return /* @__PURE__ */ jsxs(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
return /* @__PURE__ */ jsxs(
|
|
12
|
+
"li",
|
|
13
|
+
{
|
|
14
|
+
role: "treeitem",
|
|
15
|
+
"aria-level": level,
|
|
16
|
+
"aria-expanded": hasChildren ? isExpanded : void 0,
|
|
17
|
+
"aria-selected": selectable ? selected === node.id : void 0,
|
|
18
|
+
"data-id": node.id,
|
|
19
|
+
tabIndex: tabbableId === node.id ? 0 : -1,
|
|
20
|
+
className: "uix-tree__item",
|
|
21
|
+
onFocus: (e) => {
|
|
22
|
+
if (e.target === e.currentTarget) onFocusNode(node.id);
|
|
23
|
+
},
|
|
24
|
+
onClick: (e) => {
|
|
25
|
+
e.stopPropagation();
|
|
26
|
+
if (hasChildren) onToggle(node.id);
|
|
27
|
+
onSelect?.(node.id);
|
|
28
|
+
onFocusNode(node.id);
|
|
29
|
+
},
|
|
30
|
+
children: [
|
|
31
|
+
/* @__PURE__ */ jsxs("span", { className: "uix-tree__row", children: [
|
|
21
32
|
/* @__PURE__ */ jsx("span", { className: "uix-tree__toggle", children: hasChildren && /* @__PURE__ */ jsx(ChevronIcon, {}) }),
|
|
22
33
|
node.icon && /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: node.icon }),
|
|
23
|
-
node.label
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
/* @__PURE__ */ jsx("span", { className: "uix-tree__label", children: node.label })
|
|
35
|
+
] }),
|
|
36
|
+
hasChildren && isExpanded && /* @__PURE__ */ jsx("ul", { role: "group", className: "uix-tree__group", children: node.children.map((child) => /* @__PURE__ */ jsx(
|
|
37
|
+
TreeNode,
|
|
38
|
+
{
|
|
39
|
+
node: child,
|
|
40
|
+
expanded,
|
|
41
|
+
selected,
|
|
42
|
+
selectable,
|
|
43
|
+
tabbableId,
|
|
44
|
+
onToggle,
|
|
45
|
+
onSelect,
|
|
46
|
+
onFocusNode,
|
|
47
|
+
level: level + 1
|
|
48
|
+
},
|
|
49
|
+
child.id
|
|
50
|
+
)) })
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
);
|
|
40
54
|
}
|
|
41
|
-
function Tree({
|
|
55
|
+
function Tree({
|
|
56
|
+
nodes,
|
|
57
|
+
expanded: controlledExpanded,
|
|
58
|
+
defaultExpanded,
|
|
59
|
+
selected,
|
|
60
|
+
onToggle,
|
|
61
|
+
onSelect,
|
|
62
|
+
virtualize,
|
|
63
|
+
rowHeight = 32,
|
|
64
|
+
maxHeight = 384,
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
}) {
|
|
42
68
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded ?? /* @__PURE__ */ new Set());
|
|
43
69
|
const expanded = controlledExpanded ?? internalExpanded;
|
|
44
|
-
|
|
70
|
+
const rootRef = useRef(null);
|
|
71
|
+
const [focusedId, setFocusedId] = useState(void 0);
|
|
72
|
+
const selectable = selected !== void 0 || onSelect != null;
|
|
73
|
+
const handleToggle = useCallback((id) => {
|
|
45
74
|
if (!controlledExpanded) {
|
|
46
75
|
setInternalExpanded((prev) => {
|
|
47
76
|
const next = new Set(prev);
|
|
@@ -51,19 +80,202 @@ function Tree({ nodes, expanded: controlledExpanded, defaultExpanded, selected,
|
|
|
51
80
|
});
|
|
52
81
|
}
|
|
53
82
|
onToggle?.(id);
|
|
83
|
+
}, [controlledExpanded, onToggle]);
|
|
84
|
+
const flat = useMemo(() => flattenTree(nodes, expanded), [nodes, expanded]);
|
|
85
|
+
const useVirtual = virtualize ?? shouldVirtualize(flat.length);
|
|
86
|
+
const tabbableId = focusedId ?? selected ?? nodes[0]?.id;
|
|
87
|
+
if (useVirtual) {
|
|
88
|
+
return /* @__PURE__ */ jsx(
|
|
89
|
+
VirtualTreeView,
|
|
90
|
+
{
|
|
91
|
+
flat,
|
|
92
|
+
rowHeight,
|
|
93
|
+
maxHeight,
|
|
94
|
+
selectable,
|
|
95
|
+
selected,
|
|
96
|
+
tabbableId,
|
|
97
|
+
onToggle: handleToggle,
|
|
98
|
+
onSelect,
|
|
99
|
+
focusedId,
|
|
100
|
+
setFocusedId,
|
|
101
|
+
className,
|
|
102
|
+
...props
|
|
103
|
+
}
|
|
104
|
+
);
|
|
54
105
|
}
|
|
55
|
-
|
|
106
|
+
const treeItems = () => Array.from(rootRef.current?.querySelectorAll('[role="treeitem"]') ?? []);
|
|
107
|
+
const focusItem = (el) => {
|
|
108
|
+
if (el) {
|
|
109
|
+
el.focus();
|
|
110
|
+
setFocusedId(el.dataset.id);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const onKeyDown = (e) => {
|
|
114
|
+
const cur = e.target.closest('[role="treeitem"]');
|
|
115
|
+
if (!cur || !rootRef.current?.contains(cur)) return;
|
|
116
|
+
const list = treeItems();
|
|
117
|
+
const idx = list.indexOf(cur);
|
|
118
|
+
const id = cur.dataset.id;
|
|
119
|
+
const exp = cur.getAttribute("aria-expanded");
|
|
120
|
+
switch (e.key) {
|
|
121
|
+
case "ArrowDown":
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
focusItem(list[Math.min(idx + 1, list.length - 1)]);
|
|
124
|
+
break;
|
|
125
|
+
case "ArrowUp":
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
focusItem(list[Math.max(idx - 1, 0)]);
|
|
128
|
+
break;
|
|
129
|
+
case "Home":
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
focusItem(list[0]);
|
|
132
|
+
break;
|
|
133
|
+
case "End":
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
focusItem(list[list.length - 1]);
|
|
136
|
+
break;
|
|
137
|
+
case "ArrowRight":
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
if (exp === "false") handleToggle(id);
|
|
140
|
+
else if (exp === "true") focusItem(cur.querySelector(':scope > [role="group"] > [role="treeitem"]'));
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowLeft":
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
if (exp === "true") handleToggle(id);
|
|
145
|
+
else focusItem(cur.parentElement?.closest('[role="treeitem"]'));
|
|
146
|
+
break;
|
|
147
|
+
case "Enter":
|
|
148
|
+
case " ":
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
if (exp !== null) handleToggle(id);
|
|
151
|
+
onSelect?.(id);
|
|
152
|
+
setFocusedId(id);
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return /* @__PURE__ */ jsx("ul", { ref: rootRef, className: cx("uix-tree", className), role: "tree", onKeyDown, ...props, children: nodes.map((node) => /* @__PURE__ */ jsx(
|
|
56
159
|
TreeNode,
|
|
57
160
|
{
|
|
58
161
|
node,
|
|
59
162
|
expanded,
|
|
60
163
|
selected,
|
|
164
|
+
selectable,
|
|
165
|
+
tabbableId,
|
|
61
166
|
onToggle: handleToggle,
|
|
62
|
-
onSelect
|
|
167
|
+
onSelect,
|
|
168
|
+
onFocusNode: setFocusedId,
|
|
169
|
+
level: 1
|
|
63
170
|
},
|
|
64
171
|
node.id
|
|
65
172
|
)) });
|
|
66
173
|
}
|
|
174
|
+
function VirtualTreeView({
|
|
175
|
+
flat,
|
|
176
|
+
rowHeight,
|
|
177
|
+
maxHeight,
|
|
178
|
+
selectable,
|
|
179
|
+
selected,
|
|
180
|
+
tabbableId,
|
|
181
|
+
onToggle,
|
|
182
|
+
onSelect,
|
|
183
|
+
focusedId,
|
|
184
|
+
setFocusedId,
|
|
185
|
+
className,
|
|
186
|
+
...props
|
|
187
|
+
}) {
|
|
188
|
+
const scrollRef = useRef(null);
|
|
189
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
190
|
+
const pendingFocus = useRef(false);
|
|
191
|
+
const win = virtualWindow(scrollTop, maxHeight, rowHeight, flat.length);
|
|
192
|
+
const rows = flat.slice(win.start, win.end);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!pendingFocus.current || focusedId == null) return;
|
|
195
|
+
const el = scrollRef.current?.querySelector(`[role="treeitem"][data-id="${CSS.escape(focusedId)}"]`);
|
|
196
|
+
if (el) {
|
|
197
|
+
el.focus();
|
|
198
|
+
pendingFocus.current = false;
|
|
199
|
+
}
|
|
200
|
+
}, [focusedId, win.start, win.end]);
|
|
201
|
+
const moveTo = (id) => {
|
|
202
|
+
const i = flat.findIndex((f) => f.node.id === id);
|
|
203
|
+
if (i < 0) return;
|
|
204
|
+
const top = i * rowHeight;
|
|
205
|
+
let next = scrollTop;
|
|
206
|
+
if (top < scrollTop) next = top;
|
|
207
|
+
else if (top + rowHeight > scrollTop + maxHeight) next = top + rowHeight - maxHeight;
|
|
208
|
+
if (next !== scrollTop) {
|
|
209
|
+
if (scrollRef.current) scrollRef.current.scrollTop = next;
|
|
210
|
+
setScrollTop(next);
|
|
211
|
+
}
|
|
212
|
+
pendingFocus.current = true;
|
|
213
|
+
setFocusedId(id);
|
|
214
|
+
};
|
|
215
|
+
const onKeyDown = (e) => {
|
|
216
|
+
const curId = e.target.closest('[role="treeitem"]')?.dataset.id ?? focusedId;
|
|
217
|
+
const act = treeNav(flat, curId, e.key);
|
|
218
|
+
if (act.focusId === void 0 && act.toggleId === void 0 && act.selectId === void 0) return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
if (act.toggleId !== void 0) onToggle(act.toggleId);
|
|
221
|
+
if (act.selectId !== void 0) {
|
|
222
|
+
onSelect?.(act.selectId);
|
|
223
|
+
if (act.focusId === void 0) {
|
|
224
|
+
pendingFocus.current = true;
|
|
225
|
+
setFocusedId(act.selectId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (act.focusId !== void 0) moveTo(act.focusId);
|
|
229
|
+
};
|
|
230
|
+
return /* @__PURE__ */ jsxs(
|
|
231
|
+
"ul",
|
|
232
|
+
{
|
|
233
|
+
ref: scrollRef,
|
|
234
|
+
className: cx("uix-tree", "uix-tree--virtual", className),
|
|
235
|
+
role: "tree",
|
|
236
|
+
style: { maxHeight, overflow: "auto" },
|
|
237
|
+
onScroll: (e) => setScrollTop(e.currentTarget.scrollTop),
|
|
238
|
+
onKeyDown,
|
|
239
|
+
...props,
|
|
240
|
+
children: [
|
|
241
|
+
win.padTop > 0 && /* @__PURE__ */ jsx("li", { "aria-hidden": "true", style: { height: win.padTop } }),
|
|
242
|
+
rows.map((f) => {
|
|
243
|
+
const rowStyle = { height: rowHeight, paddingLeft: `calc(${f.level - 1} * var(--uix-space-5))` };
|
|
244
|
+
return /* @__PURE__ */ jsx(
|
|
245
|
+
"li",
|
|
246
|
+
{
|
|
247
|
+
role: "treeitem",
|
|
248
|
+
"aria-level": f.level,
|
|
249
|
+
"aria-setsize": f.setsize,
|
|
250
|
+
"aria-posinset": f.posinset,
|
|
251
|
+
"aria-expanded": f.hasChildren ? f.isExpanded : void 0,
|
|
252
|
+
"aria-selected": selectable ? selected === f.node.id : void 0,
|
|
253
|
+
"data-id": f.node.id,
|
|
254
|
+
tabIndex: tabbableId === f.node.id ? 0 : -1,
|
|
255
|
+
className: "uix-tree__item",
|
|
256
|
+
onFocus: (e) => {
|
|
257
|
+
if (e.target === e.currentTarget) setFocusedId(f.node.id);
|
|
258
|
+
},
|
|
259
|
+
onClick: (e) => {
|
|
260
|
+
e.stopPropagation();
|
|
261
|
+
if (f.hasChildren) onToggle(f.node.id);
|
|
262
|
+
onSelect?.(f.node.id);
|
|
263
|
+
setFocusedId(f.node.id);
|
|
264
|
+
},
|
|
265
|
+
children: /* @__PURE__ */ jsxs("span", { className: "uix-tree__row", style: rowStyle, children: [
|
|
266
|
+
/* @__PURE__ */ jsx("span", { className: "uix-tree__toggle", children: f.hasChildren && /* @__PURE__ */ jsx(ChevronIcon, {}) }),
|
|
267
|
+
f.node.icon && /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: f.node.icon }),
|
|
268
|
+
/* @__PURE__ */ jsx("span", { className: "uix-tree__label", children: f.node.label })
|
|
269
|
+
] })
|
|
270
|
+
},
|
|
271
|
+
f.node.id
|
|
272
|
+
);
|
|
273
|
+
}),
|
|
274
|
+
win.padBottom > 0 && /* @__PURE__ */ jsx("li", { "aria-hidden": "true", style: { height: win.padBottom } })
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
);
|
|
278
|
+
}
|
|
67
279
|
export {
|
|
68
280
|
Tree
|
|
69
281
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
import { Placement } from '../overlay-position.js';
|
|
3
|
+
|
|
4
|
+
interface UseAnchoredPositionOptions {
|
|
5
|
+
/** Reposition while true, and (re)attach scroll/resize listeners. */
|
|
6
|
+
open: boolean;
|
|
7
|
+
placement?: Placement;
|
|
8
|
+
offset?: number;
|
|
9
|
+
padding?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Position a floating element (a native-popover overlay) against an anchor with
|
|
13
|
+
* cross-browser flip/shift — the DOM half of the overlay-position engine (UIX-FIX-02).
|
|
14
|
+
*
|
|
15
|
+
* Measures both elements with getBoundingClientRect, applies `position: fixed` + left/top
|
|
16
|
+
* (overriding the popover's default centered `inset:0; margin:auto`), and keeps the overlay
|
|
17
|
+
* glued to its anchor on scroll/resize while `open`. Returns a `reposition` fn for callers
|
|
18
|
+
* that want to nudge it manually (e.g. right when a native popover's `toggle` fires).
|
|
19
|
+
*/
|
|
20
|
+
declare function useAnchoredPosition(anchor: RefObject<HTMLElement | null> | HTMLElement | null | undefined, floatingRef: RefObject<HTMLElement | null>, { open, placement, offset, padding }: UseAnchoredPositionOptions): () => void;
|
|
21
|
+
|
|
22
|
+
export { type UseAnchoredPositionOptions, useAnchoredPosition };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
3
|
+
import { computePosition } from "../overlay-position.js";
|
|
4
|
+
const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
|
|
5
|
+
const resolve = (a) => a && typeof a === "object" && "current" in a ? a.current : a ?? null;
|
|
6
|
+
function useAnchoredPosition(anchor, floatingRef, { open, placement = "bottom-start", offset = 6, padding = 8 }) {
|
|
7
|
+
const opts = useRef({ placement, offset, padding });
|
|
8
|
+
opts.current = { placement, offset, padding };
|
|
9
|
+
const reposition = useCallback(() => {
|
|
10
|
+
const anchorEl = resolve(anchor);
|
|
11
|
+
const floating = floatingRef.current;
|
|
12
|
+
if (!anchorEl || !floating) return;
|
|
13
|
+
const s = floating.style;
|
|
14
|
+
s.position = "fixed";
|
|
15
|
+
s.inset = "auto";
|
|
16
|
+
s.margin = "0";
|
|
17
|
+
const a = anchorEl.getBoundingClientRect();
|
|
18
|
+
const { placement: p, offset: o, padding: pad } = opts.current;
|
|
19
|
+
const pos = computePosition(
|
|
20
|
+
{ x: a.x, y: a.y, width: a.width, height: a.height },
|
|
21
|
+
{ width: floating.offsetWidth, height: floating.offsetHeight },
|
|
22
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
23
|
+
{ placement: p, offset: o, padding: pad }
|
|
24
|
+
);
|
|
25
|
+
s.left = `${Math.round(pos.x)}px`;
|
|
26
|
+
s.top = `${Math.round(pos.y)}px`;
|
|
27
|
+
floating.dataset.placement = pos.placement;
|
|
28
|
+
}, [anchor, floatingRef]);
|
|
29
|
+
useIsomorphicLayoutEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
reposition();
|
|
32
|
+
const onScroll = () => reposition();
|
|
33
|
+
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
34
|
+
window.addEventListener("resize", onScroll);
|
|
35
|
+
return () => {
|
|
36
|
+
window.removeEventListener("scroll", onScroll, true);
|
|
37
|
+
window.removeEventListener("resize", onScroll);
|
|
38
|
+
};
|
|
39
|
+
}, [open, reposition]);
|
|
40
|
+
return reposition;
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
useAnchoredPosition
|
|
44
|
+
};
|
package/dist/hooks/useDialog.js
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useRef, useEffect } from "react";
|
|
3
|
+
let lockCount = 0;
|
|
4
|
+
let saved = null;
|
|
5
|
+
function lockBodyScroll() {
|
|
6
|
+
if (typeof document === "undefined") return;
|
|
7
|
+
if (++lockCount > 1) return;
|
|
8
|
+
const el = document.scrollingElement ?? document.documentElement;
|
|
9
|
+
const scrollbarW = window.innerWidth - document.documentElement.clientWidth;
|
|
10
|
+
saved = {
|
|
11
|
+
el,
|
|
12
|
+
overflow: el.style.overflow,
|
|
13
|
+
paddingRight: el.style.paddingRight,
|
|
14
|
+
overscroll: el.style.overscrollBehavior
|
|
15
|
+
};
|
|
16
|
+
el.style.overflow = "hidden";
|
|
17
|
+
if (scrollbarW > 0) {
|
|
18
|
+
const current = parseFloat(getComputedStyle(el).paddingRight) || 0;
|
|
19
|
+
el.style.paddingRight = `${current + scrollbarW}px`;
|
|
20
|
+
}
|
|
21
|
+
el.style.overscrollBehavior = "contain";
|
|
22
|
+
}
|
|
23
|
+
function unlockBodyScroll() {
|
|
24
|
+
if (typeof document === "undefined" || lockCount === 0) return;
|
|
25
|
+
if (--lockCount > 0) return;
|
|
26
|
+
if (saved) {
|
|
27
|
+
saved.el.style.overflow = saved.overflow;
|
|
28
|
+
saved.el.style.paddingRight = saved.paddingRight;
|
|
29
|
+
saved.el.style.overscrollBehavior = saved.overscroll;
|
|
30
|
+
saved = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
3
33
|
function useDialog(open) {
|
|
4
34
|
const ref = useRef(null);
|
|
5
35
|
useEffect(() => {
|
|
6
36
|
const el = ref.current;
|
|
7
|
-
if (!el)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} else {
|
|
11
|
-
if (el.open) el.close();
|
|
37
|
+
if (!el || !open) {
|
|
38
|
+
if (el && el.open) el.close();
|
|
39
|
+
return;
|
|
12
40
|
}
|
|
41
|
+
if (!el.open) el.showModal();
|
|
42
|
+
lockBodyScroll();
|
|
13
43
|
return () => {
|
|
44
|
+
unlockBodyScroll();
|
|
14
45
|
if (el.open) el.close();
|
|
15
46
|
};
|
|
16
47
|
}, [open]);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SortKey, ColumnFilter, SelectAllState, ViewState, Row } from '../table-engine.js';
|
|
2
|
+
|
|
3
|
+
interface UseTableOptions {
|
|
4
|
+
/** row field used as the stable id (selection / pinning). Default: 'id'. */
|
|
5
|
+
idField?: string;
|
|
6
|
+
initialSort?: SortKey[];
|
|
7
|
+
initialFilters?: ColumnFilter[];
|
|
8
|
+
initialQuery?: string;
|
|
9
|
+
/** restrict full-text search to these fields (default: all values). */
|
|
10
|
+
searchFields?: readonly string[];
|
|
11
|
+
/** ids kept present + hoisted regardless of filter/sort. */
|
|
12
|
+
initialPinned?: Iterable<string>;
|
|
13
|
+
}
|
|
14
|
+
interface UseTableResult<T> {
|
|
15
|
+
/** derived view: filter → search → sort, then pinned rows hoisted to the top. */
|
|
16
|
+
rows: T[];
|
|
17
|
+
sort: SortKey[];
|
|
18
|
+
filters: ColumnFilter[];
|
|
19
|
+
query: string;
|
|
20
|
+
selected: ReadonlySet<string>;
|
|
21
|
+
pinned: ReadonlySet<string>;
|
|
22
|
+
/** header select-all tri-state over the current derived rows. */
|
|
23
|
+
selectAll: SelectAllState;
|
|
24
|
+
/** cycle a column's sort; pass additive (⇧-click) to keep it as a secondary key. */
|
|
25
|
+
toggleSort: (field: string, additive?: boolean) => void;
|
|
26
|
+
setSort: (keys: SortKey[]) => void;
|
|
27
|
+
setFilters: (filters: ColumnFilter[]) => void;
|
|
28
|
+
setQuery: (query: string) => void;
|
|
29
|
+
isSelected: (id: string) => boolean;
|
|
30
|
+
toggleRow: (id: string) => void;
|
|
31
|
+
/** toggle every currently-visible row from the header checkbox. */
|
|
32
|
+
toggleAllRows: () => void;
|
|
33
|
+
clearSelection: () => void;
|
|
34
|
+
togglePin: (id: string) => void;
|
|
35
|
+
/** current state as a ViewState + its linkable query string. */
|
|
36
|
+
view: ViewState;
|
|
37
|
+
viewQueryString: string;
|
|
38
|
+
/** hydrate sort/filters/query from a saved-view query string. */
|
|
39
|
+
applyView: (queryString: string) => void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compose the framework-agnostic table-engine into React state: sorting, typed
|
|
43
|
+
* filtering, full-text search, page-scoped selection, pinning, and linkable saved
|
|
44
|
+
* views. Presentation stays in the `<Table>` primitives + table.css; this only owns
|
|
45
|
+
* the state and the derived row list. The vanilla styleguide (`guide/app.js`) runs
|
|
46
|
+
* the same engine functions, so both layers behave identically.
|
|
47
|
+
*/
|
|
48
|
+
declare function useTable<T extends Row>(data: readonly T[], options?: UseTableOptions): UseTableResult<T>;
|
|
49
|
+
|
|
50
|
+
export { type UseTableOptions, type UseTableResult, useTable };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
multiSort,
|
|
5
|
+
toggleSort,
|
|
6
|
+
applyFilters,
|
|
7
|
+
searchRows,
|
|
8
|
+
serializeView,
|
|
9
|
+
parseView,
|
|
10
|
+
mergePinned,
|
|
11
|
+
toggleId,
|
|
12
|
+
togglePage,
|
|
13
|
+
selectAllState
|
|
14
|
+
} from "../table-engine.js";
|
|
15
|
+
function useTable(data, options = {}) {
|
|
16
|
+
const { idField = "id", searchFields } = options;
|
|
17
|
+
const [sort, setSort] = useState(() => options.initialSort ?? []);
|
|
18
|
+
const [filters, setFilters] = useState(() => options.initialFilters ?? []);
|
|
19
|
+
const [query, setQuery] = useState(() => options.initialQuery ?? "");
|
|
20
|
+
const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
|
|
21
|
+
const [pinned, setPinned] = useState(() => new Set(options.initialPinned ?? []));
|
|
22
|
+
const rows = useMemo(() => {
|
|
23
|
+
const filtered = applyFilters(data, filters);
|
|
24
|
+
const searched = searchRows(filtered, query, searchFields);
|
|
25
|
+
const sorted = multiSort(searched, sort);
|
|
26
|
+
return mergePinned(data, sorted, pinned, idField);
|
|
27
|
+
}, [data, filters, query, searchFields, sort, pinned, idField]);
|
|
28
|
+
const pageIds = useMemo(() => rows.map((r) => String(r[idField])), [rows, idField]);
|
|
29
|
+
const selectAll = selectAllState(selected, pageIds);
|
|
30
|
+
const doToggleSort = useCallback((field, additive = false) => {
|
|
31
|
+
setSort((keys) => toggleSort(keys, field, additive));
|
|
32
|
+
}, []);
|
|
33
|
+
const toggleRow = useCallback((id) => setSelected((s) => toggleId(s, id)), []);
|
|
34
|
+
const toggleAllRows = useCallback(() => setSelected((s) => togglePage(s, pageIds)), [pageIds]);
|
|
35
|
+
const clearSelection = useCallback(() => setSelected(/* @__PURE__ */ new Set()), []);
|
|
36
|
+
const togglePin = useCallback((id) => setPinned((s) => toggleId(s, id)), []);
|
|
37
|
+
const view = useMemo(() => ({ sort, filters, q: query }), [sort, filters, query]);
|
|
38
|
+
const viewQueryString = useMemo(() => serializeView(view), [view]);
|
|
39
|
+
const applyView = useCallback((queryString) => {
|
|
40
|
+
const v = parseView(queryString);
|
|
41
|
+
setSort(v.sort ?? []);
|
|
42
|
+
setFilters(v.filters ?? []);
|
|
43
|
+
setQuery(v.q ?? "");
|
|
44
|
+
}, []);
|
|
45
|
+
return {
|
|
46
|
+
rows,
|
|
47
|
+
sort,
|
|
48
|
+
filters,
|
|
49
|
+
query,
|
|
50
|
+
selected,
|
|
51
|
+
pinned,
|
|
52
|
+
selectAll,
|
|
53
|
+
toggleSort: doToggleSort,
|
|
54
|
+
setSort,
|
|
55
|
+
setFilters,
|
|
56
|
+
setQuery,
|
|
57
|
+
isSelected: (id) => selected.has(id),
|
|
58
|
+
toggleRow,
|
|
59
|
+
toggleAllRows,
|
|
60
|
+
clearSelection,
|
|
61
|
+
togglePin,
|
|
62
|
+
view,
|
|
63
|
+
viewQueryString,
|
|
64
|
+
applyView
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
useTable
|
|
69
|
+
};
|