@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.
Files changed (56) hide show
  1. package/fesm2022/toolbox-web-grid-angular-features-clipboard.mjs +15 -9
  2. package/fesm2022/toolbox-web-grid-angular-features-clipboard.mjs.map +1 -1
  3. package/fesm2022/toolbox-web-grid-angular-features-column-virtualization.mjs +5 -4
  4. package/fesm2022/toolbox-web-grid-angular-features-column-virtualization.mjs.map +1 -1
  5. package/fesm2022/toolbox-web-grid-angular-features-context-menu.mjs +5 -4
  6. package/fesm2022/toolbox-web-grid-angular-features-context-menu.mjs.map +1 -1
  7. package/fesm2022/toolbox-web-grid-angular-features-editing.mjs +1537 -1510
  8. package/fesm2022/toolbox-web-grid-angular-features-editing.mjs.map +1 -1
  9. package/fesm2022/toolbox-web-grid-angular-features-export.mjs +5 -4
  10. package/fesm2022/toolbox-web-grid-angular-features-export.mjs.map +1 -1
  11. package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs +12 -9
  12. package/fesm2022/toolbox-web-grid-angular-features-filtering.mjs.map +1 -1
  13. package/fesm2022/toolbox-web-grid-angular-features-grouping-columns.mjs +5 -4
  14. package/fesm2022/toolbox-web-grid-angular-features-grouping-columns.mjs.map +1 -1
  15. package/fesm2022/toolbox-web-grid-angular-features-grouping-rows.mjs +5 -4
  16. package/fesm2022/toolbox-web-grid-angular-features-grouping-rows.mjs.map +1 -1
  17. package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs +16 -11
  18. package/fesm2022/toolbox-web-grid-angular-features-master-detail.mjs.map +1 -1
  19. package/fesm2022/toolbox-web-grid-angular-features-multi-sort.mjs +5 -4
  20. package/fesm2022/toolbox-web-grid-angular-features-multi-sort.mjs.map +1 -1
  21. package/fesm2022/toolbox-web-grid-angular-features-pinned-columns.mjs +5 -4
  22. package/fesm2022/toolbox-web-grid-angular-features-pinned-columns.mjs.map +1 -1
  23. package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs +5 -4
  24. package/fesm2022/toolbox-web-grid-angular-features-pinned-rows.mjs.map +1 -1
  25. package/fesm2022/toolbox-web-grid-angular-features-pivot.mjs +5 -4
  26. package/fesm2022/toolbox-web-grid-angular-features-pivot.mjs.map +1 -1
  27. package/fesm2022/toolbox-web-grid-angular-features-print.mjs +7 -5
  28. package/fesm2022/toolbox-web-grid-angular-features-print.mjs.map +1 -1
  29. package/fesm2022/toolbox-web-grid-angular-features-reorder-columns.mjs +5 -4
  30. package/fesm2022/toolbox-web-grid-angular-features-reorder-columns.mjs.map +1 -1
  31. package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs +16 -11
  32. package/fesm2022/toolbox-web-grid-angular-features-responsive.mjs.map +1 -1
  33. package/fesm2022/toolbox-web-grid-angular-features-row-drag-drop.mjs +5 -4
  34. package/fesm2022/toolbox-web-grid-angular-features-row-drag-drop.mjs.map +1 -1
  35. package/fesm2022/toolbox-web-grid-angular-features-selection.mjs +13 -8
  36. package/fesm2022/toolbox-web-grid-angular-features-selection.mjs.map +1 -1
  37. package/fesm2022/toolbox-web-grid-angular-features-server-side.mjs +5 -4
  38. package/fesm2022/toolbox-web-grid-angular-features-server-side.mjs.map +1 -1
  39. package/fesm2022/toolbox-web-grid-angular-features-shell.mjs +31 -20
  40. package/fesm2022/toolbox-web-grid-angular-features-shell.mjs.map +1 -1
  41. package/fesm2022/toolbox-web-grid-angular-features-sticky-rows.mjs +5 -4
  42. package/fesm2022/toolbox-web-grid-angular-features-sticky-rows.mjs.map +1 -1
  43. package/fesm2022/toolbox-web-grid-angular-features-tooltip.mjs +5 -4
  44. package/fesm2022/toolbox-web-grid-angular-features-tooltip.mjs.map +1 -1
  45. package/fesm2022/toolbox-web-grid-angular-features-tree.mjs +5 -4
  46. package/fesm2022/toolbox-web-grid-angular-features-tree.mjs.map +1 -1
  47. package/fesm2022/toolbox-web-grid-angular-features-undo-redo.mjs +11 -7
  48. package/fesm2022/toolbox-web-grid-angular-features-undo-redo.mjs.map +1 -1
  49. package/fesm2022/toolbox-web-grid-angular-features-visibility.mjs +5 -4
  50. package/fesm2022/toolbox-web-grid-angular-features-visibility.mjs.map +1 -1
  51. package/fesm2022/toolbox-web-grid-angular.mjs +61 -71
  52. package/fesm2022/toolbox-web-grid-angular.mjs.map +1 -1
  53. package/package.json +2 -2
  54. package/types/toolbox-web-grid-angular-features-clipboard.d.ts +4 -3
  55. package/types/toolbox-web-grid-angular-features-clipboard.d.ts.map +1 -1
  56. 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), ...(ngDevMode ? [{ debugName: "template" }] : /* istanbul ignore next */ []));
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
- }, ...(ngDevMode ? [{ debugName: "onTemplateReceived" }] : /* istanbul ignore next */ []));
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: "21.2.5", ngImport: i0, type: GridColumnEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
85
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.5", type: GridColumnEditor, isStandalone: true, selector: "tbw-grid-column-editor", queries: [{ propertyName: "template", first: true, predicate: (TemplateRef), descendants: true, isSignal: true }], ngImport: i0 });
86
+ 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: "21.2.5", ngImport: i0, type: GridColumnEditor, decorators: [{
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(...(ngDevMode ? [{ debugName: "formArray" }] : /* istanbul ignore next */ []));
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, ...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
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
- }, ...(ngDevMode ? [{ debugName: "syncFormArrayToGrid" }] : /* istanbul ignore next */ []));
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: "21.2.5", ngImport: i0, type: GridFormArray, deps: [], target: i0.ɵɵFactoryTarget.Directive });
569
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: GridFormArray, isStandalone: true, selector: "tbw-grid[formArray]", inputs: { formArray: { classPropertyName: "formArray", publicName: "formArray", isSignal: true, isRequired: true, transformFunction: null }, syncValidation: { classPropertyName: "syncValidation", publicName: "syncValidation", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
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: "21.2.5", ngImport: i0, type: GridFormArray, decorators: [{
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
- // Registry for structural editor templates
579
- const structuralEditorRegistry = new Map();
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 editor template registered by the structural directive for a given column element.
582
- * Falls back to the non-structural directive registry.
586
+ * Gets the FormArrayContext from a grid element, if present.
587
+ * @internal
588
+ * @since 0.11.0
583
589
  */
584
- function getStructuralEditorTemplate(columnElement) {
585
- // First check structural directive registry
586
- const template = structuralEditorRegistry.get(columnElement);
587
- if (template)
588
- return template;
589
- // Fall back to the nested element registry
590
- const editorEl = columnElement.querySelector('tbw-grid-column-editor');
591
- if (editorEl) {
592
- return getEditorTemplate(editorEl);
593
- }
594
- return undefined;
590
+ function getLazyFormContext(gridElement) {
591
+ return gridElement[FORM_ARRAY_CONTEXT];
595
592
  }
596
593
  /**
597
- * Structural directive for cell editor rendering.
598
- *
599
- * This provides a cleaner syntax for defining custom cell editors without
600
- * the nested `<tbw-grid-column-editor>` and `<ng-template>` boilerplate.
601
- *
602
- * ## Usage
603
- *
604
- * ```html
605
- * <!-- Instead of this verbose syntax: -->
606
- * <tbw-grid-column field="status">
607
- * <tbw-grid-column-editor>
608
- * <ng-template let-value let-onCommit="onCommit" let-onCancel="onCancel">
609
- * <app-status-editor [value]="value" (commit)="onCommit($event)" (cancel)="onCancel()" />
610
- * </ng-template>
611
- * </tbw-grid-column-editor>
612
- * </tbw-grid-column>
594
+ * Directive that provides lazy FormGroup creation for grid editing.
613
595
  *
614
- * <!-- Use this cleaner syntax (with auto-wiring - no explicit bindings needed!): -->
615
- * <tbw-grid-column field="status">
616
- * <app-status-editor *tbwEditor="let value" [value]="value" />
617
- * </tbw-grid-column>
618
- * ```
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
- * ## Template Context
601
+ * ## Key Benefits
621
602
  *
622
- * The structural directive provides the same context as `GridColumnEditor`:
623
- * - `$implicit` / `value`: The cell value
624
- * - `row`: The full row data object
625
- * - `column`: The column configuration
626
- * - `onCommit`: Callback function to commit the new value (optional - auto-wired if component emits `commit` event)
627
- * - `onCancel`: Callback function to cancel editing (optional - auto-wired if component emits `cancel` event)
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
- * ## Import
607
+ * ## Usage
630
608
  *
631
609
  * ```typescript
632
- * import { TbwEditor } from '@toolbox-web/grid-angular/features/editing';
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-column field="status" editable>
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
- * @since 0.1.1
668
+ *
669
+ * @since 0.11.0
649
670
  */
650
- class TbwEditor {
651
- template = inject((TemplateRef));
671
+ class GridLazyForm {
652
672
  elementRef = inject((ElementRef));
653
- columnElement = null;
654
- constructor() {
655
- effect(() => {
656
- this.registerTemplate();
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
- registerTemplate() {
660
- // Find the parent tbw-grid-column or tbw-grid-type element
661
- let parent = this.elementRef.nativeElement?.parentElement;
662
- while (parent && parent.tagName !== 'TBW-GRID-COLUMN' && parent.tagName !== 'TBW-GRID-TYPE') {
663
- parent = parent.parentElement;
664
- }
665
- if (parent) {
666
- this.columnElement = parent;
667
- structuralEditorRegistry.set(parent, this.template);
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
- if (this.columnElement) {
672
- structuralEditorRegistry.delete(this.columnElement);
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
- * Static type guard for template context.
677
- * Uses `any` defaults for ergonomic template usage.
772
+ * Gets the FormGroup for a row if it exists (without creating).
678
773
  */
679
- static ngTemplateContextGuard(dir, ctx) {
680
- return true;
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
- * Whether the control is dirty (has been modified).
905
- * Returns false if no FormControl is available.
906
- */
907
- isDirty = computed(() => {
908
- return this.control()?.dirty ?? false;
909
- }, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
910
- /**
911
- * Whether the control has been touched.
912
- * Returns false if no FormControl is available.
913
- */
914
- isTouched = computed(() => {
915
- return this.control()?.touched ?? false;
916
- }, ...(ngDevMode ? [{ debugName: "isTouched" }] : /* istanbul ignore next */ []));
917
- /**
918
- * Whether the control has any validation errors.
783
+ * Stores the FormArrayContext on the grid element.
784
+ * This uses the same interface as GridFormArray for compatibility.
919
785
  */
920
- hasErrors = computed(() => {
921
- const ctrl = this.control();
922
- return ctrl?.errors != null && Object.keys(ctrl.errors).length > 0;
923
- }, ...(ngDevMode ? [{ debugName: "hasErrors" }] : /* istanbul ignore next */ []));
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
- * The first error message from the control's validation errors.
926
- * Returns an empty string if no errors.
868
+ * Clears the FormArrayContext from the grid element.
927
869
  */
928
- firstErrorMessage = computed(() => {
929
- const ctrl = this.control();
930
- if (!ctrl?.errors)
931
- return '';
932
- const firstKey = Object.keys(ctrl.errors)[0];
933
- return this.getErrorMessage(firstKey, ctrl.errors[firstKey]);
934
- }, ...(ngDevMode ? [{ debugName: "firstErrorMessage" }] : /* istanbul ignore next */ []));
870
+ #clearFormContext(grid) {
871
+ delete grid[FORM_ARRAY_CONTEXT];
872
+ }
873
+ // #endregion
874
+ // #region Event Handlers
935
875
  /**
936
- * All error messages from the control's validation errors.
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
- allErrorMessages = computed(() => {
939
- const ctrl = this.control();
940
- if (!ctrl?.errors)
941
- return [];
942
- return Object.entries(ctrl.errors).map(([key, value]) => this.getErrorMessage(key, value));
943
- }, ...(ngDevMode ? [{ debugName: "allErrorMessages" }] : /* istanbul ignore next */ []));
944
- // ============================================================================
945
- // Lifecycle
946
- // ============================================================================
947
- constructor() {
948
- afterNextRender(() => this._initEditCloseListener());
949
- this._destroyRef.onDestroy(() => {
950
- this._editCloseCleanup?.();
951
- this._editCloseCleanup = null;
952
- });
953
- }
954
- _initEditCloseListener() {
955
- const grid = this.elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]');
956
- if (!grid)
879
+ #updateRowIndexMap() {
880
+ const grid = this.elementRef.nativeElement;
881
+ const rows = grid?.rows;
882
+ if (!rows)
957
883
  return;
958
- let unsubBefore;
959
- let unsubClose;
960
- unsubBefore = grid.on('before-edit-close', () => {
961
- this.onBeforeEditClose();
962
- unsubBefore?.();
963
- unsubBefore = undefined;
964
- });
965
- unsubClose = grid.on('edit-close', () => {
966
- this.onEditClose();
967
- unsubClose?.();
968
- unsubClose = undefined;
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
- * Whether this editor's cell is the currently focused cell.
980
- *
981
- * In row editing mode the grid creates editors for every editable cell
982
- * in the row simultaneously. Use this to conditionally auto-focus inputs
983
- * or open panels only in the active cell.
984
- *
985
- * Performs a synchronous DOM check — safe to call from `ngAfterViewInit`.
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
- isCellFocused() {
988
- return this.elementRef.nativeElement.closest('[part="cell"]')?.classList.contains('cell-focus') ?? false;
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
- * Called **before** the grid clears editing state and destroys editor DOM.
992
- *
993
- * At this point the commit callback is still active, so subclasses can
994
- * call {@link commitValue} to flush any pending/deferred values.
995
- *
996
- * This fires only on the **commit** path (not on revert/cancel).
997
- * Use {@link onEditClose} for cleanup that should happen on both paths.
970
+ * Syncs a FormControl's validation state to the grid's visual invalid styling.
998
971
  */
999
- onBeforeEditClose() {
1000
- // Default: no-op. Subclasses override to flush pending values.
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
- * Called when the grid ends the editing session for this cell.
1004
- *
1005
- * Override to perform cleanup such as closing overlay panels, autocomplete
1006
- * dropdowns, or other floating UI that lives at `<body>` level and would
1007
- * otherwise persist after the editor DOM is removed.
1008
- *
1009
- * The listener is set up automatically via `afterNextRender` — no manual
1010
- * wiring required.
988
+ * Gets a human-readable error message from the first validation error.
1011
989
  */
1012
- onEditClose() {
1013
- // Default: no-op. Subclasses override.
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
- * Called by the grid adapter when the cell value changes externally
1017
- * (e.g., via `updateRow()` cascade or undo/redo).
1018
- *
1019
- * Override in subclasses to reset internal state (search text, selection
1020
- * flags, etc.) so the editor displays the updated value.
1021
- *
1022
- * This runs **synchronously** before the value input is updated, giving
1023
- * the editor a chance to clear stale state before the next change-detection
1024
- * pass re-reads the template.
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 _newVal The new cell value being pushed from the grid
1021
+ * @param rowIndex The row index
1022
+ * @returns The FormGroup or undefined
1027
1023
  */
1028
- onExternalValueChange(_newVal) {
1029
- // Default: no-op. Subclasses override as needed.
1024
+ getFormGroup(rowIndex) {
1025
+ return this.#getRowFormGroup(rowIndex);
1030
1026
  }
1031
1027
  /**
1032
- * Commit a new value. Emits the commit output AND dispatches a DOM event.
1033
- * The DOM event enables the grid's auto-wiring to catch the commit.
1034
- * Call this when the user confirms their edit.
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
- commitValue(newValue) {
1037
- // Emit Angular output for template bindings
1038
- this.commit.emit(newValue);
1039
- // Dispatch DOM CustomEvent for grid's auto-wiring
1040
- // This allows the adapter to catch commits without explicit (commit)="..." bindings
1041
- this.elementRef.nativeElement.dispatchEvent(new CustomEvent('commit', { detail: newValue, bubbles: true }));
1033
+ getAllFormGroups() {
1034
+ return this.formGroupCache;
1042
1035
  }
1043
1036
  /**
1044
- * Cancel editing. Emits the cancel output AND dispatches a DOM event.
1045
- * Call this when the user cancels (e.g., presses Escape).
1037
+ * Clears all cached FormGroups.
1038
+ * Useful when the underlying data changes significantly.
1046
1039
  */
1047
- cancelEdit() {
1048
- // Emit Angular output for template bindings
1049
- this.cancel.emit();
1050
- // Dispatch DOM CustomEvent for grid's auto-wiring
1051
- this.elementRef.nativeElement.dispatchEvent(new CustomEvent('cancel', { bubbles: true }));
1040
+ clearAllFormGroups() {
1041
+ this.formGroupCache.clear();
1042
+ this.rowIndexMap.clear();
1052
1043
  }
1053
1044
  /**
1054
- * Get a human-readable error message for a validation error.
1055
- * Override this method to customize error messages for your editor.
1045
+ * Validates all currently cached FormGroups.
1056
1046
  *
1057
- * @param errorKey - The validation error key (e.g., 'required', 'minlength')
1058
- * @param errorValue - The error value (e.g., { requiredLength: 5, actualLength: 3 })
1059
- * @returns A human-readable error message
1047
+ * @returns true if all FormGroups are valid, false otherwise
1060
1048
  */
1061
- getErrorMessage(errorKey, errorValue) {
1062
- switch (errorKey) {
1063
- case 'required':
1064
- return 'This field is required';
1065
- case 'minlength': {
1066
- const err = errorValue;
1067
- return `Minimum length is ${err?.requiredLength ?? 'unknown'}`;
1068
- }
1069
- case 'maxlength': {
1070
- const err = errorValue;
1071
- return `Maximum length is ${err?.requiredLength ?? 'unknown'}`;
1072
- }
1073
- case 'min': {
1074
- const err = errorValue;
1075
- return `Minimum value is ${err?.min ?? 'unknown'}`;
1076
- }
1077
- case 'max': {
1078
- const err = errorValue;
1079
- return `Maximum value is ${err?.max ?? 'unknown'}`;
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: "21.2.5", ngImport: i0, type: BaseGridEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1090
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: BaseGridEditor, isStandalone: true, inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, row: { classPropertyName: "row", publicName: "row", isSignal: true, isRequired: false, transformFunction: null }, column: { classPropertyName: "column", publicName: "column", isSignal: true, isRequired: false, transformFunction: null }, control: { classPropertyName: "control", publicName: "control", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { commit: "commit", cancel: "cancel" }, ngImport: i0 });
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: "21.2.5", ngImport: i0, type: BaseGridEditor, decorators: [{
1093
- type: Directive
1094
- }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], row: [{ type: i0.Input, args: [{ isSignal: true, alias: "row", required: false }] }], column: [{ type: i0.Input, args: [{ isSignal: true, alias: "column", required: false }] }], control: [{ type: i0.Input, args: [{ isSignal: true, alias: "control", required: false }] }], commit: [{ type: i0.Output, args: ["commit"] }], cancel: [{ type: i0.Output, args: ["cancel"] }] } });
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
- * Base class for grid editors that also work as Angular form controls.
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
- * Combines `BaseGridEditor` with `ControlValueAccessor` so the same component
1100
- * can be used inside a `<tbw-grid>` **and** in a standalone `<form>`.
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
- * ## What it provides
1110
+ * ## Template Context
1103
1111
  *
1104
- * | Member | Purpose |
1105
- * |--------|---------|
1106
- * | `cvaValue` | Signal holding the value written by the form control |
1107
- * | `disabledState` | Signal tracking `setDisabledState` calls |
1108
- * | `displayValue` | Computed that prefers grid value (`currentValue`) and falls back to `cvaValue` |
1109
- * | `commitBoth(v)` | Commits via both CVA `onChange` and grid `commitValue` |
1110
- * | `writeValue` / `registerOn*` / `setDisabledState` | Full CVA implementation |
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
- * ## Usage
1119
+ * ## Import
1113
1120
  *
1114
1121
  * ```typescript
1115
- * import { Component, forwardRef } from '@angular/core';
1116
- * import { NG_VALUE_ACCESSOR } from '@angular/forms';
1117
- * import { BaseGridEditorCVA } from '@toolbox-web/grid-angular/features/editing';
1122
+ * import { TbwEditor } from '@toolbox-web/grid-angular/features/editing';
1118
1123
  *
1119
1124
  * @Component({
1120
- * selector: 'app-date-picker',
1121
- * providers: [{
1122
- * provide: NG_VALUE_ACCESSOR,
1123
- * useExisting: forwardRef(() => DatePickerComponent),
1124
- * multi: true,
1125
- * }],
1126
- * template: `
1127
- * <input
1128
- * type="date"
1129
- * [value]="displayValue()"
1130
- * [disabled]="disabledState()"
1131
- * (change)="commitBoth($event.target.value)"
1132
- * (keydown.escape)="cancelEdit()"
1133
- * />
1134
- * `
1125
+ * imports: [TbwEditor],
1126
+ * // ...
1135
1127
  * })
1136
- * export class DatePickerComponent extends BaseGridEditorCVA<MyRow, string> {}
1137
1128
  * ```
1138
1129
  *
1139
- * > **Note:** Subclasses must still provide `NG_VALUE_ACCESSOR` themselves
1140
- * > because `forwardRef(() => ConcreteClass)` must reference the concrete
1141
- * > component — this is an Angular limitation.
1142
- *
1143
- * @typeParam TRow - The row data type
1144
- * @typeParam TValue - The cell/control value type
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
- * @since 0.13.0
1137
+ * @category Directive
1138
+ * @since 0.1.1
1147
1139
  */
1148
- class BaseGridEditorCVA extends BaseGridEditor {
1149
- // ============================================================================
1150
- // CVA State
1151
- // ============================================================================
1152
- /** Internal onChange callback registered by the form control. */
1153
- // eslint-disable-next-line @typescript-eslint/no-empty-function
1154
- _onChange = () => { };
1155
- /** Internal onTouched callback registered by the form control. */
1156
- // eslint-disable-next-line @typescript-eslint/no-empty-function
1157
- _onTouched = () => { };
1158
- /**
1159
- * Signal holding the value written by the form control via `writeValue()`.
1160
- * Updated when the form control pushes a new value (e.g. `patchValue`, `setValue`).
1161
- */
1162
- cvaValue = signal(null, ...(ngDevMode ? [{ debugName: "cvaValue" }] : /* istanbul ignore next */ []));
1163
- /**
1164
- * Signal tracking the disabled state set by the form control.
1165
- * Updated when `setDisabledState()` is called by Angular's forms module.
1166
- */
1167
- disabledState = signal(false, ...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
1168
- /**
1169
- * Resolved display value.
1170
- *
1171
- * Prefers `currentValue()` (grid context — from `control.value` or `value` input)
1172
- * and falls back to `cvaValue()` (standalone form context — from `writeValue`).
1173
- *
1174
- * Use this in your template instead of reading `currentValue()` directly
1175
- * so the component works in both grid and standalone form contexts.
1176
- */
1177
- displayValue = computed(() => {
1178
- return this.currentValue() ?? this.cvaValue();
1179
- }, ...(ngDevMode ? [{ debugName: "displayValue" }] : /* istanbul ignore next */ []));
1180
- // ============================================================================
1181
- // ControlValueAccessor Implementation
1182
- // ============================================================================
1183
- /**
1184
- * Called by Angular forms when the form control value changes programmatically.
1185
- */
1186
- writeValue(value) {
1187
- this.cvaValue.set(value);
1188
- }
1189
- /**
1190
- * Called by Angular forms to register a change callback.
1191
- */
1192
- registerOnChange(fn) {
1193
- this._onChange = fn;
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
- * Called by Angular forms to register a touched callback.
1197
- */
1198
- registerOnTouched(fn) {
1199
- this._onTouched = fn;
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
- * Called by Angular forms to set the disabled state.
1203
- */
1204
- setDisabledState(isDisabled) {
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
- * Commit a value through both the CVA (form control) and the grid.
1212
- *
1213
- * - Calls the CVA `onChange` callback (updates the form control)
1214
- * - Marks the control as touched
1215
- * - Calls `commitValue()` (emits grid commit event + DOM `CustomEvent`)
1216
- *
1217
- * Use this instead of `commitValue()` when your editor doubles as a form control.
1218
- *
1219
- * @param value - The new value to commit
1166
+ * Static type guard for template context.
1167
+ * Uses `any` defaults for ergonomic template usage.
1220
1168
  */
1221
- commitBoth(value) {
1222
- // Update CVA
1223
- this.cvaValue.set(value);
1224
- this._onChange(value);
1225
- this._onTouched();
1226
- // Update grid (null is valid for nullable columns — the grid's cell-commit
1227
- // handler and EditingPlugin will apply it correctly)
1228
- this.commitValue(value);
1169
+ static ngTemplateContextGuard(dir, ctx) {
1170
+ return true;
1229
1171
  }
1230
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: BaseGridEditorCVA, deps: null, target: i0.ɵɵFactoryTarget.Directive });
1231
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.5", type: BaseGridEditorCVA, isStandalone: true, usesInheritance: true, ngImport: i0 });
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: "21.2.5", ngImport: i0, type: BaseGridEditorCVA, decorators: [{
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
- * CSS for the overlay panel base layer.
1243
- * Injected once into `<head>` on first `BaseOverlayEditor` use.
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
- * Uses CSS Anchor Positioning as primary strategy with a JS fallback
1246
- * for browsers that don't support it (Firefox, Safari as of late 2025).
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
- const OVERLAY_STYLES = /* css */ `
1249
- .tbw-overlay-panel {
1250
- position: fixed;
1251
- z-index: 10000;
1252
- background: var(--tbw-overlay-bg, #fff);
1253
- border: 1px solid var(--tbw-overlay-border, #ccc);
1254
- border-radius: var(--tbw-overlay-radius, 4px);
1255
- box-shadow: var(--tbw-overlay-shadow, 0 4px 12px rgba(0, 0, 0, 0.15));
1256
- box-sizing: border-box;
1257
- overflow: auto;
1258
- }
1259
-
1260
- /* Hide panels that have not been initialised via initOverlay() yet.
1261
- Prevents a flash-of-unstyled-content when the subclass defers
1262
- the initOverlay call (e.g. via setTimeout or afterNextRender). */
1263
- .tbw-overlay-panel:not([data-anchor-id]) {
1264
- display: none;
1265
- }
1266
-
1267
- .tbw-overlay-panel:popover-open {
1268
- display: block;
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 that display a floating overlay panel.
1274
+ * Base class for grid cell editors.
1317
1275
  *
1318
- * Provides infrastructure for:
1319
- * - **Overlay positioning** — CSS Anchor Positioning with JS fallback
1320
- * - **Focus gating** — in row editing mode, the panel only opens for the focused cell
1321
- * - **Click-outside detection** — closes the panel when clicking outside
1322
- * - **MutationObserver** — detects cell focus changes (row editing mode)
1323
- * - **Escape handling** — closes the panel and returns focus to the inline input
1324
- * - **Synthetic Tab dispatch** — advances grid focus after overlay close
1325
- * - **Automatic teardown** — removes the panel from `<body>` and cleans up listeners
1326
- * - **External focus registration** — auto-registers the panel via `grid.registerExternalFocusContainer()` so the grid keeps `data-has-focus` and editors stay open while the overlay has focus
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, viewChild, ElementRef, effect } from '@angular/core';
1332
- * import { BaseOverlayEditor } from '@toolbox-web/grid-angular/features/editing';
1285
+ * import { Component } from '@angular/core';
1286
+ * import { BaseGridEditor } from '@toolbox-web/grid-angular/features/editing';
1333
1287
  *
1334
1288
  * @Component({
1335
- * selector: 'app-date-editor',
1336
- * template: `
1289
+ * selector: 'app-my-editor',
1290
+ * template: \`
1337
1291
  * <input
1338
- * #inlineInput
1339
- * readonly
1340
1292
  * [value]="currentValue()"
1341
- * (click)="onInlineClick()"
1342
- * (keydown)="onInlineKeydown($event)"
1293
+ * [class.is-invalid]="isInvalid()"
1294
+ * (input)="commitValue($event.target.value)"
1295
+ * (keydown.escape)="cancelEdit()"
1343
1296
  * />
1344
- * <div #panel class="tbw-overlay-panel" style="width: 280px;">
1345
- * <!-- your date picker UI here -->
1346
- * <div class="actions">
1347
- * <button (click)="selectAndClose(selectedDate)">OK</button>
1348
- * <button (click)="hideOverlay()">Cancel</button>
1349
- * </div>
1350
- * </div>
1351
- * `
1297
+ * @if (hasErrors()) {
1298
+ * <div class="error">{{ firstErrorMessage() }}</div>
1299
+ * }
1300
+ * \`
1352
1301
  * })
1353
- * export class DateEditorComponent extends BaseOverlayEditor<MyRow, string> {
1354
- * panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
1355
- * inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
1356
- *
1357
- * protected override overlayPosition = 'below' as const;
1358
- *
1359
- * constructor() {
1360
- * super();
1361
- * effect(() => {
1362
- * const panel = this.panelRef().nativeElement;
1363
- * this.initOverlay(panel);
1364
- * if (this.isCellFocused()) this.showOverlay();
1365
- * });
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
- * protected getInlineInput(): HTMLInputElement | null {
1369
- * return this.inputRef()?.nativeElement ?? null;
1370
- * }
1312
+ * ## Template Syntax
1371
1313
  *
1372
- * protected onOverlayOutsideClick(): void {
1373
- * this.hideOverlay();
1374
- * }
1314
+ * When using the base class, you only need to pass the control:
1375
1315
  *
1376
- * selectAndClose(date: string): void {
1377
- * this.commitValue(date);
1378
- * this.hideOverlay();
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
- * @example
1384
- * ```typescript
1385
- * @Component({
1386
- * template: `
1387
- * <input #inlineInput readonly [value]="currentValue()"
1388
- * (click)="onInlineClick()" (keydown)="onInlineKeydown($event)" />
1389
- * <div #panel style="width: 280px">
1390
- * <!-- overlay content -->
1391
- * </div>
1392
- * `
1393
- * })
1394
- * export class MyEditor extends BaseOverlayEditor<MyRow, string> {
1395
- * panelRef = viewChild.required<ElementRef<HTMLElement>>('panel');
1396
- * inputRef = viewChild.required<ElementRef<HTMLInputElement>>('inlineInput');
1397
- *
1398
- * constructor() {
1399
- * super();
1400
- * effect(() => this.initOverlay(this.panelRef().nativeElement));
1401
- * }
1322
+ * Or without FormArray binding (fallback to value):
1402
1323
  *
1403
- * protected getInlineInput() { return this.inputRef()?.nativeElement ?? null; }
1404
- * protected onOverlayOutsideClick() { this.hideOverlay(); }
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.13.0
1333
+ * @since 0.5.0
1412
1334
  */
1413
- class BaseOverlayEditor extends BaseGridEditor {
1414
- _elementRef = inject(ElementRef);
1415
- _overlayDestroyRef = inject(DestroyRef);
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
- // Configuration
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
- * Position of the overlay panel relative to the anchor cell.
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
- overlayPosition = 'below';
1426
- // ============================================================================
1427
- // Internal State
1428
- // ============================================================================
1429
- /** The overlay panel element (set via `initOverlay()`). */
1430
- _panel = null;
1431
- /** Whether the overlay is currently visible. */
1432
- _isOpen = false;
1433
- /** Unique anchor ID for CSS Anchor Positioning. */
1434
- _anchorId = '';
1435
- /** Whether the browser supports CSS Anchor Positioning. */
1436
- _supportsAnchor = false;
1437
- /** AbortController for all overlay-related listeners. */
1438
- _abortCtrl = null;
1439
- /** MutationObserver watching cell focus class changes. */
1440
- _focusObserver = null;
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
- super();
1446
- this._supportsAnchor = typeof CSS !== 'undefined' && CSS.supports('anchor-name', '--a');
1447
- ensureOverlayStyles();
1448
- afterNextRender(() => this._setupFocusObserver());
1449
- this._overlayDestroyRef.onDestroy(() => this.teardownOverlay());
1450
+ 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
- // Public API — Subclass Interface
1478
+ // Methods
1453
1479
  // ============================================================================
1454
1480
  /**
1455
- * Initialise the overlay with the panel element.
1481
+ * Whether this editor's cell is the currently focused cell.
1456
1482
  *
1457
- * Call this in an `effect()` or `afterNextRender()` with your `viewChild` panel reference.
1458
- * The panel is moved to `<body>` and hidden until {@link showOverlay} is called.
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
- * @param panel - The overlay panel DOM element
1487
+ * Performs a synchronous DOM check — safe to call from `ngAfterViewInit`.
1461
1488
  */
1462
- initOverlay(panel) {
1463
- this._panel = panel;
1464
- // Assign a unique anchor ID
1465
- this._anchorId = `tbw-anchor-${++anchorCounter}`;
1466
- panel.classList.add('tbw-overlay-panel');
1467
- panel.setAttribute('data-pos', this.overlayPosition);
1468
- panel.setAttribute('data-anchor-id', this._anchorId);
1469
- panel.style.display = 'none';
1470
- // Set up CSS Anchor Positioning on the cell
1471
- if (this._supportsAnchor) {
1472
- const cell = this._getCell();
1473
- if (cell) {
1474
- cell.style.setProperty('anchor-name', `--${this._anchorId}`);
1475
- panel.style.setProperty('--tbw-overlay-anchor', `--${this._anchorId}`);
1476
- }
1477
- }
1478
- // Move panel to body so it escapes grid overflow clipping
1479
- document.body.appendChild(panel);
1480
- // Register the panel as an external focus container on the grid
1481
- // so focus moving into the overlay is treated as "still in the grid"
1482
- this._getGridElement()?.registerExternalFocusContainer?.(panel);
1483
- // Set up click-outside detection
1484
- this._abortCtrl = new AbortController();
1485
- document.addEventListener('pointerdown', (e) => this._onDocumentPointerDown(e), {
1486
- signal: this._abortCtrl.signal,
1487
- });
1488
- // Close overlay when the grid viewport scrolls. Mirrors outside-click
1489
- // semantics so subclasses' `onOverlayOutsideClick()` drives commit/cancel.
1490
- // Uses the public `tbw-scroll` CustomEvent dispatched on the grid host —
1491
- // no shadow-DOM reach-arounds required (composed: true crosses boundaries).
1492
- const grid = this._getGridElement();
1493
- if (grid) {
1494
- grid.addEventListener('tbw-scroll', () => this._onGridScroll(), {
1495
- signal: this._abortCtrl.signal,
1496
- });
1497
- }
1498
- // If the focus observer already fired before the panel was initialised
1499
- // (e.g. initOverlay called from a deferred setTimeout), the showOverlay()
1500
- // call was silently ignored because _panel was still null. Catch up now.
1501
- if (this._getCell()?.classList.contains('cell-focus')) {
1502
- this.showOverlay();
1503
- this.onOverlayOpened();
1504
- }
1489
+ isCellFocused() {
1490
+ return this.elementRef.nativeElement.closest('[part="cell"]')?.classList.contains('cell-focus') ?? false;
1505
1491
  }
1506
1492
  /**
1507
- * Show the overlay panel.
1493
+ * Called **before** the grid clears editing state and destroys editor DOM.
1508
1494
  *
1509
- * If CSS Anchor Positioning is not supported, falls back to JS-based
1510
- * positioning using `getBoundingClientRect()`.
1511
- */
1512
- showOverlay() {
1513
- if (!this._panel || this._isOpen)
1514
- return;
1515
- this._isOpen = true;
1516
- this._panel.style.display = '';
1517
- // JS fallback positioning for browsers without CSS Anchor Positioning
1518
- if (!this._supportsAnchor) {
1519
- this._positionWithJs();
1520
- }
1521
- }
1522
- /**
1523
- * Hide the overlay panel.
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
- * @param suppressTabAdvance - When `true`, skip synthetic Tab dispatch
1526
- * (useful when hiding is triggered by an external focus change).
1527
- */
1528
- hideOverlay(suppressTabAdvance) {
1529
- if (!this._panel || !this._isOpen)
1530
- return;
1531
- this._isOpen = false;
1532
- this._panel.style.display = 'none';
1533
- if (!suppressTabAdvance) {
1534
- this.getInlineInput()?.focus();
1535
- }
1536
- }
1537
- /**
1538
- * Close and immediately re-open the overlay.
1539
- * Useful after the panel content changes size and needs repositioning.
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
- reopenOverlay() {
1542
- if (!this._panel)
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
- * Remove the overlay from the DOM and clean up all listeners.
1505
+ * Called when the grid ends the editing session for this cell.
1550
1506
  *
1551
- * Called automatically on `DestroyRef.onDestroy`. Can also be called
1552
- * manually if the editor needs early cleanup.
1553
- */
1554
- teardownOverlay() {
1555
- this._abortCtrl?.abort();
1556
- this._abortCtrl = null;
1557
- this._focusObserver?.disconnect();
1558
- this._focusObserver = null;
1559
- // Unregister the panel from the grid's external focus container registry
1560
- if (this._panel) {
1561
- this._getGridElement()?.unregisterExternalFocusContainer?.(this._panel);
1562
- }
1563
- if (this._panel?.parentNode) {
1564
- this._panel.parentNode.removeChild(this._panel);
1565
- }
1566
- this._panel = null;
1567
- this._isOpen = false;
1568
- // Clean up anchor-name on the cell
1569
- if (this._supportsAnchor) {
1570
- const cell = this._getCell();
1571
- if (cell) {
1572
- cell.style.removeProperty('anchor-name');
1573
- }
1574
- }
1575
- }
1576
- /**
1577
- * Override in `edit-close` handler to also hide the overlay.
1578
- * This is called automatically by `BaseGridEditor` when the grid
1579
- * ends the editing session.
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
- this.hideOverlay(true);
1515
+ // Default: no-op. Subclasses override.
1583
1516
  }
1584
- // ============================================================================
1585
- // Keyboard & Click Helpers
1586
- // ============================================================================
1587
1517
  /**
1588
- * Keydown handler for the inline readonly input.
1518
+ * Called by the grid adapter when the cell value changes externally
1519
+ * (e.g., via `updateRow()` cascade or undo/redo).
1589
1520
  *
1590
- * - **Enter / Space / ArrowDown / F2** → open overlay
1591
- * - **Escape** → calls {@link handleEscape}
1521
+ * Override in subclasses to reset internal state (search text, selection
1522
+ * flags, etc.) so the editor displays the updated value.
1592
1523
  *
1593
- * Bind this to `(keydown)` on your inline input element.
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
- onInlineKeydown(event) {
1596
- switch (event.key) {
1597
- case 'Enter':
1598
- case ' ':
1599
- case 'ArrowDown':
1600
- case 'F2':
1601
- event.preventDefault();
1602
- this.showOverlay();
1603
- this.onOverlayOpened();
1604
- break;
1605
- case 'Escape':
1606
- this.handleEscape(event);
1607
- break;
1608
- }
1530
+ onExternalValueChange(_newVal) {
1531
+ // Default: no-op. Subclasses override as needed.
1609
1532
  }
1610
1533
  /**
1611
- * Click handler for the inline input.
1612
- * Opens the overlay and calls {@link onOverlayOpened}.
1613
- *
1614
- * Bind this to `(click)` on your inline input element.
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
- onInlineClick() {
1617
- if (this._isOpen) {
1618
- this.hideOverlay();
1619
- }
1620
- else {
1621
- this.showOverlay();
1622
- this.onOverlayOpened();
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
- * Handle Escape key press.
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
- * If the overlay is open, closes it and returns focus to the inline input.
1629
- * If the overlay is already closed, cancels the edit entirely.
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
- handleEscape(event) {
1632
- if (this._isOpen) {
1633
- event.stopPropagation();
1634
- this.hideOverlay();
1635
- }
1636
- else {
1637
- this.cancelEdit();
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
- * Dispatch a synthetic Tab key event to advance grid focus.
1642
- *
1643
- * Call this after committing a value and closing the overlay so the
1644
- * grid moves focus to the next cell.
1645
- *
1646
- * @param backward - When `true`, dispatch Shift+Tab to move backwards.
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
- advanceGridFocus(backward = false) {
1649
- const cell = this._getCell();
1650
- if (!cell)
1651
- return;
1652
- cell.dispatchEvent(new KeyboardEvent('keydown', {
1653
- key: 'Tab',
1654
- shiftKey: backward,
1655
- bubbles: true,
1656
- cancelable: true,
1657
- }));
1658
- }
1664
+ cvaValue = signal(null, /* @ts-ignore */
1665
+ ...(ngDevMode ? [{ debugName: "cvaValue" }] : /* istanbul ignore next */ []));
1659
1666
  /**
1660
- * Called after the overlay is shown.
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
- * Override to focus an element inside the panel, start animations, etc.
1663
- * Default implementation is a no-op.
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
- onOverlayOpened() {
1666
- // Default: no-op. Subclasses override.
1667
- }
1681
+ displayValue = computed(() => {
1682
+ return this.currentValue() ?? this.cvaValue();
1683
+ }, /* @ts-ignore */
1684
+ ...(ngDevMode ? [{ debugName: "displayValue" }] : /* istanbul ignore next */ []));
1668
1685
  // ============================================================================
1669
- // Private Helpers
1686
+ // ControlValueAccessor Implementation
1670
1687
  // ============================================================================
1671
- /** Find the parent cell element for this editor. */
1672
- _getCell() {
1673
- return this._elementRef.nativeElement.closest('[part="cell"]') ?? null;
1674
- }
1675
- /** Find the parent `<tbw-grid>` element for this editor. */
1676
- _getGridElement() {
1677
- return this._elementRef.nativeElement.closest('tbw-grid, [data-tbw-grid]') ?? null;
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
- * JS fallback positioning for browsers without CSS Anchor Positioning.
1681
- * Uses `getBoundingClientRect()` with viewport overflow detection.
1695
+ * Called by Angular forms to register a change callback.
1682
1696
  */
1683
- _positionWithJs() {
1684
- const cell = this._getCell();
1685
- const panel = this._panel;
1686
- if (!cell || !panel)
1687
- return;
1688
- const cellRect = cell.getBoundingClientRect();
1689
- // Temporarily make visible to measure
1690
- panel.style.visibility = 'hidden';
1691
- panel.style.display = '';
1692
- const panelRect = panel.getBoundingClientRect();
1693
- panel.style.visibility = '';
1694
- const viewportH = window.innerHeight;
1695
- const viewportW = window.innerWidth;
1696
- let top;
1697
- let left;
1698
- switch (this.overlayPosition) {
1699
- case 'above': {
1700
- top = cellRect.top - panelRect.height;
1701
- left = cellRect.left;
1702
- // Flip to below if off-screen
1703
- if (top < 0)
1704
- top = cellRect.bottom;
1705
- break;
1706
- }
1707
- case 'below-right': {
1708
- top = cellRect.bottom;
1709
- left = cellRect.right - panelRect.width;
1710
- // Flip to above if off-screen
1711
- if (top + panelRect.height > viewportH)
1712
- top = cellRect.top - panelRect.height;
1713
- break;
1714
- }
1715
- case 'over-top-left': {
1716
- top = cellRect.top;
1717
- left = cellRect.left;
1718
- break;
1719
- }
1720
- case 'over-bottom-left': {
1721
- top = cellRect.bottom - panelRect.height;
1722
- left = cellRect.left;
1723
- break;
1724
- }
1725
- case 'below':
1726
- default: {
1727
- top = cellRect.bottom;
1728
- left = cellRect.left;
1729
- // Flip to above if off-screen
1730
- if (top + panelRect.height > viewportH)
1731
- top = cellRect.top - panelRect.height;
1732
- break;
1733
- }
1734
- }
1735
- // Clamp to viewport
1736
- if (left + panelRect.width > viewportW)
1737
- left = viewportW - panelRect.width - 4;
1738
- if (left < 0)
1739
- left = 4;
1740
- if (top < 0)
1741
- top = 4;
1742
- panel.style.top = `${top}px`;
1743
- panel.style.left = `${left}px`;
1697
+ registerOnChange(fn) {
1698
+ this._onChange = fn;
1744
1699
  }
1745
1700
  /**
1746
- * Document pointerdown handler for click-outside detection.
1747
- * Fires `onOverlayOutsideClick()` if the click is outside the panel
1748
- * and outside the editor's host element.
1701
+ * Called by Angular forms to register a touched callback.
1749
1702
  */
1750
- _onDocumentPointerDown(event) {
1751
- if (!this._isOpen || !this._panel)
1752
- return;
1753
- const target = event.target;
1754
- const hostEl = this._elementRef.nativeElement;
1755
- // Click inside panel or host — ignore
1756
- if (this._panel.contains(target) || hostEl.contains(target))
1757
- return;
1758
- this.onOverlayOutsideClick();
1703
+ registerOnTouched(fn) {
1704
+ this._onTouched = fn;
1759
1705
  }
1760
1706
  /**
1761
- * Handle the grid's `tbw-scroll` CustomEvent while the overlay is open.
1762
- *
1763
- * When the viewport scrolls, the anchored overlay would otherwise float
1764
- * over content that no longer corresponds to the edited cell (or the
1765
- * cell may be virtualized out entirely). Treat scroll as a dismissal
1766
- * signal equivalent to clicking outside so subclasses' overlay-outside
1767
- * handler drives the appropriate commit/cancel path.
1707
+ * Called by Angular forms to set the disabled state.
1768
1708
  */
1769
- _onGridScroll() {
1770
- if (!this._isOpen || !this._panel)
1771
- return;
1772
- this.onOverlayOutsideClick();
1709
+ setDisabledState(isDisabled) {
1710
+ this.disabledState.set(isDisabled);
1773
1711
  }
1712
+ // ============================================================================
1713
+ // Dual-Commit Helpers
1714
+ // ============================================================================
1774
1715
  /**
1775
- * Set up a MutationObserver on the parent cell to watch for
1776
- * `cell-focus` class changes. This handles row-editing mode where
1777
- * all editors exist simultaneously but only the focused cell's
1778
- * editor should have its overlay visible.
1716
+ * Commit a value through both the CVA (form control) and the grid.
1779
1717
  *
1780
- * A `justOpened` flash guard suppresses the observer from
1781
- * immediately closing the overlay when `beginBulkEdit()` moves
1782
- * focus to the first editable column. Without this guard,
1783
- * double-click triggers a "flash open then close" effect.
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
- _setupFocusObserver() {
1786
- const cell = this._getCell();
1787
- if (!cell)
1788
- return;
1789
- let justOpened = false;
1790
- let pendingHideRaf = 0;
1791
- const hostEl = this._elementRef.nativeElement;
1792
- this._focusObserver = new MutationObserver((mutations) => {
1793
- // Guard: if the editor's host element is no longer inside the cell,
1794
- // it means the component was detached (e.g., editing session ended
1795
- // and the render pipeline cleared the cell DOM). Disconnect to
1796
- // prevent orphaned observers from opening stale overlay panels.
1797
- if (!cell.contains(hostEl)) {
1798
- this._focusObserver?.disconnect();
1799
- this._focusObserver = null;
1800
- return;
1801
- }
1802
- for (const mutation of mutations) {
1803
- if (mutation.type !== 'attributes' || mutation.attributeName !== 'class')
1804
- continue;
1805
- const isFocused = cell.classList.contains('cell-focus');
1806
- if (isFocused && !this._isOpen) {
1807
- // Cell just gained focus — cancel any pending hide and open overlay.
1808
- if (pendingHideRaf) {
1809
- cancelAnimationFrame(pendingHideRaf);
1810
- pendingHideRaf = 0;
1811
- }
1812
- justOpened = true;
1813
- this.showOverlay();
1814
- this.onOverlayOpened();
1815
- // Clear the guard after a macrotask so that an immediate
1816
- // focus-away (e.g. beginBulkEdit focus adjustment) does
1817
- // not close the overlay in the same event loop tick.
1818
- setTimeout(() => {
1819
- justOpened = false;
1820
- }, 0);
1821
- }
1822
- else if (!isFocused && this._isOpen && !justOpened) {
1823
- // Cell lost focus — defer hide to allow render cycles to settle.
1824
- // Re-renders (e.g., from ResizeObserver after a footer appears)
1825
- // may transiently toggle cell-focus within the same frame.
1826
- // Deferring to the next animation frame lets the render pipeline
1827
- // finish before we decide whether the overlay should actually close.
1828
- if (pendingHideRaf)
1829
- cancelAnimationFrame(pendingHideRaf);
1830
- pendingHideRaf = requestAnimationFrame(() => {
1831
- pendingHideRaf = 0;
1832
- // Re-check settled state — cell-focus may have been re-applied
1833
- if (!cell.classList.contains('cell-focus') && this._isOpen) {
1834
- this.hideOverlay(true);
1835
- }
1836
- });
1837
- }
1838
- }
1839
- });
1840
- this._focusObserver.observe(cell, { attributes: true, attributeFilter: ['class'] });
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: "21.2.5", ngImport: i0, type: BaseOverlayEditor, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1843
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.5", type: BaseOverlayEditor, isStandalone: true, usesInheritance: true, ngImport: i0 });
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: "21.2.5", ngImport: i0, type: BaseOverlayEditor, decorators: [{
1738
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: BaseGridEditorCVA, decorators: [{
1846
1739
  type: Directive
1847
- }], ctorParameters: () => [] });
1740
+ }] });
1848
1741
 
1849
- // Symbol for storing form context on the grid element (shared with GridFormArray)
1850
- const FORM_ARRAY_CONTEXT = Symbol('formArrayContext');
1742
+ // #endregion
1743
+ // #region Global Styles
1744
+ /** Tracks whether the global overlay stylesheet has been injected. */
1745
+ let overlayStylesInjected = false;
1851
1746
  /**
1852
- * Gets the FormArrayContext from a grid element, if present.
1853
- * @internal
1854
- * @since 0.11.0
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
- function getLazyFormContext(gridElement) {
1857
- return gridElement[FORM_ARRAY_CONTEXT];
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
- * Directive that provides lazy FormGroup creation for grid editing.
1861
- *
1862
- * Unlike `GridFormArray` which creates all FormGroups upfront, this directive
1863
- * creates FormGroups on-demand only when a row enters edit mode. This provides
1864
- * much better performance for large datasets while still enabling full
1865
- * Angular Reactive Forms integration.
1866
- *
1867
- * ## Key Benefits
1821
+ * Base class for grid editors that display a floating overlay panel.
1868
1822
  *
1869
- * - **Performance**: Only creates FormGroups for rows being edited (20-100x fewer controls)
1870
- * - **Same DX**: Editors still receive `control` in their context for validation
1871
- * - **Memory efficient**: FormGroups are cleaned up when rows exit edit mode
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, inject, signal } from '@angular/core';
1877
- * import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
1878
- * import { Grid, GridLazyForm, TbwEditor } from '@toolbox-web/grid-angular/features/editing';
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
- * imports: [Grid, GridLazyForm, TbwEditor, ReactiveFormsModule],
1882
- * template: \`
1883
- * <tbw-grid
1884
- * [rows]="employees()"
1885
- * [lazyForm]="createRowForm"
1886
- * [gridConfig]="config">
1887
- *
1888
- * <tbw-grid-column field="firstName">
1889
- * <input *tbwEditor="let _; control as ctrl"
1890
- * [formControl]="ctrl"
1891
- * [class.is-invalid]="ctrl?.invalid && ctrl?.touched" />
1892
- * </tbw-grid-column>
1893
- * </tbw-grid>
1894
- * \`
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 MyComponent {
1897
- * private fb = inject(FormBuilder);
1898
- * employees = signal(generateEmployees(1000));
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
- * // Factory called when editing starts - only include editable fields!
1901
- * createRowForm = (row: Employee): FormGroup => this.fb.group({
1902
- * firstName: [row.firstName, Validators.required],
1903
- * lastName: [row.lastName, Validators.minLength(2)],
1904
- * salary: [row.salary, [Validators.required, Validators.min(0)]],
1905
- * });
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
- * gridConfig = { columns: [...] };
1881
+ * selectAndClose(date: string): void {
1882
+ * this.commitValue(date);
1883
+ * this.hideOverlay();
1884
+ * }
1908
1885
  * }
1909
1886
  * ```
1910
1887
  *
1911
- * ## How It Works
1912
- *
1913
- * 1. Rows come from `[rows]` input (plain data array)
1914
- * 2. When a cell enters edit mode, the FormGroup is created lazily
1915
- * 3. Editors receive the FormControl in their template context
1916
- * 4. On commit, FormGroup values are synced back to the row
1917
- * 5. FormGroup is cleaned up when the row exits edit mode (configurable)
1918
- *
1919
- * ## Performance Comparison
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
- * | Rows | GridFormArray (20 fields) | GridLazyForm |
1922
- * |------|---------------------------|--------------|
1923
- * | 100 | 2,000 controls | ~20 controls |
1924
- * | 500 | 10,000 controls | ~20 controls |
1925
- * | 1000 | 20,000 controls | ~20 controls |
1903
+ * constructor() {
1904
+ * super();
1905
+ * effect(() => this.initOverlay(this.panelRef().nativeElement));
1906
+ * }
1926
1907
  *
1927
- * @example
1928
- * ```html
1929
- * <tbw-grid [rows]="employees()" [lazyForm]="createRowForm" [gridConfig]="gridConfig" />
1908
+ * protected getInlineInput() { return this.inputRef()?.nativeElement ?? null; }
1909
+ * protected onOverlayOutsideClick() { this.hideOverlay(); }
1910
+ * }
1930
1911
  * ```
1931
1912
  *
1932
- * @see GridFormArray For small datasets with full upfront validation
1933
- * @category Directive
1913
+ * @typeParam TRow - The row data type
1914
+ * @typeParam TValue - The cell value type
1934
1915
  *
1935
- * @since 0.11.0
1916
+ * @since 0.13.0
1936
1917
  */
1937
- class GridLazyForm {
1938
- elementRef = inject((ElementRef));
1939
- // Cache of FormGroups by row reference
1940
- formGroupCache = new Map();
1941
- // Map from row reference to rowIndex (needed for getControl)
1942
- rowIndexMap = new Map();
1943
- // Track which row is currently being edited
1944
- editingRowIndex = null;
1945
- cellCommitUnsub = null;
1946
- rowCommitUnsub = null;
1947
- rowsChangeUnsub = null;
1948
- /**
1949
- * Factory function to create a FormGroup for a row.
1950
- * Called lazily when the row first enters edit mode.
1951
- *
1952
- * @example
1953
- * ```typescript
1954
- * createRowForm = (row: Employee): FormGroup => this.fb.group({
1955
- * firstName: [row.firstName, Validators.required],
1956
- * lastName: [row.lastName],
1957
- * salary: [row.salary, [Validators.min(0)]],
1958
- * });
1959
- * ```
1960
- */
1961
- lazyForm = input.required(...(ngDevMode ? [{ debugName: "lazyForm" }] : /* istanbul ignore next */ []));
1962
- /**
1963
- * Whether to automatically sync Angular validation state to grid's visual invalid styling.
1964
- *
1965
- * When enabled:
1966
- * - After a cell commit, if the FormControl is invalid, the cell is marked with `setInvalid()`
1967
- * - When a FormControl becomes valid, `clearInvalid()` is called
1968
- * - On `row-commit`, if the row's FormGroup has invalid controls, the commit is prevented
1969
- *
1970
- * @default true
1971
- */
1972
- syncValidation = input(true, ...(ngDevMode ? [{ debugName: "syncValidation" }] : /* istanbul ignore next */ []));
1918
+ class BaseOverlayEditor extends BaseGridEditor {
1919
+ _elementRef = inject(ElementRef);
1920
+ _overlayDestroyRef = inject(DestroyRef);
1921
+ // ============================================================================
1922
+ // Configuration
1923
+ // ============================================================================
1973
1924
  /**
1974
- * Whether to keep FormGroups cached after a row exits edit mode.
1975
- *
1976
- * - `true`: FormGroups are kept, preserving dirty/touched state across edit sessions
1977
- * - `false`: FormGroups are disposed when the row exits edit mode (default)
1925
+ * Position of the overlay panel relative to the anchor cell.
1926
+ * Override in subclasses to change the default position.
1978
1927
  *
1979
- * @default false
1980
- */
1981
- keepFormGroups = input(false, ...(ngDevMode ? [{ debugName: "keepFormGroups" }] : /* istanbul ignore next */ []));
1982
- /**
1983
- * Emitted when a row's form values change.
1984
- * Useful for auto-save, validation display, or syncing to external state.
1985
- */
1986
- rowFormChange = output();
1987
- ngOnInit() {
1988
- const grid = this.elementRef.nativeElement;
1989
- if (!grid)
1990
- return;
1991
- // Store the form context for AngularGridAdapter to access
1992
- this.#storeFormContext(grid);
1993
- // Listen for cell-commit to update FormControl and sync validation
1994
- this.cellCommitUnsub = grid.on('cell-commit', (detail) => {
1995
- this.#handleCellCommit(detail);
1996
- });
1997
- // Listen for row-commit to sync FormGroup values back to row and cleanup
1998
- this.rowCommitUnsub = grid.on('row-commit', (detail, event) => {
1999
- this.#handleRowCommit(event, detail);
2000
- });
2001
- // Listen for rows-change to update row index mappings
2002
- this.rowsChangeUnsub = grid.on('rows-change', () => {
2003
- this.#updateRowIndexMap();
2004
- });
2005
- // Initial row index mapping
2006
- this.#updateRowIndexMap();
2007
- }
2008
- ngOnDestroy() {
2009
- const grid = this.elementRef.nativeElement;
2010
- if (!grid)
2011
- return;
2012
- this.cellCommitUnsub?.();
2013
- this.rowCommitUnsub?.();
2014
- this.rowsChangeUnsub?.();
2015
- this.#clearFormContext(grid);
2016
- this.formGroupCache.clear();
2017
- this.rowIndexMap.clear();
2018
- }
2019
- // #region FormArrayContext Implementation
2020
- /**
2021
- * Gets or creates the FormGroup for a row.
2022
- * This is the core lazy initialization logic.
2023
- */
2024
- #getOrCreateFormGroup(row, rowIndex) {
2025
- let formGroup = this.formGroupCache.get(row);
2026
- if (!formGroup) {
2027
- const factory = this.lazyForm();
2028
- formGroup = factory(row, rowIndex);
2029
- this.formGroupCache.set(row, formGroup);
2030
- this.rowIndexMap.set(row, rowIndex);
2031
- }
2032
- return formGroup;
2033
- }
2034
- /**
2035
- * Gets the FormGroup for a row if it exists (without creating).
2036
- */
2037
- #getRowFormGroup(rowIndex) {
2038
- const grid = this.elementRef.nativeElement;
2039
- const rows = grid?.rows;
2040
- if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2041
- return undefined;
2042
- const row = rows[rowIndex];
2043
- return this.formGroupCache.get(row);
2044
- }
2045
- /**
2046
- * Stores the FormArrayContext on the grid element.
2047
- * This uses the same interface as GridFormArray for compatibility.
1928
+ * @default 'below'
2048
1929
  */
2049
- #storeFormContext(grid) {
2050
- const context = {
2051
- getRow: (rowIndex) => {
2052
- const rows = grid.rows;
2053
- if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2054
- return null;
2055
- return rows[rowIndex];
2056
- },
2057
- updateField: (rowIndex, field, value) => {
2058
- const formGroup = this.#getRowFormGroup(rowIndex);
2059
- if (formGroup) {
2060
- const control = formGroup.get(field);
2061
- if (control) {
2062
- control.setValue(value);
2063
- control.markAsDirty();
2064
- }
2065
- }
2066
- },
2067
- getValue: () => {
2068
- return (grid.rows ?? []);
2069
- },
2070
- // Always true for lazy forms - we create FormGroups on demand
2071
- hasFormGroups: true,
2072
- getControl: (rowIndex, field) => {
2073
- const rows = grid.rows;
2074
- if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2075
- return undefined;
2076
- const row = rows[rowIndex];
2077
- // LAZY: Create the FormGroup when first needed
2078
- const formGroup = this.#getOrCreateFormGroup(row, rowIndex);
2079
- return formGroup.get(field) ?? undefined;
2080
- },
2081
- getRowFormGroup: (rowIndex) => {
2082
- const rows = grid.rows;
2083
- if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2084
- return undefined;
2085
- const row = rows[rowIndex];
2086
- // LAZY: Create the FormGroup when first needed
2087
- return this.#getOrCreateFormGroup(row, rowIndex);
2088
- },
2089
- isRowValid: (rowIndex) => {
2090
- const formGroup = this.#getRowFormGroup(rowIndex);
2091
- // If no FormGroup exists yet, consider it valid (not edited)
2092
- if (!formGroup)
2093
- return true;
2094
- return formGroup.valid;
2095
- },
2096
- isRowTouched: (rowIndex) => {
2097
- const formGroup = this.#getRowFormGroup(rowIndex);
2098
- if (!formGroup)
2099
- return false;
2100
- return formGroup.touched;
2101
- },
2102
- isRowDirty: (rowIndex) => {
2103
- const formGroup = this.#getRowFormGroup(rowIndex);
2104
- if (!formGroup)
2105
- return false;
2106
- return formGroup.dirty;
2107
- },
2108
- getRowErrors: (rowIndex) => {
2109
- const formGroup = this.#getRowFormGroup(rowIndex);
2110
- if (!formGroup)
2111
- return null;
2112
- const errors = {};
2113
- let hasErrors = false;
2114
- Object.keys(formGroup.controls).forEach((field) => {
2115
- const control = formGroup.get(field);
2116
- if (control?.errors) {
2117
- errors[field] = control.errors;
2118
- hasErrors = true;
2119
- }
2120
- });
2121
- if (formGroup.errors) {
2122
- errors['_group'] = formGroup.errors;
2123
- hasErrors = true;
2124
- }
2125
- return hasErrors ? errors : null;
2126
- },
2127
- };
2128
- grid[FORM_ARRAY_CONTEXT] = context;
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
- * Clears the FormArrayContext from the grid element.
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
- #clearFormContext(grid) {
2134
- delete grid[FORM_ARRAY_CONTEXT];
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
- * Updates the row index map when rows change.
2140
- * This ensures we can find FormGroups by row reference after sorting/filtering.
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
- #updateRowIndexMap() {
2143
- const grid = this.elementRef.nativeElement;
2144
- const rows = grid?.rows;
2145
- if (!rows)
2017
+ showOverlay() {
2018
+ if (!this._panel || this._isOpen)
2146
2019
  return;
2147
- this.rowIndexMap.clear();
2148
- rows.forEach((row, index) => {
2149
- if (this.formGroupCache.has(row)) {
2150
- this.rowIndexMap.set(row, index);
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
- * Handles cell-commit events by updating the FormControl in the FormGroup.
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
- #handleCellCommit(detail) {
2158
- const { rowIndex, field, value, rowId } = detail;
2159
- const formGroup = this.#getRowFormGroup(rowIndex);
2160
- if (formGroup) {
2161
- const control = formGroup.get(field);
2162
- if (control) {
2163
- control.setValue(value);
2164
- control.markAsDirty();
2165
- control.markAsTouched();
2166
- // Sync Angular validation state to grid's visual invalid styling
2167
- if (this.syncValidation() && rowId) {
2168
- this.#syncControlValidationToGrid(rowId, field, control);
2169
- }
2170
- // Emit change event
2171
- const grid = this.elementRef.nativeElement;
2172
- const row = grid?.rows?.[rowIndex];
2173
- if (row) {
2174
- this.rowFormChange.emit({
2175
- rowIndex,
2176
- rowId,
2177
- row,
2178
- formGroup,
2179
- values: formGroup.value,
2180
- valid: formGroup.valid,
2181
- dirty: formGroup.dirty,
2182
- });
2183
- }
2184
- }
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
- * Handles row-commit events.
2189
- * - Prevents commit if FormGroup is invalid (when syncValidation is true)
2190
- * - Syncs FormGroup values back to the row
2191
- * - Cleans up FormGroup if keepFormGroups is false
2043
+ * Close and immediately re-open the overlay.
2044
+ * Useful after the panel content changes size and needs repositioning.
2192
2045
  */
2193
- #handleRowCommit(event, detail) {
2194
- const { rowIndex, rowId } = detail;
2195
- const grid = this.elementRef.nativeElement;
2196
- const rows = grid?.rows;
2197
- if (!rows || rowIndex < 0 || rowIndex >= rows.length)
2198
- return;
2199
- const row = rows[rowIndex];
2200
- const formGroup = this.formGroupCache.get(row);
2201
- if (!formGroup)
2202
- return;
2203
- // Prevent commit if invalid (when syncValidation is enabled)
2204
- if (this.syncValidation() && formGroup.invalid) {
2205
- // Mark all controls as touched to show validation errors
2206
- formGroup.markAllAsTouched();
2207
- event.preventDefault();
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
- // Sync FormGroup values back to the row object
2211
- if (formGroup.dirty) {
2212
- const formValue = formGroup.value;
2213
- Object.keys(formValue).forEach((field) => {
2214
- if (field in row) {
2215
- row[field] = formValue[field];
2216
- }
2217
- });
2068
+ if (this._panel?.parentNode) {
2069
+ this._panel.parentNode.removeChild(this._panel);
2218
2070
  }
2219
- // Clean up FormGroup if not keeping them
2220
- if (!this.keepFormGroups()) {
2221
- this.formGroupCache.delete(row);
2222
- this.rowIndexMap.delete(row);
2223
- // Clear any validation state in the grid
2224
- if (rowId) {
2225
- const editingPlugin = grid.getPluginByName?.('editing');
2226
- editingPlugin?.clearRowInvalid(rowId);
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
- * Syncs a FormControl's validation state to the grid's visual invalid styling.
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
- #syncControlValidationToGrid(rowId, field, control) {
2236
- const grid = this.elementRef.nativeElement;
2237
- if (!grid)
2238
- return;
2239
- const editingPlugin = grid.getPluginByName?.('editing');
2240
- if (!editingPlugin)
2241
- return;
2242
- if (control.invalid) {
2243
- const errorMessage = this.#getFirstErrorMessage(control);
2244
- editingPlugin.setInvalid(rowId, field, errorMessage);
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
- editingPlugin.clearInvalid(rowId, field);
2126
+ this.showOverlay();
2127
+ this.onOverlayOpened();
2248
2128
  }
2249
2129
  }
2250
2130
  /**
2251
- * Gets a human-readable error message from the first validation error.
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
- #getFirstErrorMessage(control) {
2254
- const errors = control.errors;
2255
- if (!errors)
2256
- return '';
2257
- const firstKey = Object.keys(errors)[0];
2258
- const error = errors[firstKey];
2259
- switch (firstKey) {
2260
- case 'required':
2261
- return 'This field is required';
2262
- case 'minlength':
2263
- return `Minimum length is ${error.requiredLength}`;
2264
- case 'maxlength':
2265
- return `Maximum length is ${error.requiredLength}`;
2266
- case 'min':
2267
- return `Minimum value is ${error.min}`;
2268
- case 'max':
2269
- return `Maximum value is ${error.max}`;
2270
- case 'email':
2271
- return 'Invalid email address';
2272
- case 'pattern':
2273
- return 'Invalid format';
2274
- default:
2275
- return typeof error === 'string' ? error : (error?.message ?? `Validation error: ${firstKey}`);
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
- * Gets the FormGroup for a row, if it exists.
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
- * @param rowIndex The row index
2285
- * @returns The FormGroup or undefined
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
- getFormGroup(rowIndex) {
2288
- return this.#getRowFormGroup(rowIndex);
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
- * Gets all cached FormGroups.
2292
- * Useful for bulk validation or inspection.
2165
+ * Called after the overlay is shown.
2293
2166
  *
2294
- * @returns Map of row objects to their FormGroups
2167
+ * Override to focus an element inside the panel, start animations, etc.
2168
+ * Default implementation is a no-op.
2295
2169
  */
2296
- getAllFormGroups() {
2297
- return this.formGroupCache;
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
- * Clears all cached FormGroups.
2301
- * Useful when the underlying data changes significantly.
2185
+ * JS fallback positioning for browsers without CSS Anchor Positioning.
2186
+ * Uses `getBoundingClientRect()` with viewport overflow detection.
2302
2187
  */
2303
- clearAllFormGroups() {
2304
- this.formGroupCache.clear();
2305
- this.rowIndexMap.clear();
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
- * Validates all currently cached FormGroups.
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
- * @returns true if all FormGroups are valid, false otherwise
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
- validateAll() {
2313
- for (const formGroup of this.formGroupCache.values()) {
2314
- if (formGroup.invalid) {
2315
- formGroup.markAllAsTouched();
2316
- return false;
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
- return true;
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: "21.2.5", ngImport: i0, type: GridLazyForm, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2322
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.5", type: GridLazyForm, isStandalone: true, selector: "tbw-grid[lazyForm]", inputs: { lazyForm: { classPropertyName: "lazyForm", publicName: "lazyForm", isSignal: true, isRequired: true, transformFunction: null }, syncValidation: { classPropertyName: "syncValidation", publicName: "syncValidation", isSignal: true, isRequired: false, transformFunction: null }, keepFormGroups: { classPropertyName: "keepFormGroups", publicName: "keepFormGroups", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { rowFormChange: "rowFormChange" }, ngImport: i0 });
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: "21.2.5", ngImport: i0, type: GridLazyForm, decorators: [{
2325
- type: Directive,
2326
- args: [{
2327
- selector: 'tbw-grid[lazyForm]',
2328
- }]
2329
- }], propDecorators: { lazyForm: [{ type: i0.Input, args: [{ isSignal: true, alias: "lazyForm", required: true }] }], syncValidation: [{ type: i0.Input, args: [{ isSignal: true, alias: "syncValidation", required: false }] }], keepFormGroups: [{ type: i0.Input, args: [{ isSignal: true, alias: "keepFormGroups", required: false }] }], rowFormChange: [{ type: i0.Output, args: ["rowFormChange"] }] } });
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) {