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