@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/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { cx } from "./cx.js";
2
2
  import { useDialog } from "./hooks/useDialog.js";
3
+ import { useTable } from "./hooks/useTable.js";
4
+ import { useAnchoredPosition } from "./hooks/useAnchoredPosition.js";
5
+ import { computePosition } from "./overlay-position.js";
3
6
  import { Button, ButtonGroup } from "./components/Button.js";
4
7
  import { Input, InputGroup } from "./components/Input.js";
5
8
  import { Textarea } from "./components/Textarea.js";
@@ -24,7 +27,22 @@ import { NavFavourites } from "./components/NavFavourites.js";
24
27
  import { Alert } from "./components/Alert.js";
25
28
  import { Spinner } from "./components/Spinner.js";
26
29
  import { Toast, Toaster } from "./components/Toast.js";
27
- import { TableWrap, Table, Th, Td, Tr } from "./components/Table.js";
30
+ import {
31
+ TableWrap,
32
+ Table,
33
+ Th,
34
+ Td,
35
+ Tr,
36
+ BulkBar,
37
+ RowActions,
38
+ RowAction,
39
+ ExpandToggle,
40
+ CellStrong,
41
+ CellSub,
42
+ Mark,
43
+ Highlighted
44
+ } from "./components/Table.js";
45
+ export * from "./table-engine.js";
28
46
  import { Pagination } from "./components/Pagination.js";
29
47
  import { StatusPill } from "./components/StatusPill.js";
30
48
  import { Progress } from "./components/Progress.js";
@@ -35,6 +53,8 @@ import { EmptyState, ErrorState, Skeleton, LoadingState } from "./components/Sta
35
53
  import { Label } from "./components/Label.js";
36
54
  import { Avatar, AvatarGroup, UserChip } from "./components/Avatar.js";
37
55
  import { Comments, Comment } from "./components/Comments.js";
56
+ import { Composer, ComposerBar } from "./components/Composer.js";
57
+ import { Segmented, SegmentedOption } from "./components/Segmented.js";
38
58
  import { Timeline, TimelineItem } from "./components/Timeline.js";
39
59
  import { Prose, Note } from "./components/Prose.js";
40
60
  import { Popover } from "./components/Popover.js";
@@ -42,27 +62,35 @@ import { CommandPalette, CommandGroup, CommandItem } from "./components/CommandP
42
62
  import { Inbox, InboxList, InboxItem, InboxDetail } from "./components/Inbox.js";
43
63
  import { Kanban, KanbanColumn, KanbanCard } from "./components/Kanban.js";
44
64
  import { Tree } from "./components/Tree.js";
65
+ import { flattenTree, treeNav } from "./tree-model.js";
45
66
  export {
46
67
  Alert,
47
68
  AppShell,
48
69
  Avatar,
49
70
  AvatarGroup,
71
+ BulkBar,
50
72
  Button,
51
73
  ButtonGroup,
52
74
  Card,
75
+ CellStrong,
76
+ CellSub,
53
77
  Checkbox,
54
78
  CommandGroup,
55
79
  CommandItem,
56
80
  CommandPalette,
57
81
  Comment,
58
82
  Comments,
83
+ Composer,
84
+ ComposerBar,
59
85
  DescriptionItem,
60
86
  DescriptionList,
61
87
  DetailLayout,
62
88
  Drawer,
63
89
  EmptyState,
64
90
  ErrorState,
91
+ ExpandToggle,
65
92
  Field,
93
+ Highlighted,
66
94
  Inbox,
67
95
  InboxDetail,
68
96
  InboxItem,
@@ -76,6 +104,7 @@ export {
76
104
  List,
77
105
  ListItem,
78
106
  LoadingState,
107
+ Mark,
79
108
  Meter,
80
109
  Modal,
81
110
  NavFavourites,
@@ -90,6 +119,10 @@ export {
90
119
  Prose,
91
120
  Radio,
92
121
  RadioGroup,
122
+ RowAction,
123
+ RowActions,
124
+ Segmented,
125
+ SegmentedOption,
93
126
  Select,
94
127
  Sidebar,
95
128
  SidebarSection,
@@ -115,6 +148,11 @@ export {
115
148
  Tr,
116
149
  Tree,
117
150
  UserChip,
151
+ computePosition,
118
152
  cx,
119
- useDialog
153
+ flattenTree,
154
+ treeNav,
155
+ useAnchoredPosition,
156
+ useDialog,
157
+ useTable
120
158
  };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * uix overlay positioning — framework-agnostic, dependency-free anchored placement.
3
+ *
4
+ * Pure geometry (no DOM): given the anchor's rect, the floating element's size, and
5
+ * the viewport, compute where to put the floating element with two behaviours:
6
+ * • flip — if the preferred side doesn't fit, use the opposite side (or whichever
7
+ * side has more room);
8
+ * • shift — slide along the cross axis so the element stays within the viewport.
9
+ *
10
+ * The React hook (useAnchoredPosition) and the vanilla styleguide both feed this
11
+ * getBoundingClientRect() values, so anchored overlays land in the same place in every
12
+ * browser — instead of relying on CSS anchor() positioning, which is Chromium-only and
13
+ * silently detaches the overlay from its trigger everywhere else (UIX-FIX-02).
14
+ *
15
+ * Everything here is deterministic and unit-tested (see overlay-position.test.mjs).
16
+ */
17
+ type Side = 'top' | 'bottom' | 'left' | 'right';
18
+ type Align = 'start' | 'center' | 'end';
19
+ type Placement = Side | `${Side}-${Align}`;
20
+ /** A viewport-space rectangle, as returned by getBoundingClientRect(). */
21
+ interface Rect {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ }
27
+ interface Size {
28
+ width: number;
29
+ height: number;
30
+ }
31
+ interface PositionOptions {
32
+ /** Preferred placement. Default `'bottom-start'`; a bare side (e.g. `'top'`) centers on the cross axis. */
33
+ placement?: Placement;
34
+ /** Gap between the anchor and the floating element, in px. Default 6. */
35
+ offset?: number;
36
+ /** Minimum gap kept from each viewport edge when shifting, in px. Default 8. */
37
+ padding?: number;
38
+ /** Flip to the opposite side when the preferred one doesn't fit. Default true. */
39
+ flip?: boolean;
40
+ /** Slide along the cross axis to stay on-screen. Default true. */
41
+ shift?: boolean;
42
+ }
43
+ interface PositionResult {
44
+ /** Viewport-space left, for `position: fixed`. */
45
+ x: number;
46
+ /** Viewport-space top, for `position: fixed`. */
47
+ y: number;
48
+ /** The placement actually used (after any flip). */
49
+ placement: Placement;
50
+ side: Side;
51
+ align: Align;
52
+ }
53
+ /**
54
+ * Compute the floating element's viewport-space position anchored to `anchor`.
55
+ * Coordinates are for `position: fixed` (same space as getBoundingClientRect).
56
+ */
57
+ declare function computePosition(anchor: Rect, floating: Size, viewport: Size, options?: PositionOptions): PositionResult;
58
+
59
+ export { type Align, type Placement, type PositionOptions, type PositionResult, type Rect, type Side, type Size, computePosition };
@@ -0,0 +1,62 @@
1
+ const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
2
+ function parsePlacement(p) {
3
+ const [side, align] = p.split("-");
4
+ return { side, align: align ?? "center" };
5
+ }
6
+ const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
7
+ function spaceOn(side, a, vp, offset) {
8
+ switch (side) {
9
+ case "top":
10
+ return a.y - offset;
11
+ case "bottom":
12
+ return vp.height - (a.y + a.height) - offset;
13
+ case "left":
14
+ return a.x - offset;
15
+ case "right":
16
+ return vp.width - (a.x + a.width) - offset;
17
+ }
18
+ }
19
+ function fitsOn(side, a, f, vp, offset, padding) {
20
+ const need = side === "top" || side === "bottom" ? f.height : f.width;
21
+ return spaceOn(side, a, vp, offset) >= need + padding;
22
+ }
23
+ function computePosition(anchor, floating, viewport, options = {}) {
24
+ const { placement = "bottom-start", offset = 6, padding = 8, flip = true, shift = true } = options;
25
+ const { side: preferred, align } = parsePlacement(placement);
26
+ let side = preferred;
27
+ if (flip && !fitsOn(side, anchor, floating, viewport, offset, padding)) {
28
+ const opp = OPPOSITE[side];
29
+ if (fitsOn(opp, anchor, floating, viewport, offset, padding) || spaceOn(opp, anchor, viewport, offset) > spaceOn(side, anchor, viewport, offset)) {
30
+ side = opp;
31
+ }
32
+ }
33
+ let x, y;
34
+ const horizontal = side === "top" || side === "bottom";
35
+ if (side === "top") y = anchor.y - floating.height - offset;
36
+ else if (side === "bottom") y = anchor.y + anchor.height + offset;
37
+ else if (side === "left") x = anchor.x - floating.width - offset;
38
+ else x = anchor.x + anchor.width + offset;
39
+ if (horizontal) {
40
+ const start = anchor.x;
41
+ const end = anchor.x + anchor.width - floating.width;
42
+ const center = anchor.x + (anchor.width - floating.width) / 2;
43
+ x = align === "start" ? start : align === "end" ? end : center;
44
+ if (shift) x = clamp(x, padding, Math.max(padding, viewport.width - floating.width - padding));
45
+ } else {
46
+ const start = anchor.y;
47
+ const end = anchor.y + anchor.height - floating.height;
48
+ const center = anchor.y + (anchor.height - floating.height) / 2;
49
+ y = align === "start" ? start : align === "end" ? end : center;
50
+ if (shift) y = clamp(y, padding, Math.max(padding, viewport.height - floating.height - padding));
51
+ }
52
+ return {
53
+ x,
54
+ y,
55
+ placement: align === "center" ? side : `${side}-${align}`,
56
+ side,
57
+ align
58
+ };
59
+ }
60
+ export {
61
+ computePosition
62
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * uix table engine — framework-agnostic, dependency-free logic for data grids.
3
+ *
4
+ * Pure functions only (no DOM, no React), so the same core drives the React
5
+ * wrappers, the vanilla styleguide, and any consumer. Everything here is
6
+ * deterministic and unit-tested (see table-engine.test.mjs). The React layer
7
+ * (useTable / Table subcomponents) composes these; products may also call them
8
+ * directly. Presentation stays in table.css via the --uix-* contract.
9
+ */
10
+ type SortDir = 'ascending' | 'descending';
11
+ interface SortKey {
12
+ /** row field to sort on */
13
+ field: string;
14
+ dir: SortDir;
15
+ }
16
+ type Primitive = string | number | boolean | null | undefined;
17
+ type Row = Record<string, unknown>;
18
+ /**
19
+ * Stable multi-column sort. `keys` are applied in priority order (first is
20
+ * primary). Returns a new array; the input is not mutated.
21
+ */
22
+ declare function multiSort<T extends Row>(rows: readonly T[], keys: readonly SortKey[]): T[];
23
+ /** Cycle a header's sort state: none → ascending → descending → none. */
24
+ declare function nextSortDir(current: SortDir | 'none' | undefined): SortDir | 'none';
25
+ /**
26
+ * Toggle a field within a multi-sort key list. Without shift, it replaces the
27
+ * list with just this field (cycling its direction); with shift, it adds/updates
28
+ * the field as a secondary key (removing it when it cycles back to none).
29
+ */
30
+ declare function toggleSort(keys: readonly SortKey[], field: string, additive?: boolean): SortKey[];
31
+ type FilterKind = 'enum' | 'text' | 'number' | 'date' | 'boolean';
32
+ type FilterOp = 'isAnyOf' | 'isNoneOf' | 'contains' | 'equals' | 'startsWith' | 'eq' | 'lt' | 'gt' | 'between' | 'is';
33
+ interface ColumnFilter {
34
+ field: string;
35
+ kind: FilterKind;
36
+ op: FilterOp;
37
+ /** value(s): a scalar, an array (enum / between), or [min,max] for between */
38
+ value: Primitive | Primitive[];
39
+ }
40
+ declare function matchFilter(row: Row, f: ColumnFilter): boolean;
41
+ /** Keep rows that satisfy EVERY active filter (AND across columns). */
42
+ declare function applyFilters<T extends Row>(rows: readonly T[], filters: readonly ColumnFilter[]): T[];
43
+ /** Rows where any of `fields` (default: all string/number values) contains `query`. */
44
+ declare function searchRows<T extends Row>(rows: readonly T[], query: string, fields?: readonly string[]): T[];
45
+ interface Segment {
46
+ text: string;
47
+ match: boolean;
48
+ }
49
+ /** Split `text` into matched / unmatched segments for <mark> rendering (case-insensitive). */
50
+ declare function highlightSegments(text: string, query: string): Segment[];
51
+ interface ViewState {
52
+ sort?: SortKey[];
53
+ filters?: ColumnFilter[];
54
+ columns?: string[];
55
+ density?: 'compact' | 'standard' | 'comfortable';
56
+ q?: string;
57
+ }
58
+ /** Serialize a view to a compact, linkable query string (no leading '?'). */
59
+ declare function serializeView(v: ViewState): string;
60
+ /** Parse a query string (with or without leading '?') back into a ViewState. */
61
+ declare function parseView(qs: string): ViewState;
62
+ interface VirtualWindow {
63
+ start: number;
64
+ end: number;
65
+ padTop: number;
66
+ padBottom: number;
67
+ total: number;
68
+ }
69
+ /**
70
+ * Compute the visible row window for a fixed row height. Renders only
71
+ * [start, end) plus `overscan` rows on each side; padTop/padBottom are spacer
72
+ * heights (px) so the scrollbar stays correct. Below `threshold` rows, callers
73
+ * should skip virtualization (render all) — see shouldVirtualize.
74
+ */
75
+ declare function virtualWindow(scrollTop: number, viewportH: number, rowH: number, count: number, overscan?: number): VirtualWindow;
76
+ declare function shouldVirtualize(count: number, threshold?: number): boolean;
77
+ /** Clamp a dragged column width to a sane minimum (and optional maximum). */
78
+ declare function clampWidth(width: number, min?: number, max?: number): number;
79
+ /** Move an item from index `from` to index `to`, returning a new array. */
80
+ declare function reorder<T>(arr: readonly T[], from: number, to: number): T[];
81
+ declare function toggleId(set: ReadonlySet<string>, id: string): Set<string>;
82
+ type SelectAllState = 'none' | 'some' | 'all';
83
+ declare function selectAllState(selected: ReadonlySet<string>, pageIds: readonly string[]): SelectAllState;
84
+ /** Toggle every id on the current page on/off based on the header checkbox. */
85
+ declare function togglePage(selected: ReadonlySet<string>, pageIds: readonly string[]): Set<string>;
86
+ /** Pinned rows (kept present regardless of filter/sort) hoisted above the rest. */
87
+ declare function mergePinned<T extends Row>(all: readonly T[], visible: readonly T[], pinnedIds: ReadonlySet<string>, idField?: string): T[];
88
+
89
+ export { type ColumnFilter, type FilterKind, type FilterOp, type Primitive, type Row, type Segment, type SelectAllState, type SortDir, type SortKey, type ViewState, type VirtualWindow, applyFilters, clampWidth, highlightSegments, matchFilter, mergePinned, multiSort, nextSortDir, parseView, reorder, searchRows, selectAllState, serializeView, shouldVirtualize, toggleId, togglePage, toggleSort, virtualWindow };
@@ -0,0 +1,251 @@
1
+ function compare(a, b) {
2
+ if (a == null && b == null) return 0;
3
+ if (a == null) return -1;
4
+ if (b == null) return 1;
5
+ if (typeof a === "number" && typeof b === "number") return a - b;
6
+ return String(a).localeCompare(String(b), void 0, { numeric: true, sensitivity: "base" });
7
+ }
8
+ function multiSort(rows, keys) {
9
+ if (!keys.length) return rows.slice();
10
+ return rows.map((row, i) => ({ row, i })).sort((x, y) => {
11
+ for (const k of keys) {
12
+ const c = compare(x.row[k.field], y.row[k.field]);
13
+ if (c !== 0) return k.dir === "ascending" ? c : -c;
14
+ }
15
+ return x.i - y.i;
16
+ }).map((w) => w.row);
17
+ }
18
+ function nextSortDir(current) {
19
+ if (current === "ascending") return "descending";
20
+ if (current === "descending") return "none";
21
+ return "ascending";
22
+ }
23
+ function toggleSort(keys, field, additive = false) {
24
+ const existing = keys.find((k) => k.field === field);
25
+ const dir = nextSortDir(existing?.dir);
26
+ if (!additive) return dir === "none" ? [] : [{ field, dir }];
27
+ const rest = keys.filter((k) => k.field !== field);
28
+ return dir === "none" ? rest : [...rest, { field, dir }];
29
+ }
30
+ function asNum(v) {
31
+ return typeof v === "number" ? v : Number(v);
32
+ }
33
+ function asTime(v) {
34
+ if (v == null) return NaN;
35
+ if (v instanceof Date) return v.getTime();
36
+ if (typeof v === "number") return v;
37
+ const s = String(v).trim();
38
+ if (s === "") return NaN;
39
+ const parsed = Date.parse(s);
40
+ return Number.isNaN(parsed) ? Number(s) : parsed;
41
+ }
42
+ function matchDate(cell, f) {
43
+ const c = asTime(cell);
44
+ if (Number.isNaN(c)) return false;
45
+ switch (f.op) {
46
+ case "eq":
47
+ return c === asTime(f.value);
48
+ case "lt":
49
+ return c < asTime(f.value);
50
+ case "gt":
51
+ return c > asTime(f.value);
52
+ case "between": {
53
+ const [lo, hi] = f.value.map(asTime);
54
+ return c >= lo && c <= hi;
55
+ }
56
+ default:
57
+ return true;
58
+ }
59
+ }
60
+ function matchFilter(row, f) {
61
+ const cell = row[f.field];
62
+ if (f.kind === "date") return matchDate(cell, f);
63
+ switch (f.op) {
64
+ case "isAnyOf":
65
+ return Array.isArray(f.value) && f.value.includes(cell);
66
+ case "isNoneOf":
67
+ return Array.isArray(f.value) && !f.value.includes(cell);
68
+ case "contains":
69
+ return String(cell ?? "").toLowerCase().includes(String(f.value ?? "").toLowerCase());
70
+ case "equals":
71
+ return String(cell ?? "") === String(f.value ?? "");
72
+ case "startsWith":
73
+ return String(cell ?? "").toLowerCase().startsWith(String(f.value ?? "").toLowerCase());
74
+ case "eq":
75
+ return asNum(cell) === asNum(f.value);
76
+ case "lt":
77
+ return asNum(cell) < asNum(f.value);
78
+ case "gt":
79
+ return asNum(cell) > asNum(f.value);
80
+ case "between": {
81
+ const [lo, hi] = f.value.map(asNum);
82
+ return asNum(cell) >= lo && asNum(cell) <= hi;
83
+ }
84
+ case "is":
85
+ return Boolean(cell) === Boolean(f.value);
86
+ default:
87
+ return true;
88
+ }
89
+ }
90
+ function applyFilters(rows, filters) {
91
+ if (!filters.length) return rows.slice();
92
+ return rows.filter((r) => filters.every((f) => matchFilter(r, f)));
93
+ }
94
+ function searchRows(rows, query, fields) {
95
+ const q = query.trim().toLowerCase();
96
+ if (!q) return rows.slice();
97
+ return rows.filter((r) => {
98
+ const vals = fields ? fields.map((f) => r[f]) : Object.values(r);
99
+ return vals.some((v) => v != null && String(v).toLowerCase().includes(q));
100
+ });
101
+ }
102
+ function highlightSegments(text, query) {
103
+ const q = query.trim();
104
+ if (!q) return [{ text, match: false }];
105
+ const out = [];
106
+ const lower = text.toLowerCase();
107
+ const ql = q.toLowerCase();
108
+ let i = 0;
109
+ for (; ; ) {
110
+ const hit = lower.indexOf(ql, i);
111
+ if (hit === -1) {
112
+ if (i < text.length) out.push({ text: text.slice(i), match: false });
113
+ break;
114
+ }
115
+ if (hit > i) out.push({ text: text.slice(i, hit), match: false });
116
+ out.push({ text: text.slice(hit, hit + q.length), match: true });
117
+ i = hit + q.length;
118
+ }
119
+ return out;
120
+ }
121
+ const ARRAY_OPS = /* @__PURE__ */ new Set(["isAnyOf", "isNoneOf", "between"]);
122
+ const escapePart = (s) => encodeURIComponent(s).replace(/~/g, "%7E");
123
+ const unescapePart = (s) => decodeURIComponent(s);
124
+ const scalarToStr = (v) => v == null ? "" : String(v);
125
+ function parseScalar(s, kind) {
126
+ switch (kind) {
127
+ case "number":
128
+ return Number(s);
129
+ case "boolean":
130
+ return s === "true";
131
+ case "date":
132
+ return /^-?\d+$/.test(s) ? Number(s) : s;
133
+ // epoch-ms number, else the ISO/date string
134
+ default:
135
+ return s;
136
+ }
137
+ }
138
+ function kindFromOp(op) {
139
+ if (op === "isAnyOf" || op === "isNoneOf") return "enum";
140
+ if (op === "is") return "boolean";
141
+ if (op === "contains" || op === "equals" || op === "startsWith") return "text";
142
+ return "number";
143
+ }
144
+ function serializeFilter(f) {
145
+ const value = Array.isArray(f.value) ? f.value.map((x) => escapePart(scalarToStr(x))).join("|") : escapePart(scalarToStr(f.value));
146
+ return `${escapePart(f.field)}~${f.kind}~${f.op}~${value}`;
147
+ }
148
+ function parseFilter(tok) {
149
+ const parts = tok.split("~");
150
+ let field, kind, op, raw;
151
+ if (parts.length >= 4) {
152
+ [field, kind, op, raw] = [unescapePart(parts[0]), parts[1], parts[2], parts[3] ?? ""];
153
+ } else {
154
+ [field, op, raw] = [unescapePart(parts[0]), parts[1], parts[2] ?? ""];
155
+ kind = kindFromOp(op);
156
+ }
157
+ if (!field || !op) return null;
158
+ const value = ARRAY_OPS.has(op) ? raw.split("|").filter((s) => s !== "").map((s) => parseScalar(unescapePart(s), kind)) : parseScalar(unescapePart(raw), kind);
159
+ return { field, kind, op, value };
160
+ }
161
+ function serializeView(v) {
162
+ const p = new URLSearchParams();
163
+ if (v.sort?.length) p.set("sort", v.sort.map((s) => `${s.field}:${s.dir === "descending" ? "d" : "a"}`).join(","));
164
+ if (v.columns?.length) p.set("cols", v.columns.join(","));
165
+ if (v.density && v.density !== "standard") p.set("density", v.density);
166
+ if (v.q) p.set("q", v.q);
167
+ if (v.filters?.length) p.set("filter", v.filters.map(serializeFilter).join(","));
168
+ return p.toString();
169
+ }
170
+ function parseView(qs) {
171
+ const p = new URLSearchParams(qs.replace(/^\?/, ""));
172
+ const v = {};
173
+ const sort = p.get("sort");
174
+ if (sort) v.sort = sort.split(",").filter(Boolean).map((tok) => {
175
+ const [field, d] = tok.split(":");
176
+ return { field, dir: d === "d" ? "descending" : "ascending" };
177
+ });
178
+ const cols = p.get("cols");
179
+ if (cols) v.columns = cols.split(",").filter(Boolean);
180
+ const density = p.get("density");
181
+ if (density === "compact" || density === "comfortable" || density === "standard") v.density = density;
182
+ const q = p.get("q");
183
+ if (q) v.q = q;
184
+ const filter = p.get("filter");
185
+ if (filter) {
186
+ v.filters = filter.split(",").filter(Boolean).map(parseFilter).filter((f) => f !== null);
187
+ }
188
+ return v;
189
+ }
190
+ function virtualWindow(scrollTop, viewportH, rowH, count, overscan = 6) {
191
+ if (rowH <= 0 || count <= 0) return { start: 0, end: count, padTop: 0, padBottom: 0, total: 0 };
192
+ const first = Math.max(0, Math.floor(scrollTop / rowH) - overscan);
193
+ const visible = Math.ceil(viewportH / rowH) + overscan * 2;
194
+ const end = Math.min(count, first + visible);
195
+ return { start: first, end, padTop: first * rowH, padBottom: (count - end) * rowH, total: count * rowH };
196
+ }
197
+ function shouldVirtualize(count, threshold = 100) {
198
+ return count > threshold;
199
+ }
200
+ function clampWidth(width, min = 64, max = Infinity) {
201
+ return Math.max(min, Math.min(max, Math.round(width)));
202
+ }
203
+ function reorder(arr, from, to) {
204
+ const next = arr.slice();
205
+ if (from < 0 || from >= next.length || to < 0 || to >= next.length) return next;
206
+ const [moved] = next.splice(from, 1);
207
+ next.splice(to, 0, moved);
208
+ return next;
209
+ }
210
+ function toggleId(set, id) {
211
+ const next = new Set(set);
212
+ next.has(id) ? next.delete(id) : next.add(id);
213
+ return next;
214
+ }
215
+ function selectAllState(selected, pageIds) {
216
+ const on = pageIds.filter((id) => selected.has(id)).length;
217
+ if (on === 0) return "none";
218
+ return on === pageIds.length ? "all" : "some";
219
+ }
220
+ function togglePage(selected, pageIds) {
221
+ const next = new Set(selected);
222
+ const all = pageIds.every((id) => next.has(id));
223
+ for (const id of pageIds) all ? next.delete(id) : next.add(id);
224
+ return next;
225
+ }
226
+ function mergePinned(all, visible, pinnedIds, idField = "id") {
227
+ if (!pinnedIds.size) return visible.slice();
228
+ const pinned = all.filter((r) => pinnedIds.has(String(r[idField])));
229
+ const pinnedSet = new Set(pinned.map((r) => String(r[idField])));
230
+ const rest = visible.filter((r) => !pinnedSet.has(String(r[idField])));
231
+ return [...pinned, ...rest];
232
+ }
233
+ export {
234
+ applyFilters,
235
+ clampWidth,
236
+ highlightSegments,
237
+ matchFilter,
238
+ mergePinned,
239
+ multiSort,
240
+ nextSortDir,
241
+ parseView,
242
+ reorder,
243
+ searchRows,
244
+ selectAllState,
245
+ serializeView,
246
+ shouldVirtualize,
247
+ toggleId,
248
+ togglePage,
249
+ toggleSort,
250
+ virtualWindow
251
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * uix tree model — framework-agnostic, dependency-free tree flattening + keyboard nav.
3
+ *
4
+ * Pure functions (no DOM, no React): flatten a nested tree into the linear list of *visible*
5
+ * rows (respecting expansion) so it can be virtualized with the table engine's virtualWindow,
6
+ * and resolve a keyboard key into a navigation decision. Both the plain and the virtualized
7
+ * <Tree> render paths use these, so the two behave identically and keyboard nav works even for
8
+ * rows that are scrolled out of the virtual window (UIX-FIX-05). Unit-tested (tree-model.test.mjs).
9
+ */
10
+ /** Minimal shape a node must have; the concrete node (label, icon, …) rides along as `T`. */
11
+ interface TreeLike<T> {
12
+ id: string;
13
+ children?: T[];
14
+ }
15
+ interface FlatNode<T> {
16
+ node: T;
17
+ /** 1-based depth, for aria-level. */
18
+ level: number;
19
+ /** number of siblings at this level (this node's parent's child count), for aria-setsize. */
20
+ setsize: number;
21
+ /** 1-based index among those siblings, for aria-posinset. */
22
+ posinset: number;
23
+ hasChildren: boolean;
24
+ isExpanded: boolean;
25
+ }
26
+ /**
27
+ * Flatten a nested tree into the visible rows, in DOM/reading order. A node's children follow
28
+ * it immediately and are included only when the node is in `expanded`.
29
+ */
30
+ declare function flattenTree<T extends TreeLike<T>>(nodes: readonly T[], expanded: ReadonlySet<string>, level?: number): FlatNode<T>[];
31
+ /** A decision produced by a key press — any subset may be set. */
32
+ interface TreeNavAction {
33
+ /** move focus (roving tabindex) to this node id. */
34
+ focusId?: string;
35
+ /** toggle this node's expansion. */
36
+ toggleId?: string;
37
+ /** select this node id. */
38
+ selectId?: string;
39
+ }
40
+ /**
41
+ * Resolve a key press against the flattened, visible node list — the single source of truth for
42
+ * both render paths. Mirrors the WAI-ARIA tree pattern: Up/Down move by row, Right expands then
43
+ * descends, Left collapses then ascends to the parent, Home/End jump, Enter/Space select (and
44
+ * toggle a parent). Returns an empty action for unhandled keys or an unknown current id.
45
+ */
46
+ declare function treeNav<T extends TreeLike<T>>(flat: readonly FlatNode<T>[], currentId: string | undefined, key: string): TreeNavAction;
47
+
48
+ export { type FlatNode, type TreeLike, type TreeNavAction, flattenTree, treeNav };
@@ -0,0 +1,49 @@
1
+ function flattenTree(nodes, expanded, level = 1) {
2
+ const out = [];
3
+ const setsize = nodes.length;
4
+ nodes.forEach((node, i) => {
5
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
6
+ const isExpanded = hasChildren && expanded.has(node.id);
7
+ out.push({ node, level, setsize, posinset: i + 1, hasChildren, isExpanded });
8
+ if (isExpanded) out.push(...flattenTree(node.children, expanded, level + 1));
9
+ });
10
+ return out;
11
+ }
12
+ function treeNav(flat, currentId, key) {
13
+ const idx = flat.findIndex((f) => f.node.id === currentId);
14
+ if (idx < 0) return {};
15
+ const cur = flat[idx];
16
+ const focusAt = (i) => {
17
+ const clamped = Math.max(0, Math.min(flat.length - 1, i));
18
+ return { focusId: flat[clamped]?.node.id };
19
+ };
20
+ switch (key) {
21
+ case "ArrowDown":
22
+ return focusAt(idx + 1);
23
+ case "ArrowUp":
24
+ return focusAt(idx - 1);
25
+ case "Home":
26
+ return focusAt(0);
27
+ case "End":
28
+ return focusAt(flat.length - 1);
29
+ case "ArrowRight":
30
+ if (cur.hasChildren && !cur.isExpanded) return { toggleId: cur.node.id };
31
+ if (cur.hasChildren && cur.isExpanded) return focusAt(idx + 1);
32
+ return {};
33
+ case "ArrowLeft":
34
+ if (cur.hasChildren && cur.isExpanded) return { toggleId: cur.node.id };
35
+ for (let i = idx - 1; i >= 0; i--) {
36
+ if (flat[i].level === cur.level - 1) return { focusId: flat[i].node.id };
37
+ }
38
+ return {};
39
+ case "Enter":
40
+ case " ":
41
+ return cur.hasChildren ? { toggleId: cur.node.id, selectId: cur.node.id } : { selectId: cur.node.id };
42
+ default:
43
+ return {};
44
+ }
45
+ }
46
+ export {
47
+ flattenTree,
48
+ treeNav
49
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor_1/react",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Thin React wrappers over the UIx v2 CSS component library.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  "scripts": {
32
32
  "build": "tsup",
33
33
  "dev": "tsup --watch",
34
- "typecheck": "tsc --noEmit"
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "node --test"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "react": "^18.0.0 || ^19.0.0",