@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.
@@ -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 = 0 }) {
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("li", { children: [
10
- /* @__PURE__ */ jsxs(
11
- "button",
12
- {
13
- className: "uix-tree__row",
14
- "aria-expanded": hasChildren ? isExpanded : void 0,
15
- "aria-selected": selected === node.id,
16
- onClick: () => {
17
- if (hasChildren) onToggle(node.id);
18
- onSelect?.(node.id);
19
- },
20
- children: [
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
- hasChildren && isExpanded && /* @__PURE__ */ jsx("ul", { children: node.children.map((child) => /* @__PURE__ */ jsx(
28
- TreeNode,
29
- {
30
- node: child,
31
- expanded,
32
- selected,
33
- onToggle,
34
- onSelect,
35
- level: level + 1
36
- },
37
- child.id
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({ nodes, expanded: controlledExpanded, defaultExpanded, selected, onToggle, onSelect, className, ...props }) {
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
- function handleToggle(id) {
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
- return /* @__PURE__ */ jsx("ul", { className: cx("uix-tree", className), role: "tree", ...props, children: nodes.map((node) => /* @__PURE__ */ jsx(
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
+ };
@@ -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) return;
8
- if (open) {
9
- if (!el.open) el.showModal();
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
+ };