@fieldia/grid 0.12.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldia/grid",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Fieldia's table of lines on AG Grid: spreadsheet editing with Fieldia's own fields in the cells.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://fieldia.dev",
@@ -21,8 +21,8 @@
21
21
  "./package.json": "./package.json"
22
22
  },
23
23
  "dependencies": {
24
- "@fieldia/core": "0.12.0",
25
- "@fieldia/widgets": "0.12.0",
24
+ "@fieldia/core": "0.13.0",
25
+ "@fieldia/widgets": "0.13.0",
26
26
  "tslib": "^2.8.1"
27
27
  },
28
28
  "peerDependencies": {
package/src/lib/grid.js CHANGED
@@ -333,6 +333,18 @@ class ChooserHeader {
333
333
  return true;
334
334
  }
335
335
  }
336
+ /** What an empty grid says in its body: the page's words (`emptyLabel`), or a short line of its own. */
337
+ class EmptyOverlay {
338
+ line;
339
+ init(params) {
340
+ this.line = document.createElement('span');
341
+ this.line.className = 'fd-help fd-grid-empty';
342
+ this.line.textContent = params.words;
343
+ }
344
+ getGui() {
345
+ return this.line;
346
+ }
347
+ }
336
348
  /** A cell being edited: the field's own Fieldia widget, writing straight into its line. */
337
349
  class FieldiaCellEditor {
338
350
  box;
@@ -344,22 +356,22 @@ class FieldiaCellEditor {
344
356
  /** The line field this editor writes: the column's, or a section's or note's text. */
345
357
  column = '';
346
358
  def;
347
- /** A date typed beside Fieldia's own calendar (options.weekNumbers): its month needs room the cell does not have. */
348
- calendar = false;
359
+ /** A section or note being edited, not an item. */
360
+ kind = null;
349
361
  note = null;
350
- floating = null;
362
+ /** A tags box in a whole line open at once, which makes its line as tall as it needs. */
363
+ grows = false;
351
364
  init(params) {
352
365
  this.params = params;
353
366
  // Not `column`: AG Grid's own params carry the column object under that name.
354
367
  const { cell } = params;
355
368
  const key = params.data.key;
356
- const kind = cell.kindOf(params.data);
369
+ const kind = (this.kind = cell.kindOf(params.data));
357
370
  const column = (this.column = kind && cell.kinds ? cell.kinds.text : params.subfield);
358
371
  const def = (this.def = kind ? (0, widgets_1.kindTextField)(cell.defs[column], kind) : cell.defs[column]);
359
372
  // A cell typed in its own way (hours as HH:MM, a per cent) is typed so in its editor too.
360
373
  const look = kind ? undefined : cell.looks?.[column];
361
374
  const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column, ...(look?.widget ? { widget: look.widget } : {}), ...(look?.options ? { options: look.options } : {}) };
362
- this.calendar = (def.type === 'date' || def.type === 'datetime') && look?.options?.['weekNumbers'] === true;
363
375
  const context = {
364
376
  form: (0, widgets_1.lineForm)(cell.form, cell.field, key),
365
377
  name: column,
@@ -379,15 +391,34 @@ class FieldiaCellEditor {
379
391
  this.box.dataset['kind'] = kind;
380
392
  this.box.setAttribute('aria-label', def.label);
381
393
  this.box.append(this.widget.element);
394
+ // A whole line open at once keeps its editors in the line (AG Grid has no popups then): a tags box makes the line as tall as it needs.
395
+ if (cell.rowMode && def.type === 'many2many') {
396
+ this.grows = true;
397
+ this.box.classList.add('fd-grid-editor-grows');
398
+ if (typeof ResizeObserver !== 'undefined')
399
+ new ResizeObserver(() => this.fitLine()).observe(this.box);
400
+ }
382
401
  if (kind === 'note')
383
402
  this.growing(this.box.querySelector('textarea'));
384
- if (cell.rowMode)
385
- this.floatList();
403
+ else if (def.type === 'text')
404
+ this.paragraph(this.box.querySelector('textarea'));
386
405
  if (this.isPopup()) {
387
406
  // AG Grid places a popup over its cell but leaves its size to the editor; a distribution's lines take the room they need.
388
407
  const { width, height } = params.eGridCell.getBoundingClientRect();
389
408
  if (def.type === 'json')
390
409
  Object.assign(this.box.style, { minWidth: `${Math.max(width, 360)}px` });
410
+ // Tags wrap in a box of their own, as wide as a few need and as tall as they take, never spilling over the lines around.
411
+ else if (def.type === 'many2many')
412
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 280)}px`, minHeight: `${height}px` });
413
+ // A paragraph is typed in a box of several lines, wide enough to read them, never spilling over the rows around.
414
+ else if (def.type === 'text')
415
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 320)}px`, minHeight: `${height}px` });
416
+ // A date is as wide as its day and time need (the stylesheet's max-content), its cell's width at the least.
417
+ else if (def.type === 'date' || def.type === 'datetime')
418
+ Object.assign(this.box.style, { minWidth: `${width}px`, height: `${height}px` });
419
+ // A link is searched by its name: wide enough to read one over a narrow column, on its row's line.
420
+ else if (def.type === 'many2one' || def.type === 'reference')
421
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 240)}px`, height: `${height}px` });
391
422
  else
392
423
  Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
393
424
  }
@@ -422,9 +453,11 @@ class FieldiaCellEditor {
422
453
  this.box.addEventListener('focusin', (event) => {
423
454
  const input = event.target;
424
455
  if (!pointing && input instanceof HTMLInputElement && input.type !== 'checkbox' && input.type !== 'radio')
425
- input.select();
456
+ selectFromStart(input);
426
457
  pointing = false;
427
458
  });
459
+ // A value longer than its cell has room for takes the cell's padding too.
460
+ this.box.addEventListener('input', () => this.fit());
428
461
  const show = () => {
429
462
  const line = this.line();
430
463
  // A cell the form found wrong stays marked while it is fixed.
@@ -450,39 +483,65 @@ class FieldiaCellEditor {
450
483
  });
451
484
  // Enter makes a new line; Ctrl/Cmd+Enter finishes the note.
452
485
  area.addEventListener('keydown', (event) => {
453
- if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey))
486
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey)
454
487
  return;
455
- event.preventDefault();
488
+ // The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
456
489
  (0, ag_grid_community_1._stopPropagationForAgGrid)(event);
490
+ if (!(event.ctrlKey || event.metaKey))
491
+ return;
492
+ event.preventDefault();
457
493
  this.params.api.stopEditing();
458
494
  });
459
495
  }
460
496
  /**
461
- * In a cell, a link's list would be cut off by the rows around it, so it
462
- * floats in the grid's own box, under its input, while the line is open.
497
+ * A paragraph (a text field) is typed in a box of several lines over its
498
+ * cell: Enter makes a new line there (the grid leaves it alone), and
499
+ * Ctrl/Cmd+Enter finishes it, as a note's does.
463
500
  */
464
- floatList() {
465
- const list = this.box.querySelector('.fd-listbox');
466
- if (!list)
501
+ paragraph(area) {
502
+ if (!area)
467
503
  return;
468
- const { layer, document: doc } = { layer: this.params.cell.layer, document: this.box.ownerDocument };
469
- list.classList.add('fd-grid-floating-list');
470
- layer.append(list);
471
- const place = () => {
472
- if (list.hidden || !this.box.isConnected)
504
+ area.addEventListener('keydown', (event) => {
505
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey)
473
506
  return;
474
- const at = (this.box.querySelector('input') ?? this.box).getBoundingClientRect();
475
- const frame = layer.getBoundingClientRect();
476
- const rtl = doc.defaultView?.getComputedStyle(layer).direction === 'rtl';
477
- list.style.insetBlockStart = `${at.bottom - frame.top + 2}px`;
478
- list.style.insetInlineStart = `${rtl ? frame.right - at.right : at.left - frame.left}px`;
479
- list.style.minWidth = `${this.params.eGridCell.getBoundingClientRect().width}px`;
480
- };
481
- const watch = new MutationObserver(place);
482
- watch.observe(list, { attributes: true, attributeFilter: ['hidden'], childList: true });
483
- this.floating = { list, watch };
484
- place();
507
+ // The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
508
+ (0, ag_grid_community_1._stopPropagationForAgGrid)(event);
509
+ if (!(event.ctrlKey || event.metaKey))
510
+ return;
511
+ event.preventDefault();
512
+ this.params.api.stopEditing();
513
+ });
514
+ }
515
+ /** A value wider than the room inside the cell's padding takes the padding too, so more of it shows. */
516
+ fit() {
517
+ const input = this.box.querySelector('input:not([type="checkbox"]):not([type="radio"])');
518
+ if (!input)
519
+ return;
520
+ if (this.isPopup()) {
521
+ // A link's name longer than its box widens the box to show it whole, as far as the window allows.
522
+ const short = input.scrollWidth - input.clientWidth;
523
+ if ((this.def.type === 'many2one' || this.def.type === 'reference') && short > 0) {
524
+ const { left, width } = this.box.getBoundingClientRect();
525
+ const room = this.box.ownerDocument.defaultView.innerWidth - left - 16;
526
+ this.box.style.minWidth = `${Math.round(Math.max(width, Math.min(width + short + 8, LINK_EDITOR_MOST, room)))}px`;
527
+ }
528
+ return;
529
+ }
530
+ if (input.scrollWidth > input.clientWidth)
531
+ this.box.classList.add('fd-grid-editor-tight');
532
+ }
533
+ /** A line as tall as its tags box needs, never shorter than a line. */
534
+ fitLine() {
535
+ const { node, api } = this.params;
536
+ if (!this.box.isConnected || api.isDestroyed())
537
+ return;
538
+ const height = Math.max(ROW_HEIGHT, this.box.scrollHeight + 2);
539
+ if (height <= (node.rowHeight ?? 0))
540
+ return;
541
+ node.setRowHeight(height);
542
+ api.onRowHeightChanged();
485
543
  }
544
+ /** A note's box as tall as its text, and its row with it. */
486
545
  resize() {
487
546
  const area = this.note;
488
547
  if (!area)
@@ -514,6 +573,9 @@ class FieldiaCellEditor {
514
573
  return this.box;
515
574
  }
516
575
  afterGuiAttached() {
576
+ this.fit();
577
+ if (this.grows)
578
+ this.fitLine();
517
579
  // With a whole line open, every editor is attached: only the one clicked takes the focus.
518
580
  if (this.params.cellStartedEdit === false)
519
581
  return;
@@ -525,28 +587,34 @@ class FieldiaCellEditor {
525
587
  }
526
588
  const input = this.box.querySelector('input');
527
589
  if (input && input.type !== 'checkbox' && document.activeElement === input)
528
- input.select();
590
+ selectFromStart(input);
529
591
  }
530
592
  getValue() {
531
593
  return this.line()?.values[this.column];
532
594
  }
533
- /** Lists that open below their input need room the cell does not have (a whole open line floats them instead); so do a distribution's lines. */
595
+ /**
596
+ * Lists that open below their input need room the cell does not have (a whole open line floats them instead); so do a
597
+ * distribution's lines, a date wider than its cell, and a paragraph's several lines (a note's grow in its own row).
598
+ */
534
599
  isPopup() {
535
600
  if (this.params.cell.rowMode)
536
601
  return false;
537
602
  const type = this.def.type;
538
- return type === 'many2one' || type === 'many2many' || type === 'reference' || type === 'json' || this.calendar;
603
+ return type === 'many2one' || type === 'many2many' || type === 'reference' || type === 'json' || type === 'date' || type === 'datetime' || (type === 'text' && !this.kind);
539
604
  }
540
605
  getPopupPosition() {
541
606
  return 'over';
542
607
  }
543
608
  destroy() {
544
609
  this.leave();
545
- this.floating?.watch.disconnect();
546
- this.floating?.list.remove();
547
610
  this.widget.destroy?.();
548
611
  const { node, api, cell, data } = this.params;
549
612
  cell.finish(data.key);
613
+ // The line closed: it gives back the height its tags box took.
614
+ if (this.grows && !api.isDestroyed()) {
615
+ node.setRowHeight(null);
616
+ api.onRowHeightChanged();
617
+ }
550
618
  // A note's row grew by hand while it was typed; drawing it afresh measures
551
619
  // it again from its text (resetting the height would make it one line tall).
552
620
  if (this.note) {
@@ -557,12 +625,34 @@ class FieldiaCellEditor {
557
625
  }
558
626
  }
559
627
  }
628
+ /**
629
+ * A box's text selected so typing replaces it, shown from its start: a
630
+ * browser scrolls a long value to its end as it is selected, and its start is
631
+ * what reads. (A date's own input has no text selection to set.)
632
+ */
633
+ function selectFromStart(input) {
634
+ try {
635
+ input.setSelectionRange(0, input.value.length, 'backward');
636
+ }
637
+ catch {
638
+ input.select();
639
+ }
640
+ input.scrollLeft = 0;
641
+ }
560
642
  // ---- the widget -------------------------------------------------------------------
561
643
  /** Past this many lines a table stops growing and scrolls inside, drawing only the rows in view. */
562
644
  const LONG_TABLE = 15;
563
645
  const ROW_HEIGHT = 40;
564
646
  const HEADER_HEIGHT = 38;
565
647
  const EDITABLE = new Set(['char', 'text', 'html', 'integer', 'float', 'monetary', 'date', 'datetime', 'selection', 'many2one', 'many2many', 'reference']);
648
+ /** A badge's pill round its words, both sides: room its column keeps besides theirs. */
649
+ const BADGE_PADDING = 20;
650
+ /** The kinds of field that hold words: a line is named by the first of them. */
651
+ const WORDS = new Set(['char', 'text', 'html', 'many2one', 'reference']);
652
+ /** The kinds of field whose column is as wide as its values need, in characters, as Flectra's lists size them. */
653
+ /** The widest a link's editor grows to show a long name, in pixels. */
654
+ const LINK_EDITOR_MOST = 480;
655
+ const FIXED_CHARS = { boolean: 4, integer: 4, float: 6, monetary: 14, date: 11, datetime: 17 };
566
656
  const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.WIDGET_LABELS.en, preferences, locale, dialogs }) => {
567
657
  const def = field;
568
658
  const kinds = def.lineKinds;
@@ -625,6 +715,15 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
625
715
  element.setAttribute('role', 'group');
626
716
  const host = document.createElement('div');
627
717
  host.className = 'fd-grid-host';
718
+ // A press on a tick in a line — chosen, or a yes or no — is the tick's alone: the grid focusing its cell would
719
+ // scroll a cell cut by the screen's edge into view, moving the tick from under the pointer, and the click would
720
+ // land on the cell instead. (A button in a cell the grid leaves alone.) The tick still takes the focus and the click.
721
+ for (const type of ['pointerdown', 'mousedown']) {
722
+ host.addEventListener(type, (event) => {
723
+ if (event.target.closest?.('.ag-cell input[type="checkbox"]'))
724
+ event.stopPropagation();
725
+ }, true);
726
+ }
628
727
  // What is wrong on which line, once a save has been refused.
629
728
  const problems = document.createElement('div');
630
729
  problems.className = 'fd-error fd-grid-problems';
@@ -734,6 +833,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
734
833
  api.setFocusedCell(index, column);
735
834
  api.startEditingCell({ rowIndex: index, colKey: column });
736
835
  };
836
+ /** A paragraph (a text field) of an item, edited in its box of several lines over the cell. */
837
+ const paragraphCell = (line, column) => !cell.rowMode && !kindOf(line) && def.fields[column]?.type === 'text';
737
838
  /**
738
839
  * Keys the grid must leave alone, and the two that run off the end of the
739
840
  * table while editing: Enter on the last line and Tab on its last editable
@@ -745,8 +846,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
745
846
  // Alt+Up/Down moves the line (below), it does not move the focus.
746
847
  if (!editing && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown'))
747
848
  return true;
748
- // In a note, Enter is a new line of the note (Ctrl/Cmd+Enter finishes it).
749
- if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && kindOf(node.data) === 'note')
849
+ // In a note, or a paragraph opened over its cell, Enter is a new line (Ctrl/Cmd+Enter finishes it).
850
+ if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && (kindOf(node.data) === 'note' || paragraphCell(node.data, column.getColId())))
750
851
  return true;
751
852
  if (!editing || event.type !== 'keydown' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
752
853
  return false;
@@ -762,8 +863,37 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
762
863
  }
763
864
  return false;
764
865
  };
866
+ // Each column's share of the width by what it holds, as Flectra's lists size theirs: yes/no, numbers and dates by
867
+ // their kind, words and links by the longest the lines have — a long name gets the room a short code does not need.
868
+ const linesOf = (value) => (value ?? []).filter((line) => !kindOf(line));
869
+ const drawnFirst = linesOf(form.getState().values[name]);
870
+ /** A column whose values are drawn as pills (widget="badge"). */
871
+ const badged = (column) => !!node.cells?.[column]?.badge || node.cells?.[column]?.widget === 'badge';
872
+ /** Whether a column is as wide as its values, not a share of the width: yes/no, numbers, money, dates and badges, unless the grid shrinks to fit. */
873
+ const fixedWidth = (column) => (!!FIXED_CHARS[def.fields[column].type] || badged(column)) && node.fit !== 'shrink';
874
+ /** The room a column's values need, in pixels: its longest value, its kind's least, a short label — and the cell's padding. */
875
+ const roomOf = (column, lines = drawnFirst) => {
876
+ const sub = def.fields[column];
877
+ const least = FIXED_CHARS[sub.type] ?? 6;
878
+ let longest = sub.type === 'boolean' ? 0 : lines.reduce((most, line) => Math.max(most, (0, widgets_1.cellText)(sub, line.values[column], line.values, locale, form.getState().values, node.cells?.[column]).length), 0);
879
+ // A badge may turn to any of its choices: room for the longest, though no line holds it yet.
880
+ if (badged(column) && sub.type === 'selection')
881
+ longest = sub.options.reduce((most, option) => Math.max(most, option.label.length), longest);
882
+ // A long label over short numbers wraps onto a second line rather than widen its column; only its longest word sets a floor.
883
+ return Math.round(Math.min(Math.max(longest, least, labelWord(column)), 40) * 7.6 + 40) + (badged(column) ? BADGE_PADDING : 0);
884
+ };
885
+ /** Its label's longest word, in characters: a wrapping header breaks between words, never inside one. */
886
+ const labelWord = (column) => Math.min(Math.max(0, ...def.fields[column].label.split(/\s+/).map((word) => word.length)), 12);
887
+ /** The column a line is named by: its first of words (a name, a description, a link), else its first. */
888
+ const titleColumn = (shown) => shown.find((column) => WORDS.has(def.fields[column].type)) ?? shown[0];
889
+ /** A line's name by its place, "Line 3", when it has none to show. */
890
+ const placeName = (n) => {
891
+ const text = (0, core_1.fill)(labels.lineN, { n });
892
+ return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
893
+ };
765
894
  const columnDefs = columns.map((column) => {
766
895
  const sub = def.fields[column];
896
+ const room = roomOf(column);
767
897
  const numeric = sub.type === 'integer' || sub.type === 'float' || sub.type === 'monetary';
768
898
  // Words get the room; numbers and yes/no take what they need.
769
899
  const wide = ['char', 'text', 'html', 'many2one', 'many2many', 'reference', 'selection'].includes(sub.type);
@@ -774,6 +904,10 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
774
904
  const base = {
775
905
  colId: column,
776
906
  headerName: sub.label,
907
+ // A long label wraps onto a second line rather than be cut, so two columns never read the same; all of it on pointing at it.
908
+ headerTooltip: sub.label,
909
+ wrapHeaderText: true,
910
+ autoHeaderHeight: true,
777
911
  valueGetter: (p) => {
778
912
  if (p.node?.rowPinned) {
779
913
  if (totals.includes(column))
@@ -803,15 +937,15 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
803
937
  },
804
938
  tooltipValueGetter: (p) => (p.node?.rowPinned ? undefined : problemAt(p.data, column, p.api)),
805
939
  suppressKeyboardEvent: keys,
806
- // Money keeps room for a bold total in the millions (EGP 1,234,567.00).
807
- minWidth: wide ? 140 : sub.type === 'monetary' ? 150 : 96,
808
- flex: wide ? 2 : sub.type === 'monetary' ? 1.3 : 1,
940
+ // Numbers, money and dates as wide as their values, never cut; words share the rest by the room they need, giving way when it is tight.
941
+ minWidth: fixedWidth(column) ? 48 : Math.max(96, Math.round(labelWord(column) * 7.6 + 40)),
942
+ ...(fixedWidth(column) ? { width: room } : { flex: room }),
809
943
  hide: optional[column] === 'hide',
810
944
  ...(node.cells?.[column]?.width ? { width: node.cells[column].width * 8 + 32, flex: 0 } : {}),
811
945
  // Columns as wide as what they hold (fit: content), sized once the lines are drawn.
812
946
  ...(node.fit === 'content' && !node.cells?.[column]?.width ? { flex: undefined, minWidth: 64 } : {}),
813
947
  // Columns that shrink to fit (fit: shrink), as Flectra's list beside a chatter: narrower before the table scrolls, headers wrapping.
814
- ...(node.fit === 'shrink' ? { minWidth: wide ? 72 : sub.type === 'monetary' ? 80 : 48, wrapHeaderText: true, autoHeaderHeight: true } : {}),
948
+ ...(node.fit === 'shrink' ? { minWidth: wide ? 72 : sub.type === 'monetary' ? 80 : 48 } : {}),
815
949
  editable: (p) => {
816
950
  if (readonly || p.node.rowPinned)
817
951
  return false;
@@ -914,6 +1048,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
914
1048
  sortable: false,
915
1049
  suppressMovable: true,
916
1050
  lockPosition: lineStart,
1051
+ // Held in sight at the line's start, however far the lines scroll sideways.
1052
+ pinned: lineStart,
917
1053
  suppressKeyboardEvent: keys,
918
1054
  });
919
1055
  }
@@ -957,6 +1093,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
957
1093
  sortable: false,
958
1094
  suppressMovable: true,
959
1095
  lockPosition: lineStart,
1096
+ // Held in sight at the line's start, however far the lines scroll sideways.
1097
+ pinned: lineStart,
960
1098
  cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: PickRenderer, params: { picking } }),
961
1099
  suppressKeyboardEvent: keys,
962
1100
  });
@@ -990,6 +1128,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
990
1128
  sortable: false,
991
1129
  suppressMovable: true,
992
1130
  lockPosition: lineEnd,
1131
+ pinned: lineEnd,
993
1132
  cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: CopyRenderer, params: { copy: copyLine, label: (n) => (0, core_1.fill)(labels.copy, { name: (0, core_1.fill)(labels.lineN, { n }) }) } }),
994
1133
  suppressKeyboardEvent: keys,
995
1134
  });
@@ -1009,6 +1148,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1009
1148
  sortable: false,
1010
1149
  suppressMovable: true,
1011
1150
  lockPosition: lineEnd,
1151
+ pinned: lineEnd,
1012
1152
  cellRendererSelector: (p) => p.node.rowPinned || kindOf(p.data)
1013
1153
  ? undefined
1014
1154
  : { component: RowButtonsRenderer, params: { buttons: node.rowButtons, stateOf: (key) => lineStates.get(key), press: (id, key) => form.runRowAction(node.id, id, key) } },
@@ -1027,6 +1167,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1027
1167
  sortable: false,
1028
1168
  suppressMovable: true,
1029
1169
  lockPosition: lineEnd,
1170
+ pinned: lineEnd,
1030
1171
  cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: OpenRenderer, params: { open: openLine, label: labels.openLine } }),
1031
1172
  suppressKeyboardEvent: keys,
1032
1173
  });
@@ -1036,6 +1177,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1036
1177
  colId: '__delete',
1037
1178
  headerName: '',
1038
1179
  lockPosition: lineEnd,
1180
+ // A line's buttons held in sight at its end, however far the lines scroll sideways.
1181
+ pinned: lineEnd,
1039
1182
  ...(optionalIds.length ? { headerComponent: ChooserHeader, headerComponentParams: { chooser } } : {}),
1040
1183
  width: 44,
1041
1184
  minWidth: 44,
@@ -1047,6 +1190,11 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1047
1190
  cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell, deletable } }),
1048
1191
  suppressKeyboardEvent: keys,
1049
1192
  });
1193
+ // The buttons' words and the empty table's, as the plain table takes them from the node's options.
1194
+ const words = (key) => {
1195
+ const said = node.options?.[key];
1196
+ return typeof said === 'string' && said.trim() ? said : null;
1197
+ };
1050
1198
  const api = (0, ag_grid_community_1.createGrid)(host, {
1051
1199
  theme: ag_grid_community_1.themeQuartz.withParams({
1052
1200
  fontFamily: 'var(--fd-font)',
@@ -1083,8 +1231,9 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1083
1231
  // Lines have few columns: all of them are drawn.
1084
1232
  suppressColumnVirtualisation: true,
1085
1233
  readOnlyEdit: true,
1086
- // An empty table shows its Add a line button below, not a message inside.
1087
- suppressNoRowsOverlay: true,
1234
+ // An empty table says so in a short line where its rows would be, never a blank row.
1235
+ noRowsOverlayComponent: EmptyOverlay,
1236
+ noRowsOverlayComponentParams: { words: words('emptyLabel') ?? labels.noLines },
1088
1237
  singleClickEdit: true,
1089
1238
  // A page may ask for a whole line to open at once.
1090
1239
  editType: node.editMode === 'row' ? 'fullRow' : undefined,
@@ -1094,10 +1243,13 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1094
1243
  // A line dragged by its handle moves among the others as it goes; where
1095
1244
  // it is let go, the form moves it too and numbers the lines again.
1096
1245
  rowDragManaged: !!sequence,
1246
+ // A line dragged is named by its words (a yes or no names nothing), else by its place.
1097
1247
  rowDragText: (p) => {
1098
1248
  const line = p.rowNode?.data;
1099
- const text = line && (kindOf(line) && kinds ? line.values[kinds.text] : line.values[columns[0]]);
1100
- return text && typeof text === 'object' ? String(text.label ?? '') : String(text ?? '');
1249
+ const column = titleColumn(columns.filter((c) => api.getColumn(c)?.isVisible() !== false));
1250
+ const value = line && (kindOf(line) && kinds ? line.values[kinds.text] : column ? line.values[column] : null);
1251
+ const text = value && typeof value === 'object' ? String(value.label ?? '') : String(value ?? '');
1252
+ return text.trim() || placeName((p.rowNode?.rowIndex ?? 0) + 1);
1101
1253
  },
1102
1254
  onRowDragEnd: (event) => {
1103
1255
  const key = event.node.data?.key;
@@ -1128,7 +1280,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1128
1280
  // A person's widths, order and choices are kept for the next visit. AG Grid
1129
1281
  // tells of each change a moment after it; bringing a layout back is told too,
1130
1282
  // and keeping it again changes nothing.
1131
- api.addEventListener('columnResized', (event) => event.finished && remember(event.source));
1283
+ // A person widens a column by dragging its edge; widths the grid sets itself (by its lines) are not theirs.
1284
+ api.addEventListener('columnResized', (event) => event.finished && event.source !== 'api' && remember(event.source));
1132
1285
  api.addEventListener('columnMoved', (event) => event.finished && remember(event.source));
1133
1286
  api.addEventListener('columnVisible', (event) => remember(event.source));
1134
1287
  /** Sizing the grid does by itself is not a choice to keep. */
@@ -1145,6 +1298,15 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1145
1298
  const personHidden = new Set(optionalIds.filter((column) => optional[column] === 'hide'));
1146
1299
  const hiddenByPerson = (column) => personHidden.has(column);
1147
1300
  const saved = preferences?.get(columnsKey);
1301
+ /** The words' columns are sized by the lines once: when the first come, unless the person arranged the columns. */
1302
+ let sizedByLines = drawnFirst.length > 0 || Array.isArray(saved) || node.fit === 'content';
1303
+ function sizeByLines(lines) {
1304
+ if (sizedByLines || !lines.length)
1305
+ return;
1306
+ sizedByLines = true;
1307
+ const state = columns.filter((column) => !node.cells?.[column]?.width).map((colId) => (fixedWidth(colId) ? { colId, width: roomOf(colId, lines), flex: null } : { colId, flex: roomOf(colId, lines) }));
1308
+ api.applyColumnState({ state });
1309
+ }
1148
1310
  if (Array.isArray(saved)) {
1149
1311
  const known = new Set(api.getColumns()?.map((c) => c.getColId()));
1150
1312
  const state = saved.filter((s) => typeof s?.colId === 'string' && known.has(s.colId));
@@ -1242,11 +1404,6 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1242
1404
  button.addEventListener('click', () => addAndEdit(values));
1243
1405
  adds.append(button);
1244
1406
  };
1245
- // The buttons' words, as the plain table takes them from the node's options.
1246
- const words = (key) => {
1247
- const said = node.options?.[key];
1248
- return typeof said === 'string' && said.trim() ? said : null;
1249
- };
1250
1407
  addButton(words('addLabel') ?? labels.addLine, {}, 'line');
1251
1408
  if (kinds) {
1252
1409
  addButton(words('addSectionLabel') ?? labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
@@ -1288,7 +1445,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1288
1445
  card.dataset['tone'] = state.tone;
1289
1446
  card.classList.toggle('fd-line-bold', !!state?.bold);
1290
1447
  const shownHere = shownColumns.filter((column) => !state?.cells[column]?.invisible);
1291
- const [first, ...rest] = shownHere;
1448
+ const first = titleColumn(shownHere);
1449
+ const rest = shownHere.filter((column) => column !== first);
1292
1450
  const text = (column) => {
1293
1451
  // A distribution says its accounts by their names, as its cell does, once they are found.
1294
1452
  const shares = distributions.get(column);
@@ -1301,7 +1459,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1301
1459
  };
1302
1460
  const title = document.createElement(dialogs ? 'button' : 'div');
1303
1461
  title.className = 'fd-line-card-title';
1304
- title.textContent = first ? text(first) || (0, core_1.fill)(labels.lineN, { n: current.indexOf(line) + 1 }) : '';
1462
+ title.textContent = first ? text(first) || placeName(current.indexOf(line) + 1) : '';
1305
1463
  if (dialogs && title instanceof HTMLButtonElement) {
1306
1464
  title.type = 'button';
1307
1465
  title.addEventListener('click', () => void openLine(line));
@@ -1447,6 +1605,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
1447
1605
  // Columns as wide as what they hold, once more as the lines change.
1448
1606
  if (node.fit === 'content')
1449
1607
  api.autoSizeAllColumns();
1608
+ sizeByLines(linesOf(current));
1450
1609
  // The ticks and copies name a line by where it is: it may have moved.
1451
1610
  if (choosing || node.options?.['copy'] === true)
1452
1611
  api.refreshCells({ columns: ['__pick', '__copy'], force: true, suppressFlash: true });