@sincpro/web-ui 1.5.0 → 1.6.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.
@@ -2,7 +2,7 @@ import { type FormMode } from "../../foundations/form.js";
2
2
  import { type DialogContext } from "../../primitives/dialog/Dialog.js";
3
3
  import type { FlagTone } from "../../primitives/notice/Flag.js";
4
4
  import type { DataColumn } from "../../primitives/table/DataTable.js";
5
- import { type EditColumn, type EditLayout } from "../../primitives/table/EditableTable.js";
5
+ import { type EditCellState, type EditColumn, type EditLayout, type EditRowState } from "../../primitives/table/EditableTable.js";
6
6
  import { type TotalsProps, type TotalsRow } from "../../primitives/value/Totals.js";
7
7
  import { type ReactNode } from "react";
8
8
  /**
@@ -41,6 +41,8 @@ export type LineEditorState = {
41
41
  export type LineItemsProps<T> = {
42
42
  lines: readonly T[];
43
43
  columns: readonly LineItemColumn<T>[];
44
+ /** The line's key on this screen — LOCAL, not its id on the server: a new line gets one
45
+ * when it is made and keeps it after saving. See «A line's key is local» above. */
44
46
  getLineId: (line: T) => string;
45
47
  /** How a line is named — in the removal notice, the editor's header and for the
46
48
  * screen reader next to each row's buttons. Defaults to the first column's text. */
@@ -52,7 +54,8 @@ export type LineItemsProps<T> = {
52
54
  onChange?: (id: string, next: T) => void;
53
55
  /** A new line, already filled in the editor (or blank, without an editor). */
54
56
  onAdd?: (line: T) => void;
55
- /** The blank line «Agregar una línea» starts from. Required for adding. */
57
+ /** The blank line «Agregar una línea» starts from. Required for adding. It carries its
58
+ * local key from here — `{ key: crypto.randomUUID(), … }` — since it has no server id. */
56
59
  newLine?: () => T;
57
60
  /** A line was removed. It is gone at once; the notice offers to undo it. */
58
61
  onRemove?: (id: string) => void;
@@ -93,6 +96,14 @@ export type LineItemsProps<T> = {
93
96
  detail?: ReactNode;
94
97
  owner?: string;
95
98
  } | null;
99
+ /** What each cell is on each line — readonly, required, hidden, its error, its warning
100
+ * («precio bajo el costo» on the price) — as the backend says it per line and field.
101
+ * `EditableTable`'s `cellState`, passed through. The error reaches the cell's control as
102
+ * `aria-invalid` and `aria-describedby`. */
103
+ cellState?: (line: T, columnId: string) => EditCellState | null;
104
+ /** What a whole line is: `readonly` is read in place and offers no pencil (a delivered
105
+ * line). A line that should not be seen is not passed in `lines`. */
106
+ rowState?: (line: T) => EditRowState | null;
96
107
  /** Sections and notes among the lines. See the file's header. */
97
108
  structure?: LineStructure<T>;
98
109
  /** The lines' order is the document's (Odoo's `sequence`): a grip at the start of each line
@@ -111,4 +122,4 @@ export type LineItemsProps<T> = {
111
122
  * removing asked first, what an empty document said. One component, built on
112
123
  * `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
113
124
  */
114
- export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, adding, onOpenLine, flag, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }: LineItemsProps<T>): import("react").JSX.Element;
125
+ export declare function LineItems<T>({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom, totals, layout, adding, onOpenLine, flag, cellState, rowState, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }: LineItemsProps<T>): import("react").JSX.Element;
@@ -38,7 +38,7 @@ function plain(v) {
38
38
  * removing asked first, what an empty document said. One component, built on
39
39
  * `EditableTable` and `Dialog`, makes the one2many the same block everywhere.
40
40
  */
41
- export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", adding, onOpenLine, flag, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }) {
41
+ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange, onAdd, newLine, onRemove, onRestore, editor, isValid, context, searchText, searchFrom = 8, totals, layout = "auto", adding, onOpenLine, flag, cellState, rowState, structure, onReorder, visibleColumnIds, onVisibleColumnIdsChange, }) {
42
42
  const copy = useUiCopy();
43
43
  const toast = useOptionalToast();
44
44
  const read = useFormMode(mode) === "read";
@@ -259,7 +259,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
259
259
  and a trash can lit on each of twenty lines are forty invitations. They
260
260
  fade instead of unmounting, so Tab still reaches them. Pressing them does
261
261
  not put the row into in-place editing. */
262
- _jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit && kindOf(line) === "line" ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openLine(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
262
+ _jsxs("span", { "data-line": id, className: "flex items-center justify-end gap-0.5 whitespace-nowrap opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 focus-within:opacity-100 in-data-stacked:opacity-100", onClick: (e) => e.stopPropagation(), children: [_jsx("span", { id: nameId, className: "sr-only", children: labelOf(line) }), canEdit && kindOf(line) === "line" && rowState?.(line)?.readonly !== true ? (_jsx(IconButton, { icon: Pencil, label: copy.lines.edit, "aria-describedby": nameId, haspopup: "dialog", onClick: () => openLine(id, line) })) : null, canRemove ? (_jsx(IconButton, { icon: Trash2, label: copy.lines.remove, tone: "danger", "aria-describedby": nameId, onClick: () => remove(line) })) : null] }));
263
263
  },
264
264
  });
265
265
  }
@@ -283,7 +283,7 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
283
283
  invalid: false,
284
284
  }, children: _jsx(SearchField, { value: query, onChange: setQuery, placeholder: copy.lines.search }) })] })) : null, _jsx("span", { className: "text-meta text-muted tabular-nums", "aria-live": "polite", children: count }), columnControl.hasOptional || addButton !== null ? (_jsxs("span", { className: "ml-auto flex items-center gap-2", children: [_jsx(ColumnsMenu, { control: columnControl }), addButton] })) : null] }), _jsx(EditableTable, { rows: shown, columns: tableColumns, getRowId: getLineId, empty: lines.length === 0
285
285
  ? copy.lines.empty
286
- : fillCopy(copy.lineItems.noMatch, { query: query.trim() }), layout: layout, ...(inline ? { onAdd: () => addInline(), addLabel: copy.lines.add } : {}), ...(bandAdds.length === 0 ? {} : { adds: bandAdds }), ...(bands === undefined ? {} : { bands }), ...(flag === undefined ? {} : { flag }), ...(onReorder === undefined || read ? {} : { onReorder }), ...(inPlace && onRemove !== undefined
286
+ : fillCopy(copy.lineItems.noMatch, { query: query.trim() }), layout: layout, ...(inline ? { onAdd: () => addInline(), addLabel: copy.lines.add } : {}), ...(bandAdds.length === 0 ? {} : { adds: bandAdds }), ...(bands === undefined ? {} : { bands }), ...(flag === undefined ? {} : { flag }), ...(cellState === undefined ? {} : { cellState }), ...(rowState === undefined ? {} : { rowState }), ...(onReorder === undefined || read ? {} : { onReorder }), ...(inPlace && onRemove !== undefined
287
287
  ? /* A blank row nobody wrote in is taken back without a notice: nobody
288
288
  removed anything, so there is nothing to undo. */
289
289
  { onDiscard: onRemove }
@@ -291,8 +291,10 @@ export function LineItems({ lines, columns, getLineId, lineLabel, mode, onChange
291
291
  ? {
292
292
  onOpen: (id) => {
293
293
  const line = lines.find((l) => getLineId(l) === id);
294
- if (line !== undefined)
294
+ /* A read-only line opens no form to change it. */
295
+ if (line !== undefined && rowState?.(line)?.readonly !== true) {
295
296
  openLine(id, line);
297
+ }
296
298
  },
297
299
  }
298
300
  : {}), ...(canChange && columns.some((c) => c.edit !== undefined)
@@ -18,7 +18,24 @@ export type FieldIds = {
18
18
  invalid: boolean;
19
19
  };
20
20
  export declare const FieldIdsContext: import("react").Context<FieldIds | null>;
21
- /** The ids of the enclosing `Field`, resolved against the control's own props. */
21
+ /**
22
+ * What a place WITHOUT a `Field` says to the control in it: what describes it and whether it
23
+ * is in error — no id, no label.
24
+ *
25
+ * Context: a cell of an editable table has no label of its own (its control carries an
26
+ * `aria-label`, the column's name) but it can be wrong — «la cantidad supera el stock» — and
27
+ * that has to reach the control the same way a field's error does, or the error is a red
28
+ * cell nobody hears. The table provides this around each control it edits; every control
29
+ * that reads `useFieldIds` (the package's and an application's own) gets `aria-invalid` and
30
+ * `aria-describedby` without the table knowing what control it is.
31
+ */
32
+ export type ControlState = {
33
+ describedBy?: string;
34
+ invalid?: boolean;
35
+ };
36
+ export declare const ControlStateContext: import("react").Context<ControlState | null>;
37
+ /** The ids of the enclosing `Field` — or, outside one, the state of the place the control
38
+ * sits in (a cell) — resolved against the control's own props. */
22
39
  export declare function useFieldIds(own: {
23
40
  id?: string;
24
41
  describedBy?: string;
@@ -2,14 +2,17 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useContext } from "react";
4
4
  export const FieldIdsContext = createContext(null);
5
- /** The ids of the enclosing `Field`, resolved against the control's own props. */
5
+ export const ControlStateContext = createContext(null);
6
+ /** The ids of the enclosing `Field` — or, outside one, the state of the place the control
7
+ * sits in (a cell) — resolved against the control's own props. */
6
8
  export function useFieldIds(own) {
7
9
  const field = useContext(FieldIdsContext);
10
+ const place = useContext(ControlStateContext);
8
11
  return {
9
12
  id: own.id ?? field?.id,
10
13
  labelledBy: field?.labelId,
11
- describedBy: own.describedBy ?? field?.describedBy,
12
- invalid: own.invalid ?? field?.invalid ?? false,
14
+ describedBy: own.describedBy ?? field?.describedBy ?? place?.describedBy,
15
+ invalid: own.invalid ?? field?.invalid ?? place?.invalid ?? false,
13
16
  };
14
17
  }
15
18
  const Ctx = createContext("edit");
@@ -1,5 +1,6 @@
1
1
  import { type Grouping } from "../../foundations/grouping.js";
2
2
  import { type FlagProps } from "../notice/Flag.js";
3
+ import { type WindowedRow } from "./rowWindow.js";
3
4
  import { type ReactNode } from "react";
4
5
  /** Where a column goes when the row is drawn as a stacked card (a narrow box).
5
6
  * `trail` sits at the top right, beside the title — a state, the row's buttons. */
@@ -46,6 +47,37 @@ export type EditBands<T> = {
46
47
  * column does not add up. The lines are the ones under it until the next section. */
47
48
  total?: (column: EditColumn<T>, lines: readonly T[]) => ReactNode | null;
48
49
  };
50
+ /** A warning drawn as a `Flag`: on a row (`flag`) or on one cell (`EditCellState.flag`). */
51
+ export type EditFlag = Omit<FlagProps, "children"> & {
52
+ detail?: ReactNode;
53
+ owner?: string;
54
+ };
55
+ /**
56
+ * What ONE cell of one row is right now — said by whoever knows the record: the states the
57
+ * backend sends per line and field. Every key left out keeps the column as declared.
58
+ *
59
+ * Context: a column declares what it is for every row (`edit`), but an ERP line decides
60
+ * per row — a delivered line keeps its quantity, a service line has no lot, a discount is
61
+ * required only past a threshold. The table does not know why; it is told, cell by cell.
62
+ */
63
+ export type EditCellState = {
64
+ /** Not written in on this row: the cell reads while the row is edited. */
65
+ readonly?: boolean;
66
+ /** Has to have a value: marked while the row is edited, and said to its control. */
67
+ required?: boolean;
68
+ /** Not there on this row (Odoo's per-row `invisible`): the cell is drawn empty. */
69
+ hidden?: boolean;
70
+ /** What is wrong with this value, said as what to put — under the value, with an icon,
71
+ * and the control is `aria-invalid` and described by it. `null` when nothing is. */
72
+ error?: string | null;
73
+ /** A warning about THIS value — «precio bajo el costo» on the price, not on the line. */
74
+ flag?: EditFlag | null;
75
+ };
76
+ /** What a whole row is right now. A row that should not be seen is not passed at all. */
77
+ export type EditRowState = {
78
+ /** Read, not written: walked with the arrows, never opened to edit. */
79
+ readonly?: boolean;
80
+ };
49
81
  /** One more way to add, beside «add a line»: a section, a note. */
50
82
  export type EditAdd = {
51
83
  label: string;
@@ -97,7 +129,7 @@ export type RowKey = "ArrowDown" | "ArrowUp" | "Home" | "End";
97
129
  * of a new record in `DataTable`: one keyboard model, three shapes. Exported for that
98
130
  * family sibling only; it is not in the barrel.
99
131
  */
100
- export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, children, }: {
132
+ export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, windowed, children, }: {
101
133
  as: "tr" | "li";
102
134
  rowId: string;
103
135
  rowRef: (node: HTMLElement | null) => void;
@@ -113,6 +145,9 @@ export declare function EditRow({ as, rowId, rowRef, editable, editing, tabbable
113
145
  /** `Tab` left the row by one of its edges. `true` = the table took it (opened another
114
146
  * row, added one); `false` = the browser moves the focus as usual. */
115
147
  onTabOut: (direction: "next" | "prev") => boolean;
148
+ /** Past a few hundred rows the table draws only the ones in view (`rowWindow`): this row's
149
+ * measure, its index, and the focus that keeps it drawn. */
150
+ windowed?: WindowedRow | undefined;
116
151
  children: ReactNode;
117
152
  }): import("react").JSX.Element;
118
153
  /**
@@ -135,15 +170,19 @@ totals, empty, grouping,
135
170
  /** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
136
171
  * compone, por el mismo motivo que `totals`: sumar significa cosas distintas
137
172
  * según el dominio, y un porcentaje se promedia, no se suma. */
138
- groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }: {
173
+ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, cellState, rowState, }: {
139
174
  rows: readonly T[];
140
175
  columns: readonly EditColumn<T>[];
176
+ /** The row's key on this screen, stable for as long as the row is drawn — a local key for
177
+ * a row the backend has not made yet, kept after it is saved. A key that changes is a new
178
+ * row to React: the row being written is unmounted and loses the focus. */
141
179
  getRowId: (row: T) => string;
142
180
  onChange?: (id: string, next: T) => void;
143
181
  onRemove?: (id: string) => void;
144
182
  /** «Add a line». Return the new row's id —or a promise of it, when the backend makes
145
183
  * the record— and the table opens that row with the focus on its first control.
146
- * Return nothing and the row just appears, closed. */
184
+ * Return nothing and the row just appears, closed. The id is the row's KEY (`getRowId`):
185
+ * give a line the backend has not made a local one, and keep it after the save. */
147
186
  onAdd?: () => string | void | Promise<string | void>;
148
187
  addLabel?: string;
149
188
  totals?: readonly (ReactNode | null)[];
@@ -184,10 +223,13 @@ groupTotal, height, layout, onOpen, onDiscard, bands, adds, flag, onReorder, }:
184
223
  * `null` for the rows with nothing to say, which should be most of them. `owner` is what
185
224
  * the record's activity says it belongs to («Línea 3 · Impresora…»); without it, the
186
225
  * row's first value. */
187
- flag?: (row: T) => (Omit<FlagProps, "children"> & {
188
- detail?: ReactNode;
189
- owner?: string;
190
- }) | null;
226
+ flag?: (row: T) => EditFlag | null;
227
+ /** What each cell is on each row — readonly, required, hidden, its error, its warning —
228
+ * as the backend says it per line. `null` keeps the column as declared. See
229
+ * `EditCellState`. */
230
+ cellState?: (row: T, columnId: string) => EditCellState | null;
231
+ /** What a whole row is: `readonly` is read, not opened. See `EditRowState`. */
232
+ rowState?: (row: T) => EditRowState | null;
191
233
  /** The rows have an order of their own — the lines of an order, Odoo's `sequence` — and it
192
234
  * is changed by dragging the grip at the start of a row, or with Alt + ↑ / ↓ on it. `before`
193
235
  * is the row it lands in front of; `null`, the end. Off while grouped (the piles decide the
@@ -2,15 +2,17 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useUiCopy } from "../../foundations/copy.js";
4
4
  import { byDensity, useDensity } from "../../foundations/density.js";
5
+ import { ControlStateContext } from "../../foundations/form.js";
5
6
  import { pathKey, pileIsOpen, pileItems, pilesOf, } from "../../foundations/grouping.js";
6
7
  import { HANDHELD_WIDTH } from "../../foundations/layout.js";
7
8
  import { stickyTop, useScrollBox } from "../../foundations/sticky.js";
8
9
  import { IconButton } from "../button/Button.js";
9
- import { NoteCard } from "../field/note.js";
10
+ import { NoteCard, NoteRegistryContext, } from "../field/note.js";
10
11
  import { Flag } from "../notice/Flag.js";
12
+ import { useRowWindow } from "./rowWindow.js";
11
13
  import { HEADER_TEXT } from "./Table.js";
12
- import { GripVertical, Plus, Trash2 } from "lucide-react";
13
- import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
14
+ import { AlertCircle, GripVertical, Plus, Trash2 } from "lucide-react";
15
+ import { Fragment, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react";
14
16
  import { useKeyboard } from "react-aria";
15
17
  /** The lines each section heads: the rows after it up to the next section. Notes do not
16
18
  * cut a section, and are not lines. */
@@ -52,6 +54,30 @@ function says(node) {
52
54
  function pileTag(pile, locale) {
53
55
  return (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { className: "shrink-0 text-micro text-muted", children: pile.field }), _jsx("span", { className: "truncate text-micro font-medium text-ink", children: pile.label }), _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: pile.count.toLocaleString(locale) })] }));
54
56
  }
57
+ /** The note a flag stands for, as the record lists it. */
58
+ function noteOf(said) {
59
+ return {
60
+ tone: said.tone,
61
+ label: said.label,
62
+ ...(said.detail === undefined ? {} : { detail: said.detail }),
63
+ };
64
+ }
65
+ /** A cell's value as words, when it is words — what a note says it belongs to when the row
66
+ * is not drawn to read it from. */
67
+ function plainText(node) {
68
+ return typeof node === "string" || typeof node === "number"
69
+ ? String(node).slice(0, 60)
70
+ : "";
71
+ }
72
+ /** The ids of the notes a row registers with its record — one for the row, one per cell. */
73
+ const rowNoteId = (rowId) => `flag-${rowId}`;
74
+ const cellNoteId = (rowId, columnId) => `flag-${rowId}-${columnId}`;
75
+ /** A cell's error, under its value: an icon and the sentence, in danger — the same shape as
76
+ * a field's. No `role="alert"`: errors that arrive with a save land on many rows at once,
77
+ * and each would interrupt the others; the control is described by it instead. */
78
+ function CellError({ id, numeric, children, }) {
79
+ return (_jsxs("span", { id: id, className: `mt-1 flex items-start gap-1 text-left text-micro font-normal whitespace-normal text-danger ${numeric === true ? "justify-end" : ""}`, children: [_jsx(AlertCircle, { "aria-hidden": true, className: "mt-px size-(--size-icon-sm) shrink-0", strokeWidth: 2 }), _jsx("span", { className: "min-w-0", children: children })] }));
80
+ }
55
81
  /** The focus ring of an operable row: drawn on the row, inset, so it is not cut by the
56
82
  * box and does not move the cells. */
57
83
  const RING = "outline-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary forced-colors:focus-visible:outline-[Highlight]";
@@ -110,7 +136,7 @@ function ownsEnter(target) {
110
136
  * of a new record in `DataTable`: one keyboard model, three shapes. Exported for that
111
137
  * family sibling only; it is not in the barrel.
112
138
  */
113
- export function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, children, }) {
139
+ export function EditRow({ as, rowId, rowRef, editable, editing, tabbable, className, onStart, onCommit, onCancel, onMove, onFocusRow, onTabOut, windowed, children, }) {
114
140
  const leaving = useRef(null);
115
141
  const stay = () => {
116
142
  if (leaving.current !== null)
@@ -218,7 +244,24 @@ export function EditRow({ as, rowId, rowRef, editable, editing, tabbable, classN
218
244
  className,
219
245
  "data-row-id": rowId,
220
246
  };
221
- return as === "li" ? (_jsx("li", { ref: rowRef, ...props, children: children })) : (_jsx("tr", { ref: rowRef, ...props, children: children }));
247
+ if (windowed !== undefined) {
248
+ /* The row that holds the focus stays drawn wherever it scrolls: a control being written
249
+ in must not be unmounted under the person's hands. */
250
+ const own = props.onFocus;
251
+ props.onFocus = (event) => {
252
+ windowed.onFocus();
253
+ own?.(event);
254
+ };
255
+ }
256
+ return as === "li" ? (_jsx("li", { ref: rowRef, ...props, children: children })) : (_jsx("tr", { ref: (node) => {
257
+ rowRef(node);
258
+ windowed?.ref(node);
259
+ }, ...(windowed === undefined
260
+ ? {}
261
+ : {
262
+ "data-index": windowed["data-index"],
263
+ "aria-rowindex": windowed["aria-rowindex"],
264
+ }), ...props, children: children }));
222
265
  }
223
266
  /**
224
267
  * A table whose rows are edited in place, one row at a time.
@@ -240,19 +283,60 @@ totals, empty, grouping,
240
283
  /** El total de una columna DENTRO de un montón, ya formateado. Lo calcula quien
241
284
  * compone, por el mismo motivo que `totals`: sumar significa cosas distintas
242
285
  * según el dominio, y un porcentaje se promedia, no se suma. */
243
- groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReorder, }) {
286
+ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReorder, cellState, rowState, }) {
244
287
  const copy = useUiCopy();
245
288
  const bandOf = (row) => bands?.kind(row) ?? null;
289
+ const uid = useId();
290
+ /* A mark, and —in a record— its card, which registers the note with the record. */
291
+ const marked = (said, noteId, withCard) => (_jsxs(_Fragment, { children: [_jsx(Flag, { tone: said.tone, label: said.label, children: said.detail }), withCard ? (_jsx(NoteCard, { id: noteId, ...(said.owner === undefined ? {} : { owner: said.owner }), note: noteOf(said) })) : null] }));
246
292
  /* The row's mark, before its first value — and, in a record's margin, its card. */
247
293
  const flagOf = (row, withCard = false) => {
248
294
  const said = flag?.(row) ?? null;
249
- if (said === null)
250
- return null;
251
- return (_jsxs(_Fragment, { children: [_jsx(Flag, { tone: said.tone, label: said.label, children: said.detail }), withCard ? (_jsx(NoteCard, { id: `flag-${getRowId(row)}`, ...(said.owner === undefined ? {} : { owner: said.owner }), note: {
252
- tone: said.tone,
253
- label: said.label,
254
- ...(said.detail === undefined ? {} : { detail: said.detail }),
255
- } })) : null] }));
295
+ return said === null ? null : marked(said, rowNoteId(getRowId(row)), withCard);
296
+ };
297
+ /* What the backend says of each cell on each row. A hidden cell says nothing else: no
298
+ error and no warning about a value that is not there. */
299
+ const stateOf = (row, column) => {
300
+ const said = cellState?.(row, column.id) ?? null;
301
+ return said?.hidden === true ? { hidden: true } : said;
302
+ };
303
+ const locked = (row) => rowState?.(row)?.readonly === true;
304
+ /* Whether a cell becomes a control while its row is edited. */
305
+ const writes = (row, column, state) => editable &&
306
+ column.edit !== undefined &&
307
+ state?.hidden !== true &&
308
+ state?.readonly !== true &&
309
+ !locked(row);
310
+ /* Ids built from a row id: `aria-describedby` is a list split on spaces, so an id that
311
+ carries one would describe the control with two ids that do not exist. */
312
+ const cellId = (rowId, columnId, what) => `${uid}-${rowId.replace(/[^\w-]/g, "_")}-${columnId.replace(/[^\w-]/g, "_")}-${what}`;
313
+ const cellFlagOf = (row, column, state, withCard = false) => {
314
+ const said = state?.flag ?? null;
315
+ return said === null
316
+ ? null
317
+ : marked(said, cellNoteId(getRowId(row), column.id), withCard);
318
+ };
319
+ /* The control of a cell, wired through the form contract: its error and «required» reach
320
+ whatever control the column wrote — the package's or the application's own. */
321
+ const controlOf = (row, column, state, set) => {
322
+ const id = getRowId(row);
323
+ const error = state?.error ?? null;
324
+ const required = state?.required === true;
325
+ const describedBy = [
326
+ error === null ? null : cellId(id, column.id, "error"),
327
+ required ? cellId(id, column.id, "required") : null,
328
+ ]
329
+ .filter((one) => one !== null)
330
+ .join(" ");
331
+ return (_jsx(ControlStateContext, { value: {
332
+ ...(describedBy === "" ? {} : { describedBy }),
333
+ invalid: error !== null,
334
+ }, children: required ? (_jsxs("span", { className: "flex min-w-0 items-start gap-1", children: [_jsx("span", { className: "min-w-0 flex-1", children: column.edit?.(row, set) }), _jsx("span", { "aria-hidden": true, className: "pt-1 text-meta text-ink", children: "*" }), _jsx("span", { id: cellId(id, column.id, "required"), className: "sr-only", children: copy.field.required })] })) : (column.edit?.(row, set)) }));
335
+ };
336
+ /* The error of a cell, under its value — read with the cell, and describing its control. */
337
+ const errorOf = (row, column, state) => {
338
+ const error = state?.error ?? null;
339
+ return error === null ? null : (_jsx(CellError, { id: cellId(getRowId(row), column.id, "error"), numeric: column.numeric, children: error }));
256
340
  };
257
341
  const underSection = useMemo(() => (bands === undefined ? null : sectionLines(rows, getRowId, bands.kind)), [rows, bands, getRowId]);
258
342
  /* Where a band's text stops: the first column with something of its own on that row. */
@@ -282,6 +366,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
282
366
  /* The id `onAdd` returned, until that row shows up in `rows` and can be opened. */
283
367
  const [arriving, setArriving] = useState(null);
284
368
  const addButton = useRef(null);
369
+ const rowById = useMemo(() => new Map(rows.map((row) => [getRowId(row), row])), [rows, getRowId]);
285
370
  /* La secuencia que se dibuja: montones y filas intercalados. Sin agrupado, las
286
371
  filas tal cual — una tabla que no agrupa no paga nada por poder hacerlo. */
287
372
  const drawn = useMemo(() => {
@@ -328,6 +413,20 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
328
413
  return () => watch.disconnect();
329
414
  }, [layout]);
330
415
  const stacked = layout === "stacked" || (layout === "auto" && narrow);
416
+ /* Past a few hundred rows only the ones in view are drawn — see `rowWindow.tsx`. Measured
417
+ at 2,000 lines, drawing them all cost a quarter of a second per keystroke in the row
418
+ being written: every row re-rendered for one letter. The window counts what is DRAWN —
419
+ group bands included, folded rows left out. The cards of a phone are not windowed: a
420
+ phone does not hold a document of two thousand lines being written. */
421
+ const view = useRowWindow({
422
+ count: stacked ? 0 : drawn.length,
423
+ getKey: (index) => {
424
+ const entry = drawn[index];
425
+ if (entry?.pile !== undefined)
426
+ return `pile:${pathKey(entry.pile.path)}`;
427
+ return entry?.row === undefined ? String(index) : getRowId(entry.row);
428
+ },
429
+ });
331
430
  const editable = onChange !== undefined;
332
431
  /* A card that opens the consumer's form is operable even with nothing to edit in
333
432
  place: it has a tab stop, takes Enter and answers a press. */
@@ -341,7 +440,68 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
341
440
  const stop = active !== null && order.includes(active) ? active : (order[0] ?? null);
342
441
  /* REORDER — a grip at the start of each row. Dragged, the row shows where it would land (a
343
442
  line over the row it goes in front of); Alt + ↑ / ↓ on the grip does the same by keyboard. */
443
+ /* The notes of the rows the window does not draw. A drawn row's mark registers its note
444
+ with the record (`NoteCard`); one scrolled out of the window would vanish from the
445
+ record's activity — «2 avisos» for a document that has nine. The table registers those
446
+ itself, and «take me there» brings the row into the window. Every render, like a
447
+ `NoteCard`: a drawn row's card takes over its note, and gives it back when it leaves. */
448
+ const registry = useContext(NoteRegistryContext);
449
+ const ownNotes = useRef(new Set());
450
+ useLayoutEffect(() => {
451
+ if (registry === null)
452
+ return;
453
+ const now = new Set();
454
+ const theirs = new Set();
455
+ if (view.on) {
456
+ const inWindow = new Set(view.rows.flatMap((slot) => {
457
+ const row = drawn[slot.index]?.row;
458
+ return row === undefined ? [] : [getRowId(row)];
459
+ }));
460
+ for (const [index, entry] of drawn.entries()) {
461
+ const row = entry.row;
462
+ if (row === undefined || bandOf(row) !== null)
463
+ continue;
464
+ const id = getRowId(row);
465
+ const said = [];
466
+ const own = flag?.(row) ?? null;
467
+ if (own !== null)
468
+ said.push({ noteId: rowNoteId(id), flag: own });
469
+ for (const column of columns) {
470
+ const cellFlag = stateOf(row, column)?.flag ?? null;
471
+ if (cellFlag !== null)
472
+ said.push({ noteId: cellNoteId(id, column.id), flag: cellFlag });
473
+ }
474
+ for (const one of said) {
475
+ if (inWindow.has(id)) {
476
+ theirs.add(one.noteId);
477
+ continue;
478
+ }
479
+ registry.put({
480
+ id: one.noteId,
481
+ note: noteOf(one.flag),
482
+ owner: one.flag.owner ?? plainText(columns[0]?.render(row)),
483
+ anchor: () => {
484
+ view.reveal(index);
485
+ return null;
486
+ },
487
+ });
488
+ now.add(one.noteId);
489
+ }
490
+ }
491
+ }
492
+ for (const noteId of ownNotes.current) {
493
+ if (!now.has(noteId) && !theirs.has(noteId))
494
+ registry.drop(noteId);
495
+ }
496
+ ownNotes.current = now;
497
+ });
498
+ useLayoutEffect(() => () => {
499
+ for (const noteId of ownNotes.current)
500
+ registry?.drop(noteId);
501
+ }, [registry]);
344
502
  const reorderable = onReorder !== undefined && grouping === undefined && !stacked;
503
+ /* Every cell of a row, the grip and the trash: what a spacer row spans. */
504
+ const width = columns.length + (onRemove === undefined ? 0 : 1) + (reorderable ? 1 : 0);
345
505
  /* A ref and not state: `dragover` can arrive before React re-renders after `dragstart`. */
346
506
  const dragged = useRef(null);
347
507
  const [landing, setLanding] = useState(undefined);
@@ -384,17 +544,34 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
384
544
  dragged.current = null;
385
545
  setLanding(undefined);
386
546
  }, className: "flex cursor-grab items-center rounded-control p-0.5 text-muted opacity-40 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 hover:text-ink focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-primary active:cursor-grabbing", children: _jsx(GripVertical, { "aria-hidden": true, className: "size-(--size-icon-sm)", strokeWidth: 2 }) }) }));
547
+ /* The focus a row is waiting for, once it is drawn: opened by the keyboard (its first or
548
+ last control), or reached by a jump (`Home`, `End`) past what the window draws. Every
549
+ render and not on `editing` alone: a row outside the window shows up a render or two
550
+ after it was asked for. */
551
+ const focusWhenDrawn = useRef(null);
552
+ const indexOf = (id) => drawn.findIndex((entry) => entry.row !== undefined && getRowId(entry.row) === id);
387
553
  useEffect(() => {
388
554
  const target = focusOnOpen.current;
389
- if (target === null || target.id !== editing)
555
+ if (target !== null && target.id === editing) {
556
+ const node = rowNodes.current.get(target.id);
557
+ if (node === undefined) {
558
+ view.reveal(indexOf(target.id));
559
+ return;
560
+ }
561
+ focusOnOpen.current = null;
562
+ const control = target.at === "last" ? lastControl(node) : firstControl(node);
563
+ (control ?? node).focus();
564
+ return;
565
+ }
566
+ const waiting = focusWhenDrawn.current;
567
+ if (waiting === null)
390
568
  return;
391
- focusOnOpen.current = null;
392
- const node = rowNodes.current.get(target.id);
569
+ const node = rowNodes.current.get(waiting);
393
570
  if (node === undefined)
394
571
  return;
395
- const control = target.at === "last" ? lastControl(node) : firstControl(node);
396
- (control ?? node).focus();
397
- }, [editing]);
572
+ focusWhenDrawn.current = null;
573
+ node.focus();
574
+ });
398
575
  const start = (id, row, viaKeyboard, { at = "first", added = false } = {}) => {
399
576
  /* A row just added is written where it appeared, card or not: it was added in place
400
577
  precisely so that it would not open somewhere else. */
@@ -403,6 +580,11 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
403
580
  onOpen?.(id);
404
581
  return;
405
582
  }
583
+ /* Read, not written: the row keeps the focus and opens nothing. */
584
+ if (locked(row)) {
585
+ setActive(id);
586
+ return;
587
+ }
406
588
  before.current = { id, row, touched: false, added };
407
589
  focusOnOpen.current = viaKeyboard ? { id, at } : null;
408
590
  setActive(id);
@@ -480,7 +662,18 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
480
662
  const tabOut = (from, direction) => {
481
663
  const was = before.current;
482
664
  const blank = was !== null && was.id === from && was.added && !was.touched;
483
- const target = tabTarget(order, from, direction, { canAdd: onAdd !== undefined, blank });
665
+ /* `Tab` walks the rows that can be written: a locked one is passed over, as a
666
+ read-only field is in a form. */
667
+ const writable = rowState === undefined
668
+ ? order
669
+ : order.filter((id) => {
670
+ const row = rowById.get(id);
671
+ return id === from || row === undefined || !locked(row);
672
+ });
673
+ const target = tabTarget(writable, from, direction, {
674
+ canAdd: onAdd !== undefined,
675
+ blank,
676
+ });
484
677
  if (target.kind === "leave")
485
678
  return false;
486
679
  commit(from, false);
@@ -488,7 +681,7 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
488
681
  add();
489
682
  return true;
490
683
  }
491
- const row = rows.find((r) => getRowId(r) === target.id);
684
+ const row = rowById.get(target.id);
492
685
  if (row === undefined)
493
686
  return false;
494
687
  start(target.id, row, true, { at: direction === "next" ? "first" : "last" });
@@ -532,7 +725,13 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
532
725
  if (id === undefined)
533
726
  return;
534
727
  setActive(id);
535
- rowNodes.current.get(id)?.focus();
728
+ const node = rowNodes.current.get(id);
729
+ if (node !== undefined) {
730
+ node.focus();
731
+ return;
732
+ }
733
+ focusWhenDrawn.current = id;
734
+ view.reveal(indexOf(id));
536
735
  };
537
736
  /* ── the stacked list: one card per row, below `--container-handheld` ── */
538
737
  const places = stackPlaces(columns);
@@ -546,7 +745,9 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
546
745
  const id = getRowId(row);
547
746
  const isEditing = editing === id;
548
747
  const set = setter(id);
549
- const parts = details.flatMap((c) => {
748
+ const states = new Map(columns.map((c) => [c.id, stateOf(row, c)]));
749
+ const shown = (c) => states.get(c.id)?.hidden !== true;
750
+ const parts = details.filter(shown).flatMap((c) => {
550
751
  const said = c.brief === undefined ? null : c.brief(row);
551
752
  if (c.brief !== undefined)
552
753
  return says(said) ? [{ id: c.id, node: said }] : [];
@@ -557,15 +758,28 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
557
758
  },
558
759
  ];
559
760
  });
560
- const controls = editable && isEditing ? columns.filter((c) => c.edit !== undefined) : [];
561
- return (_jsxs(EditRow, { as: "li", ...rowWiring(id, row), className: `group/row flex min-w-0 flex-col gap-1 rounded-control border-b border-line ${pad} ${isEditing ? "bg-primary/5" : "hover:bg-ink/4"} ${operable ? RING : ""} ${operable && !isEditing ? (opens ? "cursor-pointer" : "cursor-text") : ""}`, children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [flagOf(row), _jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 text-meta text-ink", children: titles.map((c) => (_jsx("div", { className: "min-w-0", children: c.render(row) }, c.id))) }), trails.map((c) => (_jsx("div", { className: "shrink-0", children: c.render(row) }, c.id))), isEditing && onRemove !== undefined ? (_jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", onClick: () => onRemove(id) })) : null] }), parts.length === 0 && amounts.length === 0 ? null : (_jsxs("div", { className: "flex min-w-0 items-baseline gap-3 text-meta", children: [_jsx("span", { className: "min-w-0 flex-1 text-muted tabular-nums", children: parts.map((p, i) => (_jsxs(Fragment, { children: [i > 0 ? " · " : null, p.node] }, p.id))) }), amounts.map((c) => (
761
+ const controls = isEditing
762
+ ? columns.filter((c) => writes(row, c, states.get(c.id) ?? null))
763
+ : [];
764
+ /* The errors of the cells that are not a control right now: under the card. A control
765
+ carries its own, under it. */
766
+ const loose = columns.filter((c) => !controls.includes(c) && (states.get(c.id)?.error ?? null) !== null);
767
+ return (_jsxs(EditRow, { as: "li", ...rowWiring(id, row), className: `group/row flex min-w-0 flex-col gap-1 rounded-control border-b border-line ${pad} ${isEditing ? "bg-primary/5" : "hover:bg-ink/4"} ${operable ? RING : ""} ${operable && !isEditing
768
+ ? opens
769
+ ? "cursor-pointer"
770
+ : locked(row)
771
+ ? ""
772
+ : "cursor-text"
773
+ : ""}`, children: [_jsxs("div", { className: "flex min-w-0 items-start gap-2", children: [flagOf(row), columns.map((c) => (_jsx(Fragment, { children: cellFlagOf(row, c, states.get(c.id) ?? null) }, c.id))), _jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5 text-meta text-ink", children: titles.filter(shown).map((c) => (_jsx("div", { className: "min-w-0", children: c.render(row) }, c.id))) }), trails.filter(shown).map((c) => (_jsx("div", { className: "shrink-0", children: c.render(row) }, c.id))), isEditing && onRemove !== undefined ? (_jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", onClick: () => onRemove(id) })) : null] }), parts.length === 0 && amounts.length === 0 ? null : (_jsxs("div", { className: "flex min-w-0 items-baseline gap-3 text-meta", children: [_jsx("span", { className: "min-w-0 flex-1 text-muted tabular-nums", children: parts.map((p, i) => (_jsxs(Fragment, { children: [i > 0 ? " · " : null, p.node] }, p.id))) }), amounts.filter(shown).map((c) => (
562
774
  /* The amount is read by its figure on screen and by its name aloud: a
563
775
  lone «Bs. 1.296,00» at the end of a card does not say what it is. */
564
776
  _jsxs("span", { className: "shrink-0 text-right font-medium whitespace-nowrap text-ink tabular-nums", children: [_jsxs("span", { className: "sr-only", children: [c.header, " "] }), c.render(row)] }, c.id)))] })), controls.length === 0 ? null : (
565
777
  /* Each control under the column's name: a cell had the header above it, a
566
778
  card has nothing, and a number box that does not say what it holds is a
567
779
  guess. The name is drawn for the eye; the control carries its own label. */
568
- _jsx("div", { className: "grid grid-cols-2 gap-x-3 gap-y-2 pt-1.5", children: controls.map((c) => (_jsxs("div", { "data-edit": "", className: `flex min-w-0 flex-col gap-1 ${c.numeric === true ? "" : "col-span-2"}`, children: [_jsx("span", { "aria-hidden": "true", className: "text-micro text-muted", children: c.header }), c.edit?.(row, set)] }, c.id))) }))] }, id));
780
+ _jsx("div", { className: "grid grid-cols-2 gap-x-3 gap-y-2 pt-1.5", children: controls.map((c) => (_jsxs("div", { "data-edit": "", className: `flex min-w-0 flex-col gap-1 ${c.numeric === true ? "" : "col-span-2"}`, children: [_jsx("span", { "aria-hidden": "true", className: "text-micro text-muted", children: c.header }), controlOf(row, c, states.get(c.id) ?? null, set), errorOf(row, c, states.get(c.id) ?? null)] }, c.id))) })), loose.length === 0 ? null : (
781
+ /* Under a card there is no column to stand under: each error says its column. */
782
+ _jsx("div", { className: "flex flex-col", children: loose.map((c) => (_jsxs(CellError, { id: cellId(id, c.id, "error"), children: [c.header, ": ", states.get(c.id)?.error] }, c.id))) }))] }, id));
569
783
  };
570
784
  /* A section or a note on a phone: the band and, for a section, what it adds up to in
571
785
  the card's amount; editing, its control under it. */
@@ -602,11 +816,13 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
602
816
  return null;
603
817
  return (_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-meta", children: [_jsx("dt", { className: "text-muted", children: c.header }), _jsx("dd", { className: "font-semibold text-ink tabular-nums", children: total })] }, c.id));
604
818
  }) }))] }));
605
- return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, tabIndex: box.tabIndex, className: `min-w-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => c.header === "" ? (_jsx("td", { className: "w-0 border-b border-line-strong" }, c.id)) : (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width },
819
+ return (_jsxs("div", { ref: frame, className: "min-w-0", children: [_jsx("div", { ref: box.ref, tabIndex: box.tabIndex, className: `min-w-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary ${box.className}`, style: box.style, children: stacked ? (list) : (_jsxs("table", { className: "w-full border-separate border-spacing-0 px-1 text-left", ...(view.on
820
+ ? { "aria-rowcount": 1 + drawn.length + (totals === undefined ? 0 : 1) }
821
+ : {}), children: [_jsx("thead", { className: "sticky z-10 bg-ground", style: stickyTop, children: _jsxs("tr", { ...(view.on ? { "aria-rowindex": 1 } : {}), children: [reorderable ? _jsx("td", { className: "w-0 border-b border-line-strong" }) : null, columns.map((c) => c.header === "" ? (_jsx("td", { className: "w-0 border-b border-line-strong" }, c.id)) : (_jsx("th", { scope: "col", style: c.width === undefined ? undefined : { width: c.width },
606
822
  /* A column of figures takes its figures' width (`w-0` in an auto
607
823
  table is «as narrow as its content») and the text takes the rest:
608
824
  the product name was cut while «Cantidad» had room for ten digits. */
609
- className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"} ${c.numeric === true && c.width === undefined ? "w-0" : ""}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "w-0 border-b border-line-strong" }))] }) }), _jsx("tbody", { ...(reorderable
825
+ className: `border-b border-line-strong ${HEADER_TEXT} ${cell} ${c.numeric === true ? "text-right" : "text-left"} ${c.numeric === true && c.width === undefined ? "w-0" : ""}`, children: c.header }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "w-0 border-b border-line-strong" }))] }) }), _jsxs("tbody", { ref: view.body.ref, ...(reorderable
610
826
  ? {
611
827
  onDragOver: overRow,
612
828
  onDrop: (event) => {
@@ -617,60 +833,83 @@ groupTotal, height, layout = "auto", onOpen, onDiscard, bands, adds, flag, onReo
617
833
  setLanding(undefined);
618
834
  },
619
835
  }
620
- : {}), children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length +
621
- (onRemove === undefined ? 0 : 1) +
622
- (reorderable ? 1 : 0), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (drawn.map((entry) => {
623
- if (entry.pile !== undefined) {
624
- const pile = entry.pile;
625
- const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
626
- return (
627
- /* La banda respeta la cuadrícula: el rótulo en la primera
628
- columna y cada total bajo la suya. Un total en un rincón
629
- obliga a buscar de qué columna es; alineado no hay nada que
630
- buscar. */
631
- _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "w-0 bg-ground" }) : null, columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
632
- ? {
633
- paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
634
- }
635
- : undefined, children: ci === 0
636
- ? pileTag(pile, copy.locale)
637
- : (groupTotal?.(c, inside) ?? null) }, c.id))), onRemove === undefined ? null : _jsx("td", { className: "w-0 bg-ground" })] }, `pile:${pathKey(pile.path)}`));
638
- }
639
- const row = entry.row;
640
- const id = getRowId(row);
641
- const isEditing = editing === id;
642
- const band = bandOf(row);
643
- if (band !== null) {
644
- const { span, figures } = bandFigures(row);
645
- const bandWash = isEditing
646
- ? "bg-primary/5"
647
- : band === "section"
648
- ? "bg-ground group-hover/row:bg-ink/6"
649
- : "group-hover/row:bg-ink/4";
650
- const bandMark = `border-b border-line first:rounded-l-control last:rounded-r-control ${bandWash}`;
651
- const control = isEditing && editable && bands?.edit !== undefined;
652
- return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row), className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, bandMark) : null, _jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${lineOver(id)} ${cell} ${band === "section"
653
- ? "font-semibold text-ink"
654
- : "text-muted italic"}`, children: control ? bands?.edit?.(row, setter(id)) : bands?.render(row) }), columns.slice(span).map((c, i) => (_jsx("td", { className: `text-meta text-ink ${bandMark} ${cell} ${c.numeric === true
655
- ? "text-right font-semibold tabular-nums"
656
- : ""}`, children: figures[span + i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${bandMark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id));
657
- }
658
- /* The wash lives on the cells. A background on the row paints
659
- edge to edge, and `border-collapse` would ignore a radius. */
660
- const wash = isEditing ? "bg-primary/5" : "group-hover/row:bg-ink/4";
661
- const mark = `border-b border-line first:rounded-l-control last:rounded-r-control ${wash}`;
662
- const set = setter(id);
663
- return (_jsxs(EditRow, { as: "tr", ...rowWiring(id, row),
664
- /* The focus ring is drawn on the row, inset, so it is not cut by
665
- the box and does not move the cells. */
666
- className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, mark) : null, columns.map((c, ci) => {
667
- const control = isEditing && c.edit !== undefined && editable;
668
- /* Not `mark`: that name is the row's border and wash, above. Shadowed,
669
- the cells were drawn with the class «null» — no hairline, no hover. */
670
- const flagged = ci === 0 ? flagOf(row, true) : null;
671
- return (_jsx("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${lineOver(id)} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: flagged === null ? (control ? (c.edit?.(row, set)) : (c.render(row))) : (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [flagged, _jsx("span", { className: "min-w-0 flex-1", children: control ? c.edit?.(row, set) : c.render(row) })] })) }, c.id));
672
- }), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${mark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id));
673
- })) }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "border-t border-line-strong" }) : null, columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
836
+ : {}), children: [rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columns.length +
837
+ (onRemove === undefined ? 0 : 1) +
838
+ (reorderable ? 1 : 0), className: `text-meta text-muted italic ${cell}`, children: emptyText }) })) : (view.rows.flatMap((slot, at) => {
839
+ const entry = drawn[slot.index];
840
+ if (entry === undefined)
841
+ return [];
842
+ const windowed = view.windowed(slot.index);
843
+ /* What is not drawn is a spacer of its height: the rows above the window
844
+ before the first, and the gap before a row kept only because it holds
845
+ the focus. */
846
+ const gap = view.pad(at === 0 ? 0 : view.rows[at - 1].end, slot.start, width, `gap:${slot.index}`);
847
+ if (entry.pile !== undefined) {
848
+ const pile = entry.pile;
849
+ const inside = pile.piles.length === 0 ? pile.items : pileItems(pile.piles);
850
+ return [
851
+ gap,
852
+ /* La banda respeta la cuadrícula: el rótulo en la primera
853
+ columna y cada total bajo la suya. Un total en un rincón
854
+ obliga a buscar de qué columna es; alineado no hay nada que
855
+ buscar. */
856
+ _jsxs("tr", { ...windowed, children: [reorderable ? _jsx("td", { className: "w-0 bg-ground" }) : null, columns.map((c, ci) => (_jsx("td", { className: `bg-ground text-meta ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, style: ci === 0 && pile.depth > 0
857
+ ? {
858
+ paddingLeft: `calc(var(--spacing-indent) * ${pile.depth} + 0.625rem)`,
859
+ }
860
+ : undefined, children: ci === 0
861
+ ? pileTag(pile, copy.locale)
862
+ : (groupTotal?.(c, inside) ?? null) }, c.id))), onRemove === undefined ? null : _jsx("td", { className: "w-0 bg-ground" })] }, `pile:${pathKey(pile.path)}`),
863
+ ];
864
+ }
865
+ const row = entry.row;
866
+ const id = getRowId(row);
867
+ const isEditing = editing === id;
868
+ const band = bandOf(row);
869
+ if (band !== null) {
870
+ const { span, figures } = bandFigures(row);
871
+ const bandWash = isEditing
872
+ ? "bg-primary/5"
873
+ : band === "section"
874
+ ? "bg-ground group-hover/row:bg-ink/6"
875
+ : "group-hover/row:bg-ink/4";
876
+ const bandMark = `border-b border-line first:rounded-l-control last:rounded-r-control ${bandWash}`;
877
+ const control = isEditing && editable && bands?.edit !== undefined;
878
+ return [
879
+ gap,
880
+ _jsxs(EditRow, { as: "tr", ...rowWiring(id, row), windowed: windowed, className: `group/row ${editable ? RING : ""} ${editable && !isEditing ? "cursor-text" : ""}`, children: [reorderable ? grip(id, bandMark) : null, _jsx("td", { colSpan: span, ...(control ? { "data-edit": "" } : {}), className: `text-meta ${bandMark} ${lineOver(id)} ${cell} ${band === "section"
881
+ ? "font-semibold text-ink"
882
+ : "text-muted italic"}`, children: control ? bands?.edit?.(row, setter(id)) : bands?.render(row) }), columns.slice(span).map((c, i) => (_jsx("td", { className: `text-meta text-ink ${bandMark} ${cell} ${c.numeric === true
883
+ ? "text-right font-semibold tabular-nums"
884
+ : ""}`, children: figures[span + i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${bandMark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id),
885
+ ];
886
+ }
887
+ /* The wash lives on the cells. A background on the row paints
888
+ edge to edge, and `border-collapse` would ignore a radius. */
889
+ const wash = isEditing ? "bg-primary/5" : "group-hover/row:bg-ink/4";
890
+ const mark = `border-b border-line first:rounded-l-control last:rounded-r-control ${wash}`;
891
+ const set = setter(id);
892
+ return [
893
+ gap,
894
+ _jsxs(EditRow, { as: "tr", ...rowWiring(id, row), windowed: windowed,
895
+ /* The focus ring is drawn on the row, inset, so it is not cut by
896
+ the box and does not move the cells. */
897
+ className: `group/row ${editable ? RING : ""} ${editable && !isEditing && !locked(row) ? "cursor-text" : ""}`, children: [reorderable ? grip(id, mark) : null, columns.map((c, ci) => {
898
+ const state = stateOf(row, c);
899
+ const control = isEditing && writes(row, c, state);
900
+ /* Not `mark`: that name is the row's border and wash, above. Shadowed,
901
+ the cells were drawn with the class «null» — no hairline, no hover. */
902
+ const flagged = ci === 0 ? flagOf(row, true) : null;
903
+ const cellFlag = cellFlagOf(row, c, state, true);
904
+ const value = state?.hidden === true
905
+ ? null
906
+ : control
907
+ ? controlOf(row, c, state, set)
908
+ : c.render(row);
909
+ return (_jsxs("td", { ...(control ? { "data-edit": "" } : {}), className: `text-meta text-ink ${mark} ${lineOver(id)} ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: [flagged === null && cellFlag === null ? (value) : (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", children: [flagged, cellFlag, _jsx("span", { className: "min-w-0 flex-1", children: value })] })), errorOf(row, c, state)] }, c.id));
910
+ }), onRemove === undefined ? null : (_jsx("td", { className: `w-0 py-1 pr-2 pl-1 align-middle ${mark}`, children: _jsx("span", { className: "flex opacity-0 transition-opacity duration-(--duration-instant) group-hover/row:opacity-100 group-focus-visible/row:opacity-100 focus-within:opacity-100", onClick: (e) => e.stopPropagation(), children: _jsx(IconButton, { icon: Trash2, label: copy.editable.remove, size: "sm", tone: "danger", ...(editable && stop !== id ? { tabIndex: -1 } : {}), onClick: () => onRemove(id) }) }) }))] }, id),
911
+ ];
912
+ })), rows.length === 0 ? null : view.tail(width)] }), totals === undefined ? null : (_jsx("tfoot", { children: _jsxs("tr", { children: [reorderable ? _jsx("td", { className: "border-t border-line-strong" }) : null, columns.map((c, i) => (_jsx("td", { className: `border-t border-line-strong text-meta font-semibold text-ink ${cell} ${c.numeric === true ? "text-right tabular-nums" : ""}`, children: totals[i] ?? null }, c.id))), onRemove === undefined ? null : (_jsx("td", { className: "border-t border-line-strong" }))] }) }))] })) }), onAdd === undefined && (adds === undefined || adds.length === 0) ? null : (
674
913
  /* The «add» goes under the grid, as text, not as a button away from it:
675
914
  what is about to happen is that one more row shows up right here.
676
915
  The hover is the same inset mark as a row: the shell lines the label
@@ -1,5 +1,5 @@
1
1
  export { type Aggregate, type DataColumn, DataTable, type DataTableCreate, type GroupColumn, } from "./DataTable.js";
2
- export { EditableTable, type EditAdd, type EditBands, type EditColumn, type EditLayout, sectionLines, type StackPlace, stackPlaces, type TabTarget, tabTarget, } from "./EditableTable.js";
2
+ export { EditableTable, type EditAdd, type EditBands, type EditCellState, type EditColumn, type EditFlag, type EditLayout, type EditRowState, sectionLines, type StackPlace, stackPlaces, type TabTarget, tabTarget, } from "./EditableTable.js";
3
3
  export { PileFootLine } from "./PileFootLine.js";
4
4
  export { PivotTable, type PivotTableProps, } from "./PivotTable.js";
5
5
  export { type ReportColumn, type ReportRow, type ReportRowKind, ReportTable, } from "./ReportTable.js";
@@ -36,4 +36,7 @@ export declare function useRowWindow({ count, rowsBefore, getKey, }: {
36
36
  pad: (from: number, to: number, columns: number, key: string) => ReactNode;
37
37
  /** The spacer after the last drawn row. */
38
38
  tail: (columns: number) => ReactNode;
39
+ /** Scrolls row `index` into the window, so it gets drawn — for a jump past what is drawn
40
+ * (`Home`, `End`, «take me to it»). Nothing when the window is off: every row is there. */
41
+ reveal: (index: number) => void;
39
42
  };
@@ -173,5 +173,9 @@ export function useRowWindow({ count, rowsBefore = 1, getKey, }) {
173
173
  const end = rows.length === 0 ? 0 : rows[rows.length - 1].end;
174
174
  return spacer(total - end, columns, "\u0000tail");
175
175
  },
176
+ reveal: (index) => {
177
+ if (on && scroller !== null)
178
+ v.scrollToIndex(index, { align: "auto" });
179
+ },
176
180
  };
177
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/web-ui",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",