@fieldia/grid 0.10.0 → 0.12.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/src/lib/grid.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { _stopPropagationForAgGrid, AllCommunityModule, createGrid, themeQuartz, } from 'ag-grid-community';
2
- import { fill, lineKind } from '@fieldia/core';
2
+ import { fill, isRightToLeft, lineKind } from '@fieldia/core';
3
3
  import { installGridStyles } from './styles.mjs';
4
- import { createWidget, displayValue, kindTextField, lineForm, WIDGET_LABELS } from '@fieldia/widgets';
4
+ import { cellText, createWidget, distributionIds, distributionModel, distributionWords, DRAWN_IN_CELLS, drawIcon, openLineDialog, kindTextField, lineForm, WIDGET_LABELS, } from '@fieldia/widgets';
5
5
  /**
6
6
  * A one2many as a spreadsheet, on AG Grid. Each cell is edited with the same
7
7
  * Fieldia widget the plain table uses, wired to its line, so every keystroke
@@ -17,6 +17,9 @@ const apis = new WeakMap();
17
17
  export function gridApiOf(element) {
18
18
  return apis.get(element);
19
19
  }
20
+ /** Everything a cell needs, handed to AG Grid's editors and renderers. */
21
+ /** The tones a line or a cell can take. */
22
+ const TONES = ['info', 'success', 'warning', 'danger', 'muted'];
20
23
  /** The grid's own columns (the drag handle, the delete button) start with two underscores. */
21
24
  const isTool = (id) => id.startsWith('__');
22
25
  /** The column a section or note spans from: the first field shown. */
@@ -69,9 +72,143 @@ class OpenRenderer {
69
72
  return true;
70
73
  }
71
74
  }
72
- /** The delete button at the end of each line. */
75
+ /** A line's own buttons, each shown by its condition on the line; a press runs with the line. */
76
+ class RowButtonsRenderer {
77
+ box;
78
+ params;
79
+ init(params) {
80
+ this.params = params;
81
+ this.box = document.createElement('span');
82
+ this.box.className = 'fd-grid-row-buttons';
83
+ for (const own of params.buttons) {
84
+ const button = document.createElement('button');
85
+ button.type = 'button';
86
+ const icon = drawIcon(document, own.icon);
87
+ button.className = `fd-button fd-button-link fd-line-button${icon ? ' fd-line-button-icon' : ''}`;
88
+ button.dataset['rowButton'] = own.id;
89
+ button.setAttribute('aria-label', own.label);
90
+ button.title = own.label;
91
+ button.append(icon ?? own.label);
92
+ button.addEventListener('click', async (event) => {
93
+ event.stopPropagation();
94
+ const key = this.params.data?.key;
95
+ if (!key || button.hasAttribute('aria-busy'))
96
+ return;
97
+ button.setAttribute('aria-busy', 'true');
98
+ try {
99
+ await params.press(own.id, key);
100
+ }
101
+ finally {
102
+ button.removeAttribute('aria-busy');
103
+ }
104
+ });
105
+ this.box.append(button);
106
+ }
107
+ this.show();
108
+ }
109
+ show() {
110
+ const state = this.params.data ? this.params.stateOf(this.params.data.key) : undefined;
111
+ for (const button of this.box.querySelectorAll('button'))
112
+ button.hidden = !state?.buttons[button.dataset['rowButton']];
113
+ }
114
+ getGui() {
115
+ return this.box;
116
+ }
117
+ refresh(params) {
118
+ this.params = params;
119
+ this.show();
120
+ return true;
121
+ }
122
+ }
123
+ /** A choice drawn as a pill in the tone its rules give it: Flectra's widget="badge". */
124
+ class BadgeRenderer {
125
+ pill;
126
+ init(params) {
127
+ this.pill = document.createElement('span');
128
+ this.refresh(params);
129
+ }
130
+ getGui() {
131
+ return this.pill;
132
+ }
133
+ refresh(params) {
134
+ const text = params.valueFormatted ?? String(params.value ?? '');
135
+ this.pill.className = text ? 'fd-grid-badge' : '';
136
+ this.pill.textContent = text;
137
+ const tone = params.toneOf(params.data);
138
+ if (tone)
139
+ this.pill.dataset['tone'] = tone;
140
+ else
141
+ delete this.pill.dataset['tone'];
142
+ return true;
143
+ }
144
+ }
145
+ /** An analytic distribution's cell: each account by its name and its share, "Sales 60%, Marketing 40%"; its editor opens its lines. */
146
+ class DistributionRenderer {
147
+ text;
148
+ init(params) {
149
+ this.text = document.createElement('span');
150
+ this.text.className = 'fd-grid-distribution';
151
+ this.refresh(params);
152
+ }
153
+ getGui() {
154
+ return this.text;
155
+ }
156
+ refresh(params) {
157
+ const value = params.value;
158
+ const unknown = distributionIds(value).map(String).filter((id) => params.nameOf(id) === undefined);
159
+ if (unknown.length && params.data)
160
+ params.find(params.data, unknown);
161
+ this.text.textContent = distributionWords(value, params.locale, params.nameOf);
162
+ // All of it on pointing at it, when the column is narrower than its words.
163
+ this.text.title = this.text.textContent;
164
+ return true;
165
+ }
166
+ }
167
+ /**
168
+ * A cell drawn by a widget of its own — a progress bar, priority stars, a
169
+ * state's dot — used in the cell itself with a click, as the plain table's
170
+ * are: the same Fieldia widget, wired to its line.
171
+ */
172
+ class DrawnRenderer {
173
+ box;
174
+ widget = null;
175
+ init(params) {
176
+ this.box = document.createElement('span');
177
+ this.box.className = 'fd-grid-drawn';
178
+ const { cell, subfield, look, data } = params;
179
+ if (data) {
180
+ const node = { type: 'field', id: `${cell.fieldId}.${data.key}.${subfield}`, field: subfield, widget: look.widget, ...(look.options ? { options: look.options } : {}) };
181
+ this.widget = createWidget({ form: lineForm(cell.form, cell.field, data.key), name: subfield, field: cell.defs[subfield], node, id: `${cell.fieldId}-${data.key}-${subfield}-cell`, document, labels: cell.labels, locale: cell.locale, dialogs: cell.dialogs }, cell.registry);
182
+ // Named by its column, as the plain table's cell is by its hidden label.
183
+ const named = this.widget.element.matches('[role]') ? this.widget.element : this.widget.element.querySelector('[role]');
184
+ named?.setAttribute('aria-label', cell.defs[subfield].label);
185
+ this.box.append(this.widget.element);
186
+ }
187
+ this.refresh(params);
188
+ }
189
+ getGui() {
190
+ return this.box;
191
+ }
192
+ refresh(params) {
193
+ const { cell, subfield, data } = params;
194
+ this.widget?.update({
195
+ value: data?.values[subfield],
196
+ values: data?.values ?? {},
197
+ parent: cell.form.getState().values,
198
+ readonly: params.locked(data),
199
+ required: false,
200
+ invalid: false,
201
+ });
202
+ return true;
203
+ }
204
+ destroy() {
205
+ this.widget?.destroy?.();
206
+ }
207
+ }
208
+ /** The delete button at the end of each line: hidden on a line the table keeps (`lineDelete`). */
73
209
  class DeleteRenderer {
74
210
  button;
211
+ params;
75
212
  init(params) {
76
213
  this.button = document.createElement('button');
77
214
  this.button.type = 'button';
@@ -79,17 +216,91 @@ class DeleteRenderer {
79
216
  this.button.textContent = '×';
80
217
  this.button.setAttribute('aria-label', params.cell.labels.deleteLine);
81
218
  this.button.addEventListener('click', () => {
82
- if (params.data)
83
- params.cell.form.removeLine(params.cell.field, params.data.key);
219
+ const key = this.params.data?.key;
220
+ if (key && this.params.deletable(key))
221
+ this.params.cell.form.removeLine(this.params.cell.field, key);
84
222
  });
223
+ this.refresh(params);
85
224
  }
86
225
  getGui() {
87
226
  return this.button;
88
227
  }
228
+ refresh(params) {
229
+ this.params = params;
230
+ this.button.hidden = !params.data || !params.deletable(params.data.key);
231
+ return true;
232
+ }
233
+ }
234
+ /** A line's tick, choosing it for the table's buttons for chosen lines. */
235
+ class PickRenderer {
236
+ box;
237
+ params;
238
+ init(params) {
239
+ this.box = document.createElement('input');
240
+ this.box.type = 'checkbox';
241
+ this.box.className = 'fd-checkbox fd-line-pick';
242
+ this.box.addEventListener('change', () => {
243
+ if (this.params.data)
244
+ this.params.picking.choose(this.params.data.key, this.box.checked);
245
+ });
246
+ this.refresh(params);
247
+ }
248
+ getGui() {
249
+ return this.box;
250
+ }
251
+ refresh(params) {
252
+ this.params = params;
253
+ this.box.checked = !!params.data && params.picking.isChosen(params.data.key);
254
+ this.box.disabled = params.picking.readonly();
255
+ this.box.setAttribute('aria-label', params.picking.label((params.node.rowIndex ?? 0) + 1));
256
+ return true;
257
+ }
258
+ }
259
+ /** The head's tick: every line chosen, or none. */
260
+ class PickAllHeader {
261
+ box;
262
+ init(params) {
263
+ this.box = document.createElement('input');
264
+ this.box.type = 'checkbox';
265
+ this.box.className = 'fd-checkbox fd-line-pick fd-line-pick-all';
266
+ this.box.setAttribute('aria-label', params.picking.allLabel);
267
+ this.box.addEventListener('change', () => params.picking.chooseAll(this.box.checked));
268
+ params.picking.head(this.box);
269
+ }
270
+ getGui() {
271
+ return this.box;
272
+ }
89
273
  refresh() {
90
274
  return true;
91
275
  }
92
276
  }
277
+ /** The button that puts a copy of a line right after it. */
278
+ class CopyRenderer {
279
+ button;
280
+ params;
281
+ init(params) {
282
+ this.button = document.createElement('button');
283
+ this.button.type = 'button';
284
+ this.button.className = 'fd-line-copy';
285
+ this.button.textContent = '⧉';
286
+ this.button.addEventListener('click', (event) => {
287
+ event.stopPropagation();
288
+ if (this.params.data)
289
+ this.params.copy(this.params.data.key);
290
+ });
291
+ this.refresh(params);
292
+ }
293
+ getGui() {
294
+ return this.button;
295
+ }
296
+ refresh(params) {
297
+ this.params = params;
298
+ const label = params.label((params.node.rowIndex ?? 0) + 1);
299
+ this.button.setAttribute('aria-label', label);
300
+ this.button.title = label;
301
+ return true;
302
+ }
303
+ }
93
304
  /** The header of the last column: a button that opens the column chooser. */
94
305
  class ChooserHeader {
95
306
  button;
@@ -129,6 +340,8 @@ class FieldiaCellEditor {
129
340
  /** The line field this editor writes: the column's, or a section's or note's text. */
130
341
  column = '';
131
342
  def;
343
+ /** A date typed beside Fieldia's own calendar (options.weekNumbers): its month needs room the cell does not have. */
344
+ calendar = false;
132
345
  note = null;
133
346
  floating = null;
134
347
  init(params) {
@@ -139,7 +352,10 @@ class FieldiaCellEditor {
139
352
  const kind = cell.kindOf(params.data);
140
353
  const column = (this.column = kind && cell.kinds ? cell.kinds.text : params.subfield);
141
354
  const def = (this.def = kind ? kindTextField(cell.defs[column], kind) : cell.defs[column]);
142
- const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column };
355
+ // A cell typed in its own way (hours as HH:MM, a per cent) is typed so in its editor too.
356
+ const look = kind ? undefined : cell.looks?.[column];
357
+ const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column, ...(look?.widget ? { widget: look.widget } : {}), ...(look?.options ? { options: look.options } : {}) };
358
+ this.calendar = (def.type === 'date' || def.type === 'datetime') && look?.options?.['weekNumbers'] === true;
143
359
  const context = {
144
360
  form: lineForm(cell.form, cell.field, key),
145
361
  name: column,
@@ -164,9 +380,12 @@ class FieldiaCellEditor {
164
380
  if (cell.rowMode)
165
381
  this.floatList();
166
382
  if (this.isPopup()) {
167
- // AG Grid places a popup over its cell but leaves its size to the editor.
383
+ // AG Grid places a popup over its cell but leaves its size to the editor; a distribution's lines take the room they need.
168
384
  const { width, height } = params.eGridCell.getBoundingClientRect();
169
- Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
385
+ if (def.type === 'json')
386
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 360)}px` });
387
+ else
388
+ Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
170
389
  }
171
390
  // The form never changes values in place, so holding them is enough.
172
391
  this.before = this.line()?.values ?? {};
@@ -177,6 +396,21 @@ class FieldiaCellEditor {
177
396
  else if (event.key === 'Escape')
178
397
  params.cell.cancel(key, this.before);
179
398
  });
399
+ // A day picked from a date's calendar is the whole answer: the cell keeps it and closes, as a
400
+ // spreadsheet's does. The browser says change while a date is typed too (once the year makes
401
+ // one), so a change right after a key is typing, which Enter or Tab commits. A date with a time
402
+ // stays open for its time, and a whole line open at once stays open.
403
+ if (def.type === 'date' && !cell.rowMode) {
404
+ let typedAt = 0;
405
+ this.box.addEventListener('keydown', () => (typedAt = Date.now()), true);
406
+ this.box.addEventListener('change', (event) => {
407
+ if (event.target.type !== 'date' || Date.now() - typedAt < 500)
408
+ return;
409
+ params.stopEditing();
410
+ });
411
+ // Fieldia's own calendar (options.weekNumbers) sets the day itself, and says it was picked.
412
+ this.box.addEventListener('fd-picked', () => params.stopEditing());
413
+ }
180
414
  // Like a spreadsheet, a cell reached from the keyboard has its text selected,
181
415
  // so typing replaces it; a click still puts the caret where it lands.
182
416
  let pointing = false;
@@ -192,7 +426,7 @@ class FieldiaCellEditor {
192
426
  // A cell the form found wrong stays marked while it is fixed.
193
427
  const invalid = !!cell.form.getState().errors[`${cell.field}.${key}.${column}`];
194
428
  this.box.classList.toggle('fd-grid-editor-invalid', invalid);
195
- this.widget.update({ value: line?.values[column], values: line?.values ?? {}, readonly: false, required: def.required === true, invalid });
429
+ this.widget.update({ value: line?.values[column], values: line?.values ?? {}, parent: cell.form.getState().values, readonly: false, required: def.required === true, invalid });
196
430
  };
197
431
  show();
198
432
  this.leave = cell.form.subscribe(show);
@@ -292,12 +526,12 @@ class FieldiaCellEditor {
292
526
  getValue() {
293
527
  return this.line()?.values[this.column];
294
528
  }
295
- /** Lists that open below their input need room the cell does not have (a whole open line floats them instead). */
529
+ /** 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. */
296
530
  isPopup() {
297
531
  if (this.params.cell.rowMode)
298
532
  return false;
299
533
  const type = this.def.type;
300
- return type === 'many2one' || type === 'many2many' || type === 'reference';
534
+ return type === 'many2one' || type === 'many2many' || type === 'reference' || type === 'json' || this.calendar;
301
535
  }
302
536
  getPopupPosition() {
303
537
  return 'over';
@@ -330,8 +564,28 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
330
564
  const kinds = def.lineKinds;
331
565
  const sequence = def.sequenceField;
332
566
  // The fields that say what a line is and keep the lines' order never show as columns.
333
- const columns = (node.columns ?? Object.keys(def.fields)).filter((column) => def.fields[column] && column !== kinds?.field && column !== sequence);
567
+ // Lines a line holds are edited in its form, never drawn in its row.
568
+ const columns = (node.columns ?? Object.keys(def.fields)).filter((column) => def.fields[column] && def.fields[column].type !== 'one2many' && column !== kinds?.field && column !== sequence);
334
569
  const kindOf = (line) => (line ? lineKind(def, line.values) : null);
570
+ // Its columns run the way the page reads; a column held at the start or the end of the line is held at that side.
571
+ const rtl = !!locale && isRightToLeft(locale);
572
+ const lineStart = rtl ? 'right' : 'left';
573
+ const lineEnd = rtl ? 'left' : 'right';
574
+ // The table's own rules, read by the form for each line as the form changes; a table with none asks nothing.
575
+ const ruled = !!(node.cells || node.rowTones || node.rowBold !== undefined || node.rowButtons || node.lineDelete !== undefined);
576
+ let lineStates = new Map();
577
+ /** Accounts' names an analytic distribution's cells show, by model and id, as they are found. */
578
+ const accountNames = new Map();
579
+ /** The analytic distributions among the columns, by column: their names found and said. */
580
+ const distributions = new Map();
581
+ /** Whether a line may be deleted now: always, unless the table's lineDelete keeps it. */
582
+ const deletable = (key) => node.lineDelete === undefined || (lineStates.get(key) ?? form.lineState(node.id, key)).deletable;
583
+ const ruleOf = (line, column) => (line ? lineStates.get(line.key)?.cells[column] : undefined);
584
+ /** Columns hidden by the record now. */
585
+ let ruleHidden = new Set();
586
+ /** The record's fields its money takes its currency from (`parent.currency_id`), and their values when last drawn. */
587
+ const parentCurrencies = [...new Set(Object.values(def.fields).flatMap((sub) => (sub.type === 'monetary' && sub.currencyField?.startsWith('parent.') ? [sub.currencyField.slice('parent.'.length)] : [])))];
588
+ let currencyShown = '';
335
589
  // Columns a person may hide or show, and where their choices (and widths, and order) are kept.
336
590
  const optional = node.optionalColumns ?? {};
337
591
  const optionalIds = columns.filter((column) => column in optional);
@@ -358,6 +612,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
358
612
  layer: element,
359
613
  locale,
360
614
  dialogs,
615
+ // Columns typed in a widget's own way, or with settings of their own (a calendar's week numbers): drawn ones are used in their cells instead.
616
+ looks: Object.fromEntries(Object.entries(node.cells ?? {}).flatMap(([column, rules]) => rules.widget ? (DRAWN_IN_CELLS.has(rules.widget) ? [] : [[column, { widget: rules.widget, ...(rules.options ? { options: rules.options } : {}) }]]) : rules.options ? [[column, { options: rules.options }]] : [])),
361
617
  };
362
618
  installGridStyles(document);
363
619
  element.className = 'fd-grid-lines';
@@ -372,7 +628,52 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
372
628
  problems.hidden = true;
373
629
  const adds = document.createElement('div');
374
630
  adds.className = 'fd-lines-adds';
375
- element.append(host, problems, adds);
631
+ // Lines chosen for the table's buttons for them: a bar of those buttons over the grid while any is.
632
+ const choosing = !!node.selectedButtons?.length;
633
+ const chosen = new Set();
634
+ let pickAll = null;
635
+ const chosenCount = document.createElement('span');
636
+ chosenCount.className = 'fd-lines-chosen-count';
637
+ chosenCount.setAttribute('role', 'status');
638
+ /** A press that waits for its run, and is not pressed again meanwhile. */
639
+ const pressed = async (button, run) => {
640
+ if (button.hasAttribute('aria-busy'))
641
+ return;
642
+ button.setAttribute('aria-busy', 'true');
643
+ try {
644
+ await run();
645
+ }
646
+ finally {
647
+ button.removeAttribute('aria-busy');
648
+ }
649
+ };
650
+ const ownButton = (own, className) => {
651
+ const button = document.createElement('button');
652
+ button.type = 'button';
653
+ button.className = className;
654
+ button.dataset['node'] = own.id;
655
+ const icon = drawIcon(document, own.icon);
656
+ if (icon)
657
+ button.append(icon);
658
+ button.append(own.label);
659
+ return button;
660
+ };
661
+ const chosenButtons = (node.selectedButtons ?? []).map((own) => {
662
+ const button = ownButton(own, `fd-button fd-button-${own.style ?? 'secondary'} fd-lines-chosen-button`);
663
+ // In the table's order, whatever order they were ticked in.
664
+ button.addEventListener('click', () => void pressed(button, () => form.runLinesAction(node.id, own.id, lines().filter((line) => chosen.has(line.key)).map((line) => line.key))));
665
+ return { id: own.id, button };
666
+ });
667
+ const bar = document.createElement('div');
668
+ bar.className = 'fd-lines-chosen';
669
+ bar.hidden = true;
670
+ bar.append(chosenCount, ...chosenButtons.map((b) => b.button));
671
+ // Lines as cards too (cards: narrow or always): the stylesheet shows them in the grid's place on a narrow form.
672
+ const cardList = document.createElement('div');
673
+ cardList.className = 'fd-line-cards';
674
+ if (node.cards)
675
+ element.dataset['cards'] = node.cards;
676
+ element.append(...(choosing ? [bar] : []), ...(node.cards ? [cardList] : []), host, problems, adds);
376
677
  let readonly = false;
377
678
  const lines = () => form.getState().values[name] ?? [];
378
679
  /** A line field no one types in: read-only, or worked out from the line's others. */
@@ -410,8 +711,9 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
410
711
  if (!before)
411
712
  return void fresh.delete(key);
412
713
  cancelling.delete(key);
714
+ // A line added a moment ago is taken back, unless the table keeps it: then it stays, as it was begun.
413
715
  if (fresh.delete(key))
414
- return form.removeLine(name, key);
716
+ return deletable(key) ? form.removeLine(name, key) : undefined;
415
717
  const current = lines();
416
718
  if (current.some((l) => l.key === key))
417
719
  form.setValue(name, current.map((l) => (l.key === key ? { ...l, values: before } : l)));
@@ -463,6 +765,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
463
765
  const wide = ['char', 'text', 'html', 'many2one', 'many2many', 'reference', 'selection'].includes(sub.type);
464
766
  /** Whether this column is where a section's or note's text starts. */
465
767
  const spans = (api) => spanStart(api)?.getColId() === column;
768
+ /** An analytic distribution (a json line field shown as `distribution`): its cell says its accounts, its editor opens its lines. */
769
+ const distributed = sub.type === 'json' && node.cells?.[column]?.widget === 'distribution';
466
770
  const base = {
467
771
  colId: column,
468
772
  headerName: sub.label,
@@ -476,7 +780,12 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
476
780
  return p.data?.values[column];
477
781
  return kinds && spans(p.api) ? p.data?.values[kinds.text] : null;
478
782
  },
479
- valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column)) ? String(p.value ?? '') : displayValue(sub, p.value, p.data?.values ?? {}, locale),
783
+ valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column))
784
+ ? String(p.value ?? '')
785
+ : // Hidden by its line, the cell stays, blank, so the column lines up.
786
+ ruleOf(p.data, column)?.invisible
787
+ ? ''
788
+ : cellText(sub, p.value, p.data?.values ?? {}, locale, form.getState().values, node.cells?.[column]),
480
789
  type: numeric ? 'rightAligned' : undefined,
481
790
  // A section or note runs across every column but the delete button.
482
791
  colSpan: (p) => (kindOf(p.data) && spans(p.api) ? spanWidth(p.api) : 1),
@@ -485,6 +794,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
485
794
  cellClassRules: {
486
795
  'fd-grid-kind-text': (p) => !!kindOf(p.data),
487
796
  'fd-grid-invalid': (p) => !p.node.rowPinned && !!problemAt(p.data, column, p.api),
797
+ 'fd-cell-bold': (p) => !!ruleOf(p.data, column)?.bold,
798
+ ...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => ruleOf(p.data, column)?.tone === tone])),
488
799
  },
489
800
  tooltipValueGetter: (p) => (p.node?.rowPinned ? undefined : problemAt(p.data, column, p.api)),
490
801
  suppressKeyboardEvent: keys,
@@ -492,16 +803,88 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
492
803
  minWidth: wide ? 140 : sub.type === 'monetary' ? 150 : 96,
493
804
  flex: wide ? 2 : sub.type === 'monetary' ? 1.3 : 1,
494
805
  hide: optional[column] === 'hide',
806
+ ...(node.cells?.[column]?.width ? { width: node.cells[column].width * 8 + 32, flex: 0 } : {}),
807
+ // Columns as wide as what they hold (fit: content), sized once the lines are drawn.
808
+ ...(node.fit === 'content' && !node.cells?.[column]?.width ? { flex: undefined, minWidth: 64 } : {}),
809
+ // Columns that shrink to fit (fit: shrink), as Flectra's list beside a chatter: narrower before the table scrolls, headers wrapping.
810
+ ...(node.fit === 'shrink' ? { minWidth: wide ? 72 : sub.type === 'monetary' ? 80 : 48, wrapHeaderText: true, autoHeaderHeight: true } : {}),
495
811
  editable: (p) => {
496
812
  if (readonly || p.node.rowPinned)
497
813
  return false;
498
814
  if (kindOf(p.data))
499
815
  return spans(p.api);
500
- return sub.type !== 'boolean' && EDITABLE.has(sub.type) && !locked(sub);
816
+ const rules = ruleOf(p.data, column);
817
+ return sub.type !== 'boolean' && (EDITABLE.has(sub.type) || distributed) && !locked(sub) && !rules?.readonly && !rules?.invisible;
501
818
  },
502
819
  cellEditor: FieldiaCellEditor,
503
820
  cellEditorParams: { cell, subfield: column },
504
821
  };
822
+ if (distributed) {
823
+ const model = distributionModel(node.cells?.[column]?.options);
824
+ const asked = new Set();
825
+ const params = {
826
+ model,
827
+ locale,
828
+ nameOf: (id) => accountNames.get(`${model}:${id}`),
829
+ find(line, ids) {
830
+ const fresh = ids.filter((id) => !asked.has(`${model}:${id}`));
831
+ if (!fresh.length)
832
+ return;
833
+ for (const id of fresh)
834
+ asked.add(`${model}:${id}`);
835
+ form.searchLine(name, line.key, column, '', fresh.length, { model, ids: fresh.map((id) => (/^\d+$/.test(id) ? Number(id) : id)) }).then((found) => {
836
+ for (const record of found)
837
+ accountNames.set(`${model}:${record.id}`, record.label);
838
+ if (api.isDestroyed())
839
+ return;
840
+ api.refreshCells({ columns: [column], force: true, suppressFlash: true });
841
+ // The cards say the accounts by their names too.
842
+ cardsDrawn = '';
843
+ drawCards();
844
+ }, () => undefined);
845
+ },
846
+ };
847
+ distributions.set(column, params);
848
+ return {
849
+ ...base,
850
+ // Its accounts' names take room, as words do.
851
+ minWidth: node.fit === 'shrink' ? 96 : 180,
852
+ flex: 2,
853
+ cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: DistributionRenderer, params }),
854
+ // Tab goes round the lines of its open editor; from its last box, on to the next cell.
855
+ suppressKeyboardEvent: (p) => {
856
+ if (p.editing && p.event.key === 'Tab') {
857
+ const editor = p.event.target?.closest?.('.fd-grid-editor');
858
+ const boxes = editor ? [...editor.querySelectorAll('input, button, select')].filter((b) => !b.closest('[hidden]') && !b.disabled) : [];
859
+ const at = boxes.indexOf(p.event.target);
860
+ const to = at + (p.event.shiftKey ? -1 : 1);
861
+ if (at >= 0 && to >= 0 && to < boxes.length)
862
+ return true;
863
+ }
864
+ return keys(p);
865
+ },
866
+ };
867
+ }
868
+ const drawn = node.cells?.[column]?.widget;
869
+ if (drawn && DRAWN_IN_CELLS.has(drawn) && drawn !== 'badge') {
870
+ const look = { widget: drawn, ...(node.cells?.[column]?.options ? { options: node.cells[column].options } : {}) };
871
+ return {
872
+ ...base,
873
+ // Used in the cell itself: no editor opens over it.
874
+ editable: false,
875
+ cellRendererSelector: (p) => kindOf(p.data) || p.node.rowPinned
876
+ ? undefined
877
+ : { component: DrawnRenderer, params: { cell, subfield: column, look, locked: (line) => readonly || locked(sub) || !!ruleOf(line, column)?.readonly } },
878
+ // Keys inside the widget are its own: its stars' arrows, its menu's.
879
+ suppressKeyboardEvent: (p) => p.event.target?.closest?.('.fd-grid-drawn') ? true : keys(p),
880
+ };
881
+ }
882
+ if (node.cells?.[column]?.badge || drawn === 'badge') {
883
+ return {
884
+ ...base,
885
+ cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: BadgeRenderer, params: { toneOf: (line) => ruleOf(line, column)?.tone ?? null } }),
886
+ };
887
+ }
505
888
  if (sub.type === 'boolean') {
506
889
  return {
507
890
  ...base,
@@ -526,23 +909,108 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
526
909
  resizable: false,
527
910
  sortable: false,
528
911
  suppressMovable: true,
529
- lockPosition: 'left',
912
+ lockPosition: lineStart,
530
913
  suppressKeyboardEvent: keys,
531
914
  });
532
915
  }
533
- // AG Grid copies column definitions deeply, so the button calls through, never a copy.
534
- // With dialogs, a line can be opened to see and edit every one of its fields at once.
535
- const openLine = async (line) => {
536
- if (!dialogs)
537
- return;
538
- const fields = Object.fromEntries(Object.entries(def.fields).filter(([key]) => key !== kinds?.field && key !== sequence));
539
- // The dialog shows what the onchange makes of the line as it is edited; the line itself waits for Save & Close.
540
- const values = await dialogs.editValues({ title: def.label, fields, values: line.values, readonly, recompute: (edited) => form.previewLine(name, line.key, edited) });
541
- if (!values)
916
+ // A tick at the start of each item, choosing it for the table's buttons for chosen lines; the head's chooses every one.
917
+ const picking = {
918
+ label: (n) => fill(labels.chooseLine, { name: fill(labels.lineN, { n }) }),
919
+ allLabel: labels.chooseAllLines,
920
+ isChosen: (key) => chosen.has(key),
921
+ choose(key, on) {
922
+ if (on)
923
+ chosen.add(key);
924
+ else
925
+ chosen.delete(key);
926
+ showChosen(true);
927
+ },
928
+ chooseAll(on) {
929
+ chosen.clear();
930
+ if (on)
931
+ for (const line of lines())
932
+ if (!kindOf(line))
933
+ chosen.add(line.key);
934
+ showChosen(true);
935
+ },
936
+ head: (box) => {
937
+ pickAll = box;
938
+ showChosen(false);
939
+ },
940
+ readonly: () => readonly,
941
+ };
942
+ if (choosing) {
943
+ columnDefs.unshift({
944
+ colId: '__pick',
945
+ headerName: '',
946
+ headerComponent: PickAllHeader,
947
+ headerComponentParams: { picking },
948
+ width: 36,
949
+ minWidth: 36,
950
+ maxWidth: 36,
951
+ cellClass: 'fd-grid-tools fd-grid-pick',
952
+ resizable: false,
953
+ sortable: false,
954
+ suppressMovable: true,
955
+ lockPosition: lineStart,
956
+ cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: PickRenderer, params: { picking } }),
957
+ suppressKeyboardEvent: keys,
958
+ });
959
+ }
960
+ // A copy of a line, its values too, right after it: never its saved id or its place in the order.
961
+ const copyLine = (key) => {
962
+ const all = lines();
963
+ const at = all.findIndex((l) => l.key === key);
964
+ if (readonly || at < 0)
542
965
  return;
543
- // Written in one go, so the form recalculates once.
544
- form.setValue(name, lines().map((l) => (l.key === line.key ? { ...l, values: { ...l.values, ...values } } : l)));
966
+ const values = { ...all[at].values };
967
+ if (sequence)
968
+ delete values[sequence];
969
+ const made = form.addLine(name, values);
970
+ if (sequence)
971
+ form.moveLine(name, made, at + 1);
972
+ else {
973
+ const now = [...lines()];
974
+ now.splice(at + 1, 0, ...now.splice(now.findIndex((l) => l.key === made), 1));
975
+ form.setValue(name, now);
976
+ }
545
977
  };
978
+ if (node.options?.['copy'] === true) {
979
+ columnDefs.push({
980
+ colId: '__copy',
981
+ headerName: '',
982
+ width: 40,
983
+ minWidth: 40,
984
+ cellClass: 'fd-grid-tools',
985
+ resizable: false,
986
+ sortable: false,
987
+ suppressMovable: true,
988
+ lockPosition: lineEnd,
989
+ cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: CopyRenderer, params: { copy: copyLine, label: (n) => fill(labels.copy, { name: fill(labels.lineN, { n }) }) } }),
990
+ suppressKeyboardEvent: keys,
991
+ });
992
+ }
993
+ // AG Grid copies column definitions deeply, so the button calls through, never a copy.
994
+ // With dialogs, a line can be opened to see and edit every one of its fields at once.
995
+ // Its own record's page, by the table's model (lineOpens: "record"), or every one of its fields.
996
+ const openLine = (line) => (dialogs ? openLineDialog(form, name, node, line, dialogs, readonly) : undefined);
997
+ if (node.rowButtons?.length) {
998
+ columnDefs.push({
999
+ colId: '__row_buttons',
1000
+ headerName: '',
1001
+ // An icon takes a small square; words, what they need.
1002
+ width: 16 + node.rowButtons.reduce((total, b) => total + (b.icon ? 28 : 16 + b.label.length * 7), 0),
1003
+ cellClass: 'fd-grid-tools',
1004
+ resizable: false,
1005
+ sortable: false,
1006
+ suppressMovable: true,
1007
+ lockPosition: lineEnd,
1008
+ cellRendererSelector: (p) => p.node.rowPinned || kindOf(p.data)
1009
+ ? undefined
1010
+ : { component: RowButtonsRenderer, params: { buttons: node.rowButtons, stateOf: (key) => lineStates.get(key), press: (id, key) => form.runRowAction(node.id, id, key) } },
1011
+ suppressKeyboardEvent: keys,
1012
+ });
1013
+ }
546
1014
  if (dialogs) {
547
1015
  columnDefs.push({
548
1016
  colId: '__open',
@@ -554,7 +1022,7 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
554
1022
  resizable: false,
555
1023
  sortable: false,
556
1024
  suppressMovable: true,
557
- lockPosition: 'right',
1025
+ lockPosition: lineEnd,
558
1026
  cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: OpenRenderer, params: { open: openLine, label: labels.openLine } }),
559
1027
  suppressKeyboardEvent: keys,
560
1028
  });
@@ -563,7 +1031,7 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
563
1031
  columnDefs.push({
564
1032
  colId: '__delete',
565
1033
  headerName: '',
566
- lockPosition: 'right',
1034
+ lockPosition: lineEnd,
567
1035
  ...(optionalIds.length ? { headerComponent: ChooserHeader, headerComponentParams: { chooser } } : {}),
568
1036
  width: 44,
569
1037
  minWidth: 44,
@@ -572,7 +1040,7 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
572
1040
  resizable: false,
573
1041
  sortable: false,
574
1042
  suppressMovable: true,
575
- cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell } }),
1043
+ cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell, deletable } }),
576
1044
  suppressKeyboardEvent: keys,
577
1045
  });
578
1046
  const api = createGrid(host, {
@@ -590,6 +1058,7 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
590
1058
  headerHeight: HEADER_HEIGHT,
591
1059
  }),
592
1060
  columnDefs,
1061
+ enableRtl: rtl,
593
1062
  rowData: lines(),
594
1063
  pinnedBottomRowData: totals.length ? [totalsRow(lines())] : undefined,
595
1064
  getRowId: (p) => p.data.key,
@@ -599,6 +1068,13 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
599
1068
  const kind = kindOf(p.data);
600
1069
  return kind ? `fd-grid-${kind}` : undefined;
601
1070
  },
1071
+ // A line's tone and bold, read again whenever its rules may have changed.
1072
+ rowClassRules: ruled
1073
+ ? {
1074
+ 'fd-line-bold': (p) => !!(p.data && lineStates.get(p.data.key)?.bold),
1075
+ ...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => !!p.data && lineStates.get(p.data.key)?.tone === tone])),
1076
+ }
1077
+ : undefined,
602
1078
  domLayout: 'autoHeight',
603
1079
  // Lines have few columns: all of them are drawn.
604
1080
  suppressColumnVirtualisation: true,
@@ -629,6 +1105,21 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
629
1105
  defaultColDef: { sortable: false, resizable: true },
630
1106
  }, { modules: [AllCommunityModule] });
631
1107
  apis.set(element, api);
1108
+ // Out of sight under display: none — a tab not shown, a field hidden by its condition — AG Grid measures
1109
+ // itself again every frame, for as long as it stays hidden. Taken off the page meanwhile, it rests;
1110
+ // put back the moment it has a box again, it draws itself to fit.
1111
+ const away = document.createComment('fd-grid-away');
1112
+ const View = document.defaultView;
1113
+ const sizes = View && 'ResizeObserver' in View
1114
+ ? new View.ResizeObserver(([entry]) => {
1115
+ const hidden = entry.contentRect.width === 0 && entry.contentRect.height === 0;
1116
+ if (hidden && host.isConnected)
1117
+ host.replaceWith(away);
1118
+ else if (!hidden && away.isConnected)
1119
+ away.replaceWith(host);
1120
+ })
1121
+ : null;
1122
+ sizes?.observe(element);
632
1123
  // ---- columns a person arranged: kept, and brought back ----
633
1124
  // A person's widths, order and choices are kept for the next visit. AG Grid
634
1125
  // tells of each change a moment after it; bringing a layout back is told too,
@@ -642,14 +1133,26 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
642
1133
  // A grid taking itself apart also sends column events: they are not choices.
643
1134
  if (automatic.has(source) || !preferences || api.isDestroyed())
644
1135
  return;
645
- const state = (api.getColumnState() ?? []).map(({ colId, width, flex, hide }) => ({ colId, width: width ?? null, flex: flex ?? null, hide: !!hide }));
1136
+ // A column the record hides is kept as the person last had it: the rule's hiding is not their choice.
1137
+ const state = (api.getColumnState() ?? []).map(({ colId, width, flex, hide }) => ({ colId, width: width ?? null, flex: flex ?? null, hide: ruleHidden.has(colId) ? personHidden.has(colId) : !!hide }));
646
1138
  preferences.set(columnsKey, state);
647
1139
  }
1140
+ /** The columns the person hid with the chooser, or the page starts hidden. */
1141
+ const personHidden = new Set(optionalIds.filter((column) => optional[column] === 'hide'));
1142
+ const hiddenByPerson = (column) => personHidden.has(column);
648
1143
  const saved = preferences?.get(columnsKey);
649
1144
  if (Array.isArray(saved)) {
650
1145
  const known = new Set(api.getColumns()?.map((c) => c.getColId()));
651
1146
  const state = saved.filter((s) => typeof s?.colId === 'string' && known.has(s.colId));
652
1147
  api.applyColumnState({ state, applyOrder: true });
1148
+ for (const s of state) {
1149
+ if (!optionalIds.includes(s.colId))
1150
+ continue;
1151
+ if (s.hide)
1152
+ personHidden.add(s.colId);
1153
+ else
1154
+ personHidden.delete(s.colId);
1155
+ }
653
1156
  }
654
1157
  // ---- the column chooser: a short list of the columns a person may hide ----
655
1158
  const chooserBox = document.createElement('div');
@@ -671,13 +1174,21 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
671
1174
  if (!chooserBox.hidden)
672
1175
  return closeChooser(fromKeyboard);
673
1176
  opener = button;
674
- chooserBox.replaceChildren(...optionalIds.map((column) => {
1177
+ chooserBox.replaceChildren(
1178
+ // A column the record hides now is not the person's to show.
1179
+ ...optionalIds.filter((column) => !ruleHidden.has(column)).map((column) => {
675
1180
  const label = document.createElement('label');
676
1181
  const box = document.createElement('input');
677
1182
  box.type = 'checkbox';
678
1183
  box.className = 'fd-checkbox';
679
1184
  box.checked = api.getColumn(column)?.isVisible() ?? false;
680
- box.addEventListener('change', () => api.setColumnsVisible([column], box.checked));
1185
+ box.addEventListener('change', () => {
1186
+ if (box.checked)
1187
+ personHidden.delete(column);
1188
+ else
1189
+ personHidden.add(column);
1190
+ api.setColumnsVisible([column], box.checked);
1191
+ });
681
1192
  label.append(box, document.createTextNode(def.fields[column].label));
682
1193
  return label;
683
1194
  }));
@@ -727,10 +1238,131 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
727
1238
  button.addEventListener('click', () => addAndEdit(values));
728
1239
  adds.append(button);
729
1240
  };
730
- addButton(labels.addLine, {}, 'line');
1241
+ // The buttons' words, as the plain table takes them from the node's options.
1242
+ const words = (key) => {
1243
+ const said = node.options?.[key];
1244
+ return typeof said === 'string' && said.trim() ? said : null;
1245
+ };
1246
+ addButton(words('addLabel') ?? labels.addLine, {}, 'line');
731
1247
  if (kinds) {
732
- addButton(labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
733
- addButton(labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
1248
+ addButton(words('addSectionLabel') ?? labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
1249
+ addButton(words('addNoteLabel') ?? labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
1250
+ }
1251
+ // Buttons in the control row, beside the add buttons: the record's, shown by a condition on it (Flectra's <control>).
1252
+ const controls = (node.controlButtons ?? []).map((own) => {
1253
+ const button = ownButton(own, 'fd-button fd-button-link fd-lines-add fd-lines-control');
1254
+ button.addEventListener('click', () => void pressed(button, () => form.runAction(own.id)));
1255
+ adds.append(button);
1256
+ return { id: own.id, button };
1257
+ });
1258
+ /** What the cards were last drawn from: drawn again only when it changes. */
1259
+ let cardsDrawn = '';
1260
+ /** Each line as a card: its first column as its title, the rest by their labels, its tone, its buttons, ↗ and ×. */
1261
+ function drawCards() {
1262
+ if (!node.cards)
1263
+ return;
1264
+ const current = lines();
1265
+ const hidden = new Set(columns.filter((column) => ruleHidden.has(column) || api.getColumn(column)?.isVisible() === false));
1266
+ const shownColumns = columns.filter((column) => !hidden.has(column));
1267
+ const signature = JSON.stringify([current, [...lineStates], shownColumns, readonly, form.getState().values]);
1268
+ if (signature === cardsDrawn)
1269
+ return;
1270
+ cardsDrawn = signature;
1271
+ const parent = form.getState().values;
1272
+ const cards = current.map((line) => {
1273
+ const card = document.createElement('div');
1274
+ card.className = 'fd-line-card';
1275
+ card.dataset['line'] = line.key;
1276
+ const kind = kindOf(line);
1277
+ if (kind && kinds) {
1278
+ card.classList.add(`fd-line-card-${kind}`);
1279
+ card.textContent = String(line.values[kinds.text] ?? '');
1280
+ return card;
1281
+ }
1282
+ const state = lineStates.get(line.key);
1283
+ if (state?.tone)
1284
+ card.dataset['tone'] = state.tone;
1285
+ card.classList.toggle('fd-line-bold', !!state?.bold);
1286
+ const shownHere = shownColumns.filter((column) => !state?.cells[column]?.invisible);
1287
+ const [first, ...rest] = shownHere;
1288
+ const text = (column) => {
1289
+ // A distribution says its accounts by their names, as its cell does, once they are found.
1290
+ const shares = distributions.get(column);
1291
+ if (!shares)
1292
+ return cellText(def.fields[column], line.values[column], line.values, locale, parent, node.cells?.[column]);
1293
+ const unknown = distributionIds(line.values[column]).map(String).filter((id) => shares.nameOf(id) === undefined);
1294
+ if (unknown.length)
1295
+ shares.find(line, unknown);
1296
+ return distributionWords(line.values[column], locale, shares.nameOf);
1297
+ };
1298
+ const title = document.createElement(dialogs ? 'button' : 'div');
1299
+ title.className = 'fd-line-card-title';
1300
+ title.textContent = first ? text(first) || fill(labels.lineN, { n: current.indexOf(line) + 1 }) : '';
1301
+ if (dialogs && title instanceof HTMLButtonElement) {
1302
+ title.type = 'button';
1303
+ title.addEventListener('click', () => void openLine(line));
1304
+ }
1305
+ const list = document.createElement('dl');
1306
+ for (const column of rest) {
1307
+ const dt = document.createElement('dt');
1308
+ dt.textContent = def.fields[column].label;
1309
+ const dd = document.createElement('dd');
1310
+ dd.textContent = text(column);
1311
+ const tone = state?.cells[column]?.tone;
1312
+ if (tone)
1313
+ dd.dataset['tone'] = tone;
1314
+ list.append(dt, dd);
1315
+ }
1316
+ const tools = document.createElement('div');
1317
+ tools.className = 'fd-line-card-tools';
1318
+ for (const own of node.rowButtons ?? []) {
1319
+ if (!state?.buttons[own.id])
1320
+ continue;
1321
+ const button = ownButton(own, 'fd-button fd-button-link fd-line-button');
1322
+ button.dataset['rowButton'] = own.id;
1323
+ delete button.dataset['node'];
1324
+ button.addEventListener('click', () => void pressed(button, () => form.runRowAction(node.id, own.id, line.key)));
1325
+ tools.append(button);
1326
+ }
1327
+ if (!readonly && deletable(line.key)) {
1328
+ const remove = document.createElement('button');
1329
+ remove.type = 'button';
1330
+ remove.className = 'fd-line-delete';
1331
+ remove.textContent = '×';
1332
+ remove.setAttribute('aria-label', labels.deleteLine);
1333
+ remove.addEventListener('click', () => form.removeLine(name, line.key));
1334
+ tools.append(remove);
1335
+ }
1336
+ card.append(title, list, tools);
1337
+ return card;
1338
+ });
1339
+ const items = current.filter((line) => !kindOf(line));
1340
+ const sums = totals.filter((column) => !hidden.has(column)).map((column) => {
1341
+ const sum = items.reduce((total, line) => total + Number(line.values[column] ?? 0), 0);
1342
+ return `${def.fields[column].label}: ${cellText(def.fields[column], sum, items[0]?.values ?? {}, locale, parent, node.cells?.[column])}`;
1343
+ });
1344
+ const total = sums.length ? Object.assign(document.createElement('div'), { className: 'fd-line-cards-total', textContent: sums.join(' · ') }) : null;
1345
+ cardList.replaceChildren(...cards, ...(total ? [total] : []));
1346
+ }
1347
+ /** The bar and the ticks, as the lines chosen are now; `redraw` draws the lines' ticks again. */
1348
+ function showChosen(redraw) {
1349
+ if (!choosing)
1350
+ return;
1351
+ const items = lines().filter((line) => !kindOf(line));
1352
+ for (const key of [...chosen])
1353
+ if (!items.some((line) => line.key === key))
1354
+ chosen.delete(key);
1355
+ bar.hidden = readonly || chosen.size === 0;
1356
+ chosenCount.textContent = fill(labels.linesChosen, { n: chosen.size });
1357
+ for (const { id, button } of chosenButtons)
1358
+ button.hidden = form.node(id).invisible;
1359
+ if (pickAll) {
1360
+ pickAll.checked = items.length > 0 && chosen.size === items.length;
1361
+ pickAll.indeterminate = chosen.size > 0 && chosen.size < items.length;
1362
+ pickAll.disabled = readonly || !items.length;
1363
+ }
1364
+ if (redraw)
1365
+ api.refreshCells({ columns: ['__pick'], force: true, suppressFlash: true });
734
1366
  }
735
1367
  // A focused cell that has no editor still answers keys: Space ticks yes/no,
736
1368
  // and Enter or Space presses the delete button. (AG Grid's onCellKeyDown
@@ -768,7 +1400,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
768
1400
  }
769
1401
  else if (column === '__delete') {
770
1402
  event.preventDefault();
771
- form.removeLine(name, line.key);
1403
+ if (deletable(line.key))
1404
+ form.removeLine(name, line.key);
772
1405
  }
773
1406
  });
774
1407
  // A table grows with its lines until it is long; then it keeps a height of
@@ -795,9 +1428,11 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
795
1428
  update(state) {
796
1429
  if (state.readonly !== readonly) {
797
1430
  readonly = state.readonly;
798
- api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && (c.colId === '__delete' || c.colId === '__handle'))));
1431
+ api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && ['__delete', '__handle', '__copy', '__pick'].includes(c.colId))));
799
1432
  }
800
1433
  adds.hidden = readonly;
1434
+ for (const { id, button } of controls)
1435
+ button.hidden = form.node(id).invisible;
801
1436
  const current = state.value ?? [];
802
1437
  if (current !== shown) {
803
1438
  shown = current;
@@ -805,6 +1440,35 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
805
1440
  fitHeight(current.length);
806
1441
  if (totals.length)
807
1442
  api.setGridOption('pinnedBottomRowData', [totalsRow(current)]);
1443
+ // Columns as wide as what they hold, once more as the lines change.
1444
+ if (node.fit === 'content')
1445
+ api.autoSizeAllColumns();
1446
+ // The ticks and copies name a line by where it is: it may have moved.
1447
+ if (choosing || node.options?.['copy'] === true)
1448
+ api.refreshCells({ columns: ['__pick', '__copy'], force: true, suppressFlash: true });
1449
+ }
1450
+ // Money in the record's currency: its cells written again once that currency changes.
1451
+ if (parentCurrencies.length) {
1452
+ const now = JSON.stringify(parentCurrencies.map((field) => state.values[field] ?? null));
1453
+ if (now !== currencyShown) {
1454
+ currencyShown = now;
1455
+ api.refreshCells({ force: true, suppressFlash: true });
1456
+ }
1457
+ }
1458
+ // The table's own rules as the form has them now: redrawn only when they changed.
1459
+ if (ruled) {
1460
+ const states = new Map(current.filter((line) => !kindOf(line)).map((line) => [line.key, form.lineState(node.id, line.key)]));
1461
+ const hidden = new Set(columns.filter((column) => form.columnHidden(node.id, column)));
1462
+ const before = JSON.stringify([[...lineStates], [...ruleHidden]]);
1463
+ lineStates = states;
1464
+ if (JSON.stringify([[...states], [...hidden]]) !== before) {
1465
+ const turned = columns.filter((column) => hidden.has(column) !== ruleHidden.has(column));
1466
+ ruleHidden = hidden;
1467
+ for (const column of turned)
1468
+ api.setColumnsVisible([column], !hidden.has(column) && !hiddenByPerson(column));
1469
+ api.refreshCells({ force: true, suppressFlash: true });
1470
+ api.redrawRows();
1471
+ }
808
1472
  }
809
1473
  const found = readonly ? [] : lineProblems();
810
1474
  const signature = found.map((p) => `${p.index}.${p.column}:${p.message}`).join('|');
@@ -817,10 +1481,13 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
817
1481
  problems.textContent = listed.join(' · ');
818
1482
  problems.hidden = !found.length;
819
1483
  }
1484
+ showChosen(false);
1485
+ drawCards();
820
1486
  element.setAttribute('aria-invalid', String(state.invalid || found.length > 0));
821
1487
  },
822
1488
  destroy() {
823
1489
  document.removeEventListener('mousedown', outside);
1490
+ sizes?.disconnect();
824
1491
  api.destroy();
825
1492
  apis.delete(element);
826
1493
  },