@fieldia/grid 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -122,6 +333,18 @@ class ChooserHeader {
122
333
  return true;
123
334
  }
124
335
  }
336
+ /** What an empty grid says in its body: the page's words (`emptyLabel`), or a short line of its own. */
337
+ class EmptyOverlay {
338
+ line;
339
+ init(params) {
340
+ this.line = document.createElement('span');
341
+ this.line.className = 'fd-help fd-grid-empty';
342
+ this.line.textContent = params.words;
343
+ }
344
+ getGui() {
345
+ return this.line;
346
+ }
347
+ }
125
348
  /** A cell being edited: the field's own Fieldia widget, writing straight into its line. */
126
349
  class FieldiaCellEditor {
127
350
  box;
@@ -133,17 +356,22 @@ class FieldiaCellEditor {
133
356
  /** The line field this editor writes: the column's, or a section's or note's text. */
134
357
  column = '';
135
358
  def;
359
+ /** A section or note being edited, not an item. */
360
+ kind = null;
136
361
  note = null;
137
- floating = null;
362
+ /** A tags box in a whole line open at once, which makes its line as tall as it needs. */
363
+ grows = false;
138
364
  init(params) {
139
365
  this.params = params;
140
366
  // Not `column`: AG Grid's own params carry the column object under that name.
141
367
  const { cell } = params;
142
368
  const key = params.data.key;
143
- const kind = cell.kindOf(params.data);
369
+ const kind = (this.kind = cell.kindOf(params.data));
144
370
  const column = (this.column = kind && cell.kinds ? cell.kinds.text : params.subfield);
145
371
  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 };
372
+ // A cell typed in its own way (hours as HH:MM, a per cent) is typed so in its editor too.
373
+ const look = kind ? undefined : cell.looks?.[column];
374
+ const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column, ...(look?.widget ? { widget: look.widget } : {}), ...(look?.options ? { options: look.options } : {}) };
147
375
  const context = {
148
376
  form: (0, widgets_1.lineForm)(cell.form, cell.field, key),
149
377
  name: column,
@@ -163,14 +391,36 @@ class FieldiaCellEditor {
163
391
  this.box.dataset['kind'] = kind;
164
392
  this.box.setAttribute('aria-label', def.label);
165
393
  this.box.append(this.widget.element);
394
+ // A whole line open at once keeps its editors in the line (AG Grid has no popups then): a tags box makes the line as tall as it needs.
395
+ if (cell.rowMode && def.type === 'many2many') {
396
+ this.grows = true;
397
+ this.box.classList.add('fd-grid-editor-grows');
398
+ if (typeof ResizeObserver !== 'undefined')
399
+ new ResizeObserver(() => this.fitLine()).observe(this.box);
400
+ }
166
401
  if (kind === 'note')
167
402
  this.growing(this.box.querySelector('textarea'));
168
- if (cell.rowMode)
169
- this.floatList();
403
+ else if (def.type === 'text')
404
+ this.paragraph(this.box.querySelector('textarea'));
170
405
  if (this.isPopup()) {
171
- // AG Grid places a popup over its cell but leaves its size to the editor.
406
+ // AG Grid places a popup over its cell but leaves its size to the editor; a distribution's lines take the room they need.
172
407
  const { width, height } = params.eGridCell.getBoundingClientRect();
173
- Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
408
+ if (def.type === 'json')
409
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 360)}px` });
410
+ // Tags wrap in a box of their own, as wide as a few need and as tall as they take, never spilling over the lines around.
411
+ else if (def.type === 'many2many')
412
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 280)}px`, minHeight: `${height}px` });
413
+ // A paragraph is typed in a box of several lines, wide enough to read them, never spilling over the rows around.
414
+ else if (def.type === 'text')
415
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 320)}px`, minHeight: `${height}px` });
416
+ // A date is as wide as its day and time need (the stylesheet's max-content), its cell's width at the least.
417
+ else if (def.type === 'date' || def.type === 'datetime')
418
+ Object.assign(this.box.style, { minWidth: `${width}px`, height: `${height}px` });
419
+ // A link is searched by its name: wide enough to read one over a narrow column, on its row's line.
420
+ else if (def.type === 'many2one' || def.type === 'reference')
421
+ Object.assign(this.box.style, { minWidth: `${Math.max(width, 240)}px`, height: `${height}px` });
422
+ else
423
+ Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
174
424
  }
175
425
  // The form never changes values in place, so holding them is enough.
176
426
  this.before = this.line()?.values ?? {};
@@ -181,6 +431,21 @@ class FieldiaCellEditor {
181
431
  else if (event.key === 'Escape')
182
432
  params.cell.cancel(key, this.before);
183
433
  });
434
+ // A day picked from a date's calendar is the whole answer: the cell keeps it and closes, as a
435
+ // spreadsheet's does. The browser says change while a date is typed too (once the year makes
436
+ // one), so a change right after a key is typing, which Enter or Tab commits. A date with a time
437
+ // stays open for its time, and a whole line open at once stays open.
438
+ if (def.type === 'date' && !cell.rowMode) {
439
+ let typedAt = 0;
440
+ this.box.addEventListener('keydown', () => (typedAt = Date.now()), true);
441
+ this.box.addEventListener('change', (event) => {
442
+ if (event.target.type !== 'date' || Date.now() - typedAt < 500)
443
+ return;
444
+ params.stopEditing();
445
+ });
446
+ // Fieldia's own calendar (options.weekNumbers) sets the day itself, and says it was picked.
447
+ this.box.addEventListener('fd-picked', () => params.stopEditing());
448
+ }
184
449
  // Like a spreadsheet, a cell reached from the keyboard has its text selected,
185
450
  // so typing replaces it; a click still puts the caret where it lands.
186
451
  let pointing = false;
@@ -188,15 +453,17 @@ class FieldiaCellEditor {
188
453
  this.box.addEventListener('focusin', (event) => {
189
454
  const input = event.target;
190
455
  if (!pointing && input instanceof HTMLInputElement && input.type !== 'checkbox' && input.type !== 'radio')
191
- input.select();
456
+ selectFromStart(input);
192
457
  pointing = false;
193
458
  });
459
+ // A value longer than its cell has room for takes the cell's padding too.
460
+ this.box.addEventListener('input', () => this.fit());
194
461
  const show = () => {
195
462
  const line = this.line();
196
463
  // A cell the form found wrong stays marked while it is fixed.
197
464
  const invalid = !!cell.form.getState().errors[`${cell.field}.${key}.${column}`];
198
465
  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 });
466
+ this.widget.update({ value: line?.values[column], values: line?.values ?? {}, parent: cell.form.getState().values, readonly: false, required: def.required === true, invalid });
200
467
  };
201
468
  show();
202
469
  this.leave = cell.form.subscribe(show);
@@ -216,39 +483,65 @@ class FieldiaCellEditor {
216
483
  });
217
484
  // Enter makes a new line; Ctrl/Cmd+Enter finishes the note.
218
485
  area.addEventListener('keydown', (event) => {
219
- if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey))
486
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey)
220
487
  return;
221
- event.preventDefault();
488
+ // The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
222
489
  (0, ag_grid_community_1._stopPropagationForAgGrid)(event);
490
+ if (!(event.ctrlKey || event.metaKey))
491
+ return;
492
+ event.preventDefault();
223
493
  this.params.api.stopEditing();
224
494
  });
225
495
  }
226
496
  /**
227
- * In a cell, a link's list would be cut off by the rows around it, so it
228
- * floats in the grid's own box, under its input, while the line is open.
497
+ * A paragraph (a text field) is typed in a box of several lines over its
498
+ * cell: Enter makes a new line there (the grid leaves it alone), and
499
+ * Ctrl/Cmd+Enter finishes it, as a note's does.
229
500
  */
230
- floatList() {
231
- const list = this.box.querySelector('.fd-listbox');
232
- if (!list)
501
+ paragraph(area) {
502
+ if (!area)
233
503
  return;
234
- const { layer, document: doc } = { layer: this.params.cell.layer, document: this.box.ownerDocument };
235
- list.classList.add('fd-grid-floating-list');
236
- layer.append(list);
237
- const place = () => {
238
- if (list.hidden || !this.box.isConnected)
504
+ area.addEventListener('keydown', (event) => {
505
+ if (event.key !== 'Enter' || event.shiftKey || event.altKey)
239
506
  return;
240
- const at = (this.box.querySelector('input') ?? this.box).getBoundingClientRect();
241
- const frame = layer.getBoundingClientRect();
242
- const rtl = doc.defaultView?.getComputedStyle(layer).direction === 'rtl';
243
- list.style.insetBlockStart = `${at.bottom - frame.top + 2}px`;
244
- list.style.insetInlineStart = `${rtl ? frame.right - at.right : at.left - frame.left}px`;
245
- list.style.minWidth = `${this.params.eGridCell.getBoundingClientRect().width}px`;
246
- };
247
- const watch = new MutationObserver(place);
248
- watch.observe(list, { attributes: true, attributeFilter: ['hidden'], childList: true });
249
- this.floating = { list, watch };
250
- place();
507
+ // The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
508
+ (0, ag_grid_community_1._stopPropagationForAgGrid)(event);
509
+ if (!(event.ctrlKey || event.metaKey))
510
+ return;
511
+ event.preventDefault();
512
+ this.params.api.stopEditing();
513
+ });
514
+ }
515
+ /** A value wider than the room inside the cell's padding takes the padding too, so more of it shows. */
516
+ fit() {
517
+ const input = this.box.querySelector('input:not([type="checkbox"]):not([type="radio"])');
518
+ if (!input)
519
+ return;
520
+ if (this.isPopup()) {
521
+ // A link's name longer than its box widens the box to show it whole, as far as the window allows.
522
+ const short = input.scrollWidth - input.clientWidth;
523
+ if ((this.def.type === 'many2one' || this.def.type === 'reference') && short > 0) {
524
+ const { left, width } = this.box.getBoundingClientRect();
525
+ const room = this.box.ownerDocument.defaultView.innerWidth - left - 16;
526
+ this.box.style.minWidth = `${Math.round(Math.max(width, Math.min(width + short + 8, LINK_EDITOR_MOST, room)))}px`;
527
+ }
528
+ return;
529
+ }
530
+ if (input.scrollWidth > input.clientWidth)
531
+ this.box.classList.add('fd-grid-editor-tight');
532
+ }
533
+ /** A line as tall as its tags box needs, never shorter than a line. */
534
+ fitLine() {
535
+ const { node, api } = this.params;
536
+ if (!this.box.isConnected || api.isDestroyed())
537
+ return;
538
+ const height = Math.max(ROW_HEIGHT, this.box.scrollHeight + 2);
539
+ if (height <= (node.rowHeight ?? 0))
540
+ return;
541
+ node.setRowHeight(height);
542
+ api.onRowHeightChanged();
251
543
  }
544
+ /** A note's box as tall as its text, and its row with it. */
252
545
  resize() {
253
546
  const area = this.note;
254
547
  if (!area)
@@ -280,6 +573,9 @@ class FieldiaCellEditor {
280
573
  return this.box;
281
574
  }
282
575
  afterGuiAttached() {
576
+ this.fit();
577
+ if (this.grows)
578
+ this.fitLine();
283
579
  // With a whole line open, every editor is attached: only the one clicked takes the focus.
284
580
  if (this.params.cellStartedEdit === false)
285
581
  return;
@@ -291,28 +587,34 @@ class FieldiaCellEditor {
291
587
  }
292
588
  const input = this.box.querySelector('input');
293
589
  if (input && input.type !== 'checkbox' && document.activeElement === input)
294
- input.select();
590
+ selectFromStart(input);
295
591
  }
296
592
  getValue() {
297
593
  return this.line()?.values[this.column];
298
594
  }
299
- /** Lists that open below their input need room the cell does not have (a whole open line floats them instead). */
595
+ /**
596
+ * Lists that open below their input need room the cell does not have (a whole open line floats them instead); so do a
597
+ * distribution's lines, a date wider than its cell, and a paragraph's several lines (a note's grow in its own row).
598
+ */
300
599
  isPopup() {
301
600
  if (this.params.cell.rowMode)
302
601
  return false;
303
602
  const type = this.def.type;
304
- return type === 'many2one' || type === 'many2many' || type === 'reference';
603
+ return type === 'many2one' || type === 'many2many' || type === 'reference' || type === 'json' || type === 'date' || type === 'datetime' || (type === 'text' && !this.kind);
305
604
  }
306
605
  getPopupPosition() {
307
606
  return 'over';
308
607
  }
309
608
  destroy() {
310
609
  this.leave();
311
- this.floating?.watch.disconnect();
312
- this.floating?.list.remove();
313
610
  this.widget.destroy?.();
314
611
  const { node, api, cell, data } = this.params;
315
612
  cell.finish(data.key);
613
+ // The line closed: it gives back the height its tags box took.
614
+ if (this.grows && !api.isDestroyed()) {
615
+ node.setRowHeight(null);
616
+ api.onRowHeightChanged();
617
+ }
316
618
  // A note's row grew by hand while it was typed; drawing it afresh measures
317
619
  // it again from its text (resetting the height would make it one line tall).
318
620
  if (this.note) {
@@ -323,19 +625,61 @@ class FieldiaCellEditor {
323
625
  }
324
626
  }
325
627
  }
628
+ /**
629
+ * A box's text selected so typing replaces it, shown from its start: a
630
+ * browser scrolls a long value to its end as it is selected, and its start is
631
+ * what reads. (A date's own input has no text selection to set.)
632
+ */
633
+ function selectFromStart(input) {
634
+ try {
635
+ input.setSelectionRange(0, input.value.length, 'backward');
636
+ }
637
+ catch {
638
+ input.select();
639
+ }
640
+ input.scrollLeft = 0;
641
+ }
326
642
  // ---- the widget -------------------------------------------------------------------
327
643
  /** Past this many lines a table stops growing and scrolls inside, drawing only the rows in view. */
328
644
  const LONG_TABLE = 15;
329
645
  const ROW_HEIGHT = 40;
330
646
  const HEADER_HEIGHT = 38;
331
647
  const EDITABLE = new Set(['char', 'text', 'html', 'integer', 'float', 'monetary', 'date', 'datetime', 'selection', 'many2one', 'many2many', 'reference']);
648
+ /** A badge's pill round its words, both sides: room its column keeps besides theirs. */
649
+ const BADGE_PADDING = 20;
650
+ /** The kinds of field that hold words: a line is named by the first of them. */
651
+ const WORDS = new Set(['char', 'text', 'html', 'many2one', 'reference']);
652
+ /** The kinds of field whose column is as wide as its values need, in characters, as Flectra's lists size them. */
653
+ /** The widest a link's editor grows to show a long name, in pixels. */
654
+ const LINK_EDITOR_MOST = 480;
655
+ const FIXED_CHARS = { boolean: 4, integer: 4, float: 6, monetary: 14, date: 11, datetime: 17 };
332
656
  const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.WIDGET_LABELS.en, preferences, locale, dialogs }) => {
333
657
  const def = field;
334
658
  const kinds = def.lineKinds;
335
659
  const sequence = def.sequenceField;
336
660
  // 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);
661
+ // Lines a line holds are edited in its form, never drawn in its row.
662
+ const columns = (node.columns ?? Object.keys(def.fields)).filter((column) => def.fields[column] && def.fields[column].type !== 'one2many' && column !== kinds?.field && column !== sequence);
338
663
  const kindOf = (line) => (line ? (0, core_1.lineKind)(def, line.values) : null);
664
+ // 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.
665
+ const rtl = !!locale && (0, core_1.isRightToLeft)(locale);
666
+ const lineStart = rtl ? 'right' : 'left';
667
+ const lineEnd = rtl ? 'left' : 'right';
668
+ // The table's own rules, read by the form for each line as the form changes; a table with none asks nothing.
669
+ const ruled = !!(node.cells || node.rowTones || node.rowBold !== undefined || node.rowButtons || node.lineDelete !== undefined);
670
+ let lineStates = new Map();
671
+ /** Accounts' names an analytic distribution's cells show, by model and id, as they are found. */
672
+ const accountNames = new Map();
673
+ /** The analytic distributions among the columns, by column: their names found and said. */
674
+ const distributions = new Map();
675
+ /** Whether a line may be deleted now: always, unless the table's lineDelete keeps it. */
676
+ const deletable = (key) => node.lineDelete === undefined || (lineStates.get(key) ?? form.lineState(node.id, key)).deletable;
677
+ const ruleOf = (line, column) => (line ? lineStates.get(line.key)?.cells[column] : undefined);
678
+ /** Columns hidden by the record now. */
679
+ let ruleHidden = new Set();
680
+ /** The record's fields its money takes its currency from (`parent.currency_id`), and their values when last drawn. */
681
+ const parentCurrencies = [...new Set(Object.values(def.fields).flatMap((sub) => (sub.type === 'monetary' && sub.currencyField?.startsWith('parent.') ? [sub.currencyField.slice('parent.'.length)] : [])))];
682
+ let currencyShown = '';
339
683
  // Columns a person may hide or show, and where their choices (and widths, and order) are kept.
340
684
  const optional = node.optionalColumns ?? {};
341
685
  const optionalIds = columns.filter((column) => column in optional);
@@ -362,6 +706,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
362
706
  layer: element,
363
707
  locale,
364
708
  dialogs,
709
+ // 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.
710
+ 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
711
  };
366
712
  (0, styles_1.installGridStyles)(document);
367
713
  element.className = 'fd-grid-lines';
@@ -369,6 +715,15 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
369
715
  element.setAttribute('role', 'group');
370
716
  const host = document.createElement('div');
371
717
  host.className = 'fd-grid-host';
718
+ // A press on a tick in a line — chosen, or a yes or no — is the tick's alone: the grid focusing its cell would
719
+ // scroll a cell cut by the screen's edge into view, moving the tick from under the pointer, and the click would
720
+ // land on the cell instead. (A button in a cell the grid leaves alone.) The tick still takes the focus and the click.
721
+ for (const type of ['pointerdown', 'mousedown']) {
722
+ host.addEventListener(type, (event) => {
723
+ if (event.target.closest?.('.ag-cell input[type="checkbox"]'))
724
+ event.stopPropagation();
725
+ }, true);
726
+ }
372
727
  // What is wrong on which line, once a save has been refused.
373
728
  const problems = document.createElement('div');
374
729
  problems.className = 'fd-error fd-grid-problems';
@@ -376,7 +731,52 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
376
731
  problems.hidden = true;
377
732
  const adds = document.createElement('div');
378
733
  adds.className = 'fd-lines-adds';
379
- element.append(host, problems, adds);
734
+ // Lines chosen for the table's buttons for them: a bar of those buttons over the grid while any is.
735
+ const choosing = !!node.selectedButtons?.length;
736
+ const chosen = new Set();
737
+ let pickAll = null;
738
+ const chosenCount = document.createElement('span');
739
+ chosenCount.className = 'fd-lines-chosen-count';
740
+ chosenCount.setAttribute('role', 'status');
741
+ /** A press that waits for its run, and is not pressed again meanwhile. */
742
+ const pressed = async (button, run) => {
743
+ if (button.hasAttribute('aria-busy'))
744
+ return;
745
+ button.setAttribute('aria-busy', 'true');
746
+ try {
747
+ await run();
748
+ }
749
+ finally {
750
+ button.removeAttribute('aria-busy');
751
+ }
752
+ };
753
+ const ownButton = (own, className) => {
754
+ const button = document.createElement('button');
755
+ button.type = 'button';
756
+ button.className = className;
757
+ button.dataset['node'] = own.id;
758
+ const icon = (0, widgets_1.drawIcon)(document, own.icon);
759
+ if (icon)
760
+ button.append(icon);
761
+ button.append(own.label);
762
+ return button;
763
+ };
764
+ const chosenButtons = (node.selectedButtons ?? []).map((own) => {
765
+ const button = ownButton(own, `fd-button fd-button-${own.style ?? 'secondary'} fd-lines-chosen-button`);
766
+ // In the table's order, whatever order they were ticked in.
767
+ button.addEventListener('click', () => void pressed(button, () => form.runLinesAction(node.id, own.id, lines().filter((line) => chosen.has(line.key)).map((line) => line.key))));
768
+ return { id: own.id, button };
769
+ });
770
+ const bar = document.createElement('div');
771
+ bar.className = 'fd-lines-chosen';
772
+ bar.hidden = true;
773
+ bar.append(chosenCount, ...chosenButtons.map((b) => b.button));
774
+ // Lines as cards too (cards: narrow or always): the stylesheet shows them in the grid's place on a narrow form.
775
+ const cardList = document.createElement('div');
776
+ cardList.className = 'fd-line-cards';
777
+ if (node.cards)
778
+ element.dataset['cards'] = node.cards;
779
+ element.append(...(choosing ? [bar] : []), ...(node.cards ? [cardList] : []), host, problems, adds);
380
780
  let readonly = false;
381
781
  const lines = () => form.getState().values[name] ?? [];
382
782
  /** A line field no one types in: read-only, or worked out from the line's others. */
@@ -414,8 +814,9 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
414
814
  if (!before)
415
815
  return void fresh.delete(key);
416
816
  cancelling.delete(key);
817
+ // A line added a moment ago is taken back, unless the table keeps it: then it stays, as it was begun.
417
818
  if (fresh.delete(key))
418
- return form.removeLine(name, key);
819
+ return deletable(key) ? form.removeLine(name, key) : undefined;
419
820
  const current = lines();
420
821
  if (current.some((l) => l.key === key))
421
822
  form.setValue(name, current.map((l) => (l.key === key ? { ...l, values: before } : l)));
@@ -432,6 +833,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
432
833
  api.setFocusedCell(index, column);
433
834
  api.startEditingCell({ rowIndex: index, colKey: column });
434
835
  };
836
+ /** A paragraph (a text field) of an item, edited in its box of several lines over the cell. */
837
+ const paragraphCell = (line, column) => !cell.rowMode && !kindOf(line) && def.fields[column]?.type === 'text';
435
838
  /**
436
839
  * Keys the grid must leave alone, and the two that run off the end of the
437
840
  * table while editing: Enter on the last line and Tab on its last editable
@@ -443,8 +846,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
443
846
  // Alt+Up/Down moves the line (below), it does not move the focus.
444
847
  if (!editing && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown'))
445
848
  return true;
446
- // In a note, Enter is a new line of the note (Ctrl/Cmd+Enter finishes it).
447
- if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && kindOf(node.data) === 'note')
849
+ // In a note, or a paragraph opened over its cell, Enter is a new line (Ctrl/Cmd+Enter finishes it).
850
+ if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && (kindOf(node.data) === 'note' || paragraphCell(node.data, column.getColId())))
448
851
  return true;
449
852
  if (!editing || event.type !== 'keydown' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
450
853
  return false;
@@ -460,16 +863,51 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
460
863
  }
461
864
  return false;
462
865
  };
866
+ // Each column's share of the width by what it holds, as Flectra's lists size theirs: yes/no, numbers and dates by
867
+ // their kind, words and links by the longest the lines have — a long name gets the room a short code does not need.
868
+ const linesOf = (value) => (value ?? []).filter((line) => !kindOf(line));
869
+ const drawnFirst = linesOf(form.getState().values[name]);
870
+ /** A column whose values are drawn as pills (widget="badge"). */
871
+ const badged = (column) => !!node.cells?.[column]?.badge || node.cells?.[column]?.widget === 'badge';
872
+ /** Whether a column is as wide as its values, not a share of the width: yes/no, numbers, money, dates and badges, unless the grid shrinks to fit. */
873
+ const fixedWidth = (column) => (!!FIXED_CHARS[def.fields[column].type] || badged(column)) && node.fit !== 'shrink';
874
+ /** The room a column's values need, in pixels: its longest value, its kind's least, a short label — and the cell's padding. */
875
+ const roomOf = (column, lines = drawnFirst) => {
876
+ const sub = def.fields[column];
877
+ const least = FIXED_CHARS[sub.type] ?? 6;
878
+ let longest = sub.type === 'boolean' ? 0 : lines.reduce((most, line) => Math.max(most, (0, widgets_1.cellText)(sub, line.values[column], line.values, locale, form.getState().values, node.cells?.[column]).length), 0);
879
+ // A badge may turn to any of its choices: room for the longest, though no line holds it yet.
880
+ if (badged(column) && sub.type === 'selection')
881
+ longest = sub.options.reduce((most, option) => Math.max(most, option.label.length), longest);
882
+ // A long label over short numbers wraps onto a second line rather than widen its column; only its longest word sets a floor.
883
+ return Math.round(Math.min(Math.max(longest, least, labelWord(column)), 40) * 7.6 + 40) + (badged(column) ? BADGE_PADDING : 0);
884
+ };
885
+ /** Its label's longest word, in characters: a wrapping header breaks between words, never inside one. */
886
+ const labelWord = (column) => Math.min(Math.max(0, ...def.fields[column].label.split(/\s+/).map((word) => word.length)), 12);
887
+ /** The column a line is named by: its first of words (a name, a description, a link), else its first. */
888
+ const titleColumn = (shown) => shown.find((column) => WORDS.has(def.fields[column].type)) ?? shown[0];
889
+ /** A line's name by its place, "Line 3", when it has none to show. */
890
+ const placeName = (n) => {
891
+ const text = (0, core_1.fill)(labels.lineN, { n });
892
+ return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
893
+ };
463
894
  const columnDefs = columns.map((column) => {
464
895
  const sub = def.fields[column];
896
+ const room = roomOf(column);
465
897
  const numeric = sub.type === 'integer' || sub.type === 'float' || sub.type === 'monetary';
466
898
  // Words get the room; numbers and yes/no take what they need.
467
899
  const wide = ['char', 'text', 'html', 'many2one', 'many2many', 'reference', 'selection'].includes(sub.type);
468
900
  /** Whether this column is where a section's or note's text starts. */
469
901
  const spans = (api) => spanStart(api)?.getColId() === column;
902
+ /** An analytic distribution (a json line field shown as `distribution`): its cell says its accounts, its editor opens its lines. */
903
+ const distributed = sub.type === 'json' && node.cells?.[column]?.widget === 'distribution';
470
904
  const base = {
471
905
  colId: column,
472
906
  headerName: sub.label,
907
+ // A long label wraps onto a second line rather than be cut, so two columns never read the same; all of it on pointing at it.
908
+ headerTooltip: sub.label,
909
+ wrapHeaderText: true,
910
+ autoHeaderHeight: true,
473
911
  valueGetter: (p) => {
474
912
  if (p.node?.rowPinned) {
475
913
  if (totals.includes(column))
@@ -480,7 +918,12 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
480
918
  return p.data?.values[column];
481
919
  return kinds && spans(p.api) ? p.data?.values[kinds.text] : null;
482
920
  },
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),
921
+ valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column))
922
+ ? String(p.value ?? '')
923
+ : // Hidden by its line, the cell stays, blank, so the column lines up.
924
+ ruleOf(p.data, column)?.invisible
925
+ ? ''
926
+ : (0, widgets_1.cellText)(sub, p.value, p.data?.values ?? {}, locale, form.getState().values, node.cells?.[column]),
484
927
  type: numeric ? 'rightAligned' : undefined,
485
928
  // A section or note runs across every column but the delete button.
486
929
  colSpan: (p) => (kindOf(p.data) && spans(p.api) ? spanWidth(p.api) : 1),
@@ -489,23 +932,97 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
489
932
  cellClassRules: {
490
933
  'fd-grid-kind-text': (p) => !!kindOf(p.data),
491
934
  'fd-grid-invalid': (p) => !p.node.rowPinned && !!problemAt(p.data, column, p.api),
935
+ 'fd-cell-bold': (p) => !!ruleOf(p.data, column)?.bold,
936
+ ...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => ruleOf(p.data, column)?.tone === tone])),
492
937
  },
493
938
  tooltipValueGetter: (p) => (p.node?.rowPinned ? undefined : problemAt(p.data, column, p.api)),
494
939
  suppressKeyboardEvent: keys,
495
- // Money keeps room for a bold total in the millions (EGP 1,234,567.00).
496
- minWidth: wide ? 140 : sub.type === 'monetary' ? 150 : 96,
497
- flex: wide ? 2 : sub.type === 'monetary' ? 1.3 : 1,
940
+ // Numbers, money and dates as wide as their values, never cut; words share the rest by the room they need, giving way when it is tight.
941
+ minWidth: fixedWidth(column) ? 48 : Math.max(96, Math.round(labelWord(column) * 7.6 + 40)),
942
+ ...(fixedWidth(column) ? { width: room } : { flex: room }),
498
943
  hide: optional[column] === 'hide',
944
+ ...(node.cells?.[column]?.width ? { width: node.cells[column].width * 8 + 32, flex: 0 } : {}),
945
+ // Columns as wide as what they hold (fit: content), sized once the lines are drawn.
946
+ ...(node.fit === 'content' && !node.cells?.[column]?.width ? { flex: undefined, minWidth: 64 } : {}),
947
+ // Columns that shrink to fit (fit: shrink), as Flectra's list beside a chatter: narrower before the table scrolls, headers wrapping.
948
+ ...(node.fit === 'shrink' ? { minWidth: wide ? 72 : sub.type === 'monetary' ? 80 : 48 } : {}),
499
949
  editable: (p) => {
500
950
  if (readonly || p.node.rowPinned)
501
951
  return false;
502
952
  if (kindOf(p.data))
503
953
  return spans(p.api);
504
- return sub.type !== 'boolean' && EDITABLE.has(sub.type) && !locked(sub);
954
+ const rules = ruleOf(p.data, column);
955
+ return sub.type !== 'boolean' && (EDITABLE.has(sub.type) || distributed) && !locked(sub) && !rules?.readonly && !rules?.invisible;
505
956
  },
506
957
  cellEditor: FieldiaCellEditor,
507
958
  cellEditorParams: { cell, subfield: column },
508
959
  };
960
+ if (distributed) {
961
+ const model = (0, widgets_1.distributionModel)(node.cells?.[column]?.options);
962
+ const asked = new Set();
963
+ const params = {
964
+ model,
965
+ locale,
966
+ nameOf: (id) => accountNames.get(`${model}:${id}`),
967
+ find(line, ids) {
968
+ const fresh = ids.filter((id) => !asked.has(`${model}:${id}`));
969
+ if (!fresh.length)
970
+ return;
971
+ for (const id of fresh)
972
+ asked.add(`${model}:${id}`);
973
+ form.searchLine(name, line.key, column, '', fresh.length, { model, ids: fresh.map((id) => (/^\d+$/.test(id) ? Number(id) : id)) }).then((found) => {
974
+ for (const record of found)
975
+ accountNames.set(`${model}:${record.id}`, record.label);
976
+ if (api.isDestroyed())
977
+ return;
978
+ api.refreshCells({ columns: [column], force: true, suppressFlash: true });
979
+ // The cards say the accounts by their names too.
980
+ cardsDrawn = '';
981
+ drawCards();
982
+ }, () => undefined);
983
+ },
984
+ };
985
+ distributions.set(column, params);
986
+ return {
987
+ ...base,
988
+ // Its accounts' names take room, as words do.
989
+ minWidth: node.fit === 'shrink' ? 96 : 180,
990
+ flex: 2,
991
+ cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: DistributionRenderer, params }),
992
+ // Tab goes round the lines of its open editor; from its last box, on to the next cell.
993
+ suppressKeyboardEvent: (p) => {
994
+ if (p.editing && p.event.key === 'Tab') {
995
+ const editor = p.event.target?.closest?.('.fd-grid-editor');
996
+ const boxes = editor ? [...editor.querySelectorAll('input, button, select')].filter((b) => !b.closest('[hidden]') && !b.disabled) : [];
997
+ const at = boxes.indexOf(p.event.target);
998
+ const to = at + (p.event.shiftKey ? -1 : 1);
999
+ if (at >= 0 && to >= 0 && to < boxes.length)
1000
+ return true;
1001
+ }
1002
+ return keys(p);
1003
+ },
1004
+ };
1005
+ }
1006
+ const drawn = node.cells?.[column]?.widget;
1007
+ if (drawn && widgets_1.DRAWN_IN_CELLS.has(drawn) && drawn !== 'badge') {
1008
+ const look = { widget: drawn, ...(node.cells?.[column]?.options ? { options: node.cells[column].options } : {}) };
1009
+ return {
1010
+ ...base,
1011
+ // Used in the cell itself: no editor opens over it.
1012
+ editable: false,
1013
+ cellRendererSelector: (p) => kindOf(p.data) || p.node.rowPinned
1014
+ ? undefined
1015
+ : { component: DrawnRenderer, params: { cell, subfield: column, look, locked: (line) => readonly || locked(sub) || !!ruleOf(line, column)?.readonly } },
1016
+ // Keys inside the widget are its own: its stars' arrows, its menu's.
1017
+ suppressKeyboardEvent: (p) => p.event.target?.closest?.('.fd-grid-drawn') ? true : keys(p),
1018
+ };
1019
+ }
1020
+ if (node.cells?.[column]?.badge || drawn === 'badge') {
1021
+ return {
1022
+ ...base,
1023
+ cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: BadgeRenderer, params: { toneOf: (line) => ruleOf(line, column)?.tone ?? null } }),
1024
+ };
1025
+ }
509
1026
  if (sub.type === 'boolean') {
510
1027
  return {
511
1028
  ...base,
@@ -530,23 +1047,114 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
530
1047
  resizable: false,
531
1048
  sortable: false,
532
1049
  suppressMovable: true,
533
- lockPosition: 'left',
1050
+ lockPosition: lineStart,
1051
+ // Held in sight at the line's start, however far the lines scroll sideways.
1052
+ pinned: lineStart,
534
1053
  suppressKeyboardEvent: keys,
535
1054
  });
536
1055
  }
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)
1056
+ // A tick at the start of each item, choosing it for the table's buttons for chosen lines; the head's chooses every one.
1057
+ const picking = {
1058
+ label: (n) => (0, core_1.fill)(labels.chooseLine, { name: (0, core_1.fill)(labels.lineN, { n }) }),
1059
+ allLabel: labels.chooseAllLines,
1060
+ isChosen: (key) => chosen.has(key),
1061
+ choose(key, on) {
1062
+ if (on)
1063
+ chosen.add(key);
1064
+ else
1065
+ chosen.delete(key);
1066
+ showChosen(true);
1067
+ },
1068
+ chooseAll(on) {
1069
+ chosen.clear();
1070
+ if (on)
1071
+ for (const line of lines())
1072
+ if (!kindOf(line))
1073
+ chosen.add(line.key);
1074
+ showChosen(true);
1075
+ },
1076
+ head: (box) => {
1077
+ pickAll = box;
1078
+ showChosen(false);
1079
+ },
1080
+ readonly: () => readonly,
1081
+ };
1082
+ if (choosing) {
1083
+ columnDefs.unshift({
1084
+ colId: '__pick',
1085
+ headerName: '',
1086
+ headerComponent: PickAllHeader,
1087
+ headerComponentParams: { picking },
1088
+ width: 36,
1089
+ minWidth: 36,
1090
+ maxWidth: 36,
1091
+ cellClass: 'fd-grid-tools fd-grid-pick',
1092
+ resizable: false,
1093
+ sortable: false,
1094
+ suppressMovable: true,
1095
+ lockPosition: lineStart,
1096
+ // Held in sight at the line's start, however far the lines scroll sideways.
1097
+ pinned: lineStart,
1098
+ cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: PickRenderer, params: { picking } }),
1099
+ suppressKeyboardEvent: keys,
1100
+ });
1101
+ }
1102
+ // A copy of a line, its values too, right after it: never its saved id or its place in the order.
1103
+ const copyLine = (key) => {
1104
+ const all = lines();
1105
+ const at = all.findIndex((l) => l.key === key);
1106
+ if (readonly || at < 0)
546
1107
  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)));
1108
+ const values = { ...all[at].values };
1109
+ if (sequence)
1110
+ delete values[sequence];
1111
+ const made = form.addLine(name, values);
1112
+ if (sequence)
1113
+ form.moveLine(name, made, at + 1);
1114
+ else {
1115
+ const now = [...lines()];
1116
+ now.splice(at + 1, 0, ...now.splice(now.findIndex((l) => l.key === made), 1));
1117
+ form.setValue(name, now);
1118
+ }
549
1119
  };
1120
+ if (node.options?.['copy'] === true) {
1121
+ columnDefs.push({
1122
+ colId: '__copy',
1123
+ headerName: '',
1124
+ width: 40,
1125
+ minWidth: 40,
1126
+ cellClass: 'fd-grid-tools',
1127
+ resizable: false,
1128
+ sortable: false,
1129
+ suppressMovable: true,
1130
+ lockPosition: lineEnd,
1131
+ pinned: lineEnd,
1132
+ cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: CopyRenderer, params: { copy: copyLine, label: (n) => (0, core_1.fill)(labels.copy, { name: (0, core_1.fill)(labels.lineN, { n }) }) } }),
1133
+ suppressKeyboardEvent: keys,
1134
+ });
1135
+ }
1136
+ // AG Grid copies column definitions deeply, so the button calls through, never a copy.
1137
+ // With dialogs, a line can be opened to see and edit every one of its fields at once.
1138
+ // Its own record's page, by the table's model (lineOpens: "record"), or every one of its fields.
1139
+ const openLine = (line) => (dialogs ? (0, widgets_1.openLineDialog)(form, name, node, line, dialogs, readonly) : undefined);
1140
+ if (node.rowButtons?.length) {
1141
+ columnDefs.push({
1142
+ colId: '__row_buttons',
1143
+ headerName: '',
1144
+ // An icon takes a small square; words, what they need.
1145
+ width: 16 + node.rowButtons.reduce((total, b) => total + (b.icon ? 28 : 16 + b.label.length * 7), 0),
1146
+ cellClass: 'fd-grid-tools',
1147
+ resizable: false,
1148
+ sortable: false,
1149
+ suppressMovable: true,
1150
+ lockPosition: lineEnd,
1151
+ pinned: lineEnd,
1152
+ cellRendererSelector: (p) => p.node.rowPinned || kindOf(p.data)
1153
+ ? undefined
1154
+ : { component: RowButtonsRenderer, params: { buttons: node.rowButtons, stateOf: (key) => lineStates.get(key), press: (id, key) => form.runRowAction(node.id, id, key) } },
1155
+ suppressKeyboardEvent: keys,
1156
+ });
1157
+ }
550
1158
  if (dialogs) {
551
1159
  columnDefs.push({
552
1160
  colId: '__open',
@@ -558,7 +1166,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
558
1166
  resizable: false,
559
1167
  sortable: false,
560
1168
  suppressMovable: true,
561
- lockPosition: 'right',
1169
+ lockPosition: lineEnd,
1170
+ pinned: lineEnd,
562
1171
  cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: OpenRenderer, params: { open: openLine, label: labels.openLine } }),
563
1172
  suppressKeyboardEvent: keys,
564
1173
  });
@@ -567,7 +1176,9 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
567
1176
  columnDefs.push({
568
1177
  colId: '__delete',
569
1178
  headerName: '',
570
- lockPosition: 'right',
1179
+ lockPosition: lineEnd,
1180
+ // A line's buttons held in sight at its end, however far the lines scroll sideways.
1181
+ pinned: lineEnd,
571
1182
  ...(optionalIds.length ? { headerComponent: ChooserHeader, headerComponentParams: { chooser } } : {}),
572
1183
  width: 44,
573
1184
  minWidth: 44,
@@ -576,9 +1187,14 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
576
1187
  resizable: false,
577
1188
  sortable: false,
578
1189
  suppressMovable: true,
579
- cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell } }),
1190
+ cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell, deletable } }),
580
1191
  suppressKeyboardEvent: keys,
581
1192
  });
1193
+ // The buttons' words and the empty table's, as the plain table takes them from the node's options.
1194
+ const words = (key) => {
1195
+ const said = node.options?.[key];
1196
+ return typeof said === 'string' && said.trim() ? said : null;
1197
+ };
582
1198
  const api = (0, ag_grid_community_1.createGrid)(host, {
583
1199
  theme: ag_grid_community_1.themeQuartz.withParams({
584
1200
  fontFamily: 'var(--fd-font)',
@@ -594,6 +1210,7 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
594
1210
  headerHeight: HEADER_HEIGHT,
595
1211
  }),
596
1212
  columnDefs,
1213
+ enableRtl: rtl,
597
1214
  rowData: lines(),
598
1215
  pinnedBottomRowData: totals.length ? [totalsRow(lines())] : undefined,
599
1216
  getRowId: (p) => p.data.key,
@@ -603,12 +1220,20 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
603
1220
  const kind = kindOf(p.data);
604
1221
  return kind ? `fd-grid-${kind}` : undefined;
605
1222
  },
1223
+ // A line's tone and bold, read again whenever its rules may have changed.
1224
+ rowClassRules: ruled
1225
+ ? {
1226
+ 'fd-line-bold': (p) => !!(p.data && lineStates.get(p.data.key)?.bold),
1227
+ ...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => !!p.data && lineStates.get(p.data.key)?.tone === tone])),
1228
+ }
1229
+ : undefined,
606
1230
  domLayout: 'autoHeight',
607
1231
  // Lines have few columns: all of them are drawn.
608
1232
  suppressColumnVirtualisation: true,
609
1233
  readOnlyEdit: true,
610
- // An empty table shows its Add a line button below, not a message inside.
611
- suppressNoRowsOverlay: true,
1234
+ // An empty table says so in a short line where its rows would be, never a blank row.
1235
+ noRowsOverlayComponent: EmptyOverlay,
1236
+ noRowsOverlayComponentParams: { words: words('emptyLabel') ?? labels.noLines },
612
1237
  singleClickEdit: true,
613
1238
  // A page may ask for a whole line to open at once.
614
1239
  editType: node.editMode === 'row' ? 'fullRow' : undefined,
@@ -618,10 +1243,13 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
618
1243
  // A line dragged by its handle moves among the others as it goes; where
619
1244
  // it is let go, the form moves it too and numbers the lines again.
620
1245
  rowDragManaged: !!sequence,
1246
+ // A line dragged is named by its words (a yes or no names nothing), else by its place.
621
1247
  rowDragText: (p) => {
622
1248
  const line = p.rowNode?.data;
623
- const text = line && (kindOf(line) && kinds ? line.values[kinds.text] : line.values[columns[0]]);
624
- return text && typeof text === 'object' ? String(text.label ?? '') : String(text ?? '');
1249
+ const column = titleColumn(columns.filter((c) => api.getColumn(c)?.isVisible() !== false));
1250
+ const value = line && (kindOf(line) && kinds ? line.values[kinds.text] : column ? line.values[column] : null);
1251
+ const text = value && typeof value === 'object' ? String(value.label ?? '') : String(value ?? '');
1252
+ return text.trim() || placeName((p.rowNode?.rowIndex ?? 0) + 1);
625
1253
  },
626
1254
  onRowDragEnd: (event) => {
627
1255
  const key = event.node.data?.key;
@@ -633,11 +1261,27 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
633
1261
  defaultColDef: { sortable: false, resizable: true },
634
1262
  }, { modules: [ag_grid_community_1.AllCommunityModule] });
635
1263
  apis.set(element, api);
1264
+ // Out of sight under display: none — a tab not shown, a field hidden by its condition — AG Grid measures
1265
+ // itself again every frame, for as long as it stays hidden. Taken off the page meanwhile, it rests;
1266
+ // put back the moment it has a box again, it draws itself to fit.
1267
+ const away = document.createComment('fd-grid-away');
1268
+ const View = document.defaultView;
1269
+ const sizes = View && 'ResizeObserver' in View
1270
+ ? new View.ResizeObserver(([entry]) => {
1271
+ const hidden = entry.contentRect.width === 0 && entry.contentRect.height === 0;
1272
+ if (hidden && host.isConnected)
1273
+ host.replaceWith(away);
1274
+ else if (!hidden && away.isConnected)
1275
+ away.replaceWith(host);
1276
+ })
1277
+ : null;
1278
+ sizes?.observe(element);
636
1279
  // ---- columns a person arranged: kept, and brought back ----
637
1280
  // A person's widths, order and choices are kept for the next visit. AG Grid
638
1281
  // tells of each change a moment after it; bringing a layout back is told too,
639
1282
  // and keeping it again changes nothing.
640
- api.addEventListener('columnResized', (event) => event.finished && remember(event.source));
1283
+ // A person widens a column by dragging its edge; widths the grid sets itself (by its lines) are not theirs.
1284
+ api.addEventListener('columnResized', (event) => event.finished && event.source !== 'api' && remember(event.source));
641
1285
  api.addEventListener('columnMoved', (event) => event.finished && remember(event.source));
642
1286
  api.addEventListener('columnVisible', (event) => remember(event.source));
643
1287
  /** Sizing the grid does by itself is not a choice to keep. */
@@ -646,14 +1290,35 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
646
1290
  // A grid taking itself apart also sends column events: they are not choices.
647
1291
  if (automatic.has(source) || !preferences || api.isDestroyed())
648
1292
  return;
649
- const state = (api.getColumnState() ?? []).map(({ colId, width, flex, hide }) => ({ colId, width: width ?? null, flex: flex ?? null, hide: !!hide }));
1293
+ // A column the record hides is kept as the person last had it: the rule's hiding is not their choice.
1294
+ 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
1295
  preferences.set(columnsKey, state);
651
1296
  }
1297
+ /** The columns the person hid with the chooser, or the page starts hidden. */
1298
+ const personHidden = new Set(optionalIds.filter((column) => optional[column] === 'hide'));
1299
+ const hiddenByPerson = (column) => personHidden.has(column);
652
1300
  const saved = preferences?.get(columnsKey);
1301
+ /** The words' columns are sized by the lines once: when the first come, unless the person arranged the columns. */
1302
+ let sizedByLines = drawnFirst.length > 0 || Array.isArray(saved) || node.fit === 'content';
1303
+ function sizeByLines(lines) {
1304
+ if (sizedByLines || !lines.length)
1305
+ return;
1306
+ sizedByLines = true;
1307
+ const state = columns.filter((column) => !node.cells?.[column]?.width).map((colId) => (fixedWidth(colId) ? { colId, width: roomOf(colId, lines), flex: null } : { colId, flex: roomOf(colId, lines) }));
1308
+ api.applyColumnState({ state });
1309
+ }
653
1310
  if (Array.isArray(saved)) {
654
1311
  const known = new Set(api.getColumns()?.map((c) => c.getColId()));
655
1312
  const state = saved.filter((s) => typeof s?.colId === 'string' && known.has(s.colId));
656
1313
  api.applyColumnState({ state, applyOrder: true });
1314
+ for (const s of state) {
1315
+ if (!optionalIds.includes(s.colId))
1316
+ continue;
1317
+ if (s.hide)
1318
+ personHidden.add(s.colId);
1319
+ else
1320
+ personHidden.delete(s.colId);
1321
+ }
657
1322
  }
658
1323
  // ---- the column chooser: a short list of the columns a person may hide ----
659
1324
  const chooserBox = document.createElement('div');
@@ -675,13 +1340,21 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
675
1340
  if (!chooserBox.hidden)
676
1341
  return closeChooser(fromKeyboard);
677
1342
  opener = button;
678
- chooserBox.replaceChildren(...optionalIds.map((column) => {
1343
+ chooserBox.replaceChildren(
1344
+ // A column the record hides now is not the person's to show.
1345
+ ...optionalIds.filter((column) => !ruleHidden.has(column)).map((column) => {
679
1346
  const label = document.createElement('label');
680
1347
  const box = document.createElement('input');
681
1348
  box.type = 'checkbox';
682
1349
  box.className = 'fd-checkbox';
683
1350
  box.checked = api.getColumn(column)?.isVisible() ?? false;
684
- box.addEventListener('change', () => api.setColumnsVisible([column], box.checked));
1351
+ box.addEventListener('change', () => {
1352
+ if (box.checked)
1353
+ personHidden.delete(column);
1354
+ else
1355
+ personHidden.add(column);
1356
+ api.setColumnsVisible([column], box.checked);
1357
+ });
685
1358
  label.append(box, document.createTextNode(def.fields[column].label));
686
1359
  return label;
687
1360
  }));
@@ -731,10 +1404,127 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
731
1404
  button.addEventListener('click', () => addAndEdit(values));
732
1405
  adds.append(button);
733
1406
  };
734
- addButton(labels.addLine, {}, 'line');
1407
+ addButton(words('addLabel') ?? labels.addLine, {}, 'line');
735
1408
  if (kinds) {
736
- addButton(labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
737
- addButton(labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
1409
+ addButton(words('addSectionLabel') ?? labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
1410
+ addButton(words('addNoteLabel') ?? labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
1411
+ }
1412
+ // Buttons in the control row, beside the add buttons: the record's, shown by a condition on it (Flectra's <control>).
1413
+ const controls = (node.controlButtons ?? []).map((own) => {
1414
+ const button = ownButton(own, 'fd-button fd-button-link fd-lines-add fd-lines-control');
1415
+ button.addEventListener('click', () => void pressed(button, () => form.runAction(own.id)));
1416
+ adds.append(button);
1417
+ return { id: own.id, button };
1418
+ });
1419
+ /** What the cards were last drawn from: drawn again only when it changes. */
1420
+ let cardsDrawn = '';
1421
+ /** Each line as a card: its first column as its title, the rest by their labels, its tone, its buttons, ↗ and ×. */
1422
+ function drawCards() {
1423
+ if (!node.cards)
1424
+ return;
1425
+ const current = lines();
1426
+ const hidden = new Set(columns.filter((column) => ruleHidden.has(column) || api.getColumn(column)?.isVisible() === false));
1427
+ const shownColumns = columns.filter((column) => !hidden.has(column));
1428
+ const signature = JSON.stringify([current, [...lineStates], shownColumns, readonly, form.getState().values]);
1429
+ if (signature === cardsDrawn)
1430
+ return;
1431
+ cardsDrawn = signature;
1432
+ const parent = form.getState().values;
1433
+ const cards = current.map((line) => {
1434
+ const card = document.createElement('div');
1435
+ card.className = 'fd-line-card';
1436
+ card.dataset['line'] = line.key;
1437
+ const kind = kindOf(line);
1438
+ if (kind && kinds) {
1439
+ card.classList.add(`fd-line-card-${kind}`);
1440
+ card.textContent = String(line.values[kinds.text] ?? '');
1441
+ return card;
1442
+ }
1443
+ const state = lineStates.get(line.key);
1444
+ if (state?.tone)
1445
+ card.dataset['tone'] = state.tone;
1446
+ card.classList.toggle('fd-line-bold', !!state?.bold);
1447
+ const shownHere = shownColumns.filter((column) => !state?.cells[column]?.invisible);
1448
+ const first = titleColumn(shownHere);
1449
+ const rest = shownHere.filter((column) => column !== first);
1450
+ const text = (column) => {
1451
+ // A distribution says its accounts by their names, as its cell does, once they are found.
1452
+ const shares = distributions.get(column);
1453
+ if (!shares)
1454
+ return (0, widgets_1.cellText)(def.fields[column], line.values[column], line.values, locale, parent, node.cells?.[column]);
1455
+ const unknown = (0, widgets_1.distributionIds)(line.values[column]).map(String).filter((id) => shares.nameOf(id) === undefined);
1456
+ if (unknown.length)
1457
+ shares.find(line, unknown);
1458
+ return (0, widgets_1.distributionWords)(line.values[column], locale, shares.nameOf);
1459
+ };
1460
+ const title = document.createElement(dialogs ? 'button' : 'div');
1461
+ title.className = 'fd-line-card-title';
1462
+ title.textContent = first ? text(first) || placeName(current.indexOf(line) + 1) : '';
1463
+ if (dialogs && title instanceof HTMLButtonElement) {
1464
+ title.type = 'button';
1465
+ title.addEventListener('click', () => void openLine(line));
1466
+ }
1467
+ const list = document.createElement('dl');
1468
+ for (const column of rest) {
1469
+ const dt = document.createElement('dt');
1470
+ dt.textContent = def.fields[column].label;
1471
+ const dd = document.createElement('dd');
1472
+ dd.textContent = text(column);
1473
+ const tone = state?.cells[column]?.tone;
1474
+ if (tone)
1475
+ dd.dataset['tone'] = tone;
1476
+ list.append(dt, dd);
1477
+ }
1478
+ const tools = document.createElement('div');
1479
+ tools.className = 'fd-line-card-tools';
1480
+ for (const own of node.rowButtons ?? []) {
1481
+ if (!state?.buttons[own.id])
1482
+ continue;
1483
+ const button = ownButton(own, 'fd-button fd-button-link fd-line-button');
1484
+ button.dataset['rowButton'] = own.id;
1485
+ delete button.dataset['node'];
1486
+ button.addEventListener('click', () => void pressed(button, () => form.runRowAction(node.id, own.id, line.key)));
1487
+ tools.append(button);
1488
+ }
1489
+ if (!readonly && deletable(line.key)) {
1490
+ const remove = document.createElement('button');
1491
+ remove.type = 'button';
1492
+ remove.className = 'fd-line-delete';
1493
+ remove.textContent = '×';
1494
+ remove.setAttribute('aria-label', labels.deleteLine);
1495
+ remove.addEventListener('click', () => form.removeLine(name, line.key));
1496
+ tools.append(remove);
1497
+ }
1498
+ card.append(title, list, tools);
1499
+ return card;
1500
+ });
1501
+ const items = current.filter((line) => !kindOf(line));
1502
+ const sums = totals.filter((column) => !hidden.has(column)).map((column) => {
1503
+ const sum = items.reduce((total, line) => total + Number(line.values[column] ?? 0), 0);
1504
+ return `${def.fields[column].label}: ${(0, widgets_1.cellText)(def.fields[column], sum, items[0]?.values ?? {}, locale, parent, node.cells?.[column])}`;
1505
+ });
1506
+ const total = sums.length ? Object.assign(document.createElement('div'), { className: 'fd-line-cards-total', textContent: sums.join(' · ') }) : null;
1507
+ cardList.replaceChildren(...cards, ...(total ? [total] : []));
1508
+ }
1509
+ /** The bar and the ticks, as the lines chosen are now; `redraw` draws the lines' ticks again. */
1510
+ function showChosen(redraw) {
1511
+ if (!choosing)
1512
+ return;
1513
+ const items = lines().filter((line) => !kindOf(line));
1514
+ for (const key of [...chosen])
1515
+ if (!items.some((line) => line.key === key))
1516
+ chosen.delete(key);
1517
+ bar.hidden = readonly || chosen.size === 0;
1518
+ chosenCount.textContent = (0, core_1.fill)(labels.linesChosen, { n: chosen.size });
1519
+ for (const { id, button } of chosenButtons)
1520
+ button.hidden = form.node(id).invisible;
1521
+ if (pickAll) {
1522
+ pickAll.checked = items.length > 0 && chosen.size === items.length;
1523
+ pickAll.indeterminate = chosen.size > 0 && chosen.size < items.length;
1524
+ pickAll.disabled = readonly || !items.length;
1525
+ }
1526
+ if (redraw)
1527
+ api.refreshCells({ columns: ['__pick'], force: true, suppressFlash: true });
738
1528
  }
739
1529
  // A focused cell that has no editor still answers keys: Space ticks yes/no,
740
1530
  // and Enter or Space presses the delete button. (AG Grid's onCellKeyDown
@@ -772,7 +1562,8 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
772
1562
  }
773
1563
  else if (column === '__delete') {
774
1564
  event.preventDefault();
775
- form.removeLine(name, line.key);
1565
+ if (deletable(line.key))
1566
+ form.removeLine(name, line.key);
776
1567
  }
777
1568
  });
778
1569
  // A table grows with its lines until it is long; then it keeps a height of
@@ -799,9 +1590,11 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
799
1590
  update(state) {
800
1591
  if (state.readonly !== readonly) {
801
1592
  readonly = state.readonly;
802
- api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && (c.colId === '__delete' || c.colId === '__handle'))));
1593
+ api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && ['__delete', '__handle', '__copy', '__pick'].includes(c.colId))));
803
1594
  }
804
1595
  adds.hidden = readonly;
1596
+ for (const { id, button } of controls)
1597
+ button.hidden = form.node(id).invisible;
805
1598
  const current = state.value ?? [];
806
1599
  if (current !== shown) {
807
1600
  shown = current;
@@ -809,6 +1602,36 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
809
1602
  fitHeight(current.length);
810
1603
  if (totals.length)
811
1604
  api.setGridOption('pinnedBottomRowData', [totalsRow(current)]);
1605
+ // Columns as wide as what they hold, once more as the lines change.
1606
+ if (node.fit === 'content')
1607
+ api.autoSizeAllColumns();
1608
+ sizeByLines(linesOf(current));
1609
+ // The ticks and copies name a line by where it is: it may have moved.
1610
+ if (choosing || node.options?.['copy'] === true)
1611
+ api.refreshCells({ columns: ['__pick', '__copy'], force: true, suppressFlash: true });
1612
+ }
1613
+ // Money in the record's currency: its cells written again once that currency changes.
1614
+ if (parentCurrencies.length) {
1615
+ const now = JSON.stringify(parentCurrencies.map((field) => state.values[field] ?? null));
1616
+ if (now !== currencyShown) {
1617
+ currencyShown = now;
1618
+ api.refreshCells({ force: true, suppressFlash: true });
1619
+ }
1620
+ }
1621
+ // The table's own rules as the form has them now: redrawn only when they changed.
1622
+ if (ruled) {
1623
+ const states = new Map(current.filter((line) => !kindOf(line)).map((line) => [line.key, form.lineState(node.id, line.key)]));
1624
+ const hidden = new Set(columns.filter((column) => form.columnHidden(node.id, column)));
1625
+ const before = JSON.stringify([[...lineStates], [...ruleHidden]]);
1626
+ lineStates = states;
1627
+ if (JSON.stringify([[...states], [...hidden]]) !== before) {
1628
+ const turned = columns.filter((column) => hidden.has(column) !== ruleHidden.has(column));
1629
+ ruleHidden = hidden;
1630
+ for (const column of turned)
1631
+ api.setColumnsVisible([column], !hidden.has(column) && !hiddenByPerson(column));
1632
+ api.refreshCells({ force: true, suppressFlash: true });
1633
+ api.redrawRows();
1634
+ }
812
1635
  }
813
1636
  const found = readonly ? [] : lineProblems();
814
1637
  const signature = found.map((p) => `${p.index}.${p.column}:${p.message}`).join('|');
@@ -821,10 +1644,13 @@ const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.
821
1644
  problems.textContent = listed.join(' · ');
822
1645
  problems.hidden = !found.length;
823
1646
  }
1647
+ showChosen(false);
1648
+ drawCards();
824
1649
  element.setAttribute('aria-invalid', String(state.invalid || found.length > 0));
825
1650
  },
826
1651
  destroy() {
827
1652
  document.removeEventListener('mousedown', outside);
1653
+ sizes?.disconnect();
828
1654
  api.destroy();
829
1655
  apis.delete(element);
830
1656
  },