@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.
@@ -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]);