@sincpro/web-ui 1.4.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.
Files changed (37) hide show
  1. package/dist/components/lines/LineItems.d.ts +14 -3
  2. package/dist/components/lines/LineItems.js +6 -4
  3. package/dist/components/listing/Row.d.ts +5 -4
  4. package/dist/components/listing/Row.js +5 -4
  5. package/dist/foundations/copy.d.ts +4 -0
  6. package/dist/foundations/copy.es.js +2 -0
  7. package/dist/foundations/copy.js +2 -0
  8. package/dist/foundations/form.d.ts +18 -1
  9. package/dist/foundations/form.js +6 -3
  10. package/dist/foundations/grouping.d.ts +28 -0
  11. package/dist/foundations/grouping.js +0 -0
  12. package/dist/foundations/index.d.ts +1 -1
  13. package/dist/foundations/index.js +1 -1
  14. package/dist/primitives/picker/MatchedText.d.ts +3 -2
  15. package/dist/primitives/picker/MatchedText.js +3 -2
  16. package/dist/primitives/picker/Picker.d.ts +18 -1
  17. package/dist/primitives/picker/Picker.js +5 -5
  18. package/dist/primitives/picker/TagsField.d.ts +10 -0
  19. package/dist/primitives/picker/TagsField.js +2 -2
  20. package/dist/primitives/picker/index.d.ts +2 -1
  21. package/dist/primitives/picker/index.js +2 -0
  22. package/dist/primitives/picker/tagChoices.d.ts +7 -0
  23. package/dist/primitives/picker/tagChoices.js +9 -4
  24. package/dist/primitives/table/DataTable.js +10 -11
  25. package/dist/primitives/table/EditableTable.d.ts +49 -7
  26. package/dist/primitives/table/EditableTable.js +321 -82
  27. package/dist/primitives/table/PileFootLine.d.ts +14 -0
  28. package/dist/primitives/table/PileFootLine.js +22 -0
  29. package/dist/primitives/table/index.d.ts +2 -1
  30. package/dist/primitives/table/index.js +1 -0
  31. package/dist/primitives/table/rowWindow.d.ts +3 -0
  32. package/dist/primitives/table/rowWindow.js +4 -0
  33. package/dist/views/list/List.d.ts +3 -2
  34. package/dist/views/list/ListView.js +13 -6
  35. package/dist/views/list/index.d.ts +1 -1
  36. package/dist/views/list/types.d.ts +16 -2
  37. package/package.json +1 -1
@@ -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
@@ -0,0 +1,14 @@
1
+ import type { PileFoot } from "../../foundations/grouping.js";
2
+ /**
3
+ * The foot of a short pile, as words: «Show 50 more of 49,950», or «Showing 500 of 49,950»
4
+ * when there is no next page.
5
+ *
6
+ * Context: the table's row and the list's line both draw it, each in its own frame (a `td`
7
+ * with a `colSpan`, an indented line), so this is only what goes inside. The end is quiet
8
+ * text and not a disabled button: there is nothing to press, and a control that does
9
+ * nothing is the bug this replaced.
10
+ */
11
+ export declare function PileFootLine({ foot, onMore }: {
12
+ foot: PileFoot;
13
+ onMore: () => void;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { fillCopy, useUiCopy } from "../../foundations/copy.js";
4
+ /**
5
+ * The foot of a short pile, as words: «Show 50 more of 49,950», or «Showing 500 of 49,950»
6
+ * when there is no next page.
7
+ *
8
+ * Context: the table's row and the list's line both draw it, each in its own frame (a `td`
9
+ * with a `colSpan`, an indented line), so this is only what goes inside. The end is quiet
10
+ * text and not a disabled button: there is nothing to press, and a control that does
11
+ * nothing is the bug this replaced.
12
+ */
13
+ export function PileFootLine({ foot, onMore }) {
14
+ const copy = useUiCopy();
15
+ const n = (value) => value.toLocaleString(copy.locale);
16
+ if (foot.kind === "end") {
17
+ return (_jsx("span", { className: "text-meta text-muted", children: fillCopy(copy.table.pileShown, { shown: n(foot.shown), count: n(foot.count) }) }));
18
+ }
19
+ return (_jsx("button", { type: "button", onClick: onMore, className: "rounded-control text-meta text-primary transition-colors duration-(--duration-instant) hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", children: foot.next === undefined
20
+ ? fillCopy(copy.table.pileMore, { count: n(foot.left) })
21
+ : fillCopy(copy.table.pileMoreOf, { count: n(foot.next), left: n(foot.left) }) }));
22
+ }