@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.
- package/dist/components/lines/LineItems.d.ts +14 -3
- package/dist/components/lines/LineItems.js +6 -4
- package/dist/foundations/form.d.ts +18 -1
- package/dist/foundations/form.js +6 -3
- package/dist/primitives/table/EditableTable.d.ts +49 -7
- package/dist/primitives/table/EditableTable.js +321 -82
- package/dist/primitives/table/index.d.ts +1 -1
- package/dist/primitives/table/rowWindow.d.ts +3 -0
- package/dist/primitives/table/rowWindow.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/dist/foundations/form.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
|
|
392
|
-
const node = rowNodes.current.get(target.id);
|
|
569
|
+
const node = rowNodes.current.get(waiting);
|
|
393
570
|
if (node === undefined)
|
|
394
571
|
return;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
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 =
|
|
561
|
-
|
|
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.
|
|
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",
|
|
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" }))] }) }),
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
/*
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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.
|
|
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",
|