@tensor_1/react 2.11.0 → 2.12.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.
@@ -0,0 +1,100 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef } from "react";
4
+ import { cx } from "../cx.js";
5
+ function readToken(name) {
6
+ if (typeof getComputedStyle === "undefined") return "";
7
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
8
+ }
9
+ function uixChartPalette() {
10
+ return [1, 2, 3, 4, 5, 6, 7, 8].map((i) => readToken(`--uix-chart-${i}`));
11
+ }
12
+ function ChartCore({
13
+ engine,
14
+ option,
15
+ title,
16
+ subtitle,
17
+ height = 280,
18
+ className,
19
+ style,
20
+ ariaLabel,
21
+ tableData,
22
+ tableHeaders,
23
+ onReady,
24
+ header
25
+ }) {
26
+ const containerRef = useRef(null);
27
+ const chartRef = useRef(null);
28
+ const appliedOptionRef = useRef(null);
29
+ const latestOptionRef = useRef(option);
30
+ const onReadyRef = useRef(onReady);
31
+ const tableId = useId();
32
+ latestOptionRef.current = option;
33
+ onReadyRef.current = onReady;
34
+ useEffect(() => {
35
+ const element = containerRef.current;
36
+ if (!element) return;
37
+ const chart = engine.init(element, null, { renderer: "svg" });
38
+ chartRef.current = chart;
39
+ chart.setOption(latestOptionRef.current);
40
+ appliedOptionRef.current = latestOptionRef.current;
41
+ onReadyRef.current?.(chart);
42
+ let frame = 0;
43
+ let width = element.clientWidth;
44
+ let height2 = element.clientHeight;
45
+ const observer = new ResizeObserver(() => {
46
+ const nextWidth = element.clientWidth;
47
+ const nextHeight = element.clientHeight;
48
+ if (nextWidth === width && nextHeight === height2) return;
49
+ width = nextWidth;
50
+ height2 = nextHeight;
51
+ if (frame) return;
52
+ frame = requestAnimationFrame(() => {
53
+ frame = 0;
54
+ chart.resize();
55
+ });
56
+ });
57
+ observer.observe(element);
58
+ return () => {
59
+ observer.disconnect();
60
+ if (frame) cancelAnimationFrame(frame);
61
+ chart.dispose();
62
+ chartRef.current = null;
63
+ appliedOptionRef.current = null;
64
+ };
65
+ }, [engine]);
66
+ useEffect(() => {
67
+ const chart = chartRef.current;
68
+ if (!chart || appliedOptionRef.current === option) return;
69
+ chart.setOption(option, { notMerge: false });
70
+ appliedOptionRef.current = option;
71
+ }, [option]);
72
+ const heightValue = typeof height === "number" ? `${height}px` : height;
73
+ const effectiveAriaLabel = ariaLabel ?? title ?? "Chart";
74
+ const hasTable = Boolean(tableData?.length && tableHeaders?.length);
75
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-chart", className), style, children: [
76
+ title != null || subtitle != null || header != null ? /* @__PURE__ */ jsxs("div", { children: [
77
+ header,
78
+ title ? /* @__PURE__ */ jsx("div", { className: "uix-chart__title", children: title }) : null,
79
+ subtitle ? /* @__PURE__ */ jsx("div", { className: "uix-chart__subtitle", children: subtitle }) : null
80
+ ] }) : null,
81
+ /* @__PURE__ */ jsx(
82
+ "div",
83
+ {
84
+ ref: containerRef,
85
+ style: { height: heightValue },
86
+ role: "img",
87
+ "aria-label": effectiveAriaLabel,
88
+ "aria-describedby": hasTable ? tableId : void 0
89
+ }
90
+ ),
91
+ hasTable ? /* @__PURE__ */ jsxs("table", { id: tableId, className: "sr-only", "aria-label": `${effectiveAriaLabel} \u2014 data table`, children: [
92
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { children: tableHeaders.map((label) => /* @__PURE__ */ jsx("th", { scope: "col", children: label }, label)) }) }),
93
+ /* @__PURE__ */ jsx("tbody", { children: tableData.map((row, index) => /* @__PURE__ */ jsx("tr", { children: tableHeaders.map((label) => /* @__PURE__ */ jsx("td", { children: row[label] }, label)) }, index)) })
94
+ ] }) : null
95
+ ] });
96
+ }
97
+ export {
98
+ ChartCore,
99
+ uixChartPalette
100
+ };
@@ -1,4 +1,3 @@
1
- "use client";
2
1
  import { jsx, jsxs } from "react/jsx-runtime";
3
2
  import { cx } from "../cx.js";
4
3
  function Inbox({ children, className, ...props }) {
@@ -1,4 +1,3 @@
1
- "use client";
2
1
  import { jsx } from "react/jsx-runtime";
3
2
  import { cx } from "../cx.js";
4
3
  function Meter({ value = 0, tone, className, ...props }) {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState } from "react";
3
+ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  const ChevronDownIcon = () => /* @__PURE__ */ jsx(
6
6
  "svg",
@@ -14,7 +14,7 @@ const ChevronDownIcon = () => /* @__PURE__ */ jsx(
14
14
  strokeLinecap: "round",
15
15
  strokeLinejoin: "round",
16
16
  "aria-hidden": "true",
17
- style: { flexShrink: 0 },
17
+ className: "uix-favourites__chevron",
18
18
  children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
19
19
  }
20
20
  );
@@ -30,7 +30,7 @@ const ChevronRightIcon = () => /* @__PURE__ */ jsx(
30
30
  strokeLinecap: "round",
31
31
  strokeLinejoin: "round",
32
32
  "aria-hidden": "true",
33
- style: { flexShrink: 0 },
33
+ className: "uix-favourites__chevron",
34
34
  children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
35
35
  }
36
36
  );
@@ -53,7 +53,6 @@ const MoreIcon = () => /* @__PURE__ */ jsxs(
53
53
  ]
54
54
  }
55
55
  );
56
- const MUTED = "color-mix(in srgb, var(--uix-text) 50%, transparent)";
57
56
  function NavFavourites({
58
57
  items,
59
58
  collapsed = false,
@@ -69,7 +68,7 @@ function NavFavourites({
69
68
  const regionRef = useRef(null);
70
69
  const focusAfterRef = useRef(null);
71
70
  const triggerRefs = useRef(/* @__PURE__ */ new Map());
72
- const order = items.map((i) => i.id);
71
+ const order = useMemo(() => items.map((item) => item.id), [items]);
73
72
  useEffect(() => {
74
73
  const id = focusAfterRef.current;
75
74
  if (!id) return;
@@ -77,7 +76,7 @@ function NavFavourites({
77
76
  const el = triggerRefs.current.get(id);
78
77
  if (el) el.focus();
79
78
  else headerRef.current?.focus();
80
- }, [order.join("|")]);
79
+ }, [order]);
81
80
  const handleToggle = () => {
82
81
  if (!collapsed) {
83
82
  const active = document.activeElement;
@@ -87,7 +86,7 @@ function NavFavourites({
87
86
  }
88
87
  onToggleCollapsed?.();
89
88
  };
90
- const move = (id, dir) => {
89
+ const move = useCallback((id, dir) => {
91
90
  const idx = order.indexOf(id);
92
91
  const next = idx + dir;
93
92
  if (idx < 0 || next < 0 || next >= order.length) return;
@@ -97,14 +96,18 @@ function NavFavourites({
97
96
  reordered.splice(next, 0, it);
98
97
  focusAfterRef.current = id;
99
98
  onReorder(reordered);
100
- };
101
- const remove = (id) => {
99
+ }, [onReorder, order]);
100
+ const remove = useCallback((id) => {
102
101
  const idx = order.indexOf(id);
103
102
  const neighbour = order[idx + 1] ?? order[idx - 1] ?? null;
104
103
  focusAfterRef.current = neighbour;
105
104
  onRemove(id);
106
- };
107
- return /* @__PURE__ */ jsxs("div", { className: cx("uix-favourites", className), style: { marginBottom: "var(--uix-space-1)" }, children: [
105
+ }, [onRemove, order]);
106
+ const registerTrigger = useCallback((id, element) => {
107
+ if (element) triggerRefs.current.set(id, element);
108
+ else triggerRefs.current.delete(id);
109
+ }, []);
110
+ return /* @__PURE__ */ jsxs("div", { className: cx("uix-favourites", className), children: [
108
111
  /* @__PURE__ */ jsxs(
109
112
  "button",
110
113
  {
@@ -113,18 +116,7 @@ function NavFavourites({
113
116
  "aria-expanded": !collapsed,
114
117
  "aria-controls": regionId,
115
118
  onClick: handleToggle,
116
- className: "uix-text-eyebrow",
117
- style: {
118
- display: "flex",
119
- width: "100%",
120
- alignItems: "center",
121
- gap: "6px",
122
- padding: "8px 4px 4px",
123
- border: 0,
124
- background: "transparent",
125
- cursor: "pointer",
126
- color: "inherit"
127
- },
119
+ className: "uix-favourites__heading uix-text-eyebrow",
128
120
  children: [
129
121
  collapsed ? /* @__PURE__ */ jsx(ChevronRightIcon, {}) : /* @__PURE__ */ jsx(ChevronDownIcon, {}),
130
122
  labels.heading
@@ -137,20 +129,12 @@ function NavFavourites({
137
129
  ref: regionRef,
138
130
  id: regionId,
139
131
  hidden: collapsed,
140
- style: {
141
- display: "flex",
142
- flexDirection: "column",
143
- gap: "1px",
144
- listStyle: "none",
145
- margin: 0,
146
- padding: 0
147
- },
132
+ className: "uix-favourites__list",
148
133
  children: items.length === 0 ? /* @__PURE__ */ jsx(
149
134
  "li",
150
135
  {
151
- className: "uix-text-meta",
136
+ className: "uix-favourites__empty uix-text-meta",
152
137
  "data-empty-reason": emptyReason,
153
- style: { padding: "6px 8px", color: MUTED },
154
138
  children: labels.empty
155
139
  }
156
140
  ) : items.map((item, idx) => /* @__PURE__ */ jsx(
@@ -160,10 +144,9 @@ function NavFavourites({
160
144
  isFirst: idx === 0,
161
145
  isLast: idx === items.length - 1,
162
146
  labels,
163
- onMoveUp: () => move(item.id, -1),
164
- onMoveDown: () => move(item.id, 1),
165
- onRemove: () => remove(item.id),
166
- registerTrigger: (el) => triggerRefs.current.set(item.id, el)
147
+ onMove: move,
148
+ onRemove: remove,
149
+ registerTrigger
167
150
  },
168
151
  item.id
169
152
  ))
@@ -171,26 +154,24 @@ function NavFavourites({
171
154
  )
172
155
  ] });
173
156
  }
174
- function FavouriteRow({
157
+ const FavouriteRow = memo(function FavouriteRow2({
175
158
  item,
176
159
  isFirst,
177
160
  isLast,
178
161
  labels,
179
- onMoveUp,
180
- onMoveDown,
162
+ onMove,
181
163
  onRemove,
182
164
  registerTrigger
183
165
  }) {
184
166
  const [open, setOpen] = useState(false);
185
- const [revealed, setRevealed] = useState(false);
186
167
  const menuId = useId();
187
168
  const triggerRef = useRef(null);
188
169
  const itemRefs = useRef([]);
189
170
  const optionsTemplate = labels.options ?? "{label} favourite options";
190
171
  const actions = [
191
- ...isFirst ? [] : [{ label: labels.moveUp, onSelect: onMoveUp }],
192
- ...isLast ? [] : [{ label: labels.moveDown, onSelect: onMoveDown }],
193
- { label: labels.remove.replace("{label}", item.label), onSelect: onRemove }
172
+ ...isFirst ? [] : [{ label: labels.moveUp, onSelect: () => onMove(item.id, -1) }],
173
+ ...isLast ? [] : [{ label: labels.moveDown, onSelect: () => onMove(item.id, 1) }],
174
+ { label: labels.remove.replace("{label}", item.label), onSelect: () => onRemove(item.id) }
194
175
  ];
195
176
  useEffect(() => {
196
177
  if (open) itemRefs.current[0]?.focus();
@@ -219,34 +200,27 @@ function FavouriteRow({
219
200
  if (!e.altKey) return;
220
201
  if (e.key === "ArrowUp" && !isFirst) {
221
202
  e.preventDefault();
222
- onMoveUp();
203
+ onMove(item.id, -1);
223
204
  } else if (e.key === "ArrowDown" && !isLast) {
224
205
  e.preventDefault();
225
- onMoveDown();
206
+ onMove(item.id, 1);
226
207
  }
227
208
  };
228
- const triggerVisible = revealed || open;
229
209
  return (
230
210
  // biome-ignore lint/a11y/noStaticElementInteractions: keyboard accelerators on the row; the focusable controls inside carry their own semantics.
231
211
  /* @__PURE__ */ jsxs(
232
212
  "li",
233
213
  {
234
- style: { position: "relative", display: "flex", alignItems: "center" },
214
+ className: "uix-favourites__row",
235
215
  onKeyDown: onRowKeyDown,
236
- onMouseEnter: () => setRevealed(true),
237
- onMouseLeave: () => setRevealed(false),
238
- onFocus: () => setRevealed(true),
239
- onBlur: (e) => {
240
- if (!e.currentTarget.contains(e.relatedTarget)) setRevealed(false);
241
- },
242
216
  children: [
243
- /* @__PURE__ */ jsx("div", { style: { minWidth: 0, flex: 1 }, children: item.link }),
217
+ /* @__PURE__ */ jsx("div", { className: "uix-favourites__link", children: item.link }),
244
218
  /* @__PURE__ */ jsx(
245
219
  "button",
246
220
  {
247
221
  ref: (el) => {
248
222
  triggerRef.current = el;
249
- registerTrigger(el);
223
+ registerTrigger(item.id, el);
250
224
  },
251
225
  type: "button",
252
226
  "aria-haspopup": "menu",
@@ -254,22 +228,7 @@ function FavouriteRow({
254
228
  "aria-controls": open ? menuId : void 0,
255
229
  "aria-label": optionsTemplate.replace("{label}", item.label),
256
230
  onClick: () => setOpen((v) => !v),
257
- style: {
258
- marginLeft: "auto",
259
- display: "inline-flex",
260
- height: "20px",
261
- width: "20px",
262
- flexShrink: 0,
263
- alignItems: "center",
264
- justifyContent: "center",
265
- border: 0,
266
- borderRadius: "2px",
267
- background: "transparent",
268
- cursor: "pointer",
269
- color: MUTED,
270
- opacity: triggerVisible ? 1 : 0,
271
- transition: "opacity var(--uix-dur-fast) var(--uix-ease-out)"
272
- },
231
+ className: "uix-favourites__menu-trigger",
273
232
  children: /* @__PURE__ */ jsx(MoreIcon, {})
274
233
  }
275
234
  ),
@@ -280,7 +239,7 @@ function FavouriteRow({
280
239
  "aria-hidden": "true",
281
240
  role: "presentation",
282
241
  onClick: () => setOpen(false),
283
- style: { position: "fixed", inset: 0, cursor: "default", background: "transparent", zIndex: 40 }
242
+ className: "uix-favourites__backdrop"
284
243
  }
285
244
  ),
286
245
  /* @__PURE__ */ jsx(
@@ -289,21 +248,7 @@ function FavouriteRow({
289
248
  id: menuId,
290
249
  role: "menu",
291
250
  onKeyDown: onMenuKeyDown,
292
- style: {
293
- position: "absolute",
294
- right: 0,
295
- top: "calc(100% + 2px)",
296
- zIndex: 50,
297
- minWidth: "160px",
298
- overflow: "hidden",
299
- margin: 0,
300
- padding: "4px 0",
301
- listStyle: "none",
302
- borderRadius: "6px",
303
- border: "1px solid var(--uix-border)",
304
- background: "var(--uix-surface)",
305
- boxShadow: "var(--uix-shadow-popover)"
306
- },
251
+ className: "uix-favourites__menu",
307
252
  children: actions.map((action, i) => /* @__PURE__ */ jsx("li", { role: "presentation", children: /* @__PURE__ */ jsx(
308
253
  "button",
309
254
  {
@@ -317,25 +262,7 @@ function FavouriteRow({
317
262
  setOpen(false);
318
263
  action.onSelect();
319
264
  },
320
- style: {
321
- display: "flex",
322
- width: "100%",
323
- alignItems: "center",
324
- padding: "6px 12px",
325
- border: 0,
326
- background: "transparent",
327
- cursor: "pointer",
328
- textAlign: "left",
329
- fontSize: "13px",
330
- color: "color-mix(in srgb, var(--uix-text) 85%, transparent)",
331
- transition: "background-color var(--uix-dur-fast) var(--uix-ease-out)"
332
- },
333
- onMouseEnter: (e) => {
334
- e.currentTarget.style.background = "var(--uix-bg-hover)";
335
- },
336
- onMouseLeave: (e) => {
337
- e.currentTarget.style.background = "transparent";
338
- },
265
+ className: "uix-favourites__menu-item",
339
266
  children: action.label
340
267
  }
341
268
  ) }, action.label))
@@ -346,7 +273,7 @@ function FavouriteRow({
346
273
  }
347
274
  )
348
275
  );
349
- }
276
+ });
350
277
  export {
351
278
  NavFavourites
352
279
  };
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import { createContext, useContext } from "react";
3
+ import { createContext, useContext, useMemo } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  const SegmentedCtx = createContext({});
6
6
  function Segmented({ value, onChange, children, className, ...props }) {
7
- return /* @__PURE__ */ jsx(SegmentedCtx.Provider, { value: { value, onChange }, children: /* @__PURE__ */ jsx("div", { role: "group", className: cx("uix-segmented", className), ...props, children }) });
7
+ const contextValue = useMemo(() => ({ value, onChange }), [value, onChange]);
8
+ return /* @__PURE__ */ jsx(SegmentedCtx.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { role: "group", className: cx("uix-segmented", className), ...props, children }) });
8
9
  }
9
10
  function SegmentedOption({
10
11
  value,
@@ -4,7 +4,7 @@ import { HTMLAttributes, ReactNode, ButtonHTMLAttributes, TdHTMLAttributes, Tabl
4
4
  interface TableWrapProps extends HTMLAttributes<HTMLDivElement> {
5
5
  children?: ReactNode;
6
6
  }
7
- declare function TableWrap({ children, className, ...props }: TableWrapProps): react.JSX.Element;
7
+ declare const TableWrap: react.ForwardRefExoticComponent<TableWrapProps & react.RefAttributes<HTMLDivElement>>;
8
8
  type TableDensity = 'compact' | 'standard' | 'comfortable';
9
9
  type SortDirection = 'ascending' | 'descending' | 'none';
10
10
  interface TableProps extends TableHTMLAttributes<HTMLTableElement> {
@@ -1,9 +1,10 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
2
3
  import { cx } from "../cx.js";
3
4
  import { highlightSegments } from "../table-engine.js";
4
- function TableWrap({ children, className, ...props }) {
5
- return /* @__PURE__ */ jsx("div", { className: cx("uix-table-wrap", className), ...props, children });
6
- }
5
+ const TableWrap = forwardRef(function TableWrap2({ children, className, ...props }, ref) {
6
+ return /* @__PURE__ */ jsx("div", { ref, className: cx("uix-table-wrap", className), ...props, children });
7
+ });
7
8
  function Table({
8
9
  density = "standard",
9
10
  zebra = true,
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import { createContext, useContext } from "react";
3
+ import { createContext, useContext, useMemo } from "react";
4
4
  import { cx } from "../cx.js";
5
5
  const TabsCtx = createContext({});
6
6
  function Tabs({ variant = "line", value, onChange, children, className }) {
7
- return /* @__PURE__ */ jsx(TabsCtx.Provider, { value: { value, onChange }, children: /* @__PURE__ */ jsx("div", { role: "tablist", className: cx("uix-tabs", `uix-tabs--${variant}`, className), children }) });
7
+ const contextValue = useMemo(() => ({ value, onChange }), [value, onChange]);
8
+ return /* @__PURE__ */ jsx(TabsCtx.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { role: "tablist", className: cx("uix-tabs", `uix-tabs--${variant}`, className), children }) });
8
9
  }
9
10
  function Tab({ value, children, disabled, className, onClick, ...props }) {
10
11
  const ctx = useContext(TabsCtx);
@@ -19,18 +19,22 @@ function useTable(data, options = {}) {
19
19
  const [query, setQuery] = useState(() => options.initialQuery ?? "");
20
20
  const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
21
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]);
22
+ const visibleRows = useMemo(() => {
23
+ const filtered = filters.length ? applyFilters(data, filters) : data;
24
+ const searched = query.trim() ? searchRows(filtered, query, searchFields) : filtered;
25
+ return sort.length ? multiSort(searched, sort) : searched;
26
+ }, [data, filters, query, searchFields, sort]);
27
+ const rows = useMemo(
28
+ () => pinned.size ? mergePinned(data, visibleRows, pinned, idField) : visibleRows.slice(),
29
+ [data, visibleRows, pinned, idField]
30
+ );
28
31
  const pageIds = useMemo(() => rows.map((r) => String(r[idField])), [rows, idField]);
29
32
  const selectAll = selectAllState(selected, pageIds);
30
33
  const doToggleSort = useCallback((field, additive = false) => {
31
34
  setSort((keys) => toggleSort(keys, field, additive));
32
35
  }, []);
33
36
  const toggleRow = useCallback((id) => setSelected((s) => toggleId(s, id)), []);
37
+ const isSelected = useCallback((id) => selected.has(id), [selected]);
34
38
  const toggleAllRows = useCallback(() => setSelected((s) => togglePage(s, pageIds)), [pageIds]);
35
39
  const clearSelection = useCallback(() => setSelected(/* @__PURE__ */ new Set()), []);
36
40
  const togglePin = useCallback((id) => setPinned((s) => toggleId(s, id)), []);
@@ -54,7 +58,7 @@ function useTable(data, options = {}) {
54
58
  setSort,
55
59
  setFilters,
56
60
  setQuery,
57
- isSelected: (id) => selected.has(id),
61
+ isSelected,
58
62
  toggleRow,
59
63
  toggleAllRows,
60
64
  clearSelection,
@@ -0,0 +1,27 @@
1
+ import { RefObject } from 'react';
2
+
3
+ interface UseVirtualRowsOptions {
4
+ /** Fixed rendered row height in pixels. */
5
+ rowHeight: number;
6
+ /** Rows rendered beyond each viewport edge. Default: 6. */
7
+ overscan?: number;
8
+ /** Render all rows at or below this count. Default: 100. */
9
+ threshold?: number;
10
+ }
11
+ interface UseVirtualRowsResult<T> {
12
+ /** Attach to the scrolling TableWrap. */
13
+ containerRef: RefObject<HTMLDivElement>;
14
+ rows: readonly T[];
15
+ startIndex: number;
16
+ padTop: number;
17
+ padBottom: number;
18
+ totalHeight: number;
19
+ virtualized: boolean;
20
+ }
21
+ /**
22
+ * Owns scroll/resize observation and exposes the small visible row window.
23
+ * Consumers render padTop/padBottom spacer rows inside their existing tbody.
24
+ */
25
+ declare function useVirtualRows<T>(rows: readonly T[], { rowHeight, overscan, threshold }: UseVirtualRowsOptions): UseVirtualRowsResult<T>;
26
+
27
+ export { type UseVirtualRowsOptions, type UseVirtualRowsResult, useVirtualRows };
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { shouldVirtualize, virtualWindow } from "../table-engine.js";
4
+ function useVirtualRows(rows, { rowHeight, overscan = 6, threshold = 100 }) {
5
+ const containerRef = useRef(null);
6
+ const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 });
7
+ useLayoutEffect(() => {
8
+ const element = containerRef.current;
9
+ if (!element) return;
10
+ let frame = 0;
11
+ const commitMeasurement = () => {
12
+ const next = { scrollTop: element.scrollTop, height: element.clientHeight };
13
+ setViewport((current) => current.scrollTop === next.scrollTop && current.height === next.height ? current : next);
14
+ };
15
+ const measure = () => {
16
+ if (frame) return;
17
+ frame = requestAnimationFrame(() => {
18
+ frame = 0;
19
+ commitMeasurement();
20
+ });
21
+ };
22
+ commitMeasurement();
23
+ element.addEventListener("scroll", measure, { passive: true });
24
+ const observer = new ResizeObserver(measure);
25
+ observer.observe(element);
26
+ return () => {
27
+ element.removeEventListener("scroll", measure);
28
+ observer.disconnect();
29
+ if (frame) cancelAnimationFrame(frame);
30
+ };
31
+ }, []);
32
+ const enabled = viewport.height > 0 && shouldVirtualize(rows.length, threshold);
33
+ const window = useMemo(
34
+ () => enabled ? virtualWindow(viewport.scrollTop, viewport.height, rowHeight, rows.length, overscan) : { start: 0, end: rows.length, padTop: 0, padBottom: 0, total: rows.length * rowHeight },
35
+ [enabled, overscan, rowHeight, rows.length, viewport.height, viewport.scrollTop]
36
+ );
37
+ const visibleRows = useMemo(() => rows.slice(window.start, window.end), [rows, window.start, window.end]);
38
+ return {
39
+ containerRef,
40
+ rows: visibleRows,
41
+ startIndex: window.start,
42
+ padTop: window.padTop,
43
+ padBottom: window.padBottom,
44
+ totalHeight: window.total,
45
+ virtualized: enabled
46
+ };
47
+ }
48
+ export {
49
+ useVirtualRows
50
+ };