@tensor_1/react 2.6.0 → 2.8.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.
@@ -30,8 +30,36 @@ function toggleSort(keys, field, additive = false) {
30
30
  function asNum(v) {
31
31
  return typeof v === "number" ? v : Number(v);
32
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
+ }
33
60
  function matchFilter(row, f) {
34
61
  const cell = row[f.field];
62
+ if (f.kind === "date") return matchDate(cell, f);
35
63
  switch (f.op) {
36
64
  case "isAnyOf":
37
65
  return Array.isArray(f.value) && f.value.includes(cell);
@@ -90,13 +118,53 @@ function highlightSegments(text, query) {
90
118
  }
91
119
  return out;
92
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
+ }
93
161
  function serializeView(v) {
94
162
  const p = new URLSearchParams();
95
163
  if (v.sort?.length) p.set("sort", v.sort.map((s) => `${s.field}:${s.dir === "descending" ? "d" : "a"}`).join(","));
96
164
  if (v.columns?.length) p.set("cols", v.columns.join(","));
97
165
  if (v.density && v.density !== "standard") p.set("density", v.density);
98
166
  if (v.q) p.set("q", v.q);
99
- if (v.filters?.length) p.set("filter", v.filters.map((f) => `${f.field}~${f.op}~${Array.isArray(f.value) ? f.value.join("|") : f.value ?? ""}`).join(","));
167
+ if (v.filters?.length) p.set("filter", v.filters.map(serializeFilter).join(","));
100
168
  return p.toString();
101
169
  }
102
170
  function parseView(qs) {
@@ -114,11 +182,9 @@ function parseView(qs) {
114
182
  const q = p.get("q");
115
183
  if (q) v.q = q;
116
184
  const filter = p.get("filter");
117
- if (filter) v.filters = filter.split(",").filter(Boolean).map((tok) => {
118
- const [field, op, raw] = tok.split("~");
119
- const value = raw?.includes("|") ? raw.split("|") : raw;
120
- return { field, kind: "text", op, value };
121
- });
185
+ if (filter) {
186
+ v.filters = filter.split(",").filter(Boolean).map(parseFilter).filter((f) => f !== null);
187
+ }
122
188
  return v;
123
189
  }
124
190
  function virtualWindow(scrollTop, viewportH, rowH, count, overscan = 6) {
@@ -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.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Thin React wrappers over the UIx v2 CSS component library.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",