@toolbox-web/grid-angular 1.9.0 → 2.0.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/fesm2022/toolbox-web-grid-angular-features-editing.mjs +2317 -4
  2. package/fesm2022/toolbox-web-grid-angular-features-editing.mjs.map +1 -1
  3. package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs +91 -2
  4. package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs.map +1 -1
  5. package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs +97 -4
  6. package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs.map +1 -1
  7. package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs +1 -15
  8. package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs.map +1 -1
  9. package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs +109 -3
  10. package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs.map +1 -1
  11. package/fesm2022/toolbox-web-grid-angular-features-shell.mjs +452 -0
  12. package/fesm2022/toolbox-web-grid-angular-features-shell.mjs.map +1 -1
  13. package/fesm2022/toolbox-web-grid-angular.mjs +768 -4649
  14. package/fesm2022/toolbox-web-grid-angular.mjs.map +1 -1
  15. package/package.json +2 -6
  16. package/types/toolbox-web-grid-angular-features-editing.d.ts +1181 -4
  17. package/types/toolbox-web-grid-angular-features-editing.d.ts.map +1 -1
  18. package/types/toolbox-web-grid-angular-features-filtering.d.ts +102 -7
  19. package/types/toolbox-web-grid-angular-features-filtering.d.ts.map +1 -1
  20. package/types/toolbox-web-grid-angular-features-master-detail.d.ts +88 -4
  21. package/types/toolbox-web-grid-angular-features-master-detail.d.ts.map +1 -1
  22. package/types/toolbox-web-grid-angular-features-pinned-rows.d.ts +2 -7
  23. package/types/toolbox-web-grid-angular-features-pinned-rows.d.ts.map +1 -1
  24. package/types/toolbox-web-grid-angular-features-responsive.d.ts +109 -3
  25. package/types/toolbox-web-grid-angular-features-responsive.d.ts.map +1 -1
  26. package/types/toolbox-web-grid-angular-features-shell.d.ts +229 -0
  27. package/types/toolbox-web-grid-angular-features-shell.d.ts.map +1 -1
  28. package/types/toolbox-web-grid-angular.d.ts +483 -2929
  29. package/types/toolbox-web-grid-angular.d.ts.map +1 -1
  30. package/fesm2022/toolbox-web-grid-angular-features-reorder-rows.mjs +0 -70
  31. package/fesm2022/toolbox-web-grid-angular-features-reorder-rows.mjs.map +0 -1
  32. package/types/toolbox-web-grid-angular-features-reorder-rows.d.ts +0 -25
  33. package/types/toolbox-web-grid-angular-features-reorder-rows.d.ts.map +0 -1
@@ -1,8 +1,691 @@
1
- import { registerFeatureClaim, claimEvent, unregisterFeatureClaim, unclaimEvent, registerEditorMountHook, makeFlushFocusedInput } from '@toolbox-web/grid-angular';
2
- export { BaseGridEditor, BaseGridEditorCVA, BaseOverlayEditor, GridColumnEditor, GridFormArray, GridLazyForm, TbwEditor, getFormArrayContext, getLazyFormContext } from '@toolbox-web/grid-angular';
1
+ import { registerFeatureClaim, claimEvent, unregisterFeatureClaim, unclaimEvent, registerEditorSpecBridge, registerEditorMountHook, makeFlushFocusedInput } from '@toolbox-web/grid-angular';
3
2
  import '@toolbox-web/grid/features/editing';
4
3
  import * as i0 from '@angular/core';
5
- import { inject, ElementRef, input, output, Directive } from '@angular/core';
4
+ import { inject, ElementRef, contentChild, TemplateRef, effect, Directive, DestroyRef, input, output, computed, afterNextRender, signal } from '@angular/core';
5
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
+ import { FormGroup } from '@angular/forms';
7
+ import { startWith, debounceTime } from 'rxjs/operators';
8
+
9
+ // Global registry mapping DOM elements to their templates
10
+ const editorTemplateRegistry = new Map();
11
+ /**
12
+ * Gets the editor template registered for a given element.
13
+ * Used by AngularGridAdapter to retrieve templates at render time.
14
+ */
15
+ function getEditorTemplate(element) {
16
+ return editorTemplateRegistry.get(element);
17
+ }
18
+ /**
19
+ * Directive that captures an `<ng-template>` for use as a cell editor.
20
+ *
21
+ * This enables declarative Angular component usage with proper input bindings
22
+ * that satisfy Angular's AOT compiler.
23
+ *
24
+ * ## Usage
25
+ *
26
+ * ```html
27
+ * <tbw-grid-column field="status" editable>
28
+ * <tbw-grid-column-editor>
29
+ * <ng-template let-value let-row="row" let-onCommit="onCommit" let-onCancel="onCancel">
30
+ * <app-status-select
31
+ * [value]="value"
32
+ * [row]="row"
33
+ * (commit)="onCommit($event)"
34
+ * (cancel)="onCancel()"
35
+ * />
36
+ * </ng-template>
37
+ * </tbw-grid-column-editor>
38
+ * </tbw-grid-column>
39
+ * ```
40
+ *
41
+ * The template context provides:
42
+ * - `$implicit` / `value`: The cell value
43
+ * - `row`: The full row data object
44
+ * - `column`: The column configuration
45
+ * - `onCommit`: Callback function to commit the new value
46
+ * - `onCancel`: Callback function to cancel editing
47
+ *
48
+ * Import the directive in your component:
49
+ *
50
+ * ```typescript
51
+ * import { GridColumnEditor } from '@toolbox-web/grid-angular/features/editing';
52
+ *
53
+ * @Component({
54
+ * imports: [GridColumnEditor],
55
+ * // ...
56
+ * })
57
+ * ```
58
+ *
59
+ * @category Directive
60
+ *
61
+ * @since 0.1.0
62
+ */
63
+ class GridColumnEditor {
64
+ elementRef = inject((ElementRef));
65
+ /**
66
+ * Query for the ng-template content child.
67
+ */
68
+ template = contentChild((TemplateRef), ...(ngDevMode ? [{ debugName: "template" }] : /* istanbul ignore next */ []));
69
+ /** Effect that triggers when the template is available */
70
+ onTemplateReceived = effect(() => {
71
+ const template = this.template();
72
+ if (template) {
73
+ // Register the template for this element
74
+ editorTemplateRegistry.set(this.elementRef.nativeElement, template);
75
+ }
76
+ }, ...(ngDevMode ? [{ debugName: "onTemplateReceived" }] : /* istanbul ignore next */ []));
77
+ /**
78
+ * Static type guard for template context.
79
+ * Enables type inference in templates.
80
+ */
81
+ static ngTemplateContextGuard(dir, ctx) {
82
+ return true;
83
+ }
84
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridColumnEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
85
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.5", type: GridColumnEditor, isStandalone: true, selector: "tbw-grid-column-editor", queries: [{ propertyName: "template", first: true, predicate: (TemplateRef), descendants: true, isSignal: true }], ngImport: i0 });
86
+ }
87
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridColumnEditor, decorators: [{
88
+ type: Directive,
89
+ args: [{ selector: 'tbw-grid-column-editor' }]
90
+ }], propDecorators: { template: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }] } });
91
+
92
+ // Symbol for storing form context on the grid element
93
+ const FORM_ARRAY_CONTEXT$1 = Symbol('formArrayContext');
94
+ /**
95
+ * Gets the FormArrayContext from a grid element, if present.
96
+ * @internal
97
+ * @since 0.5.0
98
+ */
99
+ function getFormArrayContext(gridElement) {
100
+ return gridElement[FORM_ARRAY_CONTEXT$1];
101
+ }
102
+ /**
103
+ * Directive that binds a FormArray directly to the grid.
104
+ *
105
+ * This is the recommended way to integrate tbw-grid with Angular Reactive Forms.
106
+ * Use a FormArray of FormGroups for row-level validation and cell-level control access.
107
+ *
108
+ * ## Usage
109
+ *
110
+ * ```typescript
111
+ * import { Component, inject } from '@angular/core';
112
+ * import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
113
+ * import { Grid, GridFormArray } from '@toolbox-web/grid-angular/features/editing';
114
+ *
115
+ * @Component({
116
+ * imports: [Grid, GridFormArray, ReactiveFormsModule],
117
+ * template: \`
118
+ * <form [formGroup]="form">
119
+ * <tbw-grid [formArray]="form.controls.rows" [columns]="columns" />
120
+ * </form>
121
+ * \`
122
+ * })
123
+ * export class MyComponent {
124
+ * private fb = inject(FormBuilder);
125
+ *
126
+ * form = this.fb.group({
127
+ * rows: this.fb.array([
128
+ * this.fb.group({ name: 'Alice', age: 30 }),
129
+ * this.fb.group({ name: 'Bob', age: 25 }),
130
+ * ])
131
+ * });
132
+ *
133
+ * columns = [
134
+ * { field: 'name', header: 'Name', editable: true },
135
+ * { field: 'age', header: 'Age', editable: true }
136
+ * ];
137
+ * }
138
+ * ```
139
+ *
140
+ * ## How It Works
141
+ *
142
+ * - **FormArray → Grid**: The grid displays the FormArray's value as rows
143
+ * - **Grid → FormArray**: When a cell is edited, the corresponding FormControl is updated
144
+ * - FormArrayContext is available for accessing cell-level controls
145
+ *
146
+ * ## Features
147
+ *
148
+ * - Works naturally with FormArray inside a FormGroup
149
+ * - Provides cell-level FormControl access for validation
150
+ * - Supports row-level validation state aggregation
151
+ * - Automatically syncs FormArray changes to the grid
152
+ *
153
+ * @example
154
+ * ```html
155
+ * <form [formGroup]="form">
156
+ * <tbw-grid [formArray]="form.controls.rows" [columns]="columns" />
157
+ * </form>
158
+ * ```
159
+ *
160
+ * @category Directive
161
+ *
162
+ * @since 0.5.0
163
+ */
164
+ class GridFormArray {
165
+ destroyRef = inject(DestroyRef);
166
+ elementRef = inject((ElementRef));
167
+ cellCommitUnsub = null;
168
+ cellCancelUnsub = null;
169
+ rowCommitUnsub = null;
170
+ touchListener = null;
171
+ valueChangesSubscription = null;
172
+ statusChangesSubscriptions = [];
173
+ /**
174
+ * The FormArray to bind to the grid.
175
+ */
176
+ formArray = input.required(...(ngDevMode ? [{ debugName: "formArray" }] : /* istanbul ignore next */ []));
177
+ /**
178
+ * Whether to automatically sync Angular validation state to grid's visual invalid styling.
179
+ *
180
+ * When enabled:
181
+ * - After a cell commit, if the FormControl is invalid, the cell is marked with `setInvalid()`
182
+ * - When a FormControl becomes valid, `clearInvalid()` is called
183
+ * - On `row-commit`, if the row's FormGroup has invalid controls, the commit is prevented
184
+ * - In grid mode: validation state is synced on initial render and updated reactively
185
+ *
186
+ * @default true
187
+ */
188
+ syncValidation = input(true, ...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
189
+ /**
190
+ * Effect that sets up valueChanges subscription when FormArray changes.
191
+ * This handles both initial binding and when the FormArray reference changes.
192
+ */
193
+ syncFormArrayToGrid = effect(() => {
194
+ const formArray = this.formArray();
195
+ const grid = this.elementRef.nativeElement;
196
+ if (!grid || !formArray)
197
+ return;
198
+ // Unsubscribe from previous FormArray if any
199
+ this.valueChangesSubscription?.unsubscribe();
200
+ // Subscribe to valueChanges to sync grid rows when FormArray content changes.
201
+ // Use startWith to immediately sync the current value.
202
+ // Note: We use getRawValue() to include disabled controls.
203
+ //
204
+ // In grid mode, editors bind directly to FormControls, so every keystroke
205
+ // fires valueChanges. We skip the sync when an editor input is focused to
206
+ // prevent destroying/recreating editors mid-edit (which orphans overlays
207
+ // like mat-autocomplete/mat-select panels and causes focus loss).
208
+ this.valueChangesSubscription = formArray.valueChanges
209
+ .pipe(startWith(formArray.getRawValue()), takeUntilDestroyed(this.destroyRef))
210
+ .subscribe(() => {
211
+ // Skip sync while an editor is actively focused in the grid.
212
+ // The FormArray already has the latest values via its own controls;
213
+ // re-setting grid.rows would create new object references and trigger
214
+ // an unnecessary render cycle.
215
+ if (this.#isEditorFocused(grid))
216
+ return;
217
+ grid.rows = formArray.getRawValue();
218
+ });
219
+ }, ...(ngDevMode ? [{ debugName: "syncFormArrayToGrid" }] : /* istanbul ignore next */ []));
220
+ ngOnInit() {
221
+ const grid = this.elementRef.nativeElement;
222
+ if (!grid)
223
+ return;
224
+ // Store the form context on the grid element for other directives to access
225
+ this.#storeFormContext(grid);
226
+ // Intercept cell-commit events to update the FormArray
227
+ this.cellCommitUnsub = grid.on('cell-commit', (detail) => {
228
+ this.#handleCellCommit(detail);
229
+ });
230
+ // Intercept cell-cancel events to revert FormControls (grid mode Escape)
231
+ this.cellCancelUnsub = grid.on('cell-cancel', (detail) => {
232
+ this.#handleCellCancel(detail);
233
+ });
234
+ // Intercept row-commit events to prevent if FormGroup is invalid
235
+ this.rowCommitUnsub = grid.on('row-commit', (detail, event) => {
236
+ if (!this.syncValidation())
237
+ return;
238
+ this.#handleRowCommit(event, detail);
239
+ });
240
+ // Mark FormArray as touched on first interaction
241
+ this.touchListener = () => {
242
+ this.formArray().markAsTouched();
243
+ // Remove after first touch
244
+ if (this.touchListener) {
245
+ grid.removeEventListener('click', this.touchListener);
246
+ this.touchListener = null;
247
+ }
248
+ };
249
+ grid.addEventListener('click', this.touchListener);
250
+ // If in grid mode with syncValidation, set up reactive validation
251
+ // Wait for grid to be ready so we can detect the editing mode
252
+ grid.ready?.().then(() => {
253
+ if (this.syncValidation() && this.#isGridMode()) {
254
+ this.#setupReactiveValidation();
255
+ // Sync initial validation state for all existing controls
256
+ this.#syncAllValidationState();
257
+ }
258
+ });
259
+ }
260
+ ngOnDestroy() {
261
+ const grid = this.elementRef.nativeElement;
262
+ if (!grid)
263
+ return;
264
+ this.cellCommitUnsub?.();
265
+ this.cellCancelUnsub?.();
266
+ this.rowCommitUnsub?.();
267
+ if (this.touchListener) {
268
+ grid.removeEventListener('click', this.touchListener);
269
+ }
270
+ if (this.valueChangesSubscription) {
271
+ this.valueChangesSubscription.unsubscribe();
272
+ }
273
+ // Clean up status change subscriptions (grid mode)
274
+ this.statusChangesSubscriptions.forEach((sub) => sub.unsubscribe());
275
+ this.statusChangesSubscriptions = [];
276
+ this.#clearFormContext(grid);
277
+ }
278
+ /**
279
+ * Checks if the EditingPlugin is in 'grid' mode.
280
+ */
281
+ #isGridMode() {
282
+ const grid = this.elementRef.nativeElement;
283
+ if (!grid)
284
+ return false;
285
+ const editingPlugin = grid.getPluginByName?.('editing');
286
+ return editingPlugin?.config?.mode === 'grid';
287
+ }
288
+ /**
289
+ * Checks if a focusable editor element inside the grid currently has focus.
290
+ * Used to skip valueChanges → grid.rows sync while a user is actively editing,
291
+ * preventing editor destruction (which orphans overlay panels like autocomplete/select).
292
+ */
293
+ #isEditorFocused(grid) {
294
+ if (!this.#isGridMode())
295
+ return false;
296
+ const active = document.activeElement;
297
+ if (!active)
298
+ return false;
299
+ // Check if the focused element is inside the grid
300
+ return grid.contains(active) && active.closest('.editing') != null;
301
+ }
302
+ /**
303
+ * Sets up reactive validation syncing for grid mode.
304
+ * Subscribes to statusChanges on all FormControls to update validation state in real-time.
305
+ */
306
+ #setupReactiveValidation() {
307
+ const formArray = this.formArray();
308
+ const grid = this.elementRef.nativeElement;
309
+ if (!grid)
310
+ return;
311
+ // Clean up any existing subscriptions
312
+ this.statusChangesSubscriptions.forEach((sub) => sub.unsubscribe());
313
+ this.statusChangesSubscriptions = [];
314
+ // Subscribe to each FormGroup's statusChanges
315
+ for (let rowIndex = 0; rowIndex < formArray.length; rowIndex++) {
316
+ const rowFormGroup = this.#getRowFormGroup(rowIndex);
317
+ if (!rowFormGroup)
318
+ continue;
319
+ // Get the row ID for this row
320
+ const rowId = this.#getRowId(grid, rowIndex);
321
+ if (!rowId)
322
+ continue;
323
+ // Subscribe to status changes for each control in the FormGroup
324
+ Object.keys(rowFormGroup.controls).forEach((field) => {
325
+ const control = rowFormGroup.get(field);
326
+ if (!control)
327
+ return;
328
+ const sub = control.statusChanges.pipe(debounceTime(50), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
329
+ this.#syncControlValidationToGrid(rowId, field, control);
330
+ });
331
+ this.statusChangesSubscriptions.push(sub);
332
+ });
333
+ }
334
+ }
335
+ /**
336
+ * Syncs validation state for all controls in the FormArray.
337
+ * Called once on init in grid mode to show pre-existing validation errors.
338
+ */
339
+ #syncAllValidationState() {
340
+ const formArray = this.formArray();
341
+ const grid = this.elementRef.nativeElement;
342
+ if (!grid)
343
+ return;
344
+ for (let rowIndex = 0; rowIndex < formArray.length; rowIndex++) {
345
+ const rowFormGroup = this.#getRowFormGroup(rowIndex);
346
+ if (!rowFormGroup)
347
+ continue;
348
+ const rowId = this.#getRowId(grid, rowIndex);
349
+ if (!rowId)
350
+ continue;
351
+ Object.keys(rowFormGroup.controls).forEach((field) => {
352
+ const control = rowFormGroup.get(field);
353
+ if (control) {
354
+ this.#syncControlValidationToGrid(rowId, field, control);
355
+ }
356
+ });
357
+ }
358
+ }
359
+ /**
360
+ * Gets the row ID for a given row index using the grid's getRowId method.
361
+ */
362
+ #getRowId(grid, rowIndex) {
363
+ try {
364
+ const rows = grid.rows;
365
+ const row = rows?.[rowIndex];
366
+ if (!row)
367
+ return undefined;
368
+ return grid.getRowId?.(row);
369
+ }
370
+ catch {
371
+ return undefined;
372
+ }
373
+ }
374
+ /**
375
+ * Checks if the FormArray contains FormGroups.
376
+ */
377
+ #isFormArrayOfFormGroups() {
378
+ const formArray = this.formArray();
379
+ if (formArray.length === 0)
380
+ return false;
381
+ return formArray.at(0) instanceof FormGroup;
382
+ }
383
+ /**
384
+ * Gets the FormGroup at a specific row index.
385
+ */
386
+ #getRowFormGroup(rowIndex) {
387
+ const formArray = this.formArray();
388
+ const rowControl = formArray.at(rowIndex);
389
+ return rowControl instanceof FormGroup ? rowControl : undefined;
390
+ }
391
+ /**
392
+ * Stores the FormArrayContext on the grid element.
393
+ */
394
+ #storeFormContext(grid) {
395
+ const getRowFormGroup = (rowIndex) => this.#getRowFormGroup(rowIndex);
396
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
397
+ const self = this;
398
+ const context = {
399
+ getRow: (rowIndex) => {
400
+ const formArray = this.formArray();
401
+ const rowControl = formArray.at(rowIndex);
402
+ return rowControl ? rowControl.value : null;
403
+ },
404
+ updateField: (rowIndex, field, value) => {
405
+ const rowFormGroup = getRowFormGroup(rowIndex);
406
+ if (rowFormGroup) {
407
+ const control = rowFormGroup.get(field);
408
+ if (control) {
409
+ control.setValue(value);
410
+ control.markAsDirty();
411
+ }
412
+ }
413
+ },
414
+ getValue: () => {
415
+ return this.formArray().getRawValue();
416
+ },
417
+ get hasFormGroups() {
418
+ return self.#isFormArrayOfFormGroups();
419
+ },
420
+ getControl: (rowIndex, field) => {
421
+ const rowFormGroup = getRowFormGroup(rowIndex);
422
+ if (!rowFormGroup)
423
+ return undefined;
424
+ return rowFormGroup.get(field) ?? undefined;
425
+ },
426
+ getRowFormGroup,
427
+ isRowValid: (rowIndex) => {
428
+ const rowFormGroup = getRowFormGroup(rowIndex);
429
+ if (!rowFormGroup)
430
+ return true;
431
+ return rowFormGroup.valid;
432
+ },
433
+ isRowTouched: (rowIndex) => {
434
+ const rowFormGroup = getRowFormGroup(rowIndex);
435
+ if (!rowFormGroup)
436
+ return false;
437
+ return rowFormGroup.touched;
438
+ },
439
+ isRowDirty: (rowIndex) => {
440
+ const rowFormGroup = getRowFormGroup(rowIndex);
441
+ if (!rowFormGroup)
442
+ return false;
443
+ return rowFormGroup.dirty;
444
+ },
445
+ getRowErrors: (rowIndex) => {
446
+ const rowFormGroup = getRowFormGroup(rowIndex);
447
+ if (!rowFormGroup)
448
+ return null;
449
+ const errors = {};
450
+ let hasErrors = false;
451
+ Object.keys(rowFormGroup.controls).forEach((field) => {
452
+ const control = rowFormGroup.get(field);
453
+ if (control?.errors) {
454
+ errors[field] = control.errors;
455
+ hasErrors = true;
456
+ }
457
+ });
458
+ if (rowFormGroup.errors) {
459
+ errors['_group'] = rowFormGroup.errors;
460
+ hasErrors = true;
461
+ }
462
+ return hasErrors ? errors : null;
463
+ },
464
+ };
465
+ grid[FORM_ARRAY_CONTEXT$1] = context;
466
+ }
467
+ /**
468
+ * Clears the FormArrayContext from the grid element.
469
+ */
470
+ #clearFormContext(grid) {
471
+ delete grid[FORM_ARRAY_CONTEXT$1];
472
+ }
473
+ /**
474
+ * Handles cell-commit events by updating the FormControl in the FormGroup.
475
+ */
476
+ #handleCellCommit(detail) {
477
+ const { rowIndex, field, value, rowId } = detail;
478
+ const rowFormGroup = this.#getRowFormGroup(rowIndex);
479
+ if (rowFormGroup) {
480
+ const control = rowFormGroup.get(field);
481
+ if (control) {
482
+ control.setValue(value);
483
+ control.markAsDirty();
484
+ control.markAsTouched();
485
+ // Sync Angular validation state to grid's visual invalid styling
486
+ if (this.syncValidation() && rowId) {
487
+ this.#syncControlValidationToGrid(rowId, field, control);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ /**
493
+ * Handles row-commit events - prevents commit if FormGroup has invalid controls.
494
+ */
495
+ #handleRowCommit(event, detail) {
496
+ const { rowIndex } = detail;
497
+ const rowFormGroup = this.#getRowFormGroup(rowIndex);
498
+ if (rowFormGroup && rowFormGroup.invalid) {
499
+ // Prevent row commit if the FormGroup is invalid
500
+ event.preventDefault();
501
+ }
502
+ }
503
+ /**
504
+ * Handles cell-cancel events (grid mode Escape) — reverts the FormControl
505
+ * to the value it had before the edit session began.
506
+ */
507
+ #handleCellCancel(detail) {
508
+ const { rowIndex, field, previousValue } = detail;
509
+ const rowFormGroup = this.#getRowFormGroup(rowIndex);
510
+ if (rowFormGroup) {
511
+ const control = rowFormGroup.get(field);
512
+ if (control) {
513
+ control.setValue(previousValue, { emitEvent: false });
514
+ control.markAsPristine();
515
+ }
516
+ }
517
+ }
518
+ /**
519
+ * Syncs a FormControl's validation state to the grid's visual invalid styling.
520
+ */
521
+ #syncControlValidationToGrid(rowId, field, control) {
522
+ const grid = this.elementRef.nativeElement;
523
+ if (!grid)
524
+ return;
525
+ // Get EditingPlugin via getPluginByName
526
+ const editingPlugin = grid.getPluginByName?.('editing');
527
+ if (!editingPlugin)
528
+ return;
529
+ if (control.invalid) {
530
+ // Get first error message to display
531
+ const errorMessage = this.#getFirstErrorMessage(control);
532
+ editingPlugin.setInvalid(rowId, field, errorMessage);
533
+ }
534
+ else {
535
+ editingPlugin.clearInvalid(rowId, field);
536
+ }
537
+ }
538
+ /**
539
+ * Gets a human-readable error message from the first validation error.
540
+ */
541
+ #getFirstErrorMessage(control) {
542
+ const errors = control.errors;
543
+ if (!errors)
544
+ return '';
545
+ const firstKey = Object.keys(errors)[0];
546
+ const error = errors[firstKey];
547
+ // Common Angular validators
548
+ switch (firstKey) {
549
+ case 'required':
550
+ return 'This field is required';
551
+ case 'minlength':
552
+ return `Minimum length is ${error.requiredLength}`;
553
+ case 'maxlength':
554
+ return `Maximum length is ${error.requiredLength}`;
555
+ case 'min':
556
+ return `Minimum value is ${error.min}`;
557
+ case 'max':
558
+ return `Maximum value is ${error.max}`;
559
+ case 'email':
560
+ return 'Invalid email address';
561
+ case 'pattern':
562
+ return 'Invalid format';
563
+ default:
564
+ // Custom validators may provide a message property
565
+ return typeof error === 'string' ? error : (error?.message ?? `Validation error: ${firstKey}`);
566
+ }
567
+ }
568
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridFormArray, deps: [], target: i0.ɵɵFactoryTarget.Directive });
569
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: GridFormArray, isStandalone: true, selector: "tbw-grid[formArray]", inputs: { formArray: { classPropertyName: "formArray", publicName: "formArray", isSignal: true, isRequired: true, transformFunction: null }, syncValidation: { classPropertyName: "syncValidation", publicName: "syncValidation", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
570
+ }
571
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridFormArray, decorators: [{
572
+ type: Directive,
573
+ args: [{
574
+ selector: 'tbw-grid[formArray]',
575
+ }]
576
+ }], propDecorators: { formArray: [{ type: i0.Input, args: [{ isSignal: true, alias: "formArray", required: true }] }], syncValidation: [{ type: i0.Input, args: [{ isSignal: true, alias: "syncValidation", required: false }] }] } });
577
+
578
+ // Registry for structural editor templates
579
+ const structuralEditorRegistry = new Map();
580
+ /**
581
+ * Gets the editor template registered by the structural directive for a given column element.
582
+ * Falls back to the non-structural directive registry.
583
+ */
584
+ function getStructuralEditorTemplate(columnElement) {
585
+ // First check structural directive registry
586
+ const template = structuralEditorRegistry.get(columnElement);
587
+ if (template)
588
+ return template;
589
+ // Fall back to the nested element registry
590
+ const editorEl = columnElement.querySelector('tbw-grid-column-editor');
591
+ if (editorEl) {
592
+ return getEditorTemplate(editorEl);
593
+ }
594
+ return undefined;
595
+ }
596
+ /**
597
+ * Structural directive for cell editor rendering.
598
+ *
599
+ * This provides a cleaner syntax for defining custom cell editors without
600
+ * the nested `<tbw-grid-column-editor>` and `<ng-template>` boilerplate.
601
+ *
602
+ * ## Usage
603
+ *
604
+ * ```html
605
+ * <!-- Instead of this verbose syntax: -->
606
+ * <tbw-grid-column field="status">
607
+ * <tbw-grid-column-editor>
608
+ * <ng-template let-value let-onCommit="onCommit" let-onCancel="onCancel">
609
+ * <app-status-editor [value]="value" (commit)="onCommit($event)" (cancel)="onCancel()" />
610
+ * </ng-template>
611
+ * </tbw-grid-column-editor>
612
+ * </tbw-grid-column>
613
+ *
614
+ * <!-- Use this cleaner syntax (with auto-wiring - no explicit bindings needed!): -->
615
+ * <tbw-grid-column field="status">
616
+ * <app-status-editor *tbwEditor="let value" [value]="value" />
617
+ * </tbw-grid-column>
618
+ * ```
619
+ *
620
+ * ## Template Context
621
+ *
622
+ * The structural directive provides the same context as `GridColumnEditor`:
623
+ * - `$implicit` / `value`: The cell value
624
+ * - `row`: The full row data object
625
+ * - `column`: The column configuration
626
+ * - `onCommit`: Callback function to commit the new value (optional - auto-wired if component emits `commit` event)
627
+ * - `onCancel`: Callback function to cancel editing (optional - auto-wired if component emits `cancel` event)
628
+ *
629
+ * ## Import
630
+ *
631
+ * ```typescript
632
+ * import { TbwEditor } from '@toolbox-web/grid-angular/features/editing';
633
+ *
634
+ * @Component({
635
+ * imports: [TbwEditor],
636
+ * // ...
637
+ * })
638
+ * ```
639
+ *
640
+ * @example
641
+ * ```html
642
+ * <tbw-grid-column field="status" editable>
643
+ * <app-status-editor *tbwEditor="let value" [value]="value" />
644
+ * </tbw-grid-column>
645
+ * ```
646
+ *
647
+ * @category Directive
648
+ * @since 0.1.1
649
+ */
650
+ class TbwEditor {
651
+ template = inject((TemplateRef));
652
+ elementRef = inject((ElementRef));
653
+ columnElement = null;
654
+ constructor() {
655
+ effect(() => {
656
+ this.registerTemplate();
657
+ });
658
+ }
659
+ registerTemplate() {
660
+ // Find the parent tbw-grid-column element
661
+ let parent = this.elementRef.nativeElement?.parentElement;
662
+ while (parent && parent.tagName !== 'TBW-GRID-COLUMN') {
663
+ parent = parent.parentElement;
664
+ }
665
+ if (parent) {
666
+ this.columnElement = parent;
667
+ structuralEditorRegistry.set(parent, this.template);
668
+ }
669
+ }
670
+ ngOnDestroy() {
671
+ if (this.columnElement) {
672
+ structuralEditorRegistry.delete(this.columnElement);
673
+ }
674
+ }
675
+ /**
676
+ * Static type guard for template context.
677
+ * Uses `any` defaults for ergonomic template usage.
678
+ */
679
+ static ngTemplateContextGuard(dir, ctx) {
680
+ return true;
681
+ }
682
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TbwEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
683
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.5", type: TbwEditor, isStandalone: true, selector: "[tbwEditor]", ngImport: i0 });
684
+ }
685
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TbwEditor, decorators: [{
686
+ type: Directive,
687
+ args: [{ selector: '[tbwEditor]' }]
688
+ }], ctorParameters: () => [] });
6
689
 
7
690
  /**
8
691
  * `GridEditingDirective` — owns `[editing]` and the editing-specific
@@ -96,6 +779,1555 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
96
779
  }]
97
780
  }], ctorParameters: () => [], propDecorators: { editing: [{ type: i0.Input, args: [{ isSignal: true, alias: "editing", required: false }] }], cellCommit: [{ type: i0.Output, args: ["cellCommit"] }], cellCancel: [{ type: i0.Output, args: ["cellCancel"] }], rowCommit: [{ type: i0.Output, args: ["rowCommit"] }], changedRowsReset: [{ type: i0.Output, args: ["changedRowsReset"] }], editOpen: [{ type: i0.Output, args: ["editOpen"] }], beforeEditClose: [{ type: i0.Output, args: ["beforeEditClose"] }], editClose: [{ type: i0.Output, args: ["editClose"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }] } });
98
781
 
782
+ /**
783
+ * Base class for grid cell editors.
784
+ *
785
+ * Provides common functionality for Angular cell editors:
786
+ * - Automatic value resolution from FormControl or value input
787
+ * - Common inputs (value, row, column, control)
788
+ * - Common outputs (commit, cancel)
789
+ * - Validation state helpers
790
+ *
791
+ * ## Usage
792
+ *
793
+ * ```typescript
794
+ * import { Component } from '@angular/core';
795
+ * import { BaseGridEditor } from '@toolbox-web/grid-angular/features/editing';
796
+ *
797
+ * @Component({
798
+ * selector: 'app-my-editor',
799
+ * template: \`
800
+ * <input
801
+ * [value]="currentValue()"
802
+ * [class.is-invalid]="isInvalid()"
803
+ * (input)="commitValue($event.target.value)"
804
+ * (keydown.escape)="cancelEdit()"
805
+ * />
806
+ * @if (hasErrors()) {
807
+ * <div class="error">{{ firstErrorMessage() }}</div>
808
+ * }
809
+ * \`
810
+ * })
811
+ * export class MyEditorComponent extends BaseGridEditor<MyRow, string> {
812
+ * // Override to customize error messages
813
+ * protected override getErrorMessage(errorKey: string): string {
814
+ * if (errorKey === 'required') return 'This field is required';
815
+ * if (errorKey === 'minlength') return 'Too short';
816
+ * return super.getErrorMessage(errorKey);
817
+ * }
818
+ * }
819
+ * ```
820
+ *
821
+ * ## Template Syntax
822
+ *
823
+ * When using the base class, you only need to pass the control:
824
+ *
825
+ * ```html
826
+ * <tbw-grid-column field="name">
827
+ * <app-my-editor *tbwEditor="let _; control as control" [control]="control" />
828
+ * </tbw-grid-column>
829
+ * ```
830
+ *
831
+ * Or without FormArray binding (fallback to value):
832
+ *
833
+ * ```html
834
+ * <tbw-grid-column field="name">
835
+ * <app-my-editor *tbwEditor="let value" [value]="value" />
836
+ * </tbw-grid-column>
837
+ * ```
838
+ *
839
+ * @typeParam TRow - The row data type
840
+ * @typeParam TValue - The cell value type
841
+ *
842
+ * @since 0.5.0
843
+ */
844
+ class BaseGridEditor {
845
+ elementRef = inject(ElementRef);
846
+ _destroyRef = inject(DestroyRef);
847
+ /** Cleanup function for the edit-close listener */
848
+ _editCloseCleanup = null;
849
+ // ============================================================================
850
+ // Inputs
851
+ // ============================================================================
852
+ /**
853
+ * The cell value. Used when FormControl is not available.
854
+ * When a FormControl is provided, value is derived from control.value instead.
855
+ */
856
+ value = input(...(ngDevMode ? [undefined, { debugName: "value" }] : /* istanbul ignore next */ []));
857
+ /**
858
+ * The full row data object.
859
+ */
860
+ row = input(...(ngDevMode ? [undefined, { debugName: "row" }] : /* istanbul ignore next */ []));
861
+ /**
862
+ * The column configuration.
863
+ */
864
+ column = input(...(ngDevMode ? [undefined, { debugName: "column" }] : /* istanbul ignore next */ []));
865
+ /**
866
+ * The FormControl for this cell, if the grid is bound to a FormArray.
867
+ * When provided, the editor uses control.value instead of the value input.
868
+ */
869
+ control = input(...(ngDevMode ? [undefined, { debugName: "control" }] : /* istanbul ignore next */ []));
870
+ // ============================================================================
871
+ // Outputs
872
+ // ============================================================================
873
+ /**
874
+ * Emits when the user commits a new value.
875
+ * Emits `null` when a nullable field is cleared.
876
+ */
877
+ commit = output();
878
+ /**
879
+ * Emits when the user cancels editing.
880
+ */
881
+ cancel = output();
882
+ // ============================================================================
883
+ // Computed State
884
+ // ============================================================================
885
+ /**
886
+ * The current value, derived from FormControl if available, otherwise from value input.
887
+ * This is the recommended way to get the current value in your editor template.
888
+ */
889
+ currentValue = computed(() => {
890
+ const ctrl = this.control();
891
+ if (ctrl) {
892
+ return ctrl.value;
893
+ }
894
+ return this.value();
895
+ }, ...(ngDevMode ? [{ debugName: "currentValue" }] : /* istanbul ignore next */ []));
896
+ /**
897
+ * Whether the control is invalid (has validation errors).
898
+ * Returns false if no FormControl is available.
899
+ */
900
+ isInvalid = computed(() => {
901
+ return this.control()?.invalid ?? false;
902
+ }, ...(ngDevMode ? [{ debugName: "isInvalid" }] : /* istanbul ignore next */ []));
903
+ /**
904
+ * Whether the control is dirty (has been modified).
905
+ * Returns false if no FormControl is available.
906
+ */
907
+ isDirty = computed(() => {
908
+ return this.control()?.dirty ?? false;
909
+ }, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
910
+ /**
911
+ * Whether the control has been touched.
912
+ * Returns false if no FormControl is available.
913
+ */
914
+ isTouched = computed(() => {
915
+ return this.control()?.touched ?? false;
916
+ }, ...(ngDevMode ? [{ debugName: "isTouched" }] : /* istanbul ignore next */ []));
917
+ /**
918
+ * Whether the control has any validation errors.
919
+ */
920
+ hasErrors = computed(() => {
921
+ const ctrl = this.control();
922
+ return ctrl?.errors != null && Object.keys(ctrl.errors).length > 0;
923
+ }, ...(ngDevMode ? [{ debugName: "hasErrors" }] : /* istanbul ignore next */ []));
924
+ /**
925
+ * The first error message from the control's validation errors.
926
+ * Returns an empty string if no errors.
927
+ */
928
+ firstErrorMessage = computed(() => {
929
+ const ctrl = this.control();
930
+ if (!ctrl?.errors)
931
+ return '';
932
+ const firstKey = Object.keys(ctrl.errors)[0];
933
+ return this.getErrorMessage(firstKey, ctrl.errors[firstKey]);
934
+ }, ...(ngDevMode ? [{ debugName: "firstErrorMessage" }] : /* istanbul ignore next */ []));
935
+ /**
936
+ * All error messages from the control's validation errors.
937
+ */
938
+ allErrorMessages = computed(() => {
939
+ const ctrl = this.control();
940
+ if (!ctrl?.errors)
941
+ return [];
942
+ return Object.entries(ctrl.errors).map(([key, value]) => this.getErrorMessage(key, value));
943
+ }, ...(ngDevMode ? [{ debugName: "allErrorMessages" }] : /* istanbul ignore next */ []));
944
+ // ============================================================================
945
+ // Lifecycle
946
+ // ============================================================================
947
+ constructor() {
948
+ afterNextRender(() => this._initEditCloseListener());
949
+ this._destroyRef.onDestroy(() => {
950
+ this._editCloseCleanup?.();
951
+ this._editCloseCleanup = null;
952
+ });
953
+ }
954
+ _initEditCloseListener() {
955
+ const grid = this.elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]');
956
+ if (!grid)
957
+ return;
958
+ let unsubBefore;
959
+ let unsubClose;
960
+ unsubBefore = grid.on('before-edit-close', () => {
961
+ this.onBeforeEditClose();
962
+ unsubBefore?.();
963
+ unsubBefore = undefined;
964
+ });
965
+ unsubClose = grid.on('edit-close', () => {
966
+ this.onEditClose();
967
+ unsubClose?.();
968
+ unsubClose = undefined;
969
+ });
970
+ this._editCloseCleanup = () => {
971
+ unsubBefore?.();
972
+ unsubClose?.();
973
+ };
974
+ }
975
+ // ============================================================================
976
+ // Methods
977
+ // ============================================================================
978
+ /**
979
+ * Whether this editor's cell is the currently focused cell.
980
+ *
981
+ * In row editing mode the grid creates editors for every editable cell
982
+ * in the row simultaneously. Use this to conditionally auto-focus inputs
983
+ * or open panels only in the active cell.
984
+ *
985
+ * Performs a synchronous DOM check — safe to call from `ngAfterViewInit`.
986
+ */
987
+ isCellFocused() {
988
+ return this.elementRef.nativeElement.closest('[part="cell"]')?.classList.contains('cell-focus') ?? false;
989
+ }
990
+ /**
991
+ * Called **before** the grid clears editing state and destroys editor DOM.
992
+ *
993
+ * At this point the commit callback is still active, so subclasses can
994
+ * call {@link commitValue} to flush any pending/deferred values.
995
+ *
996
+ * This fires only on the **commit** path (not on revert/cancel).
997
+ * Use {@link onEditClose} for cleanup that should happen on both paths.
998
+ */
999
+ onBeforeEditClose() {
1000
+ // Default: no-op. Subclasses override to flush pending values.
1001
+ }
1002
+ /**
1003
+ * Called when the grid ends the editing session for this cell.
1004
+ *
1005
+ * Override to perform cleanup such as closing overlay panels, autocomplete
1006
+ * dropdowns, or other floating UI that lives at `<body>` level and would
1007
+ * otherwise persist after the editor DOM is removed.
1008
+ *
1009
+ * The listener is set up automatically via `afterNextRender` — no manual
1010
+ * wiring required.
1011
+ */
1012
+ onEditClose() {
1013
+ // Default: no-op. Subclasses override.
1014
+ }
1015
+ /**
1016
+ * Called by the grid adapter when the cell value changes externally
1017
+ * (e.g., via `updateRow()` cascade or undo/redo).
1018
+ *
1019
+ * Override in subclasses to reset internal state (search text, selection
1020
+ * flags, etc.) so the editor displays the updated value.
1021
+ *
1022
+ * This runs **synchronously** before the value input is updated, giving
1023
+ * the editor a chance to clear stale state before the next change-detection
1024
+ * pass re-reads the template.
1025
+ *
1026
+ * @param _newVal The new cell value being pushed from the grid
1027
+ */
1028
+ onExternalValueChange(_newVal) {
1029
+ // Default: no-op. Subclasses override as needed.
1030
+ }
1031
+ /**
1032
+ * Commit a new value. Emits the commit output AND dispatches a DOM event.
1033
+ * The DOM event enables the grid's auto-wiring to catch the commit.
1034
+ * Call this when the user confirms their edit.
1035
+ */
1036
+ commitValue(newValue) {
1037
+ // Emit Angular output for template bindings
1038
+ this.commit.emit(newValue);
1039
+ // Dispatch DOM CustomEvent for grid's auto-wiring
1040
+ // This allows the adapter to catch commits without explicit (commit)="..." bindings
1041
+ this.elementRef.nativeElement.dispatchEvent(new CustomEvent('commit', { detail: newValue, bubbles: true }));
1042
+ }
1043
+ /**
1044
+ * Cancel editing. Emits the cancel output AND dispatches a DOM event.
1045
+ * Call this when the user cancels (e.g., presses Escape).
1046
+ */
1047
+ cancelEdit() {
1048
+ // Emit Angular output for template bindings
1049
+ this.cancel.emit();
1050
+ // Dispatch DOM CustomEvent for grid's auto-wiring
1051
+ this.elementRef.nativeElement.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
1052
+ }
1053
+ /**
1054
+ * Get a human-readable error message for a validation error.
1055
+ * Override this method to customize error messages for your editor.
1056
+ *
1057
+ * @param errorKey - The validation error key (e.g., 'required', 'minlength')
1058
+ * @param errorValue - The error value (e.g., { requiredLength: 5, actualLength: 3 })
1059
+ * @returns A human-readable error message
1060
+ */
1061
+ getErrorMessage(errorKey, errorValue) {
1062
+ switch (errorKey) {
1063
+ case 'required':
1064
+ return 'This field is required';
1065
+ case 'minlength': {
1066
+ const err = errorValue;
1067
+ return `Minimum length is ${err?.requiredLength ?? 'unknown'}`;
1068
+ }
1069
+ case 'maxlength': {
1070
+ const err = errorValue;
1071
+ return `Maximum length is ${err?.requiredLength ?? 'unknown'}`;
1072
+ }
1073
+ case 'min': {
1074
+ const err = errorValue;
1075
+ return `Minimum value is ${err?.min ?? 'unknown'}`;
1076
+ }
1077
+ case 'max': {
1078
+ const err = errorValue;
1079
+ return `Maximum value is ${err?.max ?? 'unknown'}`;
1080
+ }
1081
+ case 'email':
1082
+ return 'Invalid email address';
1083
+ case 'pattern':
1084
+ return 'Invalid format';
1085
+ default:
1086
+ return `Invalid value (${errorKey})`;
1087
+ }
1088
+ }
1089
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseGridEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1090
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: BaseGridEditor, isStandalone: true, inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: false, transformFunction: null }, column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: false, transformFunction: null }, control: { classPropertyName: "control", publicName: "control", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { commit: "commit", cancel: "cancel" }, ngImport: i0 });
1091
+ }
1092
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseGridEditor, decorators: [{
1093
+ type: Directive
1094
+ }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], row: [{ type: i0.Input, args: [{ isSignal: true, alias: "row", required: false }] }], column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: false }] }], control: [{ type: i0.Input, args: [{ isSignal: true, alias: "control", required: false }] }], commit: [{ type: i0.Output, args: ["commit"] }], cancel: [{ type: i0.Output, args: ["cancel"] }] } });
1095
+
1096
+ /**
1097
+ * Base class for grid editors that also work as Angular form controls.
1098
+ *
1099
+ * Combines `BaseGridEditor` with `ControlValueAccessor` so the same component
1100
+ * can be used inside a `<tbw-grid>` **and** in a standalone `<form>`.
1101
+ *
1102
+ * ## What it provides
1103
+ *
1104
+ * | Member | Purpose |
1105
+ * |--------|---------|
1106
+ * | `cvaValue` | Signal holding the value written by the form control |
1107
+ * | `disabledState` | Signal tracking `setDisabledState` calls |
1108
+ * | `displayValue` | Computed that prefers grid value (`currentValue`) and falls back to `cvaValue` |
1109
+ * | `commitBoth(v)` | Commits via both CVA `onChange` and grid `commitValue` |
1110
+ * | `writeValue` / `registerOn*` / `setDisabledState` | Full CVA implementation |
1111
+ *
1112
+ * ## Usage
1113
+ *
1114
+ * ```typescript
1115
+ * import { Component, forwardRef } from '@angular/core';
1116
+ * import { NG_VALUE_ACCESSOR } from '@angular/forms';
1117
+ * import { BaseGridEditorCVA } from '@toolbox-web/grid-angular/features/editing';
1118
+ *
1119
+ * @Component({
1120
+ * selector: 'app-date-picker',
1121
+ * providers: [{
1122
+ * provide: NG_VALUE_ACCESSOR,
1123
+ * useExisting: forwardRef(() => DatePickerComponent),
1124
+ * multi: true,
1125
+ * }],
1126
+ * template: `
1127
+ * <input
1128
+ * type="date"
1129
+ * [value]="displayValue()"
1130
+ * [disabled]="disabledState()"
1131
+ * (change)="commitBoth($event.target.value)"
1132
+ * (keydown.escape)="cancelEdit()"
1133
+ * />
1134
+ * `
1135
+ * })
1136
+ * export class DatePickerComponent extends BaseGridEditorCVA<MyRow, string> {}
1137
+ * ```
1138
+ *
1139
+ * > **Note:** Subclasses must still provide `NG_VALUE_ACCESSOR` themselves
1140
+ * > because `forwardRef(() => ConcreteClass)` must reference the concrete
1141
+ * > component — this is an Angular limitation.
1142
+ *
1143
+ * @typeParam TRow - The row data type
1144
+ * @typeParam TValue - The cell/control value type
1145
+ *
1146
+ * @since 0.13.0
1147
+ */
1148
+ class BaseGridEditorCVA extends BaseGridEditor {
1149
+ // ============================================================================
1150
+ // CVA State
1151
+ // ============================================================================
1152
+ /** Internal onChange callback registered by the form control. */
1153
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
1154
+ _onChange = () => { };
1155
+ /** Internal onTouched callback registered by the form control. */
1156
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
1157
+ _onTouched = () => { };
1158
+ /**
1159
+ * Signal holding the value written by the form control via `writeValue()`.
1160
+ * Updated when the form control pushes a new value (e.g. `patchValue`, `setValue`).
1161
+ */
1162
+ cvaValue = signal(null, ...(ngDevMode ? [{ debugName: "cvaValue" }] : /* istanbul ignore next */ []));
1163
+ /**
1164
+ * Signal tracking the disabled state set by the form control.
1165
+ * Updated when `setDisabledState()` is called by Angular's forms module.
1166
+ */
1167
+ disabledState = signal(false, ...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
1168
+ /**
1169
+ * Resolved display value.
1170
+ *
1171
+ * Prefers `currentValue()` (grid context — from `control.value` or `value` input)
1172
+ * and falls back to `cvaValue()` (standalone form context — from `writeValue`).
1173
+ *
1174
+ * Use this in your template instead of reading `currentValue()` directly
1175
+ * so the component works in both grid and standalone form contexts.
1176
+ */
1177
+ displayValue = computed(() => {
1178
+ return this.currentValue() ?? this.cvaValue();
1179
+ }, ...(ngDevMode ? [{ debugName: "displayValue" }] : /* istanbul ignore next */ []));
1180
+ // ============================================================================
1181
+ // ControlValueAccessor Implementation
1182
+ // ============================================================================
1183
+ /**
1184
+ * Called by Angular forms when the form control value changes programmatically.
1185
+ */
1186
+ writeValue(value) {
1187
+ this.cvaValue.set(value);
1188
+ }
1189
+ /**
1190
+ * Called by Angular forms to register a change callback.
1191
+ */
1192
+ registerOnChange(fn) {
1193
+ this._onChange = fn;
1194
+ }
1195
+ /**
1196
+ * Called by Angular forms to register a touched callback.
1197
+ */
1198
+ registerOnTouched(fn) {
1199
+ this._onTouched = fn;
1200
+ }
1201
+ /**
1202
+ * Called by Angular forms to set the disabled state.
1203
+ */
1204
+ setDisabledState(isDisabled) {
1205
+ this.disabledState.set(isDisabled);
1206
+ }
1207
+ // ============================================================================
1208
+ // Dual-Commit Helpers
1209
+ // ============================================================================
1210
+ /**
1211
+ * Commit a value through both the CVA (form control) and the grid.
1212
+ *
1213
+ * - Calls the CVA `onChange` callback (updates the form control)
1214
+ * - Marks the control as touched
1215
+ * - Calls `commitValue()` (emits grid commit event + DOM `CustomEvent`)
1216
+ *
1217
+ * Use this instead of `commitValue()` when your editor doubles as a form control.
1218
+ *
1219
+ * @param value - The new value to commit
1220
+ */
1221
+ commitBoth(value) {
1222
+ // Update CVA
1223
+ this.cvaValue.set(value);
1224
+ this._onChange(value);
1225
+ this._onTouched();
1226
+ // Update grid (null is valid for nullable columns — the grid's cell-commit
1227
+ // handler and EditingPlugin will apply it correctly)
1228
+ this.commitValue(value);
1229
+ }
1230
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseGridEditorCVA, deps: null, target: i0.ɵɵFactoryTarget.Directive });
1231
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.5", type: BaseGridEditorCVA, isStandalone: true, usesInheritance: true, ngImport: i0 });
1232
+ }
1233
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseGridEditorCVA, decorators: [{
1234
+ type: Directive
1235
+ }] });
1236
+
1237
+ // #endregion
1238
+ // #region Global Styles
1239
+ /** Tracks whether the global overlay stylesheet has been injected. */
1240
+ let overlayStylesInjected = false;
1241
+ /**
1242
+ * CSS for the overlay panel base layer.
1243
+ * Injected once into `<head>` on first `BaseOverlayEditor` use.
1244
+ *
1245
+ * Uses CSS Anchor Positioning as primary strategy with a JS fallback
1246
+ * for browsers that don't support it (Firefox, Safari as of late 2025).
1247
+ */
1248
+ const OVERLAY_STYLES = /* css */ `
1249
+ .tbw-overlay-panel {
1250
+ position: fixed;
1251
+ z-index: 10000;
1252
+ background: var(--tbw-overlay-bg, #fff);
1253
+ border: 1px solid var(--tbw-overlay-border, #ccc);
1254
+ border-radius: var(--tbw-overlay-radius, 4px);
1255
+ box-shadow: var(--tbw-overlay-shadow, 0 4px 12px rgba(0, 0, 0, 0.15));
1256
+ box-sizing: border-box;
1257
+ overflow: auto;
1258
+ }
1259
+
1260
+ /* Hide panels that have not been initialised via initOverlay() yet.
1261
+ Prevents a flash-of-unstyled-content when the subclass defers
1262
+ the initOverlay call (e.g. via setTimeout or afterNextRender). */
1263
+ .tbw-overlay-panel:not([data-anchor-id]) {
1264
+ display: none;
1265
+ }
1266
+
1267
+ .tbw-overlay-panel:popover-open {
1268
+ display: block;
1269
+ }
1270
+
1271
+ @supports (anchor-name: --a) {
1272
+ .tbw-overlay-panel[data-anchor-id] {
1273
+ position: fixed;
1274
+ position-anchor: var(--tbw-overlay-anchor);
1275
+ inset: unset;
1276
+ }
1277
+ .tbw-overlay-panel[data-pos="below"] {
1278
+ top: anchor(bottom);
1279
+ left: anchor(left);
1280
+ position-try-fallbacks: flip-block;
1281
+ }
1282
+ .tbw-overlay-panel[data-pos="above"] {
1283
+ bottom: anchor(top);
1284
+ left: anchor(left);
1285
+ position-try-fallbacks: flip-block;
1286
+ }
1287
+ .tbw-overlay-panel[data-pos="below-right"] {
1288
+ top: anchor(bottom);
1289
+ right: anchor(right);
1290
+ position-try-fallbacks: flip-block;
1291
+ }
1292
+ .tbw-overlay-panel[data-pos="over-top-left"] {
1293
+ top: anchor(top);
1294
+ left: anchor(left);
1295
+ }
1296
+ .tbw-overlay-panel[data-pos="over-bottom-left"] {
1297
+ bottom: anchor(bottom);
1298
+ left: anchor(left);
1299
+ }
1300
+ }
1301
+ `;
1302
+ function ensureOverlayStyles() {
1303
+ if (overlayStylesInjected)
1304
+ return;
1305
+ overlayStylesInjected = true;
1306
+ const style = document.createElement('style');
1307
+ style.setAttribute('data-tbw-overlay', '');
1308
+ style.textContent = OVERLAY_STYLES;
1309
+ document.head.appendChild(style);
1310
+ }
1311
+ // #endregion
1312
+ // #region Anchor ID Counter
1313
+ let anchorCounter = 0;
1314
+ // #endregion
1315
+ /**
1316
+ * Base class for grid editors that display a floating overlay panel.
1317
+ *
1318
+ * Provides infrastructure for:
1319
+ * - **Overlay positioning** — CSS Anchor Positioning with JS fallback
1320
+ * - **Focus gating** — in row editing mode, the panel only opens for the focused cell
1321
+ * - **Click-outside detection** — closes the panel when clicking outside
1322
+ * - **MutationObserver** — detects cell focus changes (row editing mode)
1323
+ * - **Escape handling** — closes the panel and returns focus to the inline input
1324
+ * - **Synthetic Tab dispatch** — advances grid focus after overlay close
1325
+ * - **Automatic teardown** — removes the panel from `<body>` and cleans up listeners
1326
+ * - **External focus registration** — auto-registers the panel via `grid.registerExternalFocusContainer()` so the grid keeps `data-has-focus` and editors stay open while the overlay has focus
1327
+ *
1328
+ * ## Usage
1329
+ *
1330
+ * ```typescript
1331
+ * import { Component, viewChild, ElementRef, effect } from '@angular/core';
1332
+ * import { BaseOverlayEditor } from '@toolbox-web/grid-angular/features/editing';
1333
+ *
1334
+ * @Component({
1335
+ * selector: 'app-date-editor',
1336
+ * template: `
1337
+ * <input
1338
+ * #inlineInput
1339
+ * readonly
1340
+ * [value]="currentValue()"
1341
+ * (click)="onInlineClick()"
1342
+ * (keydown)="onInlineKeydown($event)"
1343
+ * />
1344
+ * <div #panel class="tbw-overlay-panel" style="width: 280px;">
1345
+ * <!-- your date picker UI here -->
1346
+ * <div class="actions">
1347
+ * <button (click)="selectAndClose(selectedDate)">OK</button>
1348
+ * <button (click)="hideOverlay()">Cancel</button>
1349
+ * </div>
1350
+ * </div>
1351
+ * `
1352
+ * })
1353
+ * export class DateEditorComponent extends BaseOverlayEditor<MyRow, string> {
1354
+ * panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
1355
+ * inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
1356
+ *
1357
+ * protected override overlayPosition = 'below' as const;
1358
+ *
1359
+ * constructor() {
1360
+ * super();
1361
+ * effect(() => {
1362
+ * const panel = this.panelRef().nativeElement;
1363
+ * this.initOverlay(panel);
1364
+ * if (this.isCellFocused()) this.showOverlay();
1365
+ * });
1366
+ * }
1367
+ *
1368
+ * protected getInlineInput(): HTMLInputElement | null {
1369
+ * return this.inputRef()?.nativeElement ?? null;
1370
+ * }
1371
+ *
1372
+ * protected onOverlayOutsideClick(): void {
1373
+ * this.hideOverlay();
1374
+ * }
1375
+ *
1376
+ * selectAndClose(date: string): void {
1377
+ * this.commitValue(date);
1378
+ * this.hideOverlay();
1379
+ * }
1380
+ * }
1381
+ * ```
1382
+ *
1383
+ * @example
1384
+ * ```typescript
1385
+ * @Component({
1386
+ * template: `
1387
+ * <input #inlineInput readonly [value]="currentValue()"
1388
+ * (click)="onInlineClick()" (keydown)="onInlineKeydown($event)" />
1389
+ * <div #panel style="width: 280px">
1390
+ * <!-- overlay content -->
1391
+ * </div>
1392
+ * `
1393
+ * })
1394
+ * export class MyEditor extends BaseOverlayEditor<MyRow, string> {
1395
+ * panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
1396
+ * inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
1397
+ *
1398
+ * constructor() {
1399
+ * super();
1400
+ * effect(() => this.initOverlay(this.panelRef().nativeElement));
1401
+ * }
1402
+ *
1403
+ * protected getInlineInput() { return this.inputRef()?.nativeElement ?? null; }
1404
+ * protected onOverlayOutsideClick() { this.hideOverlay(); }
1405
+ * }
1406
+ * ```
1407
+ *
1408
+ * @typeParam TRow - The row data type
1409
+ * @typeParam TValue - The cell value type
1410
+ *
1411
+ * @since 0.13.0
1412
+ */
1413
+ class BaseOverlayEditor extends BaseGridEditor {
1414
+ _elementRef = inject(ElementRef);
1415
+ _overlayDestroyRef = inject(DestroyRef);
1416
+ // ============================================================================
1417
+ // Configuration
1418
+ // ============================================================================
1419
+ /**
1420
+ * Position of the overlay panel relative to the anchor cell.
1421
+ * Override in subclasses to change the default position.
1422
+ *
1423
+ * @default 'below'
1424
+ */
1425
+ overlayPosition = 'below';
1426
+ // ============================================================================
1427
+ // Internal State
1428
+ // ============================================================================
1429
+ /** The overlay panel element (set via `initOverlay()`). */
1430
+ _panel = null;
1431
+ /** Whether the overlay is currently visible. */
1432
+ _isOpen = false;
1433
+ /** Unique anchor ID for CSS Anchor Positioning. */
1434
+ _anchorId = '';
1435
+ /** Whether the browser supports CSS Anchor Positioning. */
1436
+ _supportsAnchor = false;
1437
+ /** AbortController for all overlay-related listeners. */
1438
+ _abortCtrl = null;
1439
+ /** MutationObserver watching cell focus class changes. */
1440
+ _focusObserver = null;
1441
+ // ============================================================================
1442
+ // Lifecycle
1443
+ // ============================================================================
1444
+ constructor() {
1445
+ super();
1446
+ this._supportsAnchor = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--a');
1447
+ ensureOverlayStyles();
1448
+ afterNextRender(() => this._setupFocusObserver());
1449
+ this._overlayDestroyRef.onDestroy(() => this.teardownOverlay());
1450
+ }
1451
+ // ============================================================================
1452
+ // Public API — Subclass Interface
1453
+ // ============================================================================
1454
+ /**
1455
+ * Initialise the overlay with the panel element.
1456
+ *
1457
+ * Call this in an `effect()` or `afterNextRender()` with your `viewChild` panel reference.
1458
+ * The panel is moved to `<body>` and hidden until {@link showOverlay} is called.
1459
+ *
1460
+ * @param panel - The overlay panel DOM element
1461
+ */
1462
+ initOverlay(panel) {
1463
+ this._panel = panel;
1464
+ // Assign a unique anchor ID
1465
+ this._anchorId = `tbw-anchor-${++anchorCounter}`;
1466
+ panel.classList.add('tbw-overlay-panel');
1467
+ panel.setAttribute('data-pos', this.overlayPosition);
1468
+ panel.setAttribute('data-anchor-id', this._anchorId);
1469
+ panel.style.display = 'none';
1470
+ // Set up CSS Anchor Positioning on the cell
1471
+ if (this._supportsAnchor) {
1472
+ const cell = this._getCell();
1473
+ if (cell) {
1474
+ cell.style.setProperty('anchor-name', `--${this._anchorId}`);
1475
+ panel.style.setProperty('--tbw-overlay-anchor', `--${this._anchorId}`);
1476
+ }
1477
+ }
1478
+ // Move panel to body so it escapes grid overflow clipping
1479
+ document.body.appendChild(panel);
1480
+ // Register the panel as an external focus container on the grid
1481
+ // so focus moving into the overlay is treated as "still in the grid"
1482
+ this._getGridElement()?.registerExternalFocusContainer?.(panel);
1483
+ // Set up click-outside detection
1484
+ this._abortCtrl = new AbortController();
1485
+ document.addEventListener('pointerdown', (e) => this._onDocumentPointerDown(e), {
1486
+ signal: this._abortCtrl.signal,
1487
+ });
1488
+ // Close overlay when the grid viewport scrolls. Mirrors outside-click
1489
+ // semantics so subclasses' `onOverlayOutsideClick()` drives commit/cancel.
1490
+ // Uses the public `tbw-scroll` CustomEvent dispatched on the grid host —
1491
+ // no shadow-DOM reach-arounds required (composed: true crosses boundaries).
1492
+ const grid = this._getGridElement();
1493
+ if (grid) {
1494
+ grid.addEventListener('tbw-scroll', () => this._onGridScroll(), {
1495
+ signal: this._abortCtrl.signal,
1496
+ });
1497
+ }
1498
+ // If the focus observer already fired before the panel was initialised
1499
+ // (e.g. initOverlay called from a deferred setTimeout), the showOverlay()
1500
+ // call was silently ignored because _panel was still null. Catch up now.
1501
+ if (this._getCell()?.classList.contains('cell-focus')) {
1502
+ this.showOverlay();
1503
+ this.onOverlayOpened();
1504
+ }
1505
+ }
1506
+ /**
1507
+ * Show the overlay panel.
1508
+ *
1509
+ * If CSS Anchor Positioning is not supported, falls back to JS-based
1510
+ * positioning using `getBoundingClientRect()`.
1511
+ */
1512
+ showOverlay() {
1513
+ if (!this._panel || this._isOpen)
1514
+ return;
1515
+ this._isOpen = true;
1516
+ this._panel.style.display = '';
1517
+ // JS fallback positioning for browsers without CSS Anchor Positioning
1518
+ if (!this._supportsAnchor) {
1519
+ this._positionWithJs();
1520
+ }
1521
+ }
1522
+ /**
1523
+ * Hide the overlay panel.
1524
+ *
1525
+ * @param suppressTabAdvance - When `true`, skip synthetic Tab dispatch
1526
+ * (useful when hiding is triggered by an external focus change).
1527
+ */
1528
+ hideOverlay(suppressTabAdvance) {
1529
+ if (!this._panel || !this._isOpen)
1530
+ return;
1531
+ this._isOpen = false;
1532
+ this._panel.style.display = 'none';
1533
+ if (!suppressTabAdvance) {
1534
+ this.getInlineInput()?.focus();
1535
+ }
1536
+ }
1537
+ /**
1538
+ * Close and immediately re-open the overlay.
1539
+ * Useful after the panel content changes size and needs repositioning.
1540
+ */
1541
+ reopenOverlay() {
1542
+ if (!this._panel)
1543
+ return;
1544
+ this._isOpen = false;
1545
+ this._panel.style.display = 'none';
1546
+ this.showOverlay();
1547
+ }
1548
+ /**
1549
+ * Remove the overlay from the DOM and clean up all listeners.
1550
+ *
1551
+ * Called automatically on `DestroyRef.onDestroy`. Can also be called
1552
+ * manually if the editor needs early cleanup.
1553
+ */
1554
+ teardownOverlay() {
1555
+ this._abortCtrl?.abort();
1556
+ this._abortCtrl = null;
1557
+ this._focusObserver?.disconnect();
1558
+ this._focusObserver = null;
1559
+ // Unregister the panel from the grid's external focus container registry
1560
+ if (this._panel) {
1561
+ this._getGridElement()?.unregisterExternalFocusContainer?.(this._panel);
1562
+ }
1563
+ if (this._panel?.parentNode) {
1564
+ this._panel.parentNode.removeChild(this._panel);
1565
+ }
1566
+ this._panel = null;
1567
+ this._isOpen = false;
1568
+ // Clean up anchor-name on the cell
1569
+ if (this._supportsAnchor) {
1570
+ const cell = this._getCell();
1571
+ if (cell) {
1572
+ cell.style.removeProperty('anchor-name');
1573
+ }
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Override in `edit-close` handler to also hide the overlay.
1578
+ * This is called automatically by `BaseGridEditor` when the grid
1579
+ * ends the editing session.
1580
+ */
1581
+ onEditClose() {
1582
+ this.hideOverlay(true);
1583
+ }
1584
+ // ============================================================================
1585
+ // Keyboard & Click Helpers
1586
+ // ============================================================================
1587
+ /**
1588
+ * Keydown handler for the inline readonly input.
1589
+ *
1590
+ * - **Enter / Space / ArrowDown / F2** → open overlay
1591
+ * - **Escape** → calls {@link handleEscape}
1592
+ *
1593
+ * Bind this to `(keydown)` on your inline input element.
1594
+ */
1595
+ onInlineKeydown(event) {
1596
+ switch (event.key) {
1597
+ case 'Enter':
1598
+ case ' ':
1599
+ case 'ArrowDown':
1600
+ case 'F2':
1601
+ event.preventDefault();
1602
+ this.showOverlay();
1603
+ this.onOverlayOpened();
1604
+ break;
1605
+ case 'Escape':
1606
+ this.handleEscape(event);
1607
+ break;
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Click handler for the inline input.
1612
+ * Opens the overlay and calls {@link onOverlayOpened}.
1613
+ *
1614
+ * Bind this to `(click)` on your inline input element.
1615
+ */
1616
+ onInlineClick() {
1617
+ if (this._isOpen) {
1618
+ this.hideOverlay();
1619
+ }
1620
+ else {
1621
+ this.showOverlay();
1622
+ this.onOverlayOpened();
1623
+ }
1624
+ }
1625
+ /**
1626
+ * Handle Escape key press.
1627
+ *
1628
+ * If the overlay is open, closes it and returns focus to the inline input.
1629
+ * If the overlay is already closed, cancels the edit entirely.
1630
+ */
1631
+ handleEscape(event) {
1632
+ if (this._isOpen) {
1633
+ event.stopPropagation();
1634
+ this.hideOverlay();
1635
+ }
1636
+ else {
1637
+ this.cancelEdit();
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Dispatch a synthetic Tab key event to advance grid focus.
1642
+ *
1643
+ * Call this after committing a value and closing the overlay so the
1644
+ * grid moves focus to the next cell.
1645
+ *
1646
+ * @param backward - When `true`, dispatch Shift+Tab to move backwards.
1647
+ */
1648
+ advanceGridFocus(backward = false) {
1649
+ const cell = this._getCell();
1650
+ if (!cell)
1651
+ return;
1652
+ cell.dispatchEvent(new KeyboardEvent('keydown', {
1653
+ key: 'Tab',
1654
+ shiftKey: backward,
1655
+ bubbles: true,
1656
+ cancelable: true,
1657
+ }));
1658
+ }
1659
+ /**
1660
+ * Called after the overlay is shown.
1661
+ *
1662
+ * Override to focus an element inside the panel, start animations, etc.
1663
+ * Default implementation is a no-op.
1664
+ */
1665
+ onOverlayOpened() {
1666
+ // Default: no-op. Subclasses override.
1667
+ }
1668
+ // ============================================================================
1669
+ // Private Helpers
1670
+ // ============================================================================
1671
+ /** Find the parent cell element for this editor. */
1672
+ _getCell() {
1673
+ return this._elementRef.nativeElement.closest('[part="cell"]') ?? null;
1674
+ }
1675
+ /** Find the parent `<tbw-grid>` element for this editor. */
1676
+ _getGridElement() {
1677
+ return this._elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]') ?? null;
1678
+ }
1679
+ /**
1680
+ * JS fallback positioning for browsers without CSS Anchor Positioning.
1681
+ * Uses `getBoundingClientRect()` with viewport overflow detection.
1682
+ */
1683
+ _positionWithJs() {
1684
+ const cell = this._getCell();
1685
+ const panel = this._panel;
1686
+ if (!cell || !panel)
1687
+ return;
1688
+ const cellRect = cell.getBoundingClientRect();
1689
+ // Temporarily make visible to measure
1690
+ panel.style.visibility = 'hidden';
1691
+ panel.style.display = '';
1692
+ const panelRect = panel.getBoundingClientRect();
1693
+ panel.style.visibility = '';
1694
+ const viewportH = window.innerHeight;
1695
+ const viewportW = window.innerWidth;
1696
+ let top;
1697
+ let left;
1698
+ switch (this.overlayPosition) {
1699
+ case 'above': {
1700
+ top = cellRect.top - panelRect.height;
1701
+ left = cellRect.left;
1702
+ // Flip to below if off-screen
1703
+ if (top < 0)
1704
+ top = cellRect.bottom;
1705
+ break;
1706
+ }
1707
+ case 'below-right': {
1708
+ top = cellRect.bottom;
1709
+ left = cellRect.right - panelRect.width;
1710
+ // Flip to above if off-screen
1711
+ if (top + panelRect.height > viewportH)
1712
+ top = cellRect.top - panelRect.height;
1713
+ break;
1714
+ }
1715
+ case 'over-top-left': {
1716
+ top = cellRect.top;
1717
+ left = cellRect.left;
1718
+ break;
1719
+ }
1720
+ case 'over-bottom-left': {
1721
+ top = cellRect.bottom - panelRect.height;
1722
+ left = cellRect.left;
1723
+ break;
1724
+ }
1725
+ case 'below':
1726
+ default: {
1727
+ top = cellRect.bottom;
1728
+ left = cellRect.left;
1729
+ // Flip to above if off-screen
1730
+ if (top + panelRect.height > viewportH)
1731
+ top = cellRect.top - panelRect.height;
1732
+ break;
1733
+ }
1734
+ }
1735
+ // Clamp to viewport
1736
+ if (left + panelRect.width > viewportW)
1737
+ left = viewportW - panelRect.width - 4;
1738
+ if (left < 0)
1739
+ left = 4;
1740
+ if (top < 0)
1741
+ top = 4;
1742
+ panel.style.top = `${top}px`;
1743
+ panel.style.left = `${left}px`;
1744
+ }
1745
+ /**
1746
+ * Document pointerdown handler for click-outside detection.
1747
+ * Fires `onOverlayOutsideClick()` if the click is outside the panel
1748
+ * and outside the editor's host element.
1749
+ */
1750
+ _onDocumentPointerDown(event) {
1751
+ if (!this._isOpen || !this._panel)
1752
+ return;
1753
+ const target = event.target;
1754
+ const hostEl = this._elementRef.nativeElement;
1755
+ // Click inside panel or host — ignore
1756
+ if (this._panel.contains(target) || hostEl.contains(target))
1757
+ return;
1758
+ this.onOverlayOutsideClick();
1759
+ }
1760
+ /**
1761
+ * Handle the grid's `tbw-scroll` CustomEvent while the overlay is open.
1762
+ *
1763
+ * When the viewport scrolls, the anchored overlay would otherwise float
1764
+ * over content that no longer corresponds to the edited cell (or the
1765
+ * cell may be virtualized out entirely). Treat scroll as a dismissal
1766
+ * signal equivalent to clicking outside so subclasses' overlay-outside
1767
+ * handler drives the appropriate commit/cancel path.
1768
+ */
1769
+ _onGridScroll() {
1770
+ if (!this._isOpen || !this._panel)
1771
+ return;
1772
+ this.onOverlayOutsideClick();
1773
+ }
1774
+ /**
1775
+ * Set up a MutationObserver on the parent cell to watch for
1776
+ * `cell-focus` class changes. This handles row-editing mode where
1777
+ * all editors exist simultaneously but only the focused cell's
1778
+ * editor should have its overlay visible.
1779
+ *
1780
+ * A `justOpened` flash guard suppresses the observer from
1781
+ * immediately closing the overlay when `beginBulkEdit()` moves
1782
+ * focus to the first editable column. Without this guard,
1783
+ * double-click triggers a "flash open then close" effect.
1784
+ */
1785
+ _setupFocusObserver() {
1786
+ const cell = this._getCell();
1787
+ if (!cell)
1788
+ return;
1789
+ let justOpened = false;
1790
+ let pendingHideRaf = 0;
1791
+ const hostEl = this._elementRef.nativeElement;
1792
+ this._focusObserver = new MutationObserver((mutations) => {
1793
+ // Guard: if the editor's host element is no longer inside the cell,
1794
+ // it means the component was detached (e.g., editing session ended
1795
+ // and the render pipeline cleared the cell DOM). Disconnect to
1796
+ // prevent orphaned observers from opening stale overlay panels.
1797
+ if (!cell.contains(hostEl)) {
1798
+ this._focusObserver?.disconnect();
1799
+ this._focusObserver = null;
1800
+ return;
1801
+ }
1802
+ for (const mutation of mutations) {
1803
+ if (mutation.type !== 'attributes' || mutation.attributeName !== 'class')
1804
+ continue;
1805
+ const isFocused = cell.classList.contains('cell-focus');
1806
+ if (isFocused && !this._isOpen) {
1807
+ // Cell just gained focus — cancel any pending hide and open overlay.
1808
+ if (pendingHideRaf) {
1809
+ cancelAnimationFrame(pendingHideRaf);
1810
+ pendingHideRaf = 0;
1811
+ }
1812
+ justOpened = true;
1813
+ this.showOverlay();
1814
+ this.onOverlayOpened();
1815
+ // Clear the guard after a macrotask so that an immediate
1816
+ // focus-away (e.g. beginBulkEdit focus adjustment) does
1817
+ // not close the overlay in the same event loop tick.
1818
+ setTimeout(() => {
1819
+ justOpened = false;
1820
+ }, 0);
1821
+ }
1822
+ else if (!isFocused && this._isOpen && !justOpened) {
1823
+ // Cell lost focus — defer hide to allow render cycles to settle.
1824
+ // Re-renders (e.g., from ResizeObserver after a footer appears)
1825
+ // may transiently toggle cell-focus within the same frame.
1826
+ // Deferring to the next animation frame lets the render pipeline
1827
+ // finish before we decide whether the overlay should actually close.
1828
+ if (pendingHideRaf)
1829
+ cancelAnimationFrame(pendingHideRaf);
1830
+ pendingHideRaf = requestAnimationFrame(() => {
1831
+ pendingHideRaf = 0;
1832
+ // Re-check settled state — cell-focus may have been re-applied
1833
+ if (!cell.classList.contains('cell-focus') && this._isOpen) {
1834
+ this.hideOverlay(true);
1835
+ }
1836
+ });
1837
+ }
1838
+ }
1839
+ });
1840
+ this._focusObserver.observe(cell, { attributes: true, attributeFilter: ['class'] });
1841
+ }
1842
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseOverlayEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1843
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.5", type: BaseOverlayEditor, isStandalone: true, usesInheritance: true, ngImport: i0 });
1844
+ }
1845
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseOverlayEditor, decorators: [{
1846
+ type: Directive
1847
+ }], ctorParameters: () => [] });
1848
+
1849
+ // Symbol for storing form context on the grid element (shared with GridFormArray)
1850
+ const FORM_ARRAY_CONTEXT = Symbol('formArrayContext');
1851
+ /**
1852
+ * Gets the FormArrayContext from a grid element, if present.
1853
+ * @internal
1854
+ * @since 0.11.0
1855
+ */
1856
+ function getLazyFormContext(gridElement) {
1857
+ return gridElement[FORM_ARRAY_CONTEXT];
1858
+ }
1859
+ /**
1860
+ * Directive that provides lazy FormGroup creation for grid editing.
1861
+ *
1862
+ * Unlike `GridFormArray` which creates all FormGroups upfront, this directive
1863
+ * creates FormGroups on-demand only when a row enters edit mode. This provides
1864
+ * much better performance for large datasets while still enabling full
1865
+ * Angular Reactive Forms integration.
1866
+ *
1867
+ * ## Key Benefits
1868
+ *
1869
+ * - **Performance**: Only creates FormGroups for rows being edited (20-100x fewer controls)
1870
+ * - **Same DX**: Editors still receive `control` in their context for validation
1871
+ * - **Memory efficient**: FormGroups are cleaned up when rows exit edit mode
1872
+ *
1873
+ * ## Usage
1874
+ *
1875
+ * ```typescript
1876
+ * import { Component, inject, signal } from '@angular/core';
1877
+ * import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
1878
+ * import { Grid, GridLazyForm, TbwEditor } from '@toolbox-web/grid-angular/features/editing';
1879
+ *
1880
+ * @Component({
1881
+ * imports: [Grid, GridLazyForm, TbwEditor, ReactiveFormsModule],
1882
+ * template: \`
1883
+ * <tbw-grid
1884
+ * [rows]="employees()"
1885
+ * [lazyForm]="createRowForm"
1886
+ * [gridConfig]="config">
1887
+ *
1888
+ * <tbw-grid-column field="firstName">
1889
+ * <input *tbwEditor="let _; control as ctrl"
1890
+ * [formControl]="ctrl"
1891
+ * [class.is-invalid]="ctrl?.invalid && ctrl?.touched" />
1892
+ * </tbw-grid-column>
1893
+ * </tbw-grid>
1894
+ * \`
1895
+ * })
1896
+ * export class MyComponent {
1897
+ * private fb = inject(FormBuilder);
1898
+ * employees = signal(generateEmployees(1000));
1899
+ *
1900
+ * // Factory called when editing starts - only include editable fields!
1901
+ * createRowForm = (row: Employee): FormGroup => this.fb.group({
1902
+ * firstName: [row.firstName, Validators.required],
1903
+ * lastName: [row.lastName, Validators.minLength(2)],
1904
+ * salary: [row.salary, [Validators.required, Validators.min(0)]],
1905
+ * });
1906
+ *
1907
+ * gridConfig = { columns: [...] };
1908
+ * }
1909
+ * ```
1910
+ *
1911
+ * ## How It Works
1912
+ *
1913
+ * 1. Rows come from `[rows]` input (plain data array)
1914
+ * 2. When a cell enters edit mode, the FormGroup is created lazily
1915
+ * 3. Editors receive the FormControl in their template context
1916
+ * 4. On commit, FormGroup values are synced back to the row
1917
+ * 5. FormGroup is cleaned up when the row exits edit mode (configurable)
1918
+ *
1919
+ * ## Performance Comparison
1920
+ *
1921
+ * | Rows | GridFormArray (20 fields) | GridLazyForm |
1922
+ * |------|---------------------------|--------------|
1923
+ * | 100 | 2,000 controls | ~20 controls |
1924
+ * | 500 | 10,000 controls | ~20 controls |
1925
+ * | 1000 | 20,000 controls | ~20 controls |
1926
+ *
1927
+ * @example
1928
+ * ```html
1929
+ * <tbw-grid [rows]="employees()" [lazyForm]="createRowForm" [gridConfig]="gridConfig" />
1930
+ * ```
1931
+ *
1932
+ * @see GridFormArray For small datasets with full upfront validation
1933
+ * @category Directive
1934
+ *
1935
+ * @since 0.11.0
1936
+ */
1937
+ class GridLazyForm {
1938
+ elementRef = inject((ElementRef));
1939
+ // Cache of FormGroups by row reference
1940
+ formGroupCache = new Map();
1941
+ // Map from row reference to rowIndex (needed for getControl)
1942
+ rowIndexMap = new Map();
1943
+ // Track which row is currently being edited
1944
+ editingRowIndex = null;
1945
+ cellCommitUnsub = null;
1946
+ rowCommitUnsub = null;
1947
+ rowsChangeUnsub = null;
1948
+ /**
1949
+ * Factory function to create a FormGroup for a row.
1950
+ * Called lazily when the row first enters edit mode.
1951
+ *
1952
+ * @example
1953
+ * ```typescript
1954
+ * createRowForm = (row: Employee): FormGroup => this.fb.group({
1955
+ * firstName: [row.firstName, Validators.required],
1956
+ * lastName: [row.lastName],
1957
+ * salary: [row.salary, [Validators.min(0)]],
1958
+ * });
1959
+ * ```
1960
+ */
1961
+ lazyForm = input.required(...(ngDevMode ? [{ debugName: "lazyForm" }] : /* istanbul ignore next */ []));
1962
+ /**
1963
+ * Whether to automatically sync Angular validation state to grid's visual invalid styling.
1964
+ *
1965
+ * When enabled:
1966
+ * - After a cell commit, if the FormControl is invalid, the cell is marked with `setInvalid()`
1967
+ * - When a FormControl becomes valid, `clearInvalid()` is called
1968
+ * - On `row-commit`, if the row's FormGroup has invalid controls, the commit is prevented
1969
+ *
1970
+ * @default true
1971
+ */
1972
+ syncValidation = input(true, ...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
1973
+ /**
1974
+ * Whether to keep FormGroups cached after a row exits edit mode.
1975
+ *
1976
+ * - `true`: FormGroups are kept, preserving dirty/touched state across edit sessions
1977
+ * - `false`: FormGroups are disposed when the row exits edit mode (default)
1978
+ *
1979
+ * @default false
1980
+ */
1981
+ keepFormGroups = input(false, ...(ngDevMode ? [{ debugName: "keepFormGroups" }] : /* istanbul ignore next */ []));
1982
+ /**
1983
+ * Emitted when a row's form values change.
1984
+ * Useful for auto-save, validation display, or syncing to external state.
1985
+ */
1986
+ rowFormChange = output();
1987
+ ngOnInit() {
1988
+ const grid = this.elementRef.nativeElement;
1989
+ if (!grid)
1990
+ return;
1991
+ // Store the form context for AngularGridAdapter to access
1992
+ this.#storeFormContext(grid);
1993
+ // Listen for cell-commit to update FormControl and sync validation
1994
+ this.cellCommitUnsub = grid.on('cell-commit', (detail) => {
1995
+ this.#handleCellCommit(detail);
1996
+ });
1997
+ // Listen for row-commit to sync FormGroup values back to row and cleanup
1998
+ this.rowCommitUnsub = grid.on('row-commit', (detail, event) => {
1999
+ this.#handleRowCommit(event, detail);
2000
+ });
2001
+ // Listen for rows-change to update row index mappings
2002
+ this.rowsChangeUnsub = grid.on('rows-change', () => {
2003
+ this.#updateRowIndexMap();
2004
+ });
2005
+ // Initial row index mapping
2006
+ this.#updateRowIndexMap();
2007
+ }
2008
+ ngOnDestroy() {
2009
+ const grid = this.elementRef.nativeElement;
2010
+ if (!grid)
2011
+ return;
2012
+ this.cellCommitUnsub?.();
2013
+ this.rowCommitUnsub?.();
2014
+ this.rowsChangeUnsub?.();
2015
+ this.#clearFormContext(grid);
2016
+ this.formGroupCache.clear();
2017
+ this.rowIndexMap.clear();
2018
+ }
2019
+ // #region FormArrayContext Implementation
2020
+ /**
2021
+ * Gets or creates the FormGroup for a row.
2022
+ * This is the core lazy initialization logic.
2023
+ */
2024
+ #getOrCreateFormGroup(row, rowIndex) {
2025
+ let formGroup = this.formGroupCache.get(row);
2026
+ if (!formGroup) {
2027
+ const factory = this.lazyForm();
2028
+ formGroup = factory(row, rowIndex);
2029
+ this.formGroupCache.set(row, formGroup);
2030
+ this.rowIndexMap.set(row, rowIndex);
2031
+ }
2032
+ return formGroup;
2033
+ }
2034
+ /**
2035
+ * Gets the FormGroup for a row if it exists (without creating).
2036
+ */
2037
+ #getRowFormGroup(rowIndex) {
2038
+ const grid = this.elementRef.nativeElement;
2039
+ const rows = grid?.rows;
2040
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2041
+ return undefined;
2042
+ const row = rows[rowIndex];
2043
+ return this.formGroupCache.get(row);
2044
+ }
2045
+ /**
2046
+ * Stores the FormArrayContext on the grid element.
2047
+ * This uses the same interface as GridFormArray for compatibility.
2048
+ */
2049
+ #storeFormContext(grid) {
2050
+ const context = {
2051
+ getRow: (rowIndex) => {
2052
+ const rows = grid.rows;
2053
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2054
+ return null;
2055
+ return rows[rowIndex];
2056
+ },
2057
+ updateField: (rowIndex, field, value) => {
2058
+ const formGroup = this.#getRowFormGroup(rowIndex);
2059
+ if (formGroup) {
2060
+ const control = formGroup.get(field);
2061
+ if (control) {
2062
+ control.setValue(value);
2063
+ control.markAsDirty();
2064
+ }
2065
+ }
2066
+ },
2067
+ getValue: () => {
2068
+ return (grid.rows ?? []);
2069
+ },
2070
+ // Always true for lazy forms - we create FormGroups on demand
2071
+ hasFormGroups: true,
2072
+ getControl: (rowIndex, field) => {
2073
+ const rows = grid.rows;
2074
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2075
+ return undefined;
2076
+ const row = rows[rowIndex];
2077
+ // LAZY: Create the FormGroup when first needed
2078
+ const formGroup = this.#getOrCreateFormGroup(row, rowIndex);
2079
+ return formGroup.get(field) ?? undefined;
2080
+ },
2081
+ getRowFormGroup: (rowIndex) => {
2082
+ const rows = grid.rows;
2083
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2084
+ return undefined;
2085
+ const row = rows[rowIndex];
2086
+ // LAZY: Create the FormGroup when first needed
2087
+ return this.#getOrCreateFormGroup(row, rowIndex);
2088
+ },
2089
+ isRowValid: (rowIndex) => {
2090
+ const formGroup = this.#getRowFormGroup(rowIndex);
2091
+ // If no FormGroup exists yet, consider it valid (not edited)
2092
+ if (!formGroup)
2093
+ return true;
2094
+ return formGroup.valid;
2095
+ },
2096
+ isRowTouched: (rowIndex) => {
2097
+ const formGroup = this.#getRowFormGroup(rowIndex);
2098
+ if (!formGroup)
2099
+ return false;
2100
+ return formGroup.touched;
2101
+ },
2102
+ isRowDirty: (rowIndex) => {
2103
+ const formGroup = this.#getRowFormGroup(rowIndex);
2104
+ if (!formGroup)
2105
+ return false;
2106
+ return formGroup.dirty;
2107
+ },
2108
+ getRowErrors: (rowIndex) => {
2109
+ const formGroup = this.#getRowFormGroup(rowIndex);
2110
+ if (!formGroup)
2111
+ return null;
2112
+ const errors = {};
2113
+ let hasErrors = false;
2114
+ Object.keys(formGroup.controls).forEach((field) => {
2115
+ const control = formGroup.get(field);
2116
+ if (control?.errors) {
2117
+ errors[field] = control.errors;
2118
+ hasErrors = true;
2119
+ }
2120
+ });
2121
+ if (formGroup.errors) {
2122
+ errors['_group'] = formGroup.errors;
2123
+ hasErrors = true;
2124
+ }
2125
+ return hasErrors ? errors : null;
2126
+ },
2127
+ };
2128
+ grid[FORM_ARRAY_CONTEXT] = context;
2129
+ }
2130
+ /**
2131
+ * Clears the FormArrayContext from the grid element.
2132
+ */
2133
+ #clearFormContext(grid) {
2134
+ delete grid[FORM_ARRAY_CONTEXT];
2135
+ }
2136
+ // #endregion
2137
+ // #region Event Handlers
2138
+ /**
2139
+ * Updates the row index map when rows change.
2140
+ * This ensures we can find FormGroups by row reference after sorting/filtering.
2141
+ */
2142
+ #updateRowIndexMap() {
2143
+ const grid = this.elementRef.nativeElement;
2144
+ const rows = grid?.rows;
2145
+ if (!rows)
2146
+ return;
2147
+ this.rowIndexMap.clear();
2148
+ rows.forEach((row, index) => {
2149
+ if (this.formGroupCache.has(row)) {
2150
+ this.rowIndexMap.set(row, index);
2151
+ }
2152
+ });
2153
+ }
2154
+ /**
2155
+ * Handles cell-commit events by updating the FormControl in the FormGroup.
2156
+ */
2157
+ #handleCellCommit(detail) {
2158
+ const { rowIndex, field, value, rowId } = detail;
2159
+ const formGroup = this.#getRowFormGroup(rowIndex);
2160
+ if (formGroup) {
2161
+ const control = formGroup.get(field);
2162
+ if (control) {
2163
+ control.setValue(value);
2164
+ control.markAsDirty();
2165
+ control.markAsTouched();
2166
+ // Sync Angular validation state to grid's visual invalid styling
2167
+ if (this.syncValidation() && rowId) {
2168
+ this.#syncControlValidationToGrid(rowId, field, control);
2169
+ }
2170
+ // Emit change event
2171
+ const grid = this.elementRef.nativeElement;
2172
+ const row = grid?.rows?.[rowIndex];
2173
+ if (row) {
2174
+ this.rowFormChange.emit({
2175
+ rowIndex,
2176
+ rowId,
2177
+ row,
2178
+ formGroup,
2179
+ values: formGroup.value,
2180
+ valid: formGroup.valid,
2181
+ dirty: formGroup.dirty,
2182
+ });
2183
+ }
2184
+ }
2185
+ }
2186
+ }
2187
+ /**
2188
+ * Handles row-commit events.
2189
+ * - Prevents commit if FormGroup is invalid (when syncValidation is true)
2190
+ * - Syncs FormGroup values back to the row
2191
+ * - Cleans up FormGroup if keepFormGroups is false
2192
+ */
2193
+ #handleRowCommit(event, detail) {
2194
+ const { rowIndex, rowId } = detail;
2195
+ const grid = this.elementRef.nativeElement;
2196
+ const rows = grid?.rows;
2197
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2198
+ return;
2199
+ const row = rows[rowIndex];
2200
+ const formGroup = this.formGroupCache.get(row);
2201
+ if (!formGroup)
2202
+ return;
2203
+ // Prevent commit if invalid (when syncValidation is enabled)
2204
+ if (this.syncValidation() && formGroup.invalid) {
2205
+ // Mark all controls as touched to show validation errors
2206
+ formGroup.markAllAsTouched();
2207
+ event.preventDefault();
2208
+ return;
2209
+ }
2210
+ // Sync FormGroup values back to the row object
2211
+ if (formGroup.dirty) {
2212
+ const formValue = formGroup.value;
2213
+ Object.keys(formValue).forEach((field) => {
2214
+ if (field in row) {
2215
+ row[field] = formValue[field];
2216
+ }
2217
+ });
2218
+ }
2219
+ // Clean up FormGroup if not keeping them
2220
+ if (!this.keepFormGroups()) {
2221
+ this.formGroupCache.delete(row);
2222
+ this.rowIndexMap.delete(row);
2223
+ // Clear any validation state in the grid
2224
+ if (rowId) {
2225
+ const editingPlugin = grid.getPluginByName?.('editing');
2226
+ editingPlugin?.clearRowInvalid(rowId);
2227
+ }
2228
+ }
2229
+ }
2230
+ // #endregion
2231
+ // #region Validation Sync
2232
+ /**
2233
+ * Syncs a FormControl's validation state to the grid's visual invalid styling.
2234
+ */
2235
+ #syncControlValidationToGrid(rowId, field, control) {
2236
+ const grid = this.elementRef.nativeElement;
2237
+ if (!grid)
2238
+ return;
2239
+ const editingPlugin = grid.getPluginByName?.('editing');
2240
+ if (!editingPlugin)
2241
+ return;
2242
+ if (control.invalid) {
2243
+ const errorMessage = this.#getFirstErrorMessage(control);
2244
+ editingPlugin.setInvalid(rowId, field, errorMessage);
2245
+ }
2246
+ else {
2247
+ editingPlugin.clearInvalid(rowId, field);
2248
+ }
2249
+ }
2250
+ /**
2251
+ * Gets a human-readable error message from the first validation error.
2252
+ */
2253
+ #getFirstErrorMessage(control) {
2254
+ const errors = control.errors;
2255
+ if (!errors)
2256
+ return '';
2257
+ const firstKey = Object.keys(errors)[0];
2258
+ const error = errors[firstKey];
2259
+ switch (firstKey) {
2260
+ case 'required':
2261
+ return 'This field is required';
2262
+ case 'minlength':
2263
+ return `Minimum length is ${error.requiredLength}`;
2264
+ case 'maxlength':
2265
+ return `Maximum length is ${error.requiredLength}`;
2266
+ case 'min':
2267
+ return `Minimum value is ${error.min}`;
2268
+ case 'max':
2269
+ return `Maximum value is ${error.max}`;
2270
+ case 'email':
2271
+ return 'Invalid email address';
2272
+ case 'pattern':
2273
+ return 'Invalid format';
2274
+ default:
2275
+ return typeof error === 'string' ? error : (error?.message ?? `Validation error: ${firstKey}`);
2276
+ }
2277
+ }
2278
+ // #endregion
2279
+ // #region Public API
2280
+ /**
2281
+ * Gets the FormGroup for a row, if it exists.
2282
+ * Unlike the context methods, this does NOT create a FormGroup lazily.
2283
+ *
2284
+ * @param rowIndex The row index
2285
+ * @returns The FormGroup or undefined
2286
+ */
2287
+ getFormGroup(rowIndex) {
2288
+ return this.#getRowFormGroup(rowIndex);
2289
+ }
2290
+ /**
2291
+ * Gets all cached FormGroups.
2292
+ * Useful for bulk validation or inspection.
2293
+ *
2294
+ * @returns Map of row objects to their FormGroups
2295
+ */
2296
+ getAllFormGroups() {
2297
+ return this.formGroupCache;
2298
+ }
2299
+ /**
2300
+ * Clears all cached FormGroups.
2301
+ * Useful when the underlying data changes significantly.
2302
+ */
2303
+ clearAllFormGroups() {
2304
+ this.formGroupCache.clear();
2305
+ this.rowIndexMap.clear();
2306
+ }
2307
+ /**
2308
+ * Validates all currently cached FormGroups.
2309
+ *
2310
+ * @returns true if all FormGroups are valid, false otherwise
2311
+ */
2312
+ validateAll() {
2313
+ for (const formGroup of this.formGroupCache.values()) {
2314
+ if (formGroup.invalid) {
2315
+ formGroup.markAllAsTouched();
2316
+ return false;
2317
+ }
2318
+ }
2319
+ return true;
2320
+ }
2321
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridLazyForm, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2322
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: GridLazyForm, isStandalone: true, selector: "tbw-grid[lazyForm]", inputs: { lazyForm: { classPropertyName: "lazyForm", publicName: "lazyForm", isSignal: true, isRequired: true, transformFunction: null }, syncValidation: { classPropertyName: "syncValidation", publicName: "syncValidation", isSignal: true, isRequired: false, transformFunction: null }, keepFormGroups: { classPropertyName: "keepFormGroups", publicName: "keepFormGroups", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { rowFormChange: "rowFormChange" }, ngImport: i0 });
2323
+ }
2324
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridLazyForm, decorators: [{
2325
+ type: Directive,
2326
+ args: [{
2327
+ selector: 'tbw-grid[lazyForm]',
2328
+ }]
2329
+ }], propDecorators: { lazyForm: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazyForm", required: true }] }], syncValidation: [{ type: i0.Input, args: [{ isSignal: true, alias: "syncValidation", required: false }] }], keepFormGroups: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepFormGroups", required: false }] }], rowFormChange: [{ type: i0.Output, args: ["rowFormChange"] }] } });
2330
+
99
2331
  /**
100
2332
  * Editing feature for @toolbox-web/grid-angular
101
2333
  *
@@ -116,6 +2348,87 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
116
2348
  *
117
2349
  * @packageDocumentation
118
2350
  */
2351
+ // ---------------------------------------------------------------------------
2352
+ // Install the template-based editor bridge. The grid adapter delegates
2353
+ // `createEditor()` / `canHandle()` to this function, which resolves the
2354
+ // Angular editor template (`*tbwEditor` or nested `<tbw-grid-column-editor>`),
2355
+ // wires the FormArray control (if the grid is bound to a FormArray), and
2356
+ // mounts the template via the adapter's generic editor-view primitive.
2357
+ // ---------------------------------------------------------------------------
2358
+ registerEditorSpecBridge((element, adapter) => {
2359
+ // Resolve the editor template: structural `*tbwEditor` first, then the
2360
+ // nested `<tbw-grid-column-editor>` directive. The cast target is derived
2361
+ // from `getStructuralEditorTemplate` (same package instance as the adapter),
2362
+ // NOT from a local `import type { TemplateRef } from '@angular/core'` — the
2363
+ // latter resolves through Bun's `.bun/` cache during ng-packagr build and
2364
+ // produces a brand-incompatible `TemplateRef` vs the built adapter `.d.ts`
2365
+ // (see the shell feature entry for the same guard). `StructuralEditorContext`
2366
+ // uses `any` type defaults, which preserves assignability with the generic
2367
+ // `GridEditorContext<TValue, TRow>` at the `createEditorTemplateView` call.
2368
+ const template = (getStructuralEditorTemplate(element) ?? getEditorTemplate(element));
2369
+ if (!template) {
2370
+ // No template registered — let the grid use its built-in editors. This
2371
+ // allows columns with only `*tbwRenderer` (no `*tbwEditor`) to remain
2372
+ // editable via the default text/number/boolean editors.
2373
+ return undefined;
2374
+ }
2375
+ // Find the parent grid element for FormArray context access.
2376
+ const gridElement = element.closest('tbw-grid, [data-tbw-grid]');
2377
+ return (ctx) => {
2378
+ const onCommit = (value) => ctx.commit(value);
2379
+ const onCancel = () => ctx.cancel();
2380
+ // Resolve the FormControl from the FormArrayContext, if available.
2381
+ let control;
2382
+ if (gridElement) {
2383
+ const formContext = getFormArrayContext(gridElement);
2384
+ if (formContext?.hasFormGroups) {
2385
+ const gridRows = gridElement.rows;
2386
+ if (gridRows) {
2387
+ const rowIndex = gridRows.indexOf(ctx.row);
2388
+ if (rowIndex >= 0) {
2389
+ control = formContext.getControl(rowIndex, ctx.field);
2390
+ }
2391
+ }
2392
+ }
2393
+ }
2394
+ const context = {
2395
+ $implicit: ctx.value,
2396
+ value: ctx.value,
2397
+ row: ctx.row,
2398
+ field: ctx.field,
2399
+ column: ctx.column,
2400
+ rowId: ctx.rowId ?? '',
2401
+ onCommit,
2402
+ onCancel,
2403
+ updateRow: ctx.updateRow,
2404
+ onValueChange: ctx.onValueChange,
2405
+ control,
2406
+ };
2407
+ // Mount the template via the adapter's generic editor-view primitive
2408
+ // (tracks the view in the per-cell editor pool, wraps root nodes in a
2409
+ // stable `display:contents` span, and runs editor-mount hooks).
2410
+ const { container, viewRef } = adapter.createEditorTemplateView(template, context);
2411
+ // Auto-wire: listen for commit/cancel events on the rendered component so
2412
+ // components can just emit `(commit)` / `(cancel)` without explicit
2413
+ // template bindings.
2414
+ container.addEventListener('commit', (e) => {
2415
+ ctx.commit(e.detail);
2416
+ });
2417
+ container.addEventListener('cancel', () => {
2418
+ ctx.cancel();
2419
+ });
2420
+ // Auto-update the editor when the value changes externally (e.g. updateRow
2421
+ // cascade or Escape-revert). Angular's own bindings/control flow handle the
2422
+ // re-render; we just re-sync root nodes in case control flow changed them.
2423
+ ctx.onValueChange?.((newVal) => {
2424
+ context.$implicit = newVal;
2425
+ context.value = newVal;
2426
+ viewRef.detectChanges();
2427
+ adapter.syncEditorTemplateView(viewRef, container);
2428
+ });
2429
+ return container;
2430
+ };
2431
+ });
119
2432
  // Bridge the editing plugin's `before-edit-close` event to a synchronous
120
2433
  // `.blur()` on the focused input/textarea/select inside the editor host.
121
2434
  // Angular editors that commit on `(blur)` rely on the focused control firing
@@ -131,5 +2444,5 @@ registerEditorMountHook(({ container, gridEl }) => {
131
2444
  * Generated bundle index. Do not edit.
132
2445
  */
133
2446
 
134
- export { GridEditingDirective };
2447
+ export { BaseGridEditor, BaseGridEditorCVA, BaseOverlayEditor, GridColumnEditor, GridEditingDirective, GridFormArray, GridLazyForm, TbwEditor, getEditorTemplate, getFormArrayContext, getLazyFormContext, getStructuralEditorTemplate };
135
2448
  //# sourceMappingURL=toolbox-web-grid-angular-features-editing.mjs.map