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