@toolbox-web/grid-angular 2.0.0-rc.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/toolbox-web-grid-angular-features-clipboard.mjs +15 -9
- package/fesm2022/toolbox-web-grid-angular-features-clipboard.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-column-virtualization.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-column-virtualization.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-context-menu.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-context-menu.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-editing.mjs +1537 -1510
- package/fesm2022/toolbox-web-grid-angular-features-editing.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-export.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-export.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs +12 -9
- package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-grouping-columns.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-grouping-columns.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-grouping-rows.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-grouping-rows.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs +16 -11
- package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-multi-sort.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-multi-sort.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-pinned-columns.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-pinned-columns.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-pivot.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-pivot.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-print.mjs +7 -5
- package/fesm2022/toolbox-web-grid-angular-features-print.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-reorder-columns.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-reorder-columns.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs +16 -11
- package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-row-drag-drop.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-row-drag-drop.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-selection.mjs +13 -8
- package/fesm2022/toolbox-web-grid-angular-features-selection.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-server-side.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-server-side.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-shell.mjs +31 -20
- package/fesm2022/toolbox-web-grid-angular-features-shell.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-sticky-rows.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-sticky-rows.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-tooltip.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-tooltip.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-tree.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-tree.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-undo-redo.mjs +11 -7
- package/fesm2022/toolbox-web-grid-angular-features-undo-redo.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular-features-visibility.mjs +5 -4
- package/fesm2022/toolbox-web-grid-angular-features-visibility.mjs.map +1 -1
- package/fesm2022/toolbox-web-grid-angular.mjs +61 -71
- package/fesm2022/toolbox-web-grid-angular.mjs.map +1 -1
- package/package.json +2 -2
- package/types/toolbox-web-grid-angular-features-clipboard.d.ts +4 -3
- package/types/toolbox-web-grid-angular-features-clipboard.d.ts.map +1 -1
- package/types/toolbox-web-grid-angular.d.ts.map +1 -1
|
@@ -65,7 +65,8 @@ class GridColumnEditor {
|
|
|
65
65
|
/**
|
|
66
66
|
* Query for the ng-template content child.
|
|
67
67
|
*/
|
|
68
|
-
template = contentChild((TemplateRef),
|
|
68
|
+
template = contentChild((TemplateRef), /* @ts-ignore */
|
|
69
|
+
...(ngDevMode ? [{ debugName: "template" }] : /* istanbul ignore next */ []));
|
|
69
70
|
/** Effect that triggers when the template is available */
|
|
70
71
|
onTemplateReceived = effect(() => {
|
|
71
72
|
const template = this.template();
|
|
@@ -73,7 +74,8 @@ class GridColumnEditor {
|
|
|
73
74
|
// Register the template for this element
|
|
74
75
|
editorTemplateRegistry.set(this.elementRef.nativeElement, template);
|
|
75
76
|
}
|
|
76
|
-
},
|
|
77
|
+
}, /* @ts-ignore */
|
|
78
|
+
...(ngDevMode ? [{ debugName: "onTemplateReceived" }] : /* istanbul ignore next */ []));
|
|
77
79
|
/**
|
|
78
80
|
* Static type guard for template context.
|
|
79
81
|
* Enables type inference in templates.
|
|
@@ -81,10 +83,10 @@ class GridColumnEditor {
|
|
|
81
83
|
static ngTemplateContextGuard(dir, ctx) {
|
|
82
84
|
return true;
|
|
83
85
|
}
|
|
84
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
85
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "
|
|
86
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridColumnEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
87
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.0.6", type: GridColumnEditor, isStandalone: true, selector: "tbw-grid-column-editor", queries: [{ propertyName: "template", first: true, predicate: (TemplateRef), descendants: true, isSignal: true }], ngImport: i0 });
|
|
86
88
|
}
|
|
87
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
89
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridColumnEditor, decorators: [{
|
|
88
90
|
type: Directive,
|
|
89
91
|
args: [{ selector: 'tbw-grid-column-editor' }]
|
|
90
92
|
}], propDecorators: { template: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }] } });
|
|
@@ -173,7 +175,8 @@ class GridFormArray {
|
|
|
173
175
|
/**
|
|
174
176
|
* The FormArray to bind to the grid.
|
|
175
177
|
*/
|
|
176
|
-
formArray = input.required(
|
|
178
|
+
formArray = input.required(/* @ts-ignore */
|
|
179
|
+
...(ngDevMode ? [{ debugName: "formArray" }] : /* istanbul ignore next */ []));
|
|
177
180
|
/**
|
|
178
181
|
* Whether to automatically sync Angular validation state to grid's visual invalid styling.
|
|
179
182
|
*
|
|
@@ -185,7 +188,8 @@ class GridFormArray {
|
|
|
185
188
|
*
|
|
186
189
|
* @default true
|
|
187
190
|
*/
|
|
188
|
-
syncValidation = input(true,
|
|
191
|
+
syncValidation = input(true, /* @ts-ignore */
|
|
192
|
+
...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
|
|
189
193
|
/**
|
|
190
194
|
* Effect that sets up valueChanges subscription when FormArray changes.
|
|
191
195
|
* This handles both initial binding and when the FormArray reference changes.
|
|
@@ -216,7 +220,8 @@ class GridFormArray {
|
|
|
216
220
|
return;
|
|
217
221
|
grid.rows = formArray.getRawValue();
|
|
218
222
|
});
|
|
219
|
-
},
|
|
223
|
+
}, /* @ts-ignore */
|
|
224
|
+
...(ngDevMode ? [{ debugName: "syncFormArrayToGrid" }] : /* istanbul ignore next */ []));
|
|
220
225
|
ngOnInit() {
|
|
221
226
|
const grid = this.elementRef.nativeElement;
|
|
222
227
|
if (!grid)
|
|
@@ -565,1768 +570,1786 @@ class GridFormArray {
|
|
|
565
570
|
return typeof error === 'string' ? error : (error?.message ?? `Validation error: ${firstKey}`);
|
|
566
571
|
}
|
|
567
572
|
}
|
|
568
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
569
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "
|
|
573
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridFormArray, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
574
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.6", 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
575
|
}
|
|
571
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
576
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridFormArray, decorators: [{
|
|
572
577
|
type: Directive,
|
|
573
578
|
args: [{
|
|
574
579
|
selector: 'tbw-grid[formArray]',
|
|
575
580
|
}]
|
|
576
581
|
}], propDecorators: { formArray: [{ type: i0.Input, args: [{ isSignal: true, alias: "formArray", required: true }] }], syncValidation: [{ type: i0.Input, args: [{ isSignal: true, alias: "syncValidation", required: false }] }] } });
|
|
577
582
|
|
|
578
|
-
//
|
|
579
|
-
const
|
|
583
|
+
// Symbol for storing form context on the grid element (shared with GridFormArray)
|
|
584
|
+
const FORM_ARRAY_CONTEXT = Symbol('formArrayContext');
|
|
580
585
|
/**
|
|
581
|
-
* Gets the
|
|
582
|
-
*
|
|
586
|
+
* Gets the FormArrayContext from a grid element, if present.
|
|
587
|
+
* @internal
|
|
588
|
+
* @since 0.11.0
|
|
583
589
|
*/
|
|
584
|
-
function
|
|
585
|
-
|
|
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;
|
|
590
|
+
function getLazyFormContext(gridElement) {
|
|
591
|
+
return gridElement[FORM_ARRAY_CONTEXT];
|
|
595
592
|
}
|
|
596
593
|
/**
|
|
597
|
-
*
|
|
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>
|
|
594
|
+
* Directive that provides lazy FormGroup creation for grid editing.
|
|
613
595
|
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
* ```
|
|
596
|
+
* Unlike `GridFormArray` which creates all FormGroups upfront, this directive
|
|
597
|
+
* creates FormGroups on-demand only when a row enters edit mode. This provides
|
|
598
|
+
* much better performance for large datasets while still enabling full
|
|
599
|
+
* Angular Reactive Forms integration.
|
|
619
600
|
*
|
|
620
|
-
* ##
|
|
601
|
+
* ## Key Benefits
|
|
621
602
|
*
|
|
622
|
-
*
|
|
623
|
-
* -
|
|
624
|
-
* -
|
|
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)
|
|
603
|
+
* - **Performance**: Only creates FormGroups for rows being edited (20-100x fewer controls)
|
|
604
|
+
* - **Same DX**: Editors still receive `control` in their context for validation
|
|
605
|
+
* - **Memory efficient**: FormGroups are cleaned up when rows exit edit mode
|
|
628
606
|
*
|
|
629
|
-
* ##
|
|
607
|
+
* ## Usage
|
|
630
608
|
*
|
|
631
609
|
* ```typescript
|
|
632
|
-
* import {
|
|
610
|
+
* import { Component, inject, signal } from '@angular/core';
|
|
611
|
+
* import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
612
|
+
* import { Grid, GridLazyForm, TbwEditor } from '@toolbox-web/grid-angular/features/editing';
|
|
633
613
|
*
|
|
634
614
|
* @Component({
|
|
635
|
-
* imports: [TbwEditor],
|
|
636
|
-
*
|
|
615
|
+
* imports: [Grid, GridLazyForm, TbwEditor, ReactiveFormsModule],
|
|
616
|
+
* template: \`
|
|
617
|
+
* <tbw-grid
|
|
618
|
+
* [rows]="employees()"
|
|
619
|
+
* [lazyForm]="createRowForm"
|
|
620
|
+
* [gridConfig]="config">
|
|
621
|
+
*
|
|
622
|
+
* <tbw-grid-column field="firstName">
|
|
623
|
+
* <input *tbwEditor="let _; control as ctrl"
|
|
624
|
+
* [formControl]="ctrl"
|
|
625
|
+
* [class.is-invalid]="ctrl?.invalid && ctrl?.touched" />
|
|
626
|
+
* </tbw-grid-column>
|
|
627
|
+
* </tbw-grid>
|
|
628
|
+
* \`
|
|
637
629
|
* })
|
|
630
|
+
* export class MyComponent {
|
|
631
|
+
* private fb = inject(FormBuilder);
|
|
632
|
+
* employees = signal(generateEmployees(1000));
|
|
633
|
+
*
|
|
634
|
+
* // Factory called when editing starts - only include editable fields!
|
|
635
|
+
* createRowForm = (row: Employee): FormGroup => this.fb.group({
|
|
636
|
+
* firstName: [row.firstName, Validators.required],
|
|
637
|
+
* lastName: [row.lastName, Validators.minLength(2)],
|
|
638
|
+
* salary: [row.salary, [Validators.required, Validators.min(0)]],
|
|
639
|
+
* });
|
|
640
|
+
*
|
|
641
|
+
* gridConfig = { columns: [...] };
|
|
642
|
+
* }
|
|
638
643
|
* ```
|
|
639
644
|
*
|
|
645
|
+
* ## How It Works
|
|
646
|
+
*
|
|
647
|
+
* 1. Rows come from `[rows]` input (plain data array)
|
|
648
|
+
* 2. When a cell enters edit mode, the FormGroup is created lazily
|
|
649
|
+
* 3. Editors receive the FormControl in their template context
|
|
650
|
+
* 4. On commit, FormGroup values are synced back to the row
|
|
651
|
+
* 5. FormGroup is cleaned up when the row exits edit mode (configurable)
|
|
652
|
+
*
|
|
653
|
+
* ## Performance Comparison
|
|
654
|
+
*
|
|
655
|
+
* | Rows | GridFormArray (20 fields) | GridLazyForm |
|
|
656
|
+
* |------|---------------------------|--------------|
|
|
657
|
+
* | 100 | 2,000 controls | ~20 controls |
|
|
658
|
+
* | 500 | 10,000 controls | ~20 controls |
|
|
659
|
+
* | 1000 | 20,000 controls | ~20 controls |
|
|
660
|
+
*
|
|
640
661
|
* @example
|
|
641
662
|
* ```html
|
|
642
|
-
* <tbw-grid
|
|
643
|
-
* <app-status-editor *tbwEditor="let value" [value]="value" />
|
|
644
|
-
* </tbw-grid-column>
|
|
663
|
+
* <tbw-grid [rows]="employees()" [lazyForm]="createRowForm" [gridConfig]="gridConfig" />
|
|
645
664
|
* ```
|
|
646
665
|
*
|
|
666
|
+
* @see GridFormArray For small datasets with full upfront validation
|
|
647
667
|
* @category Directive
|
|
648
|
-
*
|
|
668
|
+
*
|
|
669
|
+
* @since 0.11.0
|
|
649
670
|
*/
|
|
650
|
-
class
|
|
651
|
-
template = inject((TemplateRef));
|
|
671
|
+
class GridLazyForm {
|
|
652
672
|
elementRef = inject((ElementRef));
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
673
|
+
// Cache of FormGroups by row reference
|
|
674
|
+
formGroupCache = new Map();
|
|
675
|
+
// Map from row reference to rowIndex (needed for getControl)
|
|
676
|
+
rowIndexMap = new Map();
|
|
677
|
+
// Track which row is currently being edited
|
|
678
|
+
editingRowIndex = null;
|
|
679
|
+
cellCommitUnsub = null;
|
|
680
|
+
rowCommitUnsub = null;
|
|
681
|
+
rowsChangeUnsub = null;
|
|
682
|
+
/**
|
|
683
|
+
* Factory function to create a FormGroup for a row.
|
|
684
|
+
* Called lazily when the row first enters edit mode.
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```typescript
|
|
688
|
+
* createRowForm = (row: Employee): FormGroup => this.fb.group({
|
|
689
|
+
* firstName: [row.firstName, Validators.required],
|
|
690
|
+
* lastName: [row.lastName],
|
|
691
|
+
* salary: [row.salary, [Validators.min(0)]],
|
|
692
|
+
* });
|
|
693
|
+
* ```
|
|
694
|
+
*/
|
|
695
|
+
lazyForm = input.required(/* @ts-ignore */
|
|
696
|
+
...(ngDevMode ? [{ debugName: "lazyForm" }] : /* istanbul ignore next */ []));
|
|
697
|
+
/**
|
|
698
|
+
* Whether to automatically sync Angular validation state to grid's visual invalid styling.
|
|
699
|
+
*
|
|
700
|
+
* When enabled:
|
|
701
|
+
* - After a cell commit, if the FormControl is invalid, the cell is marked with `setInvalid()`
|
|
702
|
+
* - When a FormControl becomes valid, `clearInvalid()` is called
|
|
703
|
+
* - On `row-commit`, if the row's FormGroup has invalid controls, the commit is prevented
|
|
704
|
+
*
|
|
705
|
+
* @default true
|
|
706
|
+
*/
|
|
707
|
+
syncValidation = input(true, /* @ts-ignore */
|
|
708
|
+
...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
|
|
709
|
+
/**
|
|
710
|
+
* Whether to keep FormGroups cached after a row exits edit mode.
|
|
711
|
+
*
|
|
712
|
+
* - `true`: FormGroups are kept, preserving dirty/touched state across edit sessions
|
|
713
|
+
* - `false`: FormGroups are disposed when the row exits edit mode (default)
|
|
714
|
+
*
|
|
715
|
+
* @default false
|
|
716
|
+
*/
|
|
717
|
+
keepFormGroups = input(false, /* @ts-ignore */
|
|
718
|
+
...(ngDevMode ? [{ debugName: "keepFormGroups" }] : /* istanbul ignore next */ []));
|
|
719
|
+
/**
|
|
720
|
+
* Emitted when a row's form values change.
|
|
721
|
+
* Useful for auto-save, validation display, or syncing to external state.
|
|
722
|
+
*/
|
|
723
|
+
rowFormChange = output();
|
|
724
|
+
ngOnInit() {
|
|
725
|
+
const grid = this.elementRef.nativeElement;
|
|
726
|
+
if (!grid)
|
|
727
|
+
return;
|
|
728
|
+
// Store the form context for AngularGridAdapter to access
|
|
729
|
+
this.#storeFormContext(grid);
|
|
730
|
+
// Listen for cell-commit to update FormControl and sync validation
|
|
731
|
+
this.cellCommitUnsub = grid.on('cell-commit', (detail) => {
|
|
732
|
+
this.#handleCellCommit(detail);
|
|
657
733
|
});
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
}
|
|
734
|
+
// Listen for row-commit to sync FormGroup values back to row and cleanup
|
|
735
|
+
this.rowCommitUnsub = grid.on('row-commit', (detail, event) => {
|
|
736
|
+
this.#handleRowCommit(event, detail);
|
|
737
|
+
});
|
|
738
|
+
// Listen for rows-change to update row index mappings
|
|
739
|
+
this.rowsChangeUnsub = grid.on('rows-change', () => {
|
|
740
|
+
this.#updateRowIndexMap();
|
|
741
|
+
});
|
|
742
|
+
// Initial row index mapping
|
|
743
|
+
this.#updateRowIndexMap();
|
|
669
744
|
}
|
|
670
745
|
ngOnDestroy() {
|
|
671
|
-
|
|
672
|
-
|
|
746
|
+
const grid = this.elementRef.nativeElement;
|
|
747
|
+
if (!grid)
|
|
748
|
+
return;
|
|
749
|
+
this.cellCommitUnsub?.();
|
|
750
|
+
this.rowCommitUnsub?.();
|
|
751
|
+
this.rowsChangeUnsub?.();
|
|
752
|
+
this.#clearFormContext(grid);
|
|
753
|
+
this.formGroupCache.clear();
|
|
754
|
+
this.rowIndexMap.clear();
|
|
755
|
+
}
|
|
756
|
+
// #region FormArrayContext Implementation
|
|
757
|
+
/**
|
|
758
|
+
* Gets or creates the FormGroup for a row.
|
|
759
|
+
* This is the core lazy initialization logic.
|
|
760
|
+
*/
|
|
761
|
+
#getOrCreateFormGroup(row, rowIndex) {
|
|
762
|
+
let formGroup = this.formGroupCache.get(row);
|
|
763
|
+
if (!formGroup) {
|
|
764
|
+
const factory = this.lazyForm();
|
|
765
|
+
formGroup = factory(row, rowIndex);
|
|
766
|
+
this.formGroupCache.set(row, formGroup);
|
|
767
|
+
this.rowIndexMap.set(row, rowIndex);
|
|
673
768
|
}
|
|
769
|
+
return formGroup;
|
|
674
770
|
}
|
|
675
771
|
/**
|
|
676
|
-
*
|
|
677
|
-
* Uses `any` defaults for ergonomic template usage.
|
|
772
|
+
* Gets the FormGroup for a row if it exists (without creating).
|
|
678
773
|
*/
|
|
679
|
-
|
|
680
|
-
|
|
774
|
+
#getRowFormGroup(rowIndex) {
|
|
775
|
+
const grid = this.elementRef.nativeElement;
|
|
776
|
+
const rows = grid?.rows;
|
|
777
|
+
if (!rows || rowIndex < 0 || rowIndex >= rows.length)
|
|
778
|
+
return undefined;
|
|
779
|
+
const row = rows[rowIndex];
|
|
780
|
+
return this.formGroupCache.get(row);
|
|
681
781
|
}
|
|
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: () => [] });
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* `GridEditingDirective` — owns `[editing]` and the editing-specific
|
|
692
|
-
* outputs on `<tbw-grid>`. The shared outputs `cellChange` and `dataChange`
|
|
693
|
-
* stay on `Grid` because they are emitted by multiple sources, not just
|
|
694
|
-
* the editing plugin. See `GridFilteringDirective` for full rationale.
|
|
695
|
-
*
|
|
696
|
-
* Output type compatibility note: this directive uses the Angular adapter
|
|
697
|
-
* wrapper types `CellCommitEvent` / `RowCommitEvent` (re-exported from the
|
|
698
|
-
* package barrel) to stay binary-compatible with apps migrating from the
|
|
699
|
-
* deprecated bindings on `Grid`.
|
|
700
|
-
*
|
|
701
|
-
* @category Directive
|
|
702
|
-
*/
|
|
703
|
-
/**
|
|
704
|
-
* Owns the binding(s) `[editing], [cellCommit], [cellCancel], [rowCommit], [changedRowsReset], [editOpen], [beforeEditClose], [editClose], [dirtyChange]` on `<tbw-grid>` for the matching feature plugin. See `GridFilteringDirective` for the full rationale.
|
|
705
|
-
*
|
|
706
|
-
* @category Directive
|
|
707
|
-
*/
|
|
708
|
-
class GridEditingDirective {
|
|
709
|
-
elementRef = inject((ElementRef));
|
|
710
|
-
editing = input(...(ngDevMode ? [undefined, { debugName: "editing" }] : /* istanbul ignore next */ []));
|
|
711
|
-
cellCommit = output();
|
|
712
|
-
cellCancel = output();
|
|
713
|
-
rowCommit = output();
|
|
714
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
715
|
-
changedRowsReset = output();
|
|
716
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
717
|
-
editOpen = output();
|
|
718
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
719
|
-
beforeEditClose = output();
|
|
720
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
721
|
-
editClose = output();
|
|
722
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
723
|
-
dirtyChange = output();
|
|
724
|
-
listeners = new Map();
|
|
725
|
-
static EVENTS = [
|
|
726
|
-
'cell-commit',
|
|
727
|
-
'cell-cancel',
|
|
728
|
-
'row-commit',
|
|
729
|
-
'changed-rows-reset',
|
|
730
|
-
'edit-open',
|
|
731
|
-
'before-edit-close',
|
|
732
|
-
'edit-close',
|
|
733
|
-
'dirty-change',
|
|
734
|
-
];
|
|
735
|
-
constructor() {
|
|
736
|
-
const grid = this.elementRef.nativeElement;
|
|
737
|
-
registerFeatureClaim(grid, 'editing', () => this.editing());
|
|
738
|
-
for (const ev of GridEditingDirective.EVENTS)
|
|
739
|
-
claimEvent(grid, ev);
|
|
740
|
-
}
|
|
741
|
-
ngOnInit() {
|
|
742
|
-
const grid = this.elementRef.nativeElement;
|
|
743
|
-
const wire = (name, out) => {
|
|
744
|
-
const l = (e) => out.emit(e.detail);
|
|
745
|
-
grid.addEventListener(name, l);
|
|
746
|
-
this.listeners.set(name, l);
|
|
747
|
-
};
|
|
748
|
-
wire('cell-commit', this.cellCommit);
|
|
749
|
-
wire('cell-cancel', this.cellCancel);
|
|
750
|
-
wire('row-commit', this.rowCommit);
|
|
751
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
752
|
-
wire('changed-rows-reset', this.changedRowsReset);
|
|
753
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
754
|
-
wire('edit-open', this.editOpen);
|
|
755
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
756
|
-
wire('before-edit-close', this.beforeEditClose);
|
|
757
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
758
|
-
wire('edit-close', this.editClose);
|
|
759
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
760
|
-
wire('dirty-change', this.dirtyChange);
|
|
761
|
-
}
|
|
762
|
-
ngOnDestroy() {
|
|
763
|
-
const grid = this.elementRef.nativeElement;
|
|
764
|
-
for (const [name, l] of this.listeners)
|
|
765
|
-
grid.removeEventListener(name, l);
|
|
766
|
-
this.listeners.clear();
|
|
767
|
-
unregisterFeatureClaim(grid, 'editing');
|
|
768
|
-
for (const ev of GridEditingDirective.EVENTS)
|
|
769
|
-
unclaimEvent(grid, ev);
|
|
770
|
-
}
|
|
771
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridEditingDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
772
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: GridEditingDirective, isStandalone: true, selector: "tbw-grid[editing], tbw-grid[cellCommit], tbw-grid[cellCancel], tbw-grid[rowCommit], tbw-grid[changedRowsReset], tbw-grid[editOpen], tbw-grid[beforeEditClose], tbw-grid[editClose], tbw-grid[dirtyChange]", inputs: { editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cellCommit: "cellCommit", cellCancel: "cellCancel", rowCommit: "rowCommit", changedRowsReset: "changedRowsReset", editOpen: "editOpen", beforeEditClose: "beforeEditClose", editClose: "editClose", dirtyChange: "dirtyChange" }, ngImport: i0 });
|
|
773
|
-
}
|
|
774
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: GridEditingDirective, decorators: [{
|
|
775
|
-
type: Directive,
|
|
776
|
-
args: [{
|
|
777
|
-
selector: 'tbw-grid[editing], tbw-grid[cellCommit], tbw-grid[cellCancel], tbw-grid[rowCommit], tbw-grid[changedRowsReset], tbw-grid[editOpen], tbw-grid[beforeEditClose], tbw-grid[editClose], tbw-grid[dirtyChange]',
|
|
778
|
-
standalone: true,
|
|
779
|
-
}]
|
|
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"] }] } });
|
|
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
782
|
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
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.
|
|
783
|
+
* Stores the FormArrayContext on the grid element.
|
|
784
|
+
* This uses the same interface as GridFormArray for compatibility.
|
|
919
785
|
*/
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
786
|
+
#storeFormContext(grid) {
|
|
787
|
+
const context = {
|
|
788
|
+
getRow: (rowIndex) => {
|
|
789
|
+
const rows = grid.rows;
|
|
790
|
+
if (!rows || rowIndex < 0 || rowIndex >= rows.length)
|
|
791
|
+
return null;
|
|
792
|
+
return rows[rowIndex];
|
|
793
|
+
},
|
|
794
|
+
updateField: (rowIndex, field, value) => {
|
|
795
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
796
|
+
if (formGroup) {
|
|
797
|
+
const control = formGroup.get(field);
|
|
798
|
+
if (control) {
|
|
799
|
+
control.setValue(value);
|
|
800
|
+
control.markAsDirty();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
getValue: () => {
|
|
805
|
+
return (grid.rows ?? []);
|
|
806
|
+
},
|
|
807
|
+
// Always true for lazy forms - we create FormGroups on demand
|
|
808
|
+
hasFormGroups: true,
|
|
809
|
+
getControl: (rowIndex, field) => {
|
|
810
|
+
const rows = grid.rows;
|
|
811
|
+
if (!rows || rowIndex < 0 || rowIndex >= rows.length)
|
|
812
|
+
return undefined;
|
|
813
|
+
const row = rows[rowIndex];
|
|
814
|
+
// LAZY: Create the FormGroup when first needed
|
|
815
|
+
const formGroup = this.#getOrCreateFormGroup(row, rowIndex);
|
|
816
|
+
return formGroup.get(field) ?? undefined;
|
|
817
|
+
},
|
|
818
|
+
getRowFormGroup: (rowIndex) => {
|
|
819
|
+
const rows = grid.rows;
|
|
820
|
+
if (!rows || rowIndex < 0 || rowIndex >= rows.length)
|
|
821
|
+
return undefined;
|
|
822
|
+
const row = rows[rowIndex];
|
|
823
|
+
// LAZY: Create the FormGroup when first needed
|
|
824
|
+
return this.#getOrCreateFormGroup(row, rowIndex);
|
|
825
|
+
},
|
|
826
|
+
isRowValid: (rowIndex) => {
|
|
827
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
828
|
+
// If no FormGroup exists yet, consider it valid (not edited)
|
|
829
|
+
if (!formGroup)
|
|
830
|
+
return true;
|
|
831
|
+
return formGroup.valid;
|
|
832
|
+
},
|
|
833
|
+
isRowTouched: (rowIndex) => {
|
|
834
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
835
|
+
if (!formGroup)
|
|
836
|
+
return false;
|
|
837
|
+
return formGroup.touched;
|
|
838
|
+
},
|
|
839
|
+
isRowDirty: (rowIndex) => {
|
|
840
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
841
|
+
if (!formGroup)
|
|
842
|
+
return false;
|
|
843
|
+
return formGroup.dirty;
|
|
844
|
+
},
|
|
845
|
+
getRowErrors: (rowIndex) => {
|
|
846
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
847
|
+
if (!formGroup)
|
|
848
|
+
return null;
|
|
849
|
+
const errors = {};
|
|
850
|
+
let hasErrors = false;
|
|
851
|
+
Object.keys(formGroup.controls).forEach((field) => {
|
|
852
|
+
const control = formGroup.get(field);
|
|
853
|
+
if (control?.errors) {
|
|
854
|
+
errors[field] = control.errors;
|
|
855
|
+
hasErrors = true;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
if (formGroup.errors) {
|
|
859
|
+
errors['_group'] = formGroup.errors;
|
|
860
|
+
hasErrors = true;
|
|
861
|
+
}
|
|
862
|
+
return hasErrors ? errors : null;
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
grid[FORM_ARRAY_CONTEXT] = context;
|
|
866
|
+
}
|
|
924
867
|
/**
|
|
925
|
-
*
|
|
926
|
-
* Returns an empty string if no errors.
|
|
868
|
+
* Clears the FormArrayContext from the grid element.
|
|
927
869
|
*/
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
return this.getErrorMessage(firstKey, ctrl.errors[firstKey]);
|
|
934
|
-
}, ...(ngDevMode ? [{ debugName: "firstErrorMessage" }] : /* istanbul ignore next */ []));
|
|
870
|
+
#clearFormContext(grid) {
|
|
871
|
+
delete grid[FORM_ARRAY_CONTEXT];
|
|
872
|
+
}
|
|
873
|
+
// #endregion
|
|
874
|
+
// #region Event Handlers
|
|
935
875
|
/**
|
|
936
|
-
*
|
|
876
|
+
* Updates the row index map when rows change.
|
|
877
|
+
* This ensures we can find FormGroups by row reference after sorting/filtering.
|
|
937
878
|
*/
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
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)
|
|
879
|
+
#updateRowIndexMap() {
|
|
880
|
+
const grid = this.elementRef.nativeElement;
|
|
881
|
+
const rows = grid?.rows;
|
|
882
|
+
if (!rows)
|
|
957
883
|
return;
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
unsubBefore = undefined;
|
|
964
|
-
});
|
|
965
|
-
unsubClose = grid.on('edit-close', () => {
|
|
966
|
-
this.onEditClose();
|
|
967
|
-
unsubClose?.();
|
|
968
|
-
unsubClose = undefined;
|
|
884
|
+
this.rowIndexMap.clear();
|
|
885
|
+
rows.forEach((row, index) => {
|
|
886
|
+
if (this.formGroupCache.has(row)) {
|
|
887
|
+
this.rowIndexMap.set(row, index);
|
|
888
|
+
}
|
|
969
889
|
});
|
|
970
|
-
this._editCloseCleanup = () => {
|
|
971
|
-
unsubBefore?.();
|
|
972
|
-
unsubClose?.();
|
|
973
|
-
};
|
|
974
890
|
}
|
|
975
|
-
// ============================================================================
|
|
976
|
-
// Methods
|
|
977
|
-
// ============================================================================
|
|
978
891
|
/**
|
|
979
|
-
*
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
892
|
+
* Handles cell-commit events by updating the FormControl in the FormGroup.
|
|
893
|
+
*/
|
|
894
|
+
#handleCellCommit(detail) {
|
|
895
|
+
const { rowIndex, field, value, rowId } = detail;
|
|
896
|
+
const formGroup = this.#getRowFormGroup(rowIndex);
|
|
897
|
+
if (formGroup) {
|
|
898
|
+
const control = formGroup.get(field);
|
|
899
|
+
if (control) {
|
|
900
|
+
control.setValue(value);
|
|
901
|
+
control.markAsDirty();
|
|
902
|
+
control.markAsTouched();
|
|
903
|
+
// Sync Angular validation state to grid's visual invalid styling
|
|
904
|
+
if (this.syncValidation() && rowId) {
|
|
905
|
+
this.#syncControlValidationToGrid(rowId, field, control);
|
|
906
|
+
}
|
|
907
|
+
// Emit change event
|
|
908
|
+
const grid = this.elementRef.nativeElement;
|
|
909
|
+
const row = grid?.rows?.[rowIndex];
|
|
910
|
+
if (row) {
|
|
911
|
+
this.rowFormChange.emit({
|
|
912
|
+
rowIndex,
|
|
913
|
+
rowId,
|
|
914
|
+
row,
|
|
915
|
+
formGroup,
|
|
916
|
+
values: formGroup.value,
|
|
917
|
+
valid: formGroup.valid,
|
|
918
|
+
dirty: formGroup.dirty,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Handles row-commit events.
|
|
926
|
+
* - Prevents commit if FormGroup is invalid (when syncValidation is true)
|
|
927
|
+
* - Syncs FormGroup values back to the row
|
|
928
|
+
* - Cleans up FormGroup if keepFormGroups is false
|
|
986
929
|
*/
|
|
987
|
-
|
|
988
|
-
|
|
930
|
+
#handleRowCommit(event, detail) {
|
|
931
|
+
const { rowIndex, rowId } = detail;
|
|
932
|
+
const grid = this.elementRef.nativeElement;
|
|
933
|
+
const rows = grid?.rows;
|
|
934
|
+
if (!rows || rowIndex < 0 || rowIndex >= rows.length)
|
|
935
|
+
return;
|
|
936
|
+
const row = rows[rowIndex];
|
|
937
|
+
const formGroup = this.formGroupCache.get(row);
|
|
938
|
+
if (!formGroup)
|
|
939
|
+
return;
|
|
940
|
+
// Prevent commit if invalid (when syncValidation is enabled)
|
|
941
|
+
if (this.syncValidation() && formGroup.invalid) {
|
|
942
|
+
// Mark all controls as touched to show validation errors
|
|
943
|
+
formGroup.markAllAsTouched();
|
|
944
|
+
event.preventDefault();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// Sync FormGroup values back to the row object
|
|
948
|
+
if (formGroup.dirty) {
|
|
949
|
+
const formValue = formGroup.value;
|
|
950
|
+
Object.keys(formValue).forEach((field) => {
|
|
951
|
+
if (field in row) {
|
|
952
|
+
row[field] = formValue[field];
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
// Clean up FormGroup if not keeping them
|
|
957
|
+
if (!this.keepFormGroups()) {
|
|
958
|
+
this.formGroupCache.delete(row);
|
|
959
|
+
this.rowIndexMap.delete(row);
|
|
960
|
+
// Clear any validation state in the grid
|
|
961
|
+
if (rowId) {
|
|
962
|
+
const editingPlugin = grid.getPluginByName?.('editing');
|
|
963
|
+
editingPlugin?.clearRowInvalid(rowId);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
989
966
|
}
|
|
967
|
+
// #endregion
|
|
968
|
+
// #region Validation Sync
|
|
990
969
|
/**
|
|
991
|
-
*
|
|
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.
|
|
970
|
+
* Syncs a FormControl's validation state to the grid's visual invalid styling.
|
|
998
971
|
*/
|
|
999
|
-
|
|
1000
|
-
|
|
972
|
+
#syncControlValidationToGrid(rowId, field, control) {
|
|
973
|
+
const grid = this.elementRef.nativeElement;
|
|
974
|
+
if (!grid)
|
|
975
|
+
return;
|
|
976
|
+
const editingPlugin = grid.getPluginByName?.('editing');
|
|
977
|
+
if (!editingPlugin)
|
|
978
|
+
return;
|
|
979
|
+
if (control.invalid) {
|
|
980
|
+
const errorMessage = this.#getFirstErrorMessage(control);
|
|
981
|
+
editingPlugin.setInvalid(rowId, field, errorMessage);
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
editingPlugin.clearInvalid(rowId, field);
|
|
985
|
+
}
|
|
1001
986
|
}
|
|
1002
987
|
/**
|
|
1003
|
-
*
|
|
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.
|
|
988
|
+
* Gets a human-readable error message from the first validation error.
|
|
1011
989
|
*/
|
|
1012
|
-
|
|
1013
|
-
|
|
990
|
+
#getFirstErrorMessage(control) {
|
|
991
|
+
const errors = control.errors;
|
|
992
|
+
if (!errors)
|
|
993
|
+
return '';
|
|
994
|
+
const firstKey = Object.keys(errors)[0];
|
|
995
|
+
const error = errors[firstKey];
|
|
996
|
+
switch (firstKey) {
|
|
997
|
+
case 'required':
|
|
998
|
+
return 'This field is required';
|
|
999
|
+
case 'minlength':
|
|
1000
|
+
return `Minimum length is ${error.requiredLength}`;
|
|
1001
|
+
case 'maxlength':
|
|
1002
|
+
return `Maximum length is ${error.requiredLength}`;
|
|
1003
|
+
case 'min':
|
|
1004
|
+
return `Minimum value is ${error.min}`;
|
|
1005
|
+
case 'max':
|
|
1006
|
+
return `Maximum value is ${error.max}`;
|
|
1007
|
+
case 'email':
|
|
1008
|
+
return 'Invalid email address';
|
|
1009
|
+
case 'pattern':
|
|
1010
|
+
return 'Invalid format';
|
|
1011
|
+
default:
|
|
1012
|
+
return typeof error === 'string' ? error : (error?.message ?? `Validation error: ${firstKey}`);
|
|
1013
|
+
}
|
|
1014
1014
|
}
|
|
1015
|
+
// #endregion
|
|
1016
|
+
// #region Public API
|
|
1015
1017
|
/**
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
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.
|
|
1018
|
+
* Gets the FormGroup for a row, if it exists.
|
|
1019
|
+
* Unlike the context methods, this does NOT create a FormGroup lazily.
|
|
1025
1020
|
*
|
|
1026
|
-
* @param
|
|
1021
|
+
* @param rowIndex The row index
|
|
1022
|
+
* @returns The FormGroup or undefined
|
|
1027
1023
|
*/
|
|
1028
|
-
|
|
1029
|
-
|
|
1024
|
+
getFormGroup(rowIndex) {
|
|
1025
|
+
return this.#getRowFormGroup(rowIndex);
|
|
1030
1026
|
}
|
|
1031
1027
|
/**
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1028
|
+
* Gets all cached FormGroups.
|
|
1029
|
+
* Useful for bulk validation or inspection.
|
|
1030
|
+
*
|
|
1031
|
+
* @returns Map of row objects to their FormGroups
|
|
1035
1032
|
*/
|
|
1036
|
-
|
|
1037
|
-
|
|
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 }));
|
|
1033
|
+
getAllFormGroups() {
|
|
1034
|
+
return this.formGroupCache;
|
|
1042
1035
|
}
|
|
1043
1036
|
/**
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1037
|
+
* Clears all cached FormGroups.
|
|
1038
|
+
* Useful when the underlying data changes significantly.
|
|
1046
1039
|
*/
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
this.
|
|
1050
|
-
// Dispatch DOM CustomEvent for grid's auto-wiring
|
|
1051
|
-
this.elementRef.nativeElement.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
|
|
1040
|
+
clearAllFormGroups() {
|
|
1041
|
+
this.formGroupCache.clear();
|
|
1042
|
+
this.rowIndexMap.clear();
|
|
1052
1043
|
}
|
|
1053
1044
|
/**
|
|
1054
|
-
*
|
|
1055
|
-
* Override this method to customize error messages for your editor.
|
|
1045
|
+
* Validates all currently cached FormGroups.
|
|
1056
1046
|
*
|
|
1057
|
-
* @
|
|
1058
|
-
* @param errorValue - The error value (e.g., { requiredLength: 5, actualLength: 3 })
|
|
1059
|
-
* @returns A human-readable error message
|
|
1047
|
+
* @returns true if all FormGroups are valid, false otherwise
|
|
1060
1048
|
*/
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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'}`;
|
|
1049
|
+
validateAll() {
|
|
1050
|
+
for (const formGroup of this.formGroupCache.values()) {
|
|
1051
|
+
if (formGroup.invalid) {
|
|
1052
|
+
formGroup.markAllAsTouched();
|
|
1053
|
+
return false;
|
|
1080
1054
|
}
|
|
1081
|
-
case 'email':
|
|
1082
|
-
return 'Invalid email address';
|
|
1083
|
-
case 'pattern':
|
|
1084
|
-
return 'Invalid format';
|
|
1085
|
-
default:
|
|
1086
|
-
return `Invalid value (${errorKey})`;
|
|
1087
1055
|
}
|
|
1056
|
+
return true;
|
|
1088
1057
|
}
|
|
1089
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
1090
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "
|
|
1058
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridLazyForm, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1059
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.6", 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 });
|
|
1091
1060
|
}
|
|
1092
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
1093
|
-
type: Directive
|
|
1094
|
-
|
|
1061
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridLazyForm, decorators: [{
|
|
1062
|
+
type: Directive,
|
|
1063
|
+
args: [{
|
|
1064
|
+
selector: 'tbw-grid[lazyForm]',
|
|
1065
|
+
}]
|
|
1066
|
+
}], 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"] }] } });
|
|
1095
1067
|
|
|
1068
|
+
// Registry for structural editor templates
|
|
1069
|
+
const structuralEditorRegistry = new Map();
|
|
1096
1070
|
/**
|
|
1097
|
-
*
|
|
1071
|
+
* Gets the editor template registered by the structural directive for a given column element.
|
|
1072
|
+
* Falls back to the non-structural directive registry.
|
|
1073
|
+
*/
|
|
1074
|
+
function getStructuralEditorTemplate(columnElement) {
|
|
1075
|
+
// First check structural directive registry
|
|
1076
|
+
const template = structuralEditorRegistry.get(columnElement);
|
|
1077
|
+
if (template)
|
|
1078
|
+
return template;
|
|
1079
|
+
// Fall back to the nested element registry
|
|
1080
|
+
const editorEl = columnElement.querySelector('tbw-grid-column-editor');
|
|
1081
|
+
if (editorEl) {
|
|
1082
|
+
return getEditorTemplate(editorEl);
|
|
1083
|
+
}
|
|
1084
|
+
return undefined;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Structural directive for cell editor rendering.
|
|
1088
|
+
*
|
|
1089
|
+
* This provides a cleaner syntax for defining custom cell editors without
|
|
1090
|
+
* the nested `<tbw-grid-column-editor>` and `<ng-template>` boilerplate.
|
|
1091
|
+
*
|
|
1092
|
+
* ## Usage
|
|
1093
|
+
*
|
|
1094
|
+
* ```html
|
|
1095
|
+
* <!-- Instead of this verbose syntax: -->
|
|
1096
|
+
* <tbw-grid-column field="status">
|
|
1097
|
+
* <tbw-grid-column-editor>
|
|
1098
|
+
* <ng-template let-value let-onCommit="onCommit" let-onCancel="onCancel">
|
|
1099
|
+
* <app-status-editor [value]="value" (commit)="onCommit($event)" (cancel)="onCancel()" />
|
|
1100
|
+
* </ng-template>
|
|
1101
|
+
* </tbw-grid-column-editor>
|
|
1102
|
+
* </tbw-grid-column>
|
|
1098
1103
|
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1104
|
+
* <!-- Use this cleaner syntax (with auto-wiring - no explicit bindings needed!): -->
|
|
1105
|
+
* <tbw-grid-column field="status">
|
|
1106
|
+
* <app-status-editor *tbwEditor="let value" [value]="value" />
|
|
1107
|
+
* </tbw-grid-column>
|
|
1108
|
+
* ```
|
|
1101
1109
|
*
|
|
1102
|
-
* ##
|
|
1110
|
+
* ## Template Context
|
|
1103
1111
|
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* | `writeValue` / `registerOn*` / `setDisabledState` | Full CVA implementation |
|
|
1112
|
+
* The structural directive provides the same context as `GridColumnEditor`:
|
|
1113
|
+
* - `$implicit` / `value`: The cell value
|
|
1114
|
+
* - `row`: The full row data object
|
|
1115
|
+
* - `column`: The column configuration
|
|
1116
|
+
* - `onCommit`: Callback function to commit the new value (optional - auto-wired if component emits `commit` event)
|
|
1117
|
+
* - `onCancel`: Callback function to cancel editing (optional - auto-wired if component emits `cancel` event)
|
|
1111
1118
|
*
|
|
1112
|
-
* ##
|
|
1119
|
+
* ## Import
|
|
1113
1120
|
*
|
|
1114
1121
|
* ```typescript
|
|
1115
|
-
* import {
|
|
1116
|
-
* import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
1117
|
-
* import { BaseGridEditorCVA } from '@toolbox-web/grid-angular/features/editing';
|
|
1122
|
+
* import { TbwEditor } from '@toolbox-web/grid-angular/features/editing';
|
|
1118
1123
|
*
|
|
1119
1124
|
* @Component({
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
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
|
-
* `
|
|
1125
|
+
* imports: [TbwEditor],
|
|
1126
|
+
* // ...
|
|
1135
1127
|
* })
|
|
1136
|
-
* export class DatePickerComponent extends BaseGridEditorCVA<MyRow, string> {}
|
|
1137
1128
|
* ```
|
|
1138
1129
|
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1130
|
+
* @example
|
|
1131
|
+
* ```html
|
|
1132
|
+
* <tbw-grid-column field="status" editable>
|
|
1133
|
+
* <app-status-editor *tbwEditor="let value" [value]="value" />
|
|
1134
|
+
* </tbw-grid-column>
|
|
1135
|
+
* ```
|
|
1145
1136
|
*
|
|
1146
|
-
* @
|
|
1137
|
+
* @category Directive
|
|
1138
|
+
* @since 0.1.1
|
|
1147
1139
|
*/
|
|
1148
|
-
class
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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;
|
|
1140
|
+
class TbwEditor {
|
|
1141
|
+
template = inject((TemplateRef));
|
|
1142
|
+
elementRef = inject((ElementRef));
|
|
1143
|
+
columnElement = null;
|
|
1144
|
+
constructor() {
|
|
1145
|
+
effect(() => {
|
|
1146
|
+
this.registerTemplate();
|
|
1147
|
+
});
|
|
1194
1148
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1149
|
+
registerTemplate() {
|
|
1150
|
+
// Find the parent tbw-grid-column or tbw-grid-type element
|
|
1151
|
+
let parent = this.elementRef.nativeElement?.parentElement;
|
|
1152
|
+
while (parent && parent.tagName !== 'TBW-GRID-COLUMN' && parent.tagName !== 'TBW-GRID-TYPE') {
|
|
1153
|
+
parent = parent.parentElement;
|
|
1154
|
+
}
|
|
1155
|
+
if (parent) {
|
|
1156
|
+
this.columnElement = parent;
|
|
1157
|
+
structuralEditorRegistry.set(parent, this.template);
|
|
1158
|
+
}
|
|
1200
1159
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
this.disabledState.set(isDisabled);
|
|
1160
|
+
ngOnDestroy() {
|
|
1161
|
+
if (this.columnElement) {
|
|
1162
|
+
structuralEditorRegistry.delete(this.columnElement);
|
|
1163
|
+
}
|
|
1206
1164
|
}
|
|
1207
|
-
// ============================================================================
|
|
1208
|
-
// Dual-Commit Helpers
|
|
1209
|
-
// ============================================================================
|
|
1210
1165
|
/**
|
|
1211
|
-
*
|
|
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
|
|
1166
|
+
* Static type guard for template context.
|
|
1167
|
+
* Uses `any` defaults for ergonomic template usage.
|
|
1220
1168
|
*/
|
|
1221
|
-
|
|
1222
|
-
|
|
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);
|
|
1169
|
+
static ngTemplateContextGuard(dir, ctx) {
|
|
1170
|
+
return true;
|
|
1229
1171
|
}
|
|
1230
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
1231
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "
|
|
1172
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: TbwEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1173
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.6", type: TbwEditor, isStandalone: true, selector: "[tbwEditor]", ngImport: i0 });
|
|
1232
1174
|
}
|
|
1233
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
1234
|
-
type: Directive
|
|
1235
|
-
|
|
1175
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: TbwEditor, decorators: [{
|
|
1176
|
+
type: Directive,
|
|
1177
|
+
args: [{ selector: '[tbwEditor]' }]
|
|
1178
|
+
}], ctorParameters: () => [] });
|
|
1236
1179
|
|
|
1237
|
-
// #endregion
|
|
1238
|
-
// #region Global Styles
|
|
1239
|
-
/** Tracks whether the global overlay stylesheet has been injected. */
|
|
1240
|
-
let overlayStylesInjected = false;
|
|
1241
1180
|
/**
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1181
|
+
* `GridEditingDirective` — owns `[editing]` and the editing-specific
|
|
1182
|
+
* outputs on `<tbw-grid>`. The shared outputs `cellChange` and `dataChange`
|
|
1183
|
+
* stay on `Grid` because they are emitted by multiple sources, not just
|
|
1184
|
+
* the editing plugin. See `GridFilteringDirective` for full rationale.
|
|
1244
1185
|
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1186
|
+
* Output type compatibility note: this directive uses the Angular adapter
|
|
1187
|
+
* wrapper types `CellCommitEvent` / `RowCommitEvent` (re-exported from the
|
|
1188
|
+
* package barrel) to stay binary-compatible with apps migrating from the
|
|
1189
|
+
* deprecated bindings on `Grid`.
|
|
1190
|
+
*
|
|
1191
|
+
* @category Directive
|
|
1247
1192
|
*/
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1193
|
+
/**
|
|
1194
|
+
* Owns the binding(s) `[editing], [cellCommit], [cellCancel], [rowCommit], [changedRowsReset], [editOpen], [beforeEditClose], [editClose], [dirtyChange]` on `<tbw-grid>` for the matching feature plugin. See `GridFilteringDirective` for the full rationale.
|
|
1195
|
+
*
|
|
1196
|
+
* @category Directive
|
|
1197
|
+
*/
|
|
1198
|
+
class GridEditingDirective {
|
|
1199
|
+
elementRef = inject((ElementRef));
|
|
1200
|
+
editing = input(/* @ts-ignore */
|
|
1201
|
+
...(ngDevMode ? [undefined, { debugName: "editing" }] : /* istanbul ignore next */ []));
|
|
1202
|
+
cellCommit = output();
|
|
1203
|
+
cellCancel = output();
|
|
1204
|
+
rowCommit = output();
|
|
1205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1206
|
+
changedRowsReset = output();
|
|
1207
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1208
|
+
editOpen = output();
|
|
1209
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1210
|
+
beforeEditClose = output();
|
|
1211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1212
|
+
editClose = output();
|
|
1213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1214
|
+
dirtyChange = output();
|
|
1215
|
+
listeners = new Map();
|
|
1216
|
+
static EVENTS = [
|
|
1217
|
+
'cell-commit',
|
|
1218
|
+
'cell-cancel',
|
|
1219
|
+
'row-commit',
|
|
1220
|
+
'changed-rows-reset',
|
|
1221
|
+
'edit-open',
|
|
1222
|
+
'before-edit-close',
|
|
1223
|
+
'edit-close',
|
|
1224
|
+
'dirty-change',
|
|
1225
|
+
];
|
|
1226
|
+
constructor() {
|
|
1227
|
+
const grid = this.elementRef.nativeElement;
|
|
1228
|
+
registerFeatureClaim(grid, 'editing', () => this.editing());
|
|
1229
|
+
for (const ev of GridEditingDirective.EVENTS)
|
|
1230
|
+
claimEvent(grid, ev);
|
|
1231
|
+
}
|
|
1232
|
+
ngOnInit() {
|
|
1233
|
+
const grid = this.elementRef.nativeElement;
|
|
1234
|
+
const wire = (name, out) => {
|
|
1235
|
+
const l = (e) => out.emit(e.detail);
|
|
1236
|
+
grid.addEventListener(name, l);
|
|
1237
|
+
this.listeners.set(name, l);
|
|
1238
|
+
};
|
|
1239
|
+
wire('cell-commit', this.cellCommit);
|
|
1240
|
+
wire('cell-cancel', this.cellCancel);
|
|
1241
|
+
wire('row-commit', this.rowCommit);
|
|
1242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1243
|
+
wire('changed-rows-reset', this.changedRowsReset);
|
|
1244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1245
|
+
wire('edit-open', this.editOpen);
|
|
1246
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1247
|
+
wire('before-edit-close', this.beforeEditClose);
|
|
1248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1249
|
+
wire('edit-close', this.editClose);
|
|
1250
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1251
|
+
wire('dirty-change', this.dirtyChange);
|
|
1252
|
+
}
|
|
1253
|
+
ngOnDestroy() {
|
|
1254
|
+
const grid = this.elementRef.nativeElement;
|
|
1255
|
+
for (const [name, l] of this.listeners)
|
|
1256
|
+
grid.removeEventListener(name, l);
|
|
1257
|
+
this.listeners.clear();
|
|
1258
|
+
unregisterFeatureClaim(grid, 'editing');
|
|
1259
|
+
for (const ev of GridEditingDirective.EVENTS)
|
|
1260
|
+
unclaimEvent(grid, ev);
|
|
1261
|
+
}
|
|
1262
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridEditingDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1263
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.6", type: GridEditingDirective, isStandalone: true, selector: "tbw-grid[editing], tbw-grid[cellCommit], tbw-grid[cellCancel], tbw-grid[rowCommit], tbw-grid[changedRowsReset], tbw-grid[editOpen], tbw-grid[beforeEditClose], tbw-grid[editClose], tbw-grid[dirtyChange]", inputs: { editing: { classPropertyName: "editing", publicName: "editing", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cellCommit: "cellCommit", cellCancel: "cellCancel", rowCommit: "rowCommit", changedRowsReset: "changedRowsReset", editOpen: "editOpen", beforeEditClose: "beforeEditClose", editClose: "editClose", dirtyChange: "dirtyChange" }, ngImport: i0 });
|
|
1269
1264
|
}
|
|
1265
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: GridEditingDirective, decorators: [{
|
|
1266
|
+
type: Directive,
|
|
1267
|
+
args: [{
|
|
1268
|
+
selector: 'tbw-grid[editing], tbw-grid[cellCommit], tbw-grid[cellCancel], tbw-grid[rowCommit], tbw-grid[changedRowsReset], tbw-grid[editOpen], tbw-grid[beforeEditClose], tbw-grid[editClose], tbw-grid[dirtyChange]',
|
|
1269
|
+
standalone: true,
|
|
1270
|
+
}]
|
|
1271
|
+
}], 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"] }] } });
|
|
1270
1272
|
|
|
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
1273
|
/**
|
|
1316
|
-
* Base class for grid editors
|
|
1274
|
+
* Base class for grid cell editors.
|
|
1317
1275
|
*
|
|
1318
|
-
* Provides
|
|
1319
|
-
* -
|
|
1320
|
-
* -
|
|
1321
|
-
* -
|
|
1322
|
-
* -
|
|
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
|
|
1276
|
+
* Provides common functionality for Angular cell editors:
|
|
1277
|
+
* - Automatic value resolution from FormControl or value input
|
|
1278
|
+
* - Common inputs (value, row, column, control)
|
|
1279
|
+
* - Common outputs (commit, cancel)
|
|
1280
|
+
* - Validation state helpers
|
|
1327
1281
|
*
|
|
1328
1282
|
* ## Usage
|
|
1329
1283
|
*
|
|
1330
1284
|
* ```typescript
|
|
1331
|
-
* import { Component
|
|
1332
|
-
* import {
|
|
1285
|
+
* import { Component } from '@angular/core';
|
|
1286
|
+
* import { BaseGridEditor } from '@toolbox-web/grid-angular/features/editing';
|
|
1333
1287
|
*
|
|
1334
1288
|
* @Component({
|
|
1335
|
-
* selector: 'app-
|
|
1336
|
-
* template:
|
|
1289
|
+
* selector: 'app-my-editor',
|
|
1290
|
+
* template: \`
|
|
1337
1291
|
* <input
|
|
1338
|
-
* #inlineInput
|
|
1339
|
-
* readonly
|
|
1340
1292
|
* [value]="currentValue()"
|
|
1341
|
-
*
|
|
1342
|
-
* (
|
|
1293
|
+
* [class.is-invalid]="isInvalid()"
|
|
1294
|
+
* (input)="commitValue($event.target.value)"
|
|
1295
|
+
* (keydown.escape)="cancelEdit()"
|
|
1343
1296
|
* />
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
* <button (click)="hideOverlay()">Cancel</button>
|
|
1349
|
-
* </div>
|
|
1350
|
-
* </div>
|
|
1351
|
-
* `
|
|
1297
|
+
* @if (hasErrors()) {
|
|
1298
|
+
* <div class="error">{{ firstErrorMessage() }}</div>
|
|
1299
|
+
* }
|
|
1300
|
+
* \`
|
|
1352
1301
|
* })
|
|
1353
|
-
* export class
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
* constructor() {
|
|
1360
|
-
* super();
|
|
1361
|
-
* effect(() => {
|
|
1362
|
-
* const panel = this.panelRef().nativeElement;
|
|
1363
|
-
* this.initOverlay(panel);
|
|
1364
|
-
* if (this.isCellFocused()) this.showOverlay();
|
|
1365
|
-
* });
|
|
1302
|
+
* export class MyEditorComponent extends BaseGridEditor<MyRow, string> {
|
|
1303
|
+
* // Override to customize error messages
|
|
1304
|
+
* protected override getErrorMessage(errorKey: string): string {
|
|
1305
|
+
* if (errorKey === 'required') return 'This field is required';
|
|
1306
|
+
* if (errorKey === 'minlength') return 'Too short';
|
|
1307
|
+
* return super.getErrorMessage(errorKey);
|
|
1366
1308
|
* }
|
|
1309
|
+
* }
|
|
1310
|
+
* ```
|
|
1367
1311
|
*
|
|
1368
|
-
*
|
|
1369
|
-
* return this.inputRef()?.nativeElement ?? null;
|
|
1370
|
-
* }
|
|
1312
|
+
* ## Template Syntax
|
|
1371
1313
|
*
|
|
1372
|
-
*
|
|
1373
|
-
* this.hideOverlay();
|
|
1374
|
-
* }
|
|
1314
|
+
* When using the base class, you only need to pass the control:
|
|
1375
1315
|
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
* }
|
|
1316
|
+
* ```html
|
|
1317
|
+
* <tbw-grid-column field="name">
|
|
1318
|
+
* <app-my-editor *tbwEditor="let _; control as control" [control]="control" />
|
|
1319
|
+
* </tbw-grid-column>
|
|
1381
1320
|
* ```
|
|
1382
1321
|
*
|
|
1383
|
-
*
|
|
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
|
-
* }
|
|
1322
|
+
* Or without FormArray binding (fallback to value):
|
|
1402
1323
|
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1324
|
+
* ```html
|
|
1325
|
+
* <tbw-grid-column field="name">
|
|
1326
|
+
* <app-my-editor *tbwEditor="let value" [value]="value" />
|
|
1327
|
+
* </tbw-grid-column>
|
|
1406
1328
|
* ```
|
|
1407
1329
|
*
|
|
1408
1330
|
* @typeParam TRow - The row data type
|
|
1409
1331
|
* @typeParam TValue - The cell value type
|
|
1410
1332
|
*
|
|
1411
|
-
* @since 0.
|
|
1333
|
+
* @since 0.5.0
|
|
1412
1334
|
*/
|
|
1413
|
-
class
|
|
1414
|
-
|
|
1415
|
-
|
|
1335
|
+
class BaseGridEditor {
|
|
1336
|
+
elementRef = inject(ElementRef);
|
|
1337
|
+
_destroyRef = inject(DestroyRef);
|
|
1338
|
+
/** Cleanup function for the edit-close listener */
|
|
1339
|
+
_editCloseCleanup = null;
|
|
1416
1340
|
// ============================================================================
|
|
1417
|
-
//
|
|
1341
|
+
// Inputs
|
|
1342
|
+
// ============================================================================
|
|
1343
|
+
/**
|
|
1344
|
+
* The cell value. Used when FormControl is not available.
|
|
1345
|
+
* When a FormControl is provided, value is derived from control.value instead.
|
|
1346
|
+
*/
|
|
1347
|
+
value = input(/* @ts-ignore */
|
|
1348
|
+
...(ngDevMode ? [undefined, { debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1349
|
+
/**
|
|
1350
|
+
* The full row data object.
|
|
1351
|
+
*/
|
|
1352
|
+
row = input(/* @ts-ignore */
|
|
1353
|
+
...(ngDevMode ? [undefined, { debugName: "row" }] : /* istanbul ignore next */ []));
|
|
1354
|
+
/**
|
|
1355
|
+
* The column configuration.
|
|
1356
|
+
*/
|
|
1357
|
+
column = input(/* @ts-ignore */
|
|
1358
|
+
...(ngDevMode ? [undefined, { debugName: "column" }] : /* istanbul ignore next */ []));
|
|
1359
|
+
/**
|
|
1360
|
+
* The FormControl for this cell, if the grid is bound to a FormArray.
|
|
1361
|
+
* When provided, the editor uses control.value instead of the value input.
|
|
1362
|
+
*/
|
|
1363
|
+
control = input(/* @ts-ignore */
|
|
1364
|
+
...(ngDevMode ? [undefined, { debugName: "control" }] : /* istanbul ignore next */ []));
|
|
1365
|
+
// ============================================================================
|
|
1366
|
+
// Outputs
|
|
1367
|
+
// ============================================================================
|
|
1368
|
+
/**
|
|
1369
|
+
* Emits when the user commits a new value.
|
|
1370
|
+
* Emits `null` when a nullable field is cleared.
|
|
1371
|
+
*/
|
|
1372
|
+
commit = output();
|
|
1373
|
+
/**
|
|
1374
|
+
* Emits when the user cancels editing.
|
|
1375
|
+
*/
|
|
1376
|
+
cancel = output();
|
|
1418
1377
|
// ============================================================================
|
|
1378
|
+
// Computed State
|
|
1379
|
+
// ============================================================================
|
|
1380
|
+
/**
|
|
1381
|
+
* The current value, derived from FormControl if available, otherwise from value input.
|
|
1382
|
+
* This is the recommended way to get the current value in your editor template.
|
|
1383
|
+
*/
|
|
1384
|
+
currentValue = computed(() => {
|
|
1385
|
+
const ctrl = this.control();
|
|
1386
|
+
if (ctrl) {
|
|
1387
|
+
return ctrl.value;
|
|
1388
|
+
}
|
|
1389
|
+
return this.value();
|
|
1390
|
+
}, /* @ts-ignore */
|
|
1391
|
+
...(ngDevMode ? [{ debugName: "currentValue" }] : /* istanbul ignore next */ []));
|
|
1392
|
+
/**
|
|
1393
|
+
* Whether the control is invalid (has validation errors).
|
|
1394
|
+
* Returns false if no FormControl is available.
|
|
1395
|
+
*/
|
|
1396
|
+
isInvalid = computed(() => {
|
|
1397
|
+
return this.control()?.invalid ?? false;
|
|
1398
|
+
}, /* @ts-ignore */
|
|
1399
|
+
...(ngDevMode ? [{ debugName: "isInvalid" }] : /* istanbul ignore next */ []));
|
|
1400
|
+
/**
|
|
1401
|
+
* Whether the control is dirty (has been modified).
|
|
1402
|
+
* Returns false if no FormControl is available.
|
|
1403
|
+
*/
|
|
1404
|
+
isDirty = computed(() => {
|
|
1405
|
+
return this.control()?.dirty ?? false;
|
|
1406
|
+
}, /* @ts-ignore */
|
|
1407
|
+
...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
|
|
1408
|
+
/**
|
|
1409
|
+
* Whether the control has been touched.
|
|
1410
|
+
* Returns false if no FormControl is available.
|
|
1411
|
+
*/
|
|
1412
|
+
isTouched = computed(() => {
|
|
1413
|
+
return this.control()?.touched ?? false;
|
|
1414
|
+
}, /* @ts-ignore */
|
|
1415
|
+
...(ngDevMode ? [{ debugName: "isTouched" }] : /* istanbul ignore next */ []));
|
|
1416
|
+
/**
|
|
1417
|
+
* Whether the control has any validation errors.
|
|
1418
|
+
*/
|
|
1419
|
+
hasErrors = computed(() => {
|
|
1420
|
+
const ctrl = this.control();
|
|
1421
|
+
return ctrl?.errors != null && Object.keys(ctrl.errors).length > 0;
|
|
1422
|
+
}, /* @ts-ignore */
|
|
1423
|
+
...(ngDevMode ? [{ debugName: "hasErrors" }] : /* istanbul ignore next */ []));
|
|
1424
|
+
/**
|
|
1425
|
+
* The first error message from the control's validation errors.
|
|
1426
|
+
* Returns an empty string if no errors.
|
|
1427
|
+
*/
|
|
1428
|
+
firstErrorMessage = computed(() => {
|
|
1429
|
+
const ctrl = this.control();
|
|
1430
|
+
if (!ctrl?.errors)
|
|
1431
|
+
return '';
|
|
1432
|
+
const firstKey = Object.keys(ctrl.errors)[0];
|
|
1433
|
+
return this.getErrorMessage(firstKey, ctrl.errors[firstKey]);
|
|
1434
|
+
}, /* @ts-ignore */
|
|
1435
|
+
...(ngDevMode ? [{ debugName: "firstErrorMessage" }] : /* istanbul ignore next */ []));
|
|
1419
1436
|
/**
|
|
1420
|
-
*
|
|
1421
|
-
* Override in subclasses to change the default position.
|
|
1422
|
-
*
|
|
1423
|
-
* @default 'below'
|
|
1437
|
+
* All error messages from the control's validation errors.
|
|
1424
1438
|
*/
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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;
|
|
1439
|
+
allErrorMessages = computed(() => {
|
|
1440
|
+
const ctrl = this.control();
|
|
1441
|
+
if (!ctrl?.errors)
|
|
1442
|
+
return [];
|
|
1443
|
+
return Object.entries(ctrl.errors).map(([key, value]) => this.getErrorMessage(key, value));
|
|
1444
|
+
}, /* @ts-ignore */
|
|
1445
|
+
...(ngDevMode ? [{ debugName: "allErrorMessages" }] : /* istanbul ignore next */ []));
|
|
1441
1446
|
// ============================================================================
|
|
1442
1447
|
// Lifecycle
|
|
1443
1448
|
// ============================================================================
|
|
1444
1449
|
constructor() {
|
|
1445
|
-
|
|
1446
|
-
this.
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
+
afterNextRender(() => this._initEditCloseListener());
|
|
1451
|
+
this._destroyRef.onDestroy(() => {
|
|
1452
|
+
this._editCloseCleanup?.();
|
|
1453
|
+
this._editCloseCleanup = null;
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
_initEditCloseListener() {
|
|
1457
|
+
const grid = this.elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]');
|
|
1458
|
+
if (!grid)
|
|
1459
|
+
return;
|
|
1460
|
+
let unsubBefore;
|
|
1461
|
+
let unsubClose;
|
|
1462
|
+
unsubBefore = grid.on('before-edit-close', () => {
|
|
1463
|
+
this.onBeforeEditClose();
|
|
1464
|
+
unsubBefore?.();
|
|
1465
|
+
unsubBefore = undefined;
|
|
1466
|
+
});
|
|
1467
|
+
unsubClose = grid.on('edit-close', () => {
|
|
1468
|
+
this.onEditClose();
|
|
1469
|
+
unsubClose?.();
|
|
1470
|
+
unsubClose = undefined;
|
|
1471
|
+
});
|
|
1472
|
+
this._editCloseCleanup = () => {
|
|
1473
|
+
unsubBefore?.();
|
|
1474
|
+
unsubClose?.();
|
|
1475
|
+
};
|
|
1450
1476
|
}
|
|
1451
1477
|
// ============================================================================
|
|
1452
|
-
//
|
|
1478
|
+
// Methods
|
|
1453
1479
|
// ============================================================================
|
|
1454
1480
|
/**
|
|
1455
|
-
*
|
|
1481
|
+
* Whether this editor's cell is the currently focused cell.
|
|
1456
1482
|
*
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1483
|
+
* In row editing mode the grid creates editors for every editable cell
|
|
1484
|
+
* in the row simultaneously. Use this to conditionally auto-focus inputs
|
|
1485
|
+
* or open panels only in the active cell.
|
|
1459
1486
|
*
|
|
1460
|
-
*
|
|
1487
|
+
* Performs a synchronous DOM check — safe to call from `ngAfterViewInit`.
|
|
1461
1488
|
*/
|
|
1462
|
-
|
|
1463
|
-
this.
|
|
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
|
-
}
|
|
1489
|
+
isCellFocused() {
|
|
1490
|
+
return this.elementRef.nativeElement.closest('[part="cell"]')?.classList.contains('cell-focus') ?? false;
|
|
1505
1491
|
}
|
|
1506
1492
|
/**
|
|
1507
|
-
*
|
|
1493
|
+
* Called **before** the grid clears editing state and destroys editor DOM.
|
|
1508
1494
|
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
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.
|
|
1495
|
+
* At this point the commit callback is still active, so subclasses can
|
|
1496
|
+
* call {@link commitValue} to flush any pending/deferred values.
|
|
1524
1497
|
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
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.
|
|
1498
|
+
* This fires only on the **commit** path (not on revert/cancel).
|
|
1499
|
+
* Use {@link onEditClose} for cleanup that should happen on both paths.
|
|
1540
1500
|
*/
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
return;
|
|
1544
|
-
this._isOpen = false;
|
|
1545
|
-
this._panel.style.display = 'none';
|
|
1546
|
-
this.showOverlay();
|
|
1501
|
+
onBeforeEditClose() {
|
|
1502
|
+
// Default: no-op. Subclasses override to flush pending values.
|
|
1547
1503
|
}
|
|
1548
1504
|
/**
|
|
1549
|
-
*
|
|
1505
|
+
* Called when the grid ends the editing session for this cell.
|
|
1550
1506
|
*
|
|
1551
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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.
|
|
1507
|
+
* Override to perform cleanup such as closing overlay panels, autocomplete
|
|
1508
|
+
* dropdowns, or other floating UI that lives at `<body>` level and would
|
|
1509
|
+
* otherwise persist after the editor DOM is removed.
|
|
1510
|
+
*
|
|
1511
|
+
* The listener is set up automatically via `afterNextRender` — no manual
|
|
1512
|
+
* wiring required.
|
|
1580
1513
|
*/
|
|
1581
1514
|
onEditClose() {
|
|
1582
|
-
|
|
1515
|
+
// Default: no-op. Subclasses override.
|
|
1583
1516
|
}
|
|
1584
|
-
// ============================================================================
|
|
1585
|
-
// Keyboard & Click Helpers
|
|
1586
|
-
// ============================================================================
|
|
1587
1517
|
/**
|
|
1588
|
-
*
|
|
1518
|
+
* Called by the grid adapter when the cell value changes externally
|
|
1519
|
+
* (e.g., via `updateRow()` cascade or undo/redo).
|
|
1589
1520
|
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1521
|
+
* Override in subclasses to reset internal state (search text, selection
|
|
1522
|
+
* flags, etc.) so the editor displays the updated value.
|
|
1592
1523
|
*
|
|
1593
|
-
*
|
|
1524
|
+
* This runs **synchronously** before the value input is updated, giving
|
|
1525
|
+
* the editor a chance to clear stale state before the next change-detection
|
|
1526
|
+
* pass re-reads the template.
|
|
1527
|
+
*
|
|
1528
|
+
* @param _newVal The new cell value being pushed from the grid
|
|
1594
1529
|
*/
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
}
|
|
1530
|
+
onExternalValueChange(_newVal) {
|
|
1531
|
+
// Default: no-op. Subclasses override as needed.
|
|
1609
1532
|
}
|
|
1610
1533
|
/**
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
* Bind this to `(click)` on your inline input element.
|
|
1534
|
+
* Commit a new value. Emits the commit output AND dispatches a DOM event.
|
|
1535
|
+
* The DOM event enables the grid's auto-wiring to catch the commit.
|
|
1536
|
+
* Call this when the user confirms their edit.
|
|
1615
1537
|
*/
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1538
|
+
commitValue(newValue) {
|
|
1539
|
+
// Emit Angular output for template bindings
|
|
1540
|
+
this.commit.emit(newValue);
|
|
1541
|
+
// Dispatch DOM CustomEvent for grid's auto-wiring
|
|
1542
|
+
// This allows the adapter to catch commits without explicit (commit)="..." bindings
|
|
1543
|
+
this.elementRef.nativeElement.dispatchEvent(new CustomEvent('commit', { detail: newValue, bubbles: true }));
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Cancel editing. Emits the cancel output AND dispatches a DOM event.
|
|
1547
|
+
* Call this when the user cancels (e.g., presses Escape).
|
|
1548
|
+
*/
|
|
1549
|
+
cancelEdit() {
|
|
1550
|
+
// Emit Angular output for template bindings
|
|
1551
|
+
this.cancel.emit();
|
|
1552
|
+
// Dispatch DOM CustomEvent for grid's auto-wiring
|
|
1553
|
+
this.elementRef.nativeElement.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
|
|
1624
1554
|
}
|
|
1625
1555
|
/**
|
|
1626
|
-
*
|
|
1556
|
+
* Get a human-readable error message for a validation error.
|
|
1557
|
+
* Override this method to customize error messages for your editor.
|
|
1627
1558
|
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1559
|
+
* @param errorKey - The validation error key (e.g., 'required', 'minlength')
|
|
1560
|
+
* @param errorValue - The error value (e.g., { requiredLength: 5, actualLength: 3 })
|
|
1561
|
+
* @returns A human-readable error message
|
|
1630
1562
|
*/
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1563
|
+
getErrorMessage(errorKey, errorValue) {
|
|
1564
|
+
switch (errorKey) {
|
|
1565
|
+
case 'required':
|
|
1566
|
+
return 'This field is required';
|
|
1567
|
+
case 'minlength': {
|
|
1568
|
+
const err = errorValue;
|
|
1569
|
+
return `Minimum length is ${err?.requiredLength ?? 'unknown'}`;
|
|
1570
|
+
}
|
|
1571
|
+
case 'maxlength': {
|
|
1572
|
+
const err = errorValue;
|
|
1573
|
+
return `Maximum length is ${err?.requiredLength ?? 'unknown'}`;
|
|
1574
|
+
}
|
|
1575
|
+
case 'min': {
|
|
1576
|
+
const err = errorValue;
|
|
1577
|
+
return `Minimum value is ${err?.min ?? 'unknown'}`;
|
|
1578
|
+
}
|
|
1579
|
+
case 'max': {
|
|
1580
|
+
const err = errorValue;
|
|
1581
|
+
return `Maximum value is ${err?.max ?? 'unknown'}`;
|
|
1582
|
+
}
|
|
1583
|
+
case 'email':
|
|
1584
|
+
return 'Invalid email address';
|
|
1585
|
+
case 'pattern':
|
|
1586
|
+
return 'Invalid format';
|
|
1587
|
+
default:
|
|
1588
|
+
return `Invalid value (${errorKey})`;
|
|
1638
1589
|
}
|
|
1639
1590
|
}
|
|
1591
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseGridEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
1592
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.6", 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 });
|
|
1593
|
+
}
|
|
1594
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseGridEditor, decorators: [{
|
|
1595
|
+
type: Directive
|
|
1596
|
+
}], 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"] }] } });
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Base class for grid editors that also work as Angular form controls.
|
|
1600
|
+
*
|
|
1601
|
+
* Combines `BaseGridEditor` with `ControlValueAccessor` so the same component
|
|
1602
|
+
* can be used inside a `<tbw-grid>` **and** in a standalone `<form>`.
|
|
1603
|
+
*
|
|
1604
|
+
* ## What it provides
|
|
1605
|
+
*
|
|
1606
|
+
* | Member | Purpose |
|
|
1607
|
+
* |--------|---------|
|
|
1608
|
+
* | `cvaValue` | Signal holding the value written by the form control |
|
|
1609
|
+
* | `disabledState` | Signal tracking `setDisabledState` calls |
|
|
1610
|
+
* | `displayValue` | Computed that prefers grid value (`currentValue`) and falls back to `cvaValue` |
|
|
1611
|
+
* | `commitBoth(v)` | Commits via both CVA `onChange` and grid `commitValue` |
|
|
1612
|
+
* | `writeValue` / `registerOn*` / `setDisabledState` | Full CVA implementation |
|
|
1613
|
+
*
|
|
1614
|
+
* ## Usage
|
|
1615
|
+
*
|
|
1616
|
+
* ```typescript
|
|
1617
|
+
* import { Component, forwardRef } from '@angular/core';
|
|
1618
|
+
* import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
1619
|
+
* import { BaseGridEditorCVA } from '@toolbox-web/grid-angular/features/editing';
|
|
1620
|
+
*
|
|
1621
|
+
* @Component({
|
|
1622
|
+
* selector: 'app-date-picker',
|
|
1623
|
+
* providers: [{
|
|
1624
|
+
* provide: NG_VALUE_ACCESSOR,
|
|
1625
|
+
* useExisting: forwardRef(() => DatePickerComponent),
|
|
1626
|
+
* multi: true,
|
|
1627
|
+
* }],
|
|
1628
|
+
* template: `
|
|
1629
|
+
* <input
|
|
1630
|
+
* type="date"
|
|
1631
|
+
* [value]="displayValue()"
|
|
1632
|
+
* [disabled]="disabledState()"
|
|
1633
|
+
* (change)="commitBoth($event.target.value)"
|
|
1634
|
+
* (keydown.escape)="cancelEdit()"
|
|
1635
|
+
* />
|
|
1636
|
+
* `
|
|
1637
|
+
* })
|
|
1638
|
+
* export class DatePickerComponent extends BaseGridEditorCVA<MyRow, string> {}
|
|
1639
|
+
* ```
|
|
1640
|
+
*
|
|
1641
|
+
* > **Note:** Subclasses must still provide `NG_VALUE_ACCESSOR` themselves
|
|
1642
|
+
* > because `forwardRef(() => ConcreteClass)` must reference the concrete
|
|
1643
|
+
* > component — this is an Angular limitation.
|
|
1644
|
+
*
|
|
1645
|
+
* @typeParam TRow - The row data type
|
|
1646
|
+
* @typeParam TValue - The cell/control value type
|
|
1647
|
+
*
|
|
1648
|
+
* @since 0.13.0
|
|
1649
|
+
*/
|
|
1650
|
+
class BaseGridEditorCVA extends BaseGridEditor {
|
|
1651
|
+
// ============================================================================
|
|
1652
|
+
// CVA State
|
|
1653
|
+
// ============================================================================
|
|
1654
|
+
/** Internal onChange callback registered by the form control. */
|
|
1655
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1656
|
+
_onChange = () => { };
|
|
1657
|
+
/** Internal onTouched callback registered by the form control. */
|
|
1658
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
1659
|
+
_onTouched = () => { };
|
|
1640
1660
|
/**
|
|
1641
|
-
*
|
|
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.
|
|
1661
|
+
* Signal holding the value written by the form control via `writeValue()`.
|
|
1662
|
+
* Updated when the form control pushes a new value (e.g. `patchValue`, `setValue`).
|
|
1647
1663
|
*/
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
}
|
|
1664
|
+
cvaValue = signal(null, /* @ts-ignore */
|
|
1665
|
+
...(ngDevMode ? [{ debugName: "cvaValue" }] : /* istanbul ignore next */ []));
|
|
1659
1666
|
/**
|
|
1660
|
-
*
|
|
1667
|
+
* Signal tracking the disabled state set by the form control.
|
|
1668
|
+
* Updated when `setDisabledState()` is called by Angular's forms module.
|
|
1669
|
+
*/
|
|
1670
|
+
disabledState = signal(false, /* @ts-ignore */
|
|
1671
|
+
...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
|
|
1672
|
+
/**
|
|
1673
|
+
* Resolved display value.
|
|
1661
1674
|
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1675
|
+
* Prefers `currentValue()` (grid context — from `control.value` or `value` input)
|
|
1676
|
+
* and falls back to `cvaValue()` (standalone form context — from `writeValue`).
|
|
1677
|
+
*
|
|
1678
|
+
* Use this in your template instead of reading `currentValue()` directly
|
|
1679
|
+
* so the component works in both grid and standalone form contexts.
|
|
1664
1680
|
*/
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
}
|
|
1681
|
+
displayValue = computed(() => {
|
|
1682
|
+
return this.currentValue() ?? this.cvaValue();
|
|
1683
|
+
}, /* @ts-ignore */
|
|
1684
|
+
...(ngDevMode ? [{ debugName: "displayValue" }] : /* istanbul ignore next */ []));
|
|
1668
1685
|
// ============================================================================
|
|
1669
|
-
//
|
|
1686
|
+
// ControlValueAccessor Implementation
|
|
1670
1687
|
// ============================================================================
|
|
1671
|
-
/**
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
_getGridElement() {
|
|
1677
|
-
return this._elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]') ?? null;
|
|
1688
|
+
/**
|
|
1689
|
+
* Called by Angular forms when the form control value changes programmatically.
|
|
1690
|
+
*/
|
|
1691
|
+
writeValue(value) {
|
|
1692
|
+
this.cvaValue.set(value);
|
|
1678
1693
|
}
|
|
1679
1694
|
/**
|
|
1680
|
-
*
|
|
1681
|
-
* Uses `getBoundingClientRect()` with viewport overflow detection.
|
|
1695
|
+
* Called by Angular forms to register a change callback.
|
|
1682
1696
|
*/
|
|
1683
|
-
|
|
1684
|
-
|
|
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`;
|
|
1697
|
+
registerOnChange(fn) {
|
|
1698
|
+
this._onChange = fn;
|
|
1744
1699
|
}
|
|
1745
1700
|
/**
|
|
1746
|
-
*
|
|
1747
|
-
* Fires `onOverlayOutsideClick()` if the click is outside the panel
|
|
1748
|
-
* and outside the editor's host element.
|
|
1701
|
+
* Called by Angular forms to register a touched callback.
|
|
1749
1702
|
*/
|
|
1750
|
-
|
|
1751
|
-
|
|
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();
|
|
1703
|
+
registerOnTouched(fn) {
|
|
1704
|
+
this._onTouched = fn;
|
|
1759
1705
|
}
|
|
1760
1706
|
/**
|
|
1761
|
-
*
|
|
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.
|
|
1707
|
+
* Called by Angular forms to set the disabled state.
|
|
1768
1708
|
*/
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
return;
|
|
1772
|
-
this.onOverlayOutsideClick();
|
|
1709
|
+
setDisabledState(isDisabled) {
|
|
1710
|
+
this.disabledState.set(isDisabled);
|
|
1773
1711
|
}
|
|
1712
|
+
// ============================================================================
|
|
1713
|
+
// Dual-Commit Helpers
|
|
1714
|
+
// ============================================================================
|
|
1774
1715
|
/**
|
|
1775
|
-
*
|
|
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.
|
|
1716
|
+
* Commit a value through both the CVA (form control) and the grid.
|
|
1779
1717
|
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1718
|
+
* - Calls the CVA `onChange` callback (updates the form control)
|
|
1719
|
+
* - Marks the control as touched
|
|
1720
|
+
* - Calls `commitValue()` (emits grid commit event + DOM `CustomEvent`)
|
|
1721
|
+
*
|
|
1722
|
+
* Use this instead of `commitValue()` when your editor doubles as a form control.
|
|
1723
|
+
*
|
|
1724
|
+
* @param value - The new value to commit
|
|
1784
1725
|
*/
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
this.
|
|
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'] });
|
|
1726
|
+
commitBoth(value) {
|
|
1727
|
+
// Update CVA
|
|
1728
|
+
this.cvaValue.set(value);
|
|
1729
|
+
this._onChange(value);
|
|
1730
|
+
this._onTouched();
|
|
1731
|
+
// Update grid (null is valid for nullable columns — the grid's cell-commit
|
|
1732
|
+
// handler and EditingPlugin will apply it correctly)
|
|
1733
|
+
this.commitValue(value);
|
|
1841
1734
|
}
|
|
1842
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
1843
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "
|
|
1735
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseGridEditorCVA, deps: null, target: i0.ɵɵFactoryTarget.Directive });
|
|
1736
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.6", type: BaseGridEditorCVA, isStandalone: true, usesInheritance: true, ngImport: i0 });
|
|
1844
1737
|
}
|
|
1845
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
1738
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseGridEditorCVA, decorators: [{
|
|
1846
1739
|
type: Directive
|
|
1847
|
-
}]
|
|
1740
|
+
}] });
|
|
1848
1741
|
|
|
1849
|
-
//
|
|
1850
|
-
|
|
1742
|
+
// #endregion
|
|
1743
|
+
// #region Global Styles
|
|
1744
|
+
/** Tracks whether the global overlay stylesheet has been injected. */
|
|
1745
|
+
let overlayStylesInjected = false;
|
|
1851
1746
|
/**
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1747
|
+
* CSS for the overlay panel base layer.
|
|
1748
|
+
* Injected once into `<head>` on first `BaseOverlayEditor` use.
|
|
1749
|
+
*
|
|
1750
|
+
* Uses CSS Anchor Positioning as primary strategy with a JS fallback
|
|
1751
|
+
* for browsers that don't support it (Firefox, Safari as of late 2025).
|
|
1855
1752
|
*/
|
|
1856
|
-
|
|
1857
|
-
|
|
1753
|
+
const OVERLAY_STYLES = /* css */ `
|
|
1754
|
+
.tbw-overlay-panel {
|
|
1755
|
+
position: fixed;
|
|
1756
|
+
z-index: 10000;
|
|
1757
|
+
background: var(--tbw-overlay-bg, #fff);
|
|
1758
|
+
border: 1px solid var(--tbw-overlay-border, #ccc);
|
|
1759
|
+
border-radius: var(--tbw-overlay-radius, 4px);
|
|
1760
|
+
box-shadow: var(--tbw-overlay-shadow, 0 4px 12px rgba(0, 0, 0, 0.15));
|
|
1761
|
+
box-sizing: border-box;
|
|
1762
|
+
overflow: auto;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/* Hide panels that have not been initialised via initOverlay() yet.
|
|
1766
|
+
Prevents a flash-of-unstyled-content when the subclass defers
|
|
1767
|
+
the initOverlay call (e.g. via setTimeout or afterNextRender). */
|
|
1768
|
+
.tbw-overlay-panel:not([data-anchor-id]) {
|
|
1769
|
+
display: none;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
.tbw-overlay-panel:popover-open {
|
|
1773
|
+
display: block;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
@supports (anchor-name: --a) {
|
|
1777
|
+
.tbw-overlay-panel[data-anchor-id] {
|
|
1778
|
+
position: fixed;
|
|
1779
|
+
position-anchor: var(--tbw-overlay-anchor);
|
|
1780
|
+
inset: unset;
|
|
1781
|
+
}
|
|
1782
|
+
.tbw-overlay-panel[data-pos="below"] {
|
|
1783
|
+
top: anchor(bottom);
|
|
1784
|
+
left: anchor(left);
|
|
1785
|
+
position-try-fallbacks: flip-block;
|
|
1786
|
+
}
|
|
1787
|
+
.tbw-overlay-panel[data-pos="above"] {
|
|
1788
|
+
bottom: anchor(top);
|
|
1789
|
+
left: anchor(left);
|
|
1790
|
+
position-try-fallbacks: flip-block;
|
|
1791
|
+
}
|
|
1792
|
+
.tbw-overlay-panel[data-pos="below-right"] {
|
|
1793
|
+
top: anchor(bottom);
|
|
1794
|
+
right: anchor(right);
|
|
1795
|
+
position-try-fallbacks: flip-block;
|
|
1796
|
+
}
|
|
1797
|
+
.tbw-overlay-panel[data-pos="over-top-left"] {
|
|
1798
|
+
top: anchor(top);
|
|
1799
|
+
left: anchor(left);
|
|
1800
|
+
}
|
|
1801
|
+
.tbw-overlay-panel[data-pos="over-bottom-left"] {
|
|
1802
|
+
bottom: anchor(bottom);
|
|
1803
|
+
left: anchor(left);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
`;
|
|
1807
|
+
function ensureOverlayStyles() {
|
|
1808
|
+
if (overlayStylesInjected)
|
|
1809
|
+
return;
|
|
1810
|
+
overlayStylesInjected = true;
|
|
1811
|
+
const style = document.createElement('style');
|
|
1812
|
+
style.setAttribute('data-tbw-overlay', '');
|
|
1813
|
+
style.textContent = OVERLAY_STYLES;
|
|
1814
|
+
document.head.appendChild(style);
|
|
1858
1815
|
}
|
|
1816
|
+
// #endregion
|
|
1817
|
+
// #region Anchor ID Counter
|
|
1818
|
+
let anchorCounter = 0;
|
|
1819
|
+
// #endregion
|
|
1859
1820
|
/**
|
|
1860
|
-
*
|
|
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
|
|
1821
|
+
* Base class for grid editors that display a floating overlay panel.
|
|
1868
1822
|
*
|
|
1869
|
-
*
|
|
1870
|
-
* - **
|
|
1871
|
-
* - **
|
|
1823
|
+
* Provides infrastructure for:
|
|
1824
|
+
* - **Overlay positioning** — CSS Anchor Positioning with JS fallback
|
|
1825
|
+
* - **Focus gating** — in row editing mode, the panel only opens for the focused cell
|
|
1826
|
+
* - **Click-outside detection** — closes the panel when clicking outside
|
|
1827
|
+
* - **MutationObserver** — detects cell focus changes (row editing mode)
|
|
1828
|
+
* - **Escape handling** — closes the panel and returns focus to the inline input
|
|
1829
|
+
* - **Synthetic Tab dispatch** — advances grid focus after overlay close
|
|
1830
|
+
* - **Automatic teardown** — removes the panel from `<body>` and cleans up listeners
|
|
1831
|
+
* - **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
|
|
1872
1832
|
*
|
|
1873
1833
|
* ## Usage
|
|
1874
1834
|
*
|
|
1875
1835
|
* ```typescript
|
|
1876
|
-
* import { Component,
|
|
1877
|
-
* import {
|
|
1878
|
-
* import { Grid, GridLazyForm, TbwEditor } from '@toolbox-web/grid-angular/features/editing';
|
|
1836
|
+
* import { Component, viewChild, ElementRef, effect } from '@angular/core';
|
|
1837
|
+
* import { BaseOverlayEditor } from '@toolbox-web/grid-angular/features/editing';
|
|
1879
1838
|
*
|
|
1880
1839
|
* @Component({
|
|
1881
|
-
*
|
|
1882
|
-
* template:
|
|
1883
|
-
* <
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
* [
|
|
1887
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
1892
|
-
*
|
|
1893
|
-
*
|
|
1894
|
-
*
|
|
1840
|
+
* selector: 'app-date-editor',
|
|
1841
|
+
* template: `
|
|
1842
|
+
* <input
|
|
1843
|
+
* #inlineInput
|
|
1844
|
+
* readonly
|
|
1845
|
+
* [value]="currentValue()"
|
|
1846
|
+
* (click)="onInlineClick()"
|
|
1847
|
+
* (keydown)="onInlineKeydown($event)"
|
|
1848
|
+
* />
|
|
1849
|
+
* <div #panel class="tbw-overlay-panel" style="width: 280px;">
|
|
1850
|
+
* <!-- your date picker UI here -->
|
|
1851
|
+
* <div class="actions">
|
|
1852
|
+
* <button (click)="selectAndClose(selectedDate)">OK</button>
|
|
1853
|
+
* <button (click)="hideOverlay()">Cancel</button>
|
|
1854
|
+
* </div>
|
|
1855
|
+
* </div>
|
|
1856
|
+
* `
|
|
1895
1857
|
* })
|
|
1896
|
-
* export class
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1858
|
+
* export class DateEditorComponent extends BaseOverlayEditor<MyRow, string> {
|
|
1859
|
+
* panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
|
|
1860
|
+
* inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
|
|
1899
1861
|
*
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1862
|
+
* protected override overlayPosition = 'below' as const;
|
|
1863
|
+
*
|
|
1864
|
+
* constructor() {
|
|
1865
|
+
* super();
|
|
1866
|
+
* effect(() => {
|
|
1867
|
+
* const panel = this.panelRef().nativeElement;
|
|
1868
|
+
* this.initOverlay(panel);
|
|
1869
|
+
* if (this.isCellFocused()) this.showOverlay();
|
|
1870
|
+
* });
|
|
1871
|
+
* }
|
|
1872
|
+
*
|
|
1873
|
+
* protected getInlineInput(): HTMLInputElement | null {
|
|
1874
|
+
* return this.inputRef()?.nativeElement ?? null;
|
|
1875
|
+
* }
|
|
1876
|
+
*
|
|
1877
|
+
* protected onOverlayOutsideClick(): void {
|
|
1878
|
+
* this.hideOverlay();
|
|
1879
|
+
* }
|
|
1906
1880
|
*
|
|
1907
|
-
*
|
|
1881
|
+
* selectAndClose(date: string): void {
|
|
1882
|
+
* this.commitValue(date);
|
|
1883
|
+
* this.hideOverlay();
|
|
1884
|
+
* }
|
|
1908
1885
|
* }
|
|
1909
1886
|
* ```
|
|
1910
1887
|
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1888
|
+
* @example
|
|
1889
|
+
* ```typescript
|
|
1890
|
+
* @Component({
|
|
1891
|
+
* template: `
|
|
1892
|
+
* <input #inlineInput readonly [value]="currentValue()"
|
|
1893
|
+
* (click)="onInlineClick()" (keydown)="onInlineKeydown($event)" />
|
|
1894
|
+
* <div #panel style="width: 280px">
|
|
1895
|
+
* <!-- overlay content -->
|
|
1896
|
+
* </div>
|
|
1897
|
+
* `
|
|
1898
|
+
* })
|
|
1899
|
+
* export class MyEditor extends BaseOverlayEditor<MyRow, string> {
|
|
1900
|
+
* panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
|
|
1901
|
+
* inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
|
|
1920
1902
|
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
* | 1000 | 20,000 controls | ~20 controls |
|
|
1903
|
+
* constructor() {
|
|
1904
|
+
* super();
|
|
1905
|
+
* effect(() => this.initOverlay(this.panelRef().nativeElement));
|
|
1906
|
+
* }
|
|
1926
1907
|
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1908
|
+
* protected getInlineInput() { return this.inputRef()?.nativeElement ?? null; }
|
|
1909
|
+
* protected onOverlayOutsideClick() { this.hideOverlay(); }
|
|
1910
|
+
* }
|
|
1930
1911
|
* ```
|
|
1931
1912
|
*
|
|
1932
|
-
* @
|
|
1933
|
-
* @
|
|
1913
|
+
* @typeParam TRow - The row data type
|
|
1914
|
+
* @typeParam TValue - The cell value type
|
|
1934
1915
|
*
|
|
1935
|
-
* @since 0.
|
|
1916
|
+
* @since 0.13.0
|
|
1936
1917
|
*/
|
|
1937
|
-
class
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
//
|
|
1942
|
-
|
|
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 */ []));
|
|
1918
|
+
class BaseOverlayEditor extends BaseGridEditor {
|
|
1919
|
+
_elementRef = inject(ElementRef);
|
|
1920
|
+
_overlayDestroyRef = inject(DestroyRef);
|
|
1921
|
+
// ============================================================================
|
|
1922
|
+
// Configuration
|
|
1923
|
+
// ============================================================================
|
|
1973
1924
|
/**
|
|
1974
|
-
*
|
|
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)
|
|
1925
|
+
* Position of the overlay panel relative to the anchor cell.
|
|
1926
|
+
* Override in subclasses to change the default position.
|
|
1978
1927
|
*
|
|
1979
|
-
* @default
|
|
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.
|
|
1928
|
+
* @default 'below'
|
|
2048
1929
|
*/
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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;
|
|
1930
|
+
overlayPosition = 'below';
|
|
1931
|
+
// ============================================================================
|
|
1932
|
+
// Internal State
|
|
1933
|
+
// ============================================================================
|
|
1934
|
+
/** The overlay panel element (set via `initOverlay()`). */
|
|
1935
|
+
_panel = null;
|
|
1936
|
+
/** Whether the overlay is currently visible. */
|
|
1937
|
+
_isOpen = false;
|
|
1938
|
+
/** Unique anchor ID for CSS Anchor Positioning. */
|
|
1939
|
+
_anchorId = '';
|
|
1940
|
+
/** Whether the browser supports CSS Anchor Positioning. */
|
|
1941
|
+
_supportsAnchor = false;
|
|
1942
|
+
/** AbortController for all overlay-related listeners. */
|
|
1943
|
+
_abortCtrl = null;
|
|
1944
|
+
/** MutationObserver watching cell focus class changes. */
|
|
1945
|
+
_focusObserver = null;
|
|
1946
|
+
// ============================================================================
|
|
1947
|
+
// Lifecycle
|
|
1948
|
+
// ============================================================================
|
|
1949
|
+
constructor() {
|
|
1950
|
+
super();
|
|
1951
|
+
this._supportsAnchor = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--a');
|
|
1952
|
+
ensureOverlayStyles();
|
|
1953
|
+
afterNextRender(() => this._setupFocusObserver());
|
|
1954
|
+
this._overlayDestroyRef.onDestroy(() => this.teardownOverlay());
|
|
2129
1955
|
}
|
|
1956
|
+
// ============================================================================
|
|
1957
|
+
// Public API — Subclass Interface
|
|
1958
|
+
// ============================================================================
|
|
2130
1959
|
/**
|
|
2131
|
-
*
|
|
1960
|
+
* Initialise the overlay with the panel element.
|
|
1961
|
+
*
|
|
1962
|
+
* Call this in an `effect()` or `afterNextRender()` with your `viewChild` panel reference.
|
|
1963
|
+
* The panel is moved to `<body>` and hidden until {@link showOverlay} is called.
|
|
1964
|
+
*
|
|
1965
|
+
* @param panel - The overlay panel DOM element
|
|
2132
1966
|
*/
|
|
2133
|
-
|
|
2134
|
-
|
|
1967
|
+
initOverlay(panel) {
|
|
1968
|
+
this._panel = panel;
|
|
1969
|
+
// Assign a unique anchor ID
|
|
1970
|
+
this._anchorId = `tbw-anchor-${++anchorCounter}`;
|
|
1971
|
+
panel.classList.add('tbw-overlay-panel');
|
|
1972
|
+
panel.setAttribute('data-pos', this.overlayPosition);
|
|
1973
|
+
panel.setAttribute('data-anchor-id', this._anchorId);
|
|
1974
|
+
panel.style.display = 'none';
|
|
1975
|
+
// Set up CSS Anchor Positioning on the cell
|
|
1976
|
+
if (this._supportsAnchor) {
|
|
1977
|
+
const cell = this._getCell();
|
|
1978
|
+
if (cell) {
|
|
1979
|
+
cell.style.setProperty('anchor-name', `--${this._anchorId}`);
|
|
1980
|
+
panel.style.setProperty('--tbw-overlay-anchor', `--${this._anchorId}`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
// Move panel to body so it escapes grid overflow clipping
|
|
1984
|
+
document.body.appendChild(panel);
|
|
1985
|
+
// Register the panel as an external focus container on the grid
|
|
1986
|
+
// so focus moving into the overlay is treated as "still in the grid"
|
|
1987
|
+
this._getGridElement()?.registerExternalFocusContainer?.(panel);
|
|
1988
|
+
// Set up click-outside detection
|
|
1989
|
+
this._abortCtrl = new AbortController();
|
|
1990
|
+
document.addEventListener('pointerdown', (e) => this._onDocumentPointerDown(e), {
|
|
1991
|
+
signal: this._abortCtrl.signal,
|
|
1992
|
+
});
|
|
1993
|
+
// Close overlay when the grid viewport scrolls. Mirrors outside-click
|
|
1994
|
+
// semantics so subclasses' `onOverlayOutsideClick()` drives commit/cancel.
|
|
1995
|
+
// Uses the public `tbw-scroll` CustomEvent dispatched on the grid host —
|
|
1996
|
+
// no shadow-DOM reach-arounds required (composed: true crosses boundaries).
|
|
1997
|
+
const grid = this._getGridElement();
|
|
1998
|
+
if (grid) {
|
|
1999
|
+
grid.addEventListener('tbw-scroll', () => this._onGridScroll(), {
|
|
2000
|
+
signal: this._abortCtrl.signal,
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
// If the focus observer already fired before the panel was initialised
|
|
2004
|
+
// (e.g. initOverlay called from a deferred setTimeout), the showOverlay()
|
|
2005
|
+
// call was silently ignored because _panel was still null. Catch up now.
|
|
2006
|
+
if (this._getCell()?.classList.contains('cell-focus')) {
|
|
2007
|
+
this.showOverlay();
|
|
2008
|
+
this.onOverlayOpened();
|
|
2009
|
+
}
|
|
2135
2010
|
}
|
|
2136
|
-
// #endregion
|
|
2137
|
-
// #region Event Handlers
|
|
2138
2011
|
/**
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2012
|
+
* Show the overlay panel.
|
|
2013
|
+
*
|
|
2014
|
+
* If CSS Anchor Positioning is not supported, falls back to JS-based
|
|
2015
|
+
* positioning using `getBoundingClientRect()`.
|
|
2141
2016
|
*/
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
const rows = grid?.rows;
|
|
2145
|
-
if (!rows)
|
|
2017
|
+
showOverlay() {
|
|
2018
|
+
if (!this._panel || this._isOpen)
|
|
2146
2019
|
return;
|
|
2147
|
-
this.
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
}
|
|
2020
|
+
this._isOpen = true;
|
|
2021
|
+
this._panel.style.display = '';
|
|
2022
|
+
// JS fallback positioning for browsers without CSS Anchor Positioning
|
|
2023
|
+
if (!this._supportsAnchor) {
|
|
2024
|
+
this._positionWithJs();
|
|
2025
|
+
}
|
|
2153
2026
|
}
|
|
2154
2027
|
/**
|
|
2155
|
-
*
|
|
2028
|
+
* Hide the overlay panel.
|
|
2029
|
+
*
|
|
2030
|
+
* @param suppressTabAdvance - When `true`, skip synthetic Tab dispatch
|
|
2031
|
+
* (useful when hiding is triggered by an external focus change).
|
|
2156
2032
|
*/
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
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
|
-
}
|
|
2033
|
+
hideOverlay(suppressTabAdvance) {
|
|
2034
|
+
if (!this._panel || !this._isOpen)
|
|
2035
|
+
return;
|
|
2036
|
+
this._isOpen = false;
|
|
2037
|
+
this._panel.style.display = 'none';
|
|
2038
|
+
if (!suppressTabAdvance) {
|
|
2039
|
+
this.getInlineInput()?.focus();
|
|
2185
2040
|
}
|
|
2186
2041
|
}
|
|
2187
2042
|
/**
|
|
2188
|
-
*
|
|
2189
|
-
*
|
|
2190
|
-
* - Syncs FormGroup values back to the row
|
|
2191
|
-
* - Cleans up FormGroup if keepFormGroups is false
|
|
2043
|
+
* Close and immediately re-open the overlay.
|
|
2044
|
+
* Useful after the panel content changes size and needs repositioning.
|
|
2192
2045
|
*/
|
|
2193
|
-
|
|
2194
|
-
|
|
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();
|
|
2046
|
+
reopenOverlay() {
|
|
2047
|
+
if (!this._panel)
|
|
2208
2048
|
return;
|
|
2049
|
+
this._isOpen = false;
|
|
2050
|
+
this._panel.style.display = 'none';
|
|
2051
|
+
this.showOverlay();
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Remove the overlay from the DOM and clean up all listeners.
|
|
2055
|
+
*
|
|
2056
|
+
* Called automatically on `DestroyRef.onDestroy`. Can also be called
|
|
2057
|
+
* manually if the editor needs early cleanup.
|
|
2058
|
+
*/
|
|
2059
|
+
teardownOverlay() {
|
|
2060
|
+
this._abortCtrl?.abort();
|
|
2061
|
+
this._abortCtrl = null;
|
|
2062
|
+
this._focusObserver?.disconnect();
|
|
2063
|
+
this._focusObserver = null;
|
|
2064
|
+
// Unregister the panel from the grid's external focus container registry
|
|
2065
|
+
if (this._panel) {
|
|
2066
|
+
this._getGridElement()?.unregisterExternalFocusContainer?.(this._panel);
|
|
2209
2067
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
const formValue = formGroup.value;
|
|
2213
|
-
Object.keys(formValue).forEach((field) => {
|
|
2214
|
-
if (field in row) {
|
|
2215
|
-
row[field] = formValue[field];
|
|
2216
|
-
}
|
|
2217
|
-
});
|
|
2068
|
+
if (this._panel?.parentNode) {
|
|
2069
|
+
this._panel.parentNode.removeChild(this._panel);
|
|
2218
2070
|
}
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
if (
|
|
2225
|
-
|
|
2226
|
-
editingPlugin?.clearRowInvalid(rowId);
|
|
2071
|
+
this._panel = null;
|
|
2072
|
+
this._isOpen = false;
|
|
2073
|
+
// Clean up anchor-name on the cell
|
|
2074
|
+
if (this._supportsAnchor) {
|
|
2075
|
+
const cell = this._getCell();
|
|
2076
|
+
if (cell) {
|
|
2077
|
+
cell.style.removeProperty('anchor-name');
|
|
2227
2078
|
}
|
|
2228
2079
|
}
|
|
2229
2080
|
}
|
|
2230
|
-
// #endregion
|
|
2231
|
-
// #region Validation Sync
|
|
2232
2081
|
/**
|
|
2233
|
-
*
|
|
2082
|
+
* Override in `edit-close` handler to also hide the overlay.
|
|
2083
|
+
* This is called automatically by `BaseGridEditor` when the grid
|
|
2084
|
+
* ends the editing session.
|
|
2234
2085
|
*/
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2086
|
+
onEditClose() {
|
|
2087
|
+
this.hideOverlay(true);
|
|
2088
|
+
}
|
|
2089
|
+
// ============================================================================
|
|
2090
|
+
// Keyboard & Click Helpers
|
|
2091
|
+
// ============================================================================
|
|
2092
|
+
/**
|
|
2093
|
+
* Keydown handler for the inline readonly input.
|
|
2094
|
+
*
|
|
2095
|
+
* - **Enter / Space / ArrowDown / F2** → open overlay
|
|
2096
|
+
* - **Escape** → calls {@link handleEscape}
|
|
2097
|
+
*
|
|
2098
|
+
* Bind this to `(keydown)` on your inline input element.
|
|
2099
|
+
*/
|
|
2100
|
+
onInlineKeydown(event) {
|
|
2101
|
+
switch (event.key) {
|
|
2102
|
+
case 'Enter':
|
|
2103
|
+
case ' ':
|
|
2104
|
+
case 'ArrowDown':
|
|
2105
|
+
case 'F2':
|
|
2106
|
+
event.preventDefault();
|
|
2107
|
+
this.showOverlay();
|
|
2108
|
+
this.onOverlayOpened();
|
|
2109
|
+
break;
|
|
2110
|
+
case 'Escape':
|
|
2111
|
+
this.handleEscape(event);
|
|
2112
|
+
break;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Click handler for the inline input.
|
|
2117
|
+
* Opens the overlay and calls {@link onOverlayOpened}.
|
|
2118
|
+
*
|
|
2119
|
+
* Bind this to `(click)` on your inline input element.
|
|
2120
|
+
*/
|
|
2121
|
+
onInlineClick() {
|
|
2122
|
+
if (this._isOpen) {
|
|
2123
|
+
this.hideOverlay();
|
|
2245
2124
|
}
|
|
2246
2125
|
else {
|
|
2247
|
-
|
|
2126
|
+
this.showOverlay();
|
|
2127
|
+
this.onOverlayOpened();
|
|
2248
2128
|
}
|
|
2249
2129
|
}
|
|
2250
2130
|
/**
|
|
2251
|
-
*
|
|
2131
|
+
* Handle Escape key press.
|
|
2132
|
+
*
|
|
2133
|
+
* If the overlay is open, closes it and returns focus to the inline input.
|
|
2134
|
+
* If the overlay is already closed, cancels the edit entirely.
|
|
2252
2135
|
*/
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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}`);
|
|
2136
|
+
handleEscape(event) {
|
|
2137
|
+
if (this._isOpen) {
|
|
2138
|
+
event.stopPropagation();
|
|
2139
|
+
this.hideOverlay();
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
this.cancelEdit();
|
|
2276
2143
|
}
|
|
2277
2144
|
}
|
|
2278
|
-
// #endregion
|
|
2279
|
-
// #region Public API
|
|
2280
2145
|
/**
|
|
2281
|
-
*
|
|
2282
|
-
* Unlike the context methods, this does NOT create a FormGroup lazily.
|
|
2146
|
+
* Dispatch a synthetic Tab key event to advance grid focus.
|
|
2283
2147
|
*
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2148
|
+
* Call this after committing a value and closing the overlay so the
|
|
2149
|
+
* grid moves focus to the next cell.
|
|
2150
|
+
*
|
|
2151
|
+
* @param backward - When `true`, dispatch Shift+Tab to move backwards.
|
|
2286
2152
|
*/
|
|
2287
|
-
|
|
2288
|
-
|
|
2153
|
+
advanceGridFocus(backward = false) {
|
|
2154
|
+
const cell = this._getCell();
|
|
2155
|
+
if (!cell)
|
|
2156
|
+
return;
|
|
2157
|
+
cell.dispatchEvent(new KeyboardEvent('keydown', {
|
|
2158
|
+
key: 'Tab',
|
|
2159
|
+
shiftKey: backward,
|
|
2160
|
+
bubbles: true,
|
|
2161
|
+
cancelable: true,
|
|
2162
|
+
}));
|
|
2289
2163
|
}
|
|
2290
2164
|
/**
|
|
2291
|
-
*
|
|
2292
|
-
* Useful for bulk validation or inspection.
|
|
2165
|
+
* Called after the overlay is shown.
|
|
2293
2166
|
*
|
|
2294
|
-
*
|
|
2167
|
+
* Override to focus an element inside the panel, start animations, etc.
|
|
2168
|
+
* Default implementation is a no-op.
|
|
2295
2169
|
*/
|
|
2296
|
-
|
|
2297
|
-
|
|
2170
|
+
onOverlayOpened() {
|
|
2171
|
+
// Default: no-op. Subclasses override.
|
|
2172
|
+
}
|
|
2173
|
+
// ============================================================================
|
|
2174
|
+
// Private Helpers
|
|
2175
|
+
// ============================================================================
|
|
2176
|
+
/** Find the parent cell element for this editor. */
|
|
2177
|
+
_getCell() {
|
|
2178
|
+
return this._elementRef.nativeElement.closest('[part="cell"]') ?? null;
|
|
2179
|
+
}
|
|
2180
|
+
/** Find the parent `<tbw-grid>` element for this editor. */
|
|
2181
|
+
_getGridElement() {
|
|
2182
|
+
return this._elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]') ?? null;
|
|
2298
2183
|
}
|
|
2299
2184
|
/**
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2185
|
+
* JS fallback positioning for browsers without CSS Anchor Positioning.
|
|
2186
|
+
* Uses `getBoundingClientRect()` with viewport overflow detection.
|
|
2302
2187
|
*/
|
|
2303
|
-
|
|
2304
|
-
this.
|
|
2305
|
-
this.
|
|
2188
|
+
_positionWithJs() {
|
|
2189
|
+
const cell = this._getCell();
|
|
2190
|
+
const panel = this._panel;
|
|
2191
|
+
if (!cell || !panel)
|
|
2192
|
+
return;
|
|
2193
|
+
const cellRect = cell.getBoundingClientRect();
|
|
2194
|
+
// Temporarily make visible to measure
|
|
2195
|
+
panel.style.visibility = 'hidden';
|
|
2196
|
+
panel.style.display = '';
|
|
2197
|
+
const panelRect = panel.getBoundingClientRect();
|
|
2198
|
+
panel.style.visibility = '';
|
|
2199
|
+
const viewportH = window.innerHeight;
|
|
2200
|
+
const viewportW = window.innerWidth;
|
|
2201
|
+
let top;
|
|
2202
|
+
let left;
|
|
2203
|
+
switch (this.overlayPosition) {
|
|
2204
|
+
case 'above': {
|
|
2205
|
+
top = cellRect.top - panelRect.height;
|
|
2206
|
+
left = cellRect.left;
|
|
2207
|
+
// Flip to below if off-screen
|
|
2208
|
+
if (top < 0)
|
|
2209
|
+
top = cellRect.bottom;
|
|
2210
|
+
break;
|
|
2211
|
+
}
|
|
2212
|
+
case 'below-right': {
|
|
2213
|
+
top = cellRect.bottom;
|
|
2214
|
+
left = cellRect.right - panelRect.width;
|
|
2215
|
+
// Flip to above if off-screen
|
|
2216
|
+
if (top + panelRect.height > viewportH)
|
|
2217
|
+
top = cellRect.top - panelRect.height;
|
|
2218
|
+
break;
|
|
2219
|
+
}
|
|
2220
|
+
case 'over-top-left': {
|
|
2221
|
+
top = cellRect.top;
|
|
2222
|
+
left = cellRect.left;
|
|
2223
|
+
break;
|
|
2224
|
+
}
|
|
2225
|
+
case 'over-bottom-left': {
|
|
2226
|
+
top = cellRect.bottom - panelRect.height;
|
|
2227
|
+
left = cellRect.left;
|
|
2228
|
+
break;
|
|
2229
|
+
}
|
|
2230
|
+
case 'below':
|
|
2231
|
+
default: {
|
|
2232
|
+
top = cellRect.bottom;
|
|
2233
|
+
left = cellRect.left;
|
|
2234
|
+
// Flip to above if off-screen
|
|
2235
|
+
if (top + panelRect.height > viewportH)
|
|
2236
|
+
top = cellRect.top - panelRect.height;
|
|
2237
|
+
break;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
// Clamp to viewport
|
|
2241
|
+
if (left + panelRect.width > viewportW)
|
|
2242
|
+
left = viewportW - panelRect.width - 4;
|
|
2243
|
+
if (left < 0)
|
|
2244
|
+
left = 4;
|
|
2245
|
+
if (top < 0)
|
|
2246
|
+
top = 4;
|
|
2247
|
+
panel.style.top = `${top}px`;
|
|
2248
|
+
panel.style.left = `${left}px`;
|
|
2306
2249
|
}
|
|
2307
2250
|
/**
|
|
2308
|
-
*
|
|
2251
|
+
* Document pointerdown handler for click-outside detection.
|
|
2252
|
+
* Fires `onOverlayOutsideClick()` if the click is outside the panel
|
|
2253
|
+
* and outside the editor's host element.
|
|
2254
|
+
*/
|
|
2255
|
+
_onDocumentPointerDown(event) {
|
|
2256
|
+
if (!this._isOpen || !this._panel)
|
|
2257
|
+
return;
|
|
2258
|
+
const target = event.target;
|
|
2259
|
+
const hostEl = this._elementRef.nativeElement;
|
|
2260
|
+
// Click inside panel or host — ignore
|
|
2261
|
+
if (this._panel.contains(target) || hostEl.contains(target))
|
|
2262
|
+
return;
|
|
2263
|
+
this.onOverlayOutsideClick();
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Handle the grid's `tbw-scroll` CustomEvent while the overlay is open.
|
|
2309
2267
|
*
|
|
2310
|
-
*
|
|
2268
|
+
* When the viewport scrolls, the anchored overlay would otherwise float
|
|
2269
|
+
* over content that no longer corresponds to the edited cell (or the
|
|
2270
|
+
* cell may be virtualized out entirely). Treat scroll as a dismissal
|
|
2271
|
+
* signal equivalent to clicking outside so subclasses' overlay-outside
|
|
2272
|
+
* handler drives the appropriate commit/cancel path.
|
|
2311
2273
|
*/
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2274
|
+
_onGridScroll() {
|
|
2275
|
+
if (!this._isOpen || !this._panel)
|
|
2276
|
+
return;
|
|
2277
|
+
this.onOverlayOutsideClick();
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Set up a MutationObserver on the parent cell to watch for
|
|
2281
|
+
* `cell-focus` class changes. This handles row-editing mode where
|
|
2282
|
+
* all editors exist simultaneously but only the focused cell's
|
|
2283
|
+
* editor should have its overlay visible.
|
|
2284
|
+
*
|
|
2285
|
+
* A `justOpened` flash guard suppresses the observer from
|
|
2286
|
+
* immediately closing the overlay when `beginBulkEdit()` moves
|
|
2287
|
+
* focus to the first editable column. Without this guard,
|
|
2288
|
+
* double-click triggers a "flash open then close" effect.
|
|
2289
|
+
*/
|
|
2290
|
+
_setupFocusObserver() {
|
|
2291
|
+
const cell = this._getCell();
|
|
2292
|
+
if (!cell)
|
|
2293
|
+
return;
|
|
2294
|
+
let justOpened = false;
|
|
2295
|
+
let pendingHideRaf = 0;
|
|
2296
|
+
const hostEl = this._elementRef.nativeElement;
|
|
2297
|
+
this._focusObserver = new MutationObserver((mutations) => {
|
|
2298
|
+
// Guard: if the editor's host element is no longer inside the cell,
|
|
2299
|
+
// it means the component was detached (e.g., editing session ended
|
|
2300
|
+
// and the render pipeline cleared the cell DOM). Disconnect to
|
|
2301
|
+
// prevent orphaned observers from opening stale overlay panels.
|
|
2302
|
+
if (!cell.contains(hostEl)) {
|
|
2303
|
+
this._focusObserver?.disconnect();
|
|
2304
|
+
this._focusObserver = null;
|
|
2305
|
+
return;
|
|
2317
2306
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2307
|
+
for (const mutation of mutations) {
|
|
2308
|
+
if (mutation.type !== 'attributes' || mutation.attributeName !== 'class')
|
|
2309
|
+
continue;
|
|
2310
|
+
const isFocused = cell.classList.contains('cell-focus');
|
|
2311
|
+
if (isFocused && !this._isOpen) {
|
|
2312
|
+
// Cell just gained focus — cancel any pending hide and open overlay.
|
|
2313
|
+
if (pendingHideRaf) {
|
|
2314
|
+
cancelAnimationFrame(pendingHideRaf);
|
|
2315
|
+
pendingHideRaf = 0;
|
|
2316
|
+
}
|
|
2317
|
+
justOpened = true;
|
|
2318
|
+
this.showOverlay();
|
|
2319
|
+
this.onOverlayOpened();
|
|
2320
|
+
// Clear the guard after a macrotask so that an immediate
|
|
2321
|
+
// focus-away (e.g. beginBulkEdit focus adjustment) does
|
|
2322
|
+
// not close the overlay in the same event loop tick.
|
|
2323
|
+
setTimeout(() => {
|
|
2324
|
+
justOpened = false;
|
|
2325
|
+
}, 0);
|
|
2326
|
+
}
|
|
2327
|
+
else if (!isFocused && this._isOpen && !justOpened) {
|
|
2328
|
+
// Cell lost focus — defer hide to allow render cycles to settle.
|
|
2329
|
+
// Re-renders (e.g., from ResizeObserver after a footer appears)
|
|
2330
|
+
// may transiently toggle cell-focus within the same frame.
|
|
2331
|
+
// Deferring to the next animation frame lets the render pipeline
|
|
2332
|
+
// finish before we decide whether the overlay should actually close.
|
|
2333
|
+
if (pendingHideRaf)
|
|
2334
|
+
cancelAnimationFrame(pendingHideRaf);
|
|
2335
|
+
pendingHideRaf = requestAnimationFrame(() => {
|
|
2336
|
+
pendingHideRaf = 0;
|
|
2337
|
+
// Re-check settled state — cell-focus may have been re-applied
|
|
2338
|
+
if (!cell.classList.contains('cell-focus') && this._isOpen) {
|
|
2339
|
+
this.hideOverlay(true);
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
this._focusObserver.observe(cell, { attributes: true, attributeFilter: ['class'] });
|
|
2320
2346
|
}
|
|
2321
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
2322
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "
|
|
2347
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseOverlayEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
2348
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.6", type: BaseOverlayEditor, isStandalone: true, usesInheritance: true, ngImport: i0 });
|
|
2323
2349
|
}
|
|
2324
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
2325
|
-
type: Directive
|
|
2326
|
-
|
|
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"] }] } });
|
|
2350
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseOverlayEditor, decorators: [{
|
|
2351
|
+
type: Directive
|
|
2352
|
+
}], ctorParameters: () => [] });
|
|
2330
2353
|
|
|
2331
2354
|
/**
|
|
2332
2355
|
* Editing feature for @toolbox-web/grid-angular
|
|
@@ -2378,9 +2401,13 @@ registerEditorSpecBridge((element, adapter) => {
|
|
|
2378
2401
|
const onCommit = (value) => ctx.commit(value);
|
|
2379
2402
|
const onCancel = () => ctx.cancel();
|
|
2380
2403
|
// Resolve the FormControl from the FormArrayContext, if available.
|
|
2404
|
+
// Both GridFormArray and GridLazyForm expose a FormArrayContext, but each
|
|
2405
|
+
// stores it under its own module-local Symbol. Check both so control-based
|
|
2406
|
+
// template editors (`*tbwEditor="let _; control as control"`) receive the
|
|
2407
|
+
// FormControl whether the grid uses `[formArray]` or `[lazyForm]`.
|
|
2381
2408
|
let control;
|
|
2382
2409
|
if (gridElement) {
|
|
2383
|
-
const formContext = getFormArrayContext(gridElement);
|
|
2410
|
+
const formContext = getFormArrayContext(gridElement) ?? getLazyFormContext(gridElement);
|
|
2384
2411
|
if (formContext?.hasFormGroups) {
|
|
2385
2412
|
const gridRows = gridElement.rows;
|
|
2386
2413
|
if (gridRows) {
|