@sincpro/web-ui 1.1.0 → 1.2.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.
@@ -381,7 +381,12 @@ export function SearchBar({ facets, model, criteria, onCriteriaChange, placehold
381
381
  onCriteriaChange([]);
382
382
  setOpenGroup(null);
383
383
  input.current?.focus();
384
- }, "aria-label": copy.search.clearAll, title: copy.search.clearAll, className: `my-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center rounded-control px-1.5 transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${bySurface(sup, "text-muted hover:bg-ink/5 hover:text-ink", "text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: _jsx(X, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }) })), columns.length === 0 && onOpenBuilder === undefined ? null : (_jsxs("button", { type: "button", onClick: () => setPanel((v) => !v), "aria-expanded": panel, "aria-controls": panel ? id : undefined, title: copy.search.tools, className: `my-0.5 mr-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center gap-1 rounded-control border px-2 text-micro transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${puestos > 0 || panel
384
+ }, "aria-label": copy.search.clearAll, title: copy.search.clearAll, className: `my-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center rounded-control px-1.5 transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${bySurface(sup, "text-muted hover:bg-ink/5 hover:text-ink", "text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: _jsx(X, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }) })), columns.length === 0 && onOpenBuilder === undefined ? null : (_jsxs("button", { type: "button", onClick: () => setPanel((v) => !v), "aria-expanded": panel, "aria-controls": panel ? id : undefined, "aria-label": puestos === 0
385
+ ? copy.search.tools
386
+ : fillCopy(puestos === 1 ? copy.search.toolsSetOne : copy.search.toolsSetMany, {
387
+ label: copy.search.tools,
388
+ count: puestos.toLocaleString(copy.locale),
389
+ }), title: copy.search.tools, className: `my-0.5 mr-0.5 inline-flex h-(--size-control-sm) shrink-0 items-center gap-1 rounded-control border px-2 text-micro transition-colors duration-(--duration-instant) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary ${puestos > 0 || panel
385
390
  ? bySurface(sup, "border-accent/40 bg-accent/18 text-ink", "border-chrome-link/35 bg-chrome-link/15 text-chrome-link")
386
391
  : bySurface(sup, "border-transparent text-muted hover:bg-ink/5 hover:text-ink", "border-transparent text-chrome-muted hover:bg-chrome-ink/10 hover:text-chrome-ink")}`, children: [_jsx(ListFilter, { className: "size-(--size-icon-sm)", strokeWidth: 2, "aria-hidden": true }), puestos > 0 ? _jsx("span", { className: "text-micro tabular-nums", children: puestos }) : null] }))] }), trailing, open ? (_jsxs("div", { className: `absolute top-full z-30 mt-1 overflow-hidden rounded-panel border border-line-strong bg-surface shadow-float ${asTags ? "left-0 w-max max-w-full" : "right-0 left-0"}`, children: [openJoin === undefined ? null : (_jsxs("div", { className: "flex items-center gap-1.5 border-line border-b px-3 py-1.5 text-micro text-muted", children: [_jsx(CornerDownRight, { className: "size-3 shrink-0", strokeWidth: 2, "aria-hidden": true }), _jsx("span", { className: "min-w-0 truncate", children: copy.search.landsHere }), _jsx("span", { className: "shrink-0 rounded-pill border border-accent/40 bg-accent/18 px-1.5 text-ink", children: copy.search.joins[openJoin === "not" ? "none" : openJoin] })] })), asTags ? (
387
392
  /* UNA LISTA, no una tira. Se está ELIGIENDO: el ojo baja por una
@@ -125,6 +125,10 @@ export type UiCopy = {
125
125
  };
126
126
  search: {
127
127
  tools: string;
128
+ /** The name of the panel button once something is set: it shows the count, and what
129
+ * the button SHOWS has to be inside its name (WCAG 2.5.3) — `{label}` is `tools`. */
130
+ toolsSetOne: string;
131
+ toolsSetMany: string;
128
132
  editChip: string;
129
133
  editNamed: string;
130
134
  removeChip: string;
@@ -127,6 +127,8 @@ export const SPANISH_COPY = {
127
127
  },
128
128
  search: {
129
129
  tools: "Filtrar, agrupar y favoritos",
130
+ toolsSetOne: "{label}, {count} puesto",
131
+ toolsSetMany: "{label}, {count} puestos",
130
132
  editChip: "Clic para editar",
131
133
  editNamed: "Editar {label}",
132
134
  removeChip: "Quitar {label}: {value}",
@@ -90,6 +90,8 @@ export const ENGLISH_COPY = {
90
90
  },
91
91
  search: {
92
92
  tools: "Filter, group and favorites",
93
+ toolsSetOne: "{label}, {count} applied",
94
+ toolsSetMany: "{label}, {count} applied",
93
95
  editChip: "Click to edit",
94
96
  editNamed: "Edit {label}",
95
97
  removeChip: "Remove {label}: {value}",
@@ -6,6 +6,7 @@ import { CellCopy } from "./CellCopy.js";
6
6
  import { CellEditor } from "./CellEditor.js";
7
7
  import { EditRow, firstControl } from "./EditableTable.js";
8
8
  import { RowActions } from "./RowActions.js";
9
+ import { useRowWindow } from "./rowWindow.js";
9
10
  import { SelectBox, Table, Td, Th, THead, Tr, } from "./Table.js";
10
11
  import { aggregationFns, columnGroupingFeature, createColumnHelper, createExpandedRowModel, createGroupedRowModel, createSortedRowModel, rowAggregationFeature, rowExpandingFeature, rowSortingFeature, sortFns, tableFeatures, useTable, } from "@tanstack/react-table";
11
12
  import { ChevronRight, Plus } from "lucide-react";
@@ -258,6 +259,14 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
258
259
  return true;
259
260
  });
260
261
  const selecting = onSelectedChange !== undefined;
262
+ const width = columns.length + (selecting ? 1 : 0) + (rowActions === undefined ? 0 : 1);
263
+ /* Past a few hundred rows only the ones in view are drawn — see `rowWindow.tsx`. It
264
+ windows what is DRAWN, group rows included and folded rows left out: that is the
265
+ sequence a person scrolls through. */
266
+ const view = useRowWindow({
267
+ count: drawn.length,
268
+ getKey: (i) => drawn[i]?.id ?? String(i),
269
+ });
261
270
  const leaves = useMemo(() => rows.filter((r) => r.subRows.length === 0), [rows]);
262
271
  /* The aggregates are computed by the component and not by TanStack: its
263
272
  `aggregationFn` lives in the GROUP model, and a footer exists precisely when
@@ -273,8 +282,20 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
273
282
  n.delete(id);
274
283
  onSelectedChange?.(n);
275
284
  }
276
- const grid = (_jsxs(Table, { height: height, children: [_jsxs(THead, { children: [selecting ? (_jsx(Th, { width: 36, children: _jsx(SelectBox, { label: copy.selection.all, checked: all, indeterminate: some, onChange: (v) => onSelectedChange?.(v ? new Set(leaves.map((r) => r.id)) : new Set()) }) })) : null, columns.map((c) => (_jsx(Th, { sortKey: c.sortable === false ? undefined : c.id, sort: sort, onSort: onSortChange, numeric: c.numeric, width: c.width, children: c.header }, c.id))), rowActions === undefined ? null : _jsx(Th, { width: 1, children: "" })] }), _jsxs("tbody", { children: [drawn.map((row) => {
285
+ const grid = (_jsxs(Table, { height: height, ...(view.on
286
+ ? {
287
+ rowCount: 1 + drawn.length + (draft === null ? 0 : 1) + (totals ? 1 : 0),
288
+ }
289
+ : {}), children: [_jsxs(THead, { ...(view.on ? { rowIndex: 1 } : {}), children: [selecting ? (_jsx(Th, { width: 36, children: _jsx(SelectBox, { label: copy.selection.all, checked: all, indeterminate: some, onChange: (v) => onSelectedChange?.(v ? new Set(leaves.map((r) => r.id)) : new Set()) }) })) : null, columns.map((c) => (_jsx(Th, { sortKey: c.sortable === false ? undefined : c.id, sort: sort, onSort: onSortChange, numeric: c.numeric, width: c.width, children: c.header }, c.id))), rowActions === undefined ? null : _jsx(Th, { width: 1, children: "" })] }), _jsxs("tbody", { ref: view.body.ref, children: [view.rows.flatMap((slot, at) => {
290
+ const row = drawn[slot.index];
291
+ if (row === undefined)
292
+ return [];
277
293
  const isGroup = row.subRows.length > 0;
294
+ const windowed = view.windowed(slot.index);
295
+ /* What is not drawn between the previous drawn row and this one: the rows
296
+ above the view before the first, and the gap before a row kept only because
297
+ it has the focus. */
298
+ const gap = view.pad(at === 0 ? 0 : view.rows[at - 1].end, slot.start, width, `\u0000pad${slot.index}`);
278
299
  if (isGroup) {
279
300
  const path = pathOf(row);
280
301
  const key = pathKey(path);
@@ -287,22 +308,27 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
287
308
  const open = collapsed === undefined
288
309
  ? row.getIsExpanded()
289
310
  : collapsed.includes(key) !== true;
290
- return (
291
- /* The group row: it spans the whole width, says which value it
292
- groups by and how many are inside. The counter is what makes it
293
- useful without opening it. */
294
- _jsx("tr", { className: "border-b border-line bg-ground", children: _jsx("td", { colSpan: columns.length + (selecting ? 1 : 0) + (rowActions === undefined ? 0 : 1), className: "px-3 py-1.5", style: depth === 0
295
- ? undefined
296
- : { paddingLeft: `calc(var(--spacing-indent) * ${depth} + 0.75rem)` }, children: _jsxs("button", { type: "button", onClick: () => onGroupToggle === undefined ? row.toggleExpanded() : onGroupToggle(key), "aria-expanded": open, className: "inline-flex items-center gap-1.5 rounded-control text-meta font-medium text-ink transition-colors duration-(--duration-instant) hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: [_jsx(ChevronRight, { className: `size-(--size-icon-sm) text-muted transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }), level === undefined ? null : (_jsx("span", { className: "text-micro font-normal text-muted/70", children: level.label })), _jsx("span", { children: level === undefined ? value : level.labelOf(value) }), _jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro tabular-nums text-muted", children: row.getLeafRows().length.toLocaleString(copy.locale) })] }) }) }, row.id));
311
+ return [
312
+ gap,
313
+ /* The group row: it spans the whole width, says which value it
314
+ groups by and how many are inside. The counter is what makes it
315
+ useful without opening it. */
316
+ _jsx("tr", { ...windowed, className: "border-b border-line bg-ground", children: _jsx("td", { colSpan: width, className: "px-3 py-1.5", style: depth === 0
317
+ ? undefined
318
+ : { paddingLeft: `calc(var(--spacing-indent) * ${depth} + 0.75rem)` }, children: _jsxs("button", { type: "button", onClick: () => onGroupToggle === undefined ? row.toggleExpanded() : onGroupToggle(key), "aria-expanded": open, className: "inline-flex items-center gap-1.5 rounded-control text-meta font-medium text-ink transition-colors duration-(--duration-instant) hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: [_jsx(ChevronRight, { className: `size-(--size-icon-sm) text-muted transition-transform duration-(--duration-instant) ${open ? "rotate-90" : ""}`, strokeWidth: 2, "aria-hidden": true }), level === undefined ? null : (_jsx("span", { className: "text-micro font-normal text-muted/70", children: level.label })), _jsx("span", { children: level === undefined ? value : level.labelOf(value) }), _jsx("span", { className: "inline-flex shrink-0 items-center rounded-pill border border-line-strong bg-ink/5 px-2 py-0.5 text-micro tabular-nums text-muted", children: row.getLeafRows().length.toLocaleString(copy.locale) })] }) }) }, row.id),
319
+ ];
297
320
  }
298
321
  const original = row.original;
299
- return (_jsxs(Tr, { selected: selected?.has(row.id) === true, onClick: onRowClick === undefined ? undefined : () => onRowClick(original), children: [selecting ? (_jsx(Td, { children: _jsx(SelectBox, { label: copy.selection.row, checked: selected?.has(row.id) === true, onChange: (v) => toggle(row.id, v) }) })) : null, columns.map((c) => {
300
- const t = c.tone?.(original);
301
- const n = c.note?.(original);
302
- const edit = c.edit?.when?.(original) === false ? undefined : c.edit;
303
- return (_jsx(Td, { numeric: c.numeric, mono: c.mono === true, ...(t === undefined ? {} : { tone: t }), ...(n === undefined ? {} : { title: n }), children: edit !== undefined ? (_jsx(CellEditor, { label: fillCopy(copy.inlineEdit.editCell, { column: c.header }), numeric: c.numeric === true, editor: (done) => edit.render(original, done), children: drawCell(copy, c, original) })) : copyable && c.copy !== false ? (_jsx(CellCopy, { header: c.header, numeric: c.numeric === true, ...(c.copy === undefined ? {} : { value: c.copy(original) }), children: drawCell(copy, c, original) })) : (drawCell(copy, c, original)) }, c.id));
304
- }), rowActions === undefined ? null : (_jsx(RowActions, { actions: rowActions(original) }))] }, row.id));
305
- }), create === undefined || draft === null ? null : (_jsxs(EditRow, { as: "tr", rowId: "\\u0000draft", rowRef: (node) => {
322
+ return [
323
+ gap,
324
+ _jsxs(Tr, { ...(windowed === undefined ? {} : { windowed }), selected: selected?.has(row.id) === true, onClick: onRowClick === undefined ? undefined : () => onRowClick(original), children: [selecting ? (_jsx(Td, { children: _jsx(SelectBox, { label: copy.selection.row, checked: selected?.has(row.id) === true, onChange: (v) => toggle(row.id, v) }) })) : null, columns.map((c) => {
325
+ const t = c.tone?.(original);
326
+ const n = c.note?.(original);
327
+ const edit = c.edit?.when?.(original) === false ? undefined : c.edit;
328
+ return (_jsx(Td, { numeric: c.numeric, mono: c.mono === true, ...(t === undefined ? {} : { tone: t }), ...(n === undefined ? {} : { title: n }), children: edit !== undefined ? (_jsx(CellEditor, { label: fillCopy(copy.inlineEdit.editCell, { column: c.header }), numeric: c.numeric === true, editor: (done) => edit.render(original, done), children: drawCell(copy, c, original) })) : copyable && c.copy !== false ? (_jsx(CellCopy, { header: c.header, numeric: c.numeric === true, ...(c.copy === undefined ? {} : { value: c.copy(original) }), children: drawCell(copy, c, original) })) : (drawCell(copy, c, original)) }, c.id));
329
+ }), rowActions === undefined ? null : (_jsx(RowActions, { actions: rowActions(original) }))] }, row.id),
330
+ ];
331
+ }), view.tail(width), create === undefined || draft === null ? null : (_jsxs(EditRow, { as: "tr", rowId: "\\u0000draft", rowRef: (node) => {
306
332
  draftRow.current = node;
307
333
  }, editable: true, editing: true, tabbable: false, className: "group/row", onStart: () => { }, onCommit: saveDraft, onCancel: () => {
308
334
  backToAdd.current = true;
@@ -318,7 +344,7 @@ export function DataTable({ data, columns, getRowId, sort, onSortChange, levels,
318
344
  }, children: [selecting ? _jsx("td", { className: DRAFT_CELL }) : null, columns.map((c) => (_jsx("td", { ...(c.input === undefined ? {} : { "data-edit": "" }), className: `${DRAFT_CELL} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: c.input?.(draft, (next) => {
319
345
  touched.current = true;
320
346
  create.onChange(next);
321
- }) ?? null }, c.id))), rowActions === undefined ? null : _jsx("td", { className: DRAFT_CELL })] }))] }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
347
+ }) ?? null }, c.id))), rowActions === undefined ? null : _jsx("td", { className: DRAFT_CELL })] }))] }), footer === null ? null : (_jsx("tfoot", { className: "sticky bottom-0 z-10 bg-ground", children: _jsxs("tr", { className: "border-t border-line-strong", "aria-rowindex": view.on ? 2 + drawn.length + (draft === null ? 0 : 1) : undefined, children: [selecting ? _jsx("td", {}) : null, columns.map((c, i) => {
322
348
  const value = footer[i] ?? null;
323
349
  return (_jsx("td", { className: `px-3 py-1.5 text-meta font-semibold text-ink ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: i === 0 && value === null ? (_jsxs("span", { className: "text-muted", children: [data.length.toLocaleString(copy.locale), " ", data.length === 1 ? copy.table.row : copy.table.rows] })) : value === null ? null : (aggregateLabel(copy, c, value)) }, c.id));
324
350
  }), rowActions === undefined ? null : _jsx("td", {})] }) }))] }));
@@ -1,4 +1,5 @@
1
1
  import type { Tone } from "../../foundations/types.js";
2
+ import type { WindowedRow } from "./rowWindow.js";
2
3
  import { type ReactNode } from "react";
3
4
  export type SortDirection = "asc" | "desc";
4
5
  export interface SortState {
@@ -8,17 +9,23 @@ export interface SortState {
8
9
  export declare function Table({ children,
9
10
  /** With a height, the table scrolls on its own and the header sticks.
10
11
  * Without a height, the page scrolls and the header goes away. */
11
- height, }: {
12
+ height, rowCount, }: {
12
13
  children: ReactNode;
13
14
  /** A number means pixels; a string goes through as is, so `26rem` follows the
14
15
  * system scale instead of pinning a size in pixels. */
15
16
  height?: number | string;
17
+ /** How many rows the table REALLY has, header and footer included, when it draws only
18
+ * some of them (`useRowWindow`): a screen reader says «row 412 of 3 000» and not
19
+ * «row 12 of 40». Leave it out when every row is drawn. */
20
+ rowCount?: number;
16
21
  }): import("react").JSX.Element;
17
22
  /** The text of a column header, shared by every table of the family. Ink and semibold, not
18
23
  * muted: in grey at the cells' weight the names of the columns read as one more row. */
19
24
  export declare const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
20
- export declare function THead({ children }: {
25
+ export declare function THead({ children, rowIndex, }: {
21
26
  children: ReactNode;
27
+ /** Its `aria-rowindex`, set only when the table says its `rowCount`. */
28
+ rowIndex?: number;
22
29
  }): import("react").JSX.Element;
23
30
  /** A column. If it gets a `sortKey`, it can be sorted.
24
31
  *
@@ -42,10 +49,12 @@ export declare function Th({ children, sortKey, sort, onSort, numeric, align, wi
42
49
  * system scale instead of pinning a size in pixels. */
43
50
  width?: number | string;
44
51
  }): import("react").JSX.Element;
45
- export declare function Tr({ children, onClick, selected, }: {
52
+ export declare function Tr({ children, onClick, selected, windowed, }: {
46
53
  children: ReactNode;
47
54
  onClick?: () => void;
48
55
  selected?: boolean;
56
+ /** Set by a table that draws only the rows in view (`useRowWindow`). */
57
+ windowed?: WindowedRow;
49
58
  }): import("react").JSX.Element;
50
59
  /**
51
60
  * A row that opens its own detail underneath.
@@ -6,25 +6,25 @@ import { createContext, useContext, useId } from "react";
6
6
  export function Table({ children,
7
7
  /** With a height, the table scrolls on its own and the header sticks.
8
8
  * Without a height, the page scrolls and the header goes away. */
9
- height, }) {
9
+ height, rowCount, }) {
10
10
  const caja = useScrollBox(height === undefined ? {} : { height });
11
11
  return (
12
12
  /* Cuándo esta caja es un contenedor de scroll y cuándo no lo decide
13
13
  `useScrollBox`: ahí vive el dilema entero y ahí vive su explicación. Las
14
14
  tres tablas del sistema lo resolvían con tres copias de la misma
15
15
  medición. */
16
- _jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsx("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: children }) }));
16
+ _jsx("div", { ref: caja.ref, className: `min-w-0 ${caja.className}`, style: caja.style, children: _jsx("table", { "aria-rowcount": rowCount, className: "w-full border-separate border-spacing-0 px-1 text-left", children: children }) }));
17
17
  }
18
18
  /** The text of a column header, shared by every table of the family. Ink and semibold, not
19
19
  * muted: in grey at the cells' weight the names of the columns read as one more row. */
20
20
  export const HEADER_TEXT = "text-meta font-semibold whitespace-nowrap text-ink";
21
- export function THead({ children }) {
21
+ export function THead({ children, rowIndex, }) {
22
22
  return (
23
23
  /* Se pega DEBAJO de lo que ya esté pegado —la barra de la aplicación y la
24
24
  de la lista— y no en `top: 0`, que lo metía atrás de las dos. Sin alto en
25
25
  `Table` el que scrollea es la página, y ahí la diferencia es justamente
26
26
  la que hacía desaparecer los nombres de columna. Ver `system/sticky.tsx`. */
27
- _jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsx("tr", { children: children }) }));
27
+ _jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsx("tr", { "aria-rowindex": rowIndex, children: children }) }));
28
28
  }
29
29
  /** A column. If it gets a `sortKey`, it can be sorted.
30
30
  *
@@ -59,8 +59,8 @@ const ROW_ON = "bg-primary/8";
59
59
  function rowWash({ selected, interactive }) {
60
60
  return selected ? ROW_ON : interactive ? ROW_WASH : "";
61
61
  }
62
- export function Tr({ children, onClick, selected = false, }) {
63
- return (_jsx(RowMark.Provider, { value: { selected, interactive: onClick !== undefined }, children: _jsx("tr", { onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
62
+ export function Tr({ children, onClick, selected = false, windowed, }) {
63
+ return (_jsx(RowMark.Provider, { value: { selected, interactive: onClick !== undefined }, children: _jsx("tr", { ...windowed, onClick: onClick, className: `group/row ${onClick === undefined ? "" : "cursor-pointer"}`, children: children }) }));
64
64
  }
65
65
  /**
66
66
  * A row that opens its own detail underneath.
@@ -0,0 +1,39 @@
1
+ import { type ReactNode, type RefObject } from "react";
2
+ /** From how many rows the table draws only the ones in view. */
3
+ export declare const WINDOW_FROM = 200;
4
+ /** What a drawn row has to carry: spread on its `<tr>`. */
5
+ export type WindowedRow = {
6
+ ref: (node: HTMLTableRowElement | null) => void;
7
+ "data-index": number;
8
+ "aria-rowindex": number;
9
+ onFocus: () => void;
10
+ };
11
+ /**
12
+ * Which rows of `count` to draw. Spread `body` on the `<tbody>`, draw `rows` in the
13
+ * order given — `windowed(i)` on each one's `<tr>` — with `pad(...)` between them, and
14
+ * pass `rowCount` to the table.
15
+ *
16
+ * `rowsBefore` is how many rows the table has above the body (the header), so the
17
+ * `aria-rowindex` of the rows counts from there.
18
+ */
19
+ export declare function useRowWindow({ count, rowsBefore, getKey, }: {
20
+ count: number;
21
+ rowsBefore?: number;
22
+ getKey: (index: number) => string;
23
+ }): {
24
+ on: boolean;
25
+ body: {
26
+ ref: RefObject<HTMLTableSectionElement | null>;
27
+ };
28
+ /** The indices to draw, in order. All of them when the window is off. */
29
+ rows: readonly {
30
+ index: number;
31
+ start: number;
32
+ end: number;
33
+ }[];
34
+ windowed: (index: number) => WindowedRow | undefined;
35
+ /** The spacer that stands for the rows not drawn before `start`. */
36
+ pad: (from: number, to: number, columns: number, key: string) => ReactNode;
37
+ /** The spacer after the last drawn row. */
38
+ tail: (columns: number) => ReactNode;
39
+ };
@@ -0,0 +1,177 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useVirtualizer, useWindowVirtualizer } from "@tanstack/react-virtual";
4
+ import { useLayoutEffect, useRef, useState } from "react";
5
+ /* ==================================================================== *
6
+ * ROW WINDOW — the rows of a `<table>` that are worth drawing.
7
+ *
8
+ * Context: `DataTable` drew every row it got. A page of eighty is nothing; a
9
+ * «load more» that reached three thousand was three thousand rows of cells,
10
+ * copy buttons and editors, and the tab froze. web-framework put a ceiling on
11
+ * «load more» and kept the pages BECAUSE of it. This is what lifts the ceiling.
12
+ *
13
+ * ── It stays a `<table>` ────────────────────────────────────────────
14
+ *
15
+ * `SheetTable` positions `<div>` rows absolutely. A list cannot: its columns
16
+ * size themselves from the content, its header sticks with `position: sticky`
17
+ * and its rows carry the row semantics a screen reader walks. So the rows that
18
+ * are NOT drawn become one spacer row above and one below — real height, no
19
+ * content, `aria-hidden` —, and the table says how many rows it really has
20
+ * (`aria-rowcount`) and where each drawn one sits (`aria-rowindex`).
21
+ *
22
+ * ── The scroller is found, not declared ─────────────────────────────
23
+ *
24
+ * A list inside `AppFrame` scrolls with the PAGE — that is what keeps the
25
+ * application bar, the list bar and the header stuck one under the other. The
26
+ * same list inside a composer panel or a `Peek` scrolls inside THAT box, and a
27
+ * `DataTable` with `height` scrolls inside its own. The nearest ancestor that
28
+ * really scrolls vertically is the one; none means the page. Giving the table a
29
+ * scroll box of its own instead would break the sticky stack — the trap
30
+ * AGENTS.md writes down for `overflow-hidden`.
31
+ *
32
+ * ── Only past a size ────────────────────────────────────────────────
33
+ *
34
+ * Under `WINDOW_FROM` rows everything is drawn, as before: the browser's find,
35
+ * printing and a row that is always there are worth more than the few
36
+ * milliseconds saved on a page of eighty. Over it, the find of the browser only
37
+ * sees what is drawn — the search bar is how a set that large is searched.
38
+ *
39
+ * ── What has focus stays drawn ──────────────────────────────────────
40
+ *
41
+ * A cell being edited, a copy button just pressed: if its row scrolled out and
42
+ * got unmounted, the focus would fall to `<body>` and the popover with it. The
43
+ * last row that took focus is always kept in the window, wherever it is.
44
+ * Tabbing forward works without help: the next row is in the overscan, the
45
+ * browser scrolls it into view, and the window follows.
46
+ * ==================================================================== */
47
+ /** From how many rows the table draws only the ones in view. */
48
+ export const WINDOW_FROM = 200;
49
+ /** A first guess of a row's height; each drawn row is measured after. */
50
+ const ESTIMATE = 36;
51
+ /** Rows drawn beyond each edge of the view. */
52
+ const OVERSCAN = 10;
53
+ /** Rows drawn before the scroller is known — the first frame only. */
54
+ const FIRST_FRAME = 40;
55
+ /** The nearest ancestor that scrolls vertically, or the page. */
56
+ function scrollerOf(node) {
57
+ for (let el = node.parentElement; el !== null; el = el.parentElement) {
58
+ if (el === document.body || el === document.documentElement)
59
+ break;
60
+ const { overflowY } = getComputedStyle(el);
61
+ const scrolls = overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
62
+ /* A box that is `overflow-auto` only because the table is WIDER than it grows with
63
+ its content and never scrolls down: it is not the one. */
64
+ if (scrolls && el.scrollHeight > el.clientHeight + 1)
65
+ return el;
66
+ }
67
+ return "page";
68
+ }
69
+ /** Where the body starts inside the scroller's content — what the window counts from. */
70
+ function offsetIn(body, scroller) {
71
+ const top = body.getBoundingClientRect().top;
72
+ if (scroller === "page")
73
+ return top + window.scrollY;
74
+ return top - scroller.getBoundingClientRect().top + scroller.scrollTop;
75
+ }
76
+ /**
77
+ * Which rows of `count` to draw. Spread `body` on the `<tbody>`, draw `rows` in the
78
+ * order given — `windowed(i)` on each one's `<tr>` — with `pad(...)` between them, and
79
+ * pass `rowCount` to the table.
80
+ *
81
+ * `rowsBefore` is how many rows the table has above the body (the header), so the
82
+ * `aria-rowindex` of the rows counts from there.
83
+ */
84
+ export function useRowWindow({ count, rowsBefore = 1, getKey, }) {
85
+ const on = count >= WINDOW_FROM;
86
+ const body = useRef(null);
87
+ const [scroller, setScroller] = useState(null);
88
+ const [margin, setMargin] = useState(0);
89
+ const pinned = useRef(null);
90
+ /* Every render, before paint: which box scrolls and where the body starts in it. The
91
+ window re-renders on scroll, so what moves above the table — metrics that loaded,
92
+ chips that wrapped — is caught on the next one. Set only on a real change: each set
93
+ is one more render. No dependency list on purpose: what it measures is the layout,
94
+ which no prop of this hook describes; it settles because it sets only on a change. */
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ useLayoutEffect(() => {
97
+ const node = body.current;
98
+ if (!on || node === null)
99
+ return;
100
+ const found = scrollerOf(node);
101
+ if (found !== scroller)
102
+ setScroller(found);
103
+ const at = offsetIn(node, found);
104
+ if (Math.abs(at - margin) > 1)
105
+ setMargin(at);
106
+ });
107
+ const options = {
108
+ count,
109
+ estimateSize: () => ESTIMATE,
110
+ overscan: OVERSCAN,
111
+ scrollMargin: margin,
112
+ getItemKey: getKey,
113
+ rangeExtractor: (range) => {
114
+ const first = Math.max(0, range.startIndex - OVERSCAN);
115
+ const last = Math.min(count - 1, range.endIndex + OVERSCAN);
116
+ const out = [];
117
+ for (let i = first; i <= last; i += 1)
118
+ out.push(i);
119
+ const keep = pinned.current;
120
+ if (keep !== null && keep < count && (keep < first || keep > last)) {
121
+ out.push(keep);
122
+ out.sort((a, b) => a - b);
123
+ }
124
+ return out;
125
+ },
126
+ };
127
+ const inBox = useVirtualizer({
128
+ ...options,
129
+ enabled: on && scroller !== null && scroller !== "page",
130
+ getScrollElement: () => (scroller === null || scroller === "page" ? null : scroller),
131
+ });
132
+ const inPage = useWindowVirtualizer({
133
+ ...options,
134
+ enabled: on && scroller === "page",
135
+ });
136
+ const v = scroller === "page" ? inPage : inBox;
137
+ const all = () => Array.from({ length: count }, (_, index) => ({
138
+ index,
139
+ start: index * ESTIMATE,
140
+ end: (index + 1) * ESTIMATE,
141
+ }));
142
+ /* The positions come back in the scroller's coordinates; the spacers live inside the
143
+ body, so they are counted from where it starts. */
144
+ const rows = !on
145
+ ? all()
146
+ : scroller === null
147
+ ? all().slice(0, FIRST_FRAME)
148
+ : v.getVirtualItems().map((item) => ({
149
+ index: item.index,
150
+ start: item.start - margin,
151
+ end: item.end - margin,
152
+ }));
153
+ const total = !on ? 0 : scroller === null ? count * ESTIMATE : v.getTotalSize();
154
+ const spacer = (height, columns, key) => height <= 0 ? null : (_jsx("tr", { "aria-hidden": true, style: { height }, children: _jsx("td", { colSpan: columns, className: "p-0" }) }, key));
155
+ return {
156
+ on,
157
+ body: { ref: body },
158
+ rows,
159
+ windowed: (index) => !on
160
+ ? undefined
161
+ : {
162
+ ref: scroller === null ? () => { } : v.measureElement,
163
+ "data-index": index,
164
+ "aria-rowindex": index + rowsBefore + 1,
165
+ onFocus: () => {
166
+ pinned.current = index;
167
+ },
168
+ },
169
+ pad: (from, to, columns, key) => (on ? spacer(to - from, columns, key) : null),
170
+ tail: (columns) => {
171
+ if (!on)
172
+ return null;
173
+ const end = rows.length === 0 ? 0 : rows[rows.length - 1].end;
174
+ return spacer(total - end, columns, "\u0000tail");
175
+ },
176
+ };
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/web-ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "An enterprise web design system for business software — ERPs, back offices, management tools — in React and Tailwind: tokens, primitives, components and the two views every management screen is made of.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",