@firestitch/form 18.0.29 → 18.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/app/components/form-dialog-actions/form-dialog-actions.component.d.ts +17 -2
  2. package/app/directives/button.directive.d.ts +9 -1
  3. package/app/directives/confirm-tabs/confirm-tabs.directive.d.ts +32 -0
  4. package/app/directives/confirm-tabs/index.d.ts +1 -0
  5. package/app/directives/form/form.directive.d.ts +89 -29
  6. package/app/directives/form-base/form-base.directive.d.ts +86 -6
  7. package/app/directives/form-container/form-container.directive.d.ts +80 -0
  8. package/app/directives/form-container/index.d.ts +1 -0
  9. package/app/directives/index.d.ts +2 -1
  10. package/app/fs-form.module.d.ts +3 -2
  11. package/app/helpers/index.d.ts +1 -0
  12. package/app/helpers/within-same-dialog.d.ts +18 -0
  13. package/app/interfaces/form-owner.d.ts +17 -0
  14. package/app/interfaces/index.d.ts +1 -0
  15. package/app/services/fsform.service.d.ts +5 -5
  16. package/esm2022/app/components/form-dialog-actions/form-dialog-actions.component.mjs +50 -17
  17. package/esm2022/app/directives/button.directive.mjs +26 -9
  18. package/esm2022/app/directives/confirm-tabs/confirm-tabs.directive.mjs +50 -0
  19. package/esm2022/app/directives/confirm-tabs/index.mjs +2 -0
  20. package/esm2022/app/directives/form/form.directive.mjs +247 -241
  21. package/esm2022/app/directives/form-base/form-base.directive.mjs +255 -8
  22. package/esm2022/app/directives/form-container/form-container.directive.mjs +164 -0
  23. package/esm2022/app/directives/form-container/index.mjs +2 -0
  24. package/esm2022/app/directives/index.mjs +3 -2
  25. package/esm2022/app/fs-form.module.mjs +11 -6
  26. package/esm2022/app/guards/form-deactivate.guard.mjs +1 -1
  27. package/esm2022/app/helpers/index.mjs +2 -1
  28. package/esm2022/app/helpers/within-same-dialog.mjs +19 -0
  29. package/esm2022/app/interfaces/form-owner.mjs +2 -0
  30. package/esm2022/app/interfaces/index.mjs +2 -1
  31. package/esm2022/app/services/fsform.service.mjs +10 -7
  32. package/esm2022/public_api.mjs +3 -2
  33. package/fesm2022/firestitch-form.mjs +811 -320
  34. package/fesm2022/firestitch-form.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/public_api.d.ts +3 -1
  37. package/app/directives/form-group/form-group.directive.d.ts +0 -15
  38. package/app/directives/form-group/index.d.ts +0 -1
  39. package/esm2022/app/directives/form-group/form-group.directive.mjs +0 -35
  40. package/esm2022/app/directives/form-group/index.mjs +0 -2
@@ -1,6 +1,6 @@
1
1
  import { NgClass, NgTemplateOutlet, CommonModule } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { inject, Component, ChangeDetectionStrategy, Injectable, NgZone, ChangeDetectorRef, QueryList, Directive, Input, ContentChildren, ElementRef, EventEmitter, Output, HostBinding, HostListener, InjectionToken, Renderer2, Injector, ViewContainerRef, NgModule } from '@angular/core';
3
+ import { inject, Component, ChangeDetectionStrategy, Injectable, NgZone, ChangeDetectorRef, ElementRef, QueryList, Directive, Input, ContentChildren, EventEmitter, Output, HostBinding, HostListener, InjectionToken, Renderer2, Injector, ViewContainerRef, NgModule } from '@angular/core';
4
4
  import { UntypedFormGroup, NgForm, NgControl, Validators, NgModel, FormsModule } from '@angular/forms';
5
5
  import { MatButton, MatButtonModule } from '@angular/material/button';
6
6
  import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material/core';
@@ -8,13 +8,13 @@ import * as i1 from '@angular/material/dialog';
8
8
  import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule, MatDialog, MatDialogClose } from '@angular/material/dialog';
9
9
  import * as i2 from '@firestitch/dialog';
10
10
  import { FsDialogModule } from '@firestitch/dialog';
11
- import { Subject, of, BehaviorSubject, throwError, fromEvent, iif, defer, from, isObservable } from 'rxjs';
12
- import { switchMap, map, catchError, takeUntil, filter, tap, take, mergeMap, delay, first, startWith, mapTo } from 'rxjs/operators';
13
- import { ActivatedRoute } from '@angular/router';
11
+ import { Subject, of, fromEvent, iif, defer, EMPTY, BehaviorSubject, concat, throwError, combineLatest, from, isObservable } from 'rxjs';
12
+ import { switchMap, map, catchError, filter, takeUntil, tap, startWith, toArray, take, mergeMap, delay, first, mapTo } from 'rxjs/operators';
14
13
  import { guid, email, isEmpty, isNumeric, phone, url } from '@firestitch/common';
15
- import { DrawerRef } from '@firestitch/drawer';
16
14
  import { FsMessage, MessageMode } from '@firestitch/message';
15
+ import { ActivatedRoute } from '@angular/router';
17
16
  import { MatTabGroup } from '@angular/material/tabs';
17
+ import { DrawerRef } from '@firestitch/drawer';
18
18
  import { values, keys, isObject } from 'lodash-es';
19
19
  import { isValid } from 'date-fns';
20
20
 
@@ -59,6 +59,49 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
59
59
  ], template: "<fs-dialog>\n <h1 mat-dialog-title>\n {{ title }}\n </h1>\n <mat-dialog-content>\n {{ message }}\n </mat-dialog-content>\n <mat-dialog-actions>\n <button\n type=\"button\"\n color=\"primary\"\n (click)=\"save()\"\n mat-button>\n {{ saveLabel }}\n </button>\n <button\n type=\"button\"\n (click)=\"discard()\"\n mat-button>\n {{ discardLabel }}\n </button>\n <button\n type=\"button\"\n (click)=\"cancel()\"\n mat-button>\n {{ cancelLabel }}\n </button>\n </mat-dialog-actions>\n</fs-dialog>", styles: [":host{width:100%}.form-buttons.save-create .close-button:not(.cancel-button){float:right}\n"] }]
60
60
  }] });
61
61
 
62
+ /**
63
+ * Whether an owner found through DI belongs to the same dialog as whoever found
64
+ * it - and may therefore be joined, or driven by a button.
65
+ *
66
+ * A form set must never span a dialog boundary. Owners are resolved by walking
67
+ * the element injector, and CDK parents a dialog opened with `viewContainerRef`
68
+ * to the opener's injector, so a dialog opened from inside another dialog's form
69
+ * would otherwise register into that form's set: the outer footer would flip to
70
+ * Save the moment the inner dialog opened and would then submit the inner form,
71
+ * while the inner dialog - no longer the owner of its own set - would stop
72
+ * confirming its unsaved changes on backdrop click.
73
+ *
74
+ * Identity of the `MatDialogRef` is the test: everything inside one dialog
75
+ * injects the same instance, and everything outside any dialog gets null.
76
+ */
77
+ function withinSameDialog(owner, dialogRef) {
78
+ return !!owner && owner.dialogRef === dialogRef;
79
+ }
80
+
81
+ function getFormErrors(control, key) {
82
+ let errors = null;
83
+ if (control.invalid && control.errors) {
84
+ errors = {
85
+ [key]: { ...control.errors },
86
+ };
87
+ }
88
+ if (control instanceof UntypedFormGroup) {
89
+ Object.entries(control.controls)
90
+ .forEach(([name, childControl]) => {
91
+ const childErrors = getFormErrors(childControl, name);
92
+ if (childErrors) {
93
+ if (!errors) {
94
+ errors = { ...childErrors };
95
+ }
96
+ else {
97
+ Object.assign(errors, childErrors);
98
+ }
99
+ }
100
+ });
101
+ }
102
+ return errors;
103
+ }
104
+
62
105
  var ConfirmResult;
63
106
  (function (ConfirmResult) {
64
107
  ConfirmResult["Save"] = "save";
@@ -68,6 +111,18 @@ var ConfirmResult;
68
111
  ConfirmResult["NoChanges"] = "noChanges";
69
112
  })(ConfirmResult || (ConfirmResult = {}));
70
113
 
114
+ var FormStatus;
115
+ (function (FormStatus) {
116
+ FormStatus["Valid"] = "valid";
117
+ FormStatus["Invalid"] = "invalid";
118
+ FormStatus["Validating"] = "validating";
119
+ FormStatus["Submitting"] = "submitting";
120
+ FormStatus["Submitted"] = "submitted";
121
+ FormStatus["Error"] = "error";
122
+ FormStatus["Success"] = "success";
123
+ FormStatus["Completing"] = "completing";
124
+ })(FormStatus || (FormStatus = {}));
125
+
71
126
  function confirmResultContinue(result) {
72
127
  return (result === ConfirmResult.Discard ||
73
128
  result === ConfirmResult.Save ||
@@ -81,18 +136,6 @@ function getActiveRoute(route) {
81
136
  return route;
82
137
  }
83
138
 
84
- var FormStatus;
85
- (function (FormStatus) {
86
- FormStatus["Valid"] = "valid";
87
- FormStatus["Invalid"] = "invalid";
88
- FormStatus["Validating"] = "validating";
89
- FormStatus["Submitting"] = "submitting";
90
- FormStatus["Submitted"] = "submitted";
91
- FormStatus["Error"] = "error";
92
- FormStatus["Success"] = "success";
93
- FormStatus["Completing"] = "completing";
94
- })(FormStatus || (FormStatus = {}));
95
-
96
139
  class FsForm {
97
140
  // value is array for future possibilities of extension
98
141
  _formDirectiveStore = new WeakMap();
@@ -109,11 +152,13 @@ class FsForm {
109
152
  this._formDirectiveStore.delete(routeComponent);
110
153
  }
111
154
  hasChanges(form) {
112
- return Object.keys(form.ngForm.control.controls)
113
- .some((key) => {
114
- const control = form.ngForm.control.controls[key];
115
- return control.dirty;
116
- });
155
+ // Spans the linked set, so a dialog whose fields live in a nested tab form
156
+ // still knows it has changes. A form linked out with `[link]="false"` is
157
+ // absent from that set and never triggers the prompt - which is the point of
158
+ // an instant-save panel: it has nothing outstanding to warn about.
159
+ return form.linkedForms
160
+ .some((linkedForm) => Object.keys(linkedForm.ngForm.control.controls)
161
+ .some((key) => linkedForm.ngForm.control.controls[key].dirty));
117
162
  }
118
163
  confirmUnsaved(form) {
119
164
  if (!form.confirm || !this.hasChanges(form)) {
@@ -152,7 +197,8 @@ class FsForm {
152
197
  return of(ConfirmResult.Discard);
153
198
  }
154
199
  if (result === 'save') {
155
- form.ngForm.control.markAsPristine();
200
+ form.linkedForms
201
+ .forEach((linkedForm) => linkedForm.ngForm.control.markAsPristine());
156
202
  return form.submit$({ confirmed: true })
157
203
  .pipe(map((submitEvent) => {
158
204
  if (submitEvent.error) {
@@ -205,46 +251,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
205
251
  }]
206
252
  }] });
207
253
 
208
- function getFormErrors(control, key) {
209
- let errors = null;
210
- if (control.invalid && control.errors) {
211
- errors = {
212
- [key]: { ...control.errors },
213
- };
214
- }
215
- if (control instanceof UntypedFormGroup) {
216
- Object.entries(control.controls)
217
- .forEach(([name, childControl]) => {
218
- const childErrors = getFormErrors(childControl, name);
219
- if (childErrors) {
220
- if (!errors) {
221
- errors = { ...childErrors };
222
- }
223
- else {
224
- Object.assign(errors, childErrors);
225
- }
226
- }
227
- });
228
- }
229
- return errors;
230
- }
231
-
254
+ /**
255
+ * Shared by the two things that can own a set of forms: an `fsForm` sitting at
256
+ * the root of its linked set, and an `fsFormContainer` holding forms that are
257
+ * siblings rather than nested.
258
+ *
259
+ * It holds the submit-button registry both of them expose to buttons, and the
260
+ * lifecycle that belongs to the set as a whole rather than to any one form -
261
+ * the dialog backdrop and Escape, Ctrl+S, the browser close prompt, the drawer
262
+ * and tab-switch confirms. Exactly one owner registers that lifecycle; see
263
+ * `_registerOwnerLifecycle`.
264
+ */
232
265
  class FsFormBaseDirective {
233
266
  confirmTabs = true;
234
- tabGroup;
267
+ confirm = true;
268
+ confirmDialog = true;
269
+ confirmDrawer = true;
270
+ confirmBrowser = true;
271
+ shortcuts = true; // Ctrl + s
272
+ deactivationGuard = true;
235
273
  _ngZone = inject(NgZone);
236
274
  _cdRef = inject(ChangeDetectorRef);
275
+ _element = inject(ElementRef);
276
+ _form = inject(FsForm);
277
+ _route = inject(ActivatedRoute);
278
+ _dialogRef = inject(MatDialogRef, { optional: true });
279
+ _drawerRef = inject(DrawerRef, { optional: true });
280
+ _dialogBackdropEscape = false;
281
+ _activatedRouteConfig;
237
282
  _tabGroups = new QueryList();
238
283
  _buttons = new QueryList();
239
284
  _activeSubmitButton;
285
+ _confirmTabGroups = new WeakSet();
240
286
  _destroy$ = new Subject();
287
+ /**
288
+ * The dialog this owner lives in, or null outside one. Used to keep a form set
289
+ * from spanning a dialog boundary - see `withinSameDialog`.
290
+ */
291
+ get dialogRef() {
292
+ return this._dialogRef;
293
+ }
294
+ ngOnInit() {
295
+ this._registerOwnerLifecycle();
296
+ }
241
297
  ngAfterContentInit() {
242
- this._registerConfirmTabs();
298
+ this._registerOwnerContentLifecycle();
243
299
  }
244
300
  get destroy$() {
245
301
  return this._destroy$.asObservable();
246
302
  }
247
303
  ngOnDestroy() {
304
+ this._cleanupCanDeactivate();
248
305
  this._destroy$.next(null);
249
306
  this._destroy$.complete();
250
307
  }
@@ -257,6 +314,16 @@ class FsFormBaseDirective {
257
314
  set activeSubmitButton(button) {
258
315
  this._activeSubmitButton = button;
259
316
  }
317
+ /**
318
+ * Bring a tab group into this owner's unsaved-changes confirm.
319
+ *
320
+ * `_tabGroups` only reaches groups in this owner's projected content, so a group
321
+ * declared inside a child component's own template has to announce itself.
322
+ * `fsFormConfirmTabs` calls this - consumers never call it directly.
323
+ */
324
+ registerTabGroup(tabGroup) {
325
+ this._registerConfirmTabGroup(tabGroup);
326
+ }
260
327
  addButton(button) {
261
328
  this._buttons
262
329
  .reset([
@@ -277,6 +344,160 @@ class FsFormBaseDirective {
277
344
  ]);
278
345
  this._buttons.notifyOnChanges();
279
346
  }
347
+ /**
348
+ * Everything here acts on the form set as a whole, so exactly one thing may
349
+ * register it. A form linked into a parent defers to that parent; every form
350
+ * inside a container defers to the container. Registering twice would put two
351
+ * confirms on the same backdrop click and two handlers on the same Ctrl+S.
352
+ */
353
+ _registerOwnerLifecycle() {
354
+ if (this.deactivationGuard) {
355
+ this._registerCanDeactivateGuard();
356
+ }
357
+ this._registerConfirmDialogBackdropEscape();
358
+ this._listenHotKeys();
359
+ this._listenWindowClose();
360
+ }
361
+ /** The half of the same ownership that needs projected content resolved. */
362
+ _registerOwnerContentLifecycle() {
363
+ this._registerConfirmTabs();
364
+ this._registerConfirmDrawerClose();
365
+ this._registerDrawerClose();
366
+ }
367
+ /**
368
+ * Whether the element is inside what this owner covers. Overridden by the
369
+ * container, whose own element is a poor proxy: it may be an `<ng-container>`,
370
+ * which is a comment node and contains nothing.
371
+ */
372
+ _containsElement(el) {
373
+ return this._nodeContains(this._element.nativeElement, el);
374
+ }
375
+ _nodeContains(root, el) {
376
+ if (!root || !el) {
377
+ return false;
378
+ }
379
+ if (el === root) {
380
+ return true;
381
+ }
382
+ return el.parentElement ? this._nodeContains(root, el.parentElement) : false;
383
+ }
384
+ _formClose() {
385
+ if (this.confirm && this.confirmDialog) {
386
+ this.triggerConfirm()
387
+ .pipe(filter((result) => confirmResultContinue(result)), switchMap((result) => {
388
+ return result === ConfirmResult.NoChanges || result === ConfirmResult.Discard
389
+ ? of(null)
390
+ : this.submitted$;
391
+ }), takeUntil(this._destroy$))
392
+ .subscribe((result) => {
393
+ this._dialogRef.close(result?.response);
394
+ });
395
+ }
396
+ else {
397
+ this._dialogRef.close(null);
398
+ }
399
+ }
400
+ _listenWindowClose() {
401
+ fromEvent(window, 'beforeunload')
402
+ .pipe(takeUntil(this._destroy$))
403
+ .subscribe((event) => {
404
+ if (this.confirm && this.confirmBrowser && this.dirtyLinked) {
405
+ event.returnValue = false;
406
+ }
407
+ });
408
+ }
409
+ _activeDialog(el, dialog) {
410
+ return this._nodeContains(dialog, el);
411
+ }
412
+ _listenHotKeys() {
413
+ this._ngZone.runOutsideAngular(() => {
414
+ fromEvent(document, 'keydown')
415
+ .pipe(takeUntil(this._destroy$))
416
+ .subscribe((event) => {
417
+ if (this._dialogBackdropEscape && event.code === 'Escape') {
418
+ const cdkOverlayPane = Array
419
+ .from(document.querySelectorAll('.cdk-overlay-pane')).pop();
420
+ const activeDialog = this
421
+ ._activeDialog(document.getElementById(this._dialogRef.id), cdkOverlayPane);
422
+ if (activeDialog) {
423
+ this._ngZone.run(() => {
424
+ this._formClose();
425
+ });
426
+ }
427
+ }
428
+ if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
429
+ event.preventDefault();
430
+ if (this.shortcuts) {
431
+ if (this._containsElement(document.activeElement)) {
432
+ this._ngZone.run(() => {
433
+ this.triggerSubmit();
434
+ });
435
+ }
436
+ }
437
+ }
438
+ });
439
+ });
440
+ }
441
+ _registerConfirmDialogBackdropEscape() {
442
+ if (this._dialogRef) {
443
+ this._dialogBackdropEscape = !this._dialogRef?.disableClose;
444
+ if (this._dialogBackdropEscape) {
445
+ this._dialogRef.backdropClick()
446
+ .pipe(takeUntil(this._destroy$))
447
+ .subscribe(() => {
448
+ this._formClose();
449
+ });
450
+ this._destroy$
451
+ .subscribe(() => {
452
+ this._dialogRef.disableClose = false;
453
+ });
454
+ }
455
+ }
456
+ }
457
+ _registerDrawerClose() {
458
+ if (this._drawerRef) {
459
+ this._drawerRef.closeStart$
460
+ .pipe(takeUntil(this._destroy$))
461
+ .subscribe((subscriber) => {
462
+ if (this.submitting) {
463
+ this.status$
464
+ .pipe(filter((status) => status === FormStatus.Success || status === FormStatus.Error), takeUntil(this._destroy$))
465
+ .subscribe((status) => {
466
+ if (status === FormStatus.Success) {
467
+ subscriber.next(null);
468
+ subscriber.complete();
469
+ }
470
+ else {
471
+ subscriber.error();
472
+ }
473
+ });
474
+ }
475
+ else {
476
+ subscriber.next(null);
477
+ subscriber.complete();
478
+ }
479
+ });
480
+ }
481
+ }
482
+ _registerConfirmDrawerClose() {
483
+ if (this._drawerRef) {
484
+ this._drawerRef.closeStart$
485
+ .pipe(switchMap((subscriber) => {
486
+ return iif(() => !!this.confirm && this.confirmDrawer, this.triggerConfirm()
487
+ .pipe(map((result) => confirmResultContinue(result)), tap((result) => {
488
+ if (result) {
489
+ subscriber.next(null);
490
+ subscriber.complete();
491
+ }
492
+ })), defer(() => {
493
+ subscriber.next(null);
494
+ subscriber.complete();
495
+ return of(null);
496
+ }));
497
+ }), takeUntil(this._destroy$))
498
+ .subscribe();
499
+ }
500
+ }
280
501
  _registerConfirmTabs() {
281
502
  this._registerConfirmTabGroups(this._tabGroups.toArray());
282
503
  this._tabGroups.changes
@@ -291,6 +512,15 @@ class FsFormBaseDirective {
291
512
  });
292
513
  }
293
514
  _registerConfirmTabGroup(tabGroup) {
515
+ // `_tabGroups.changes` re-emits the whole list, not just what arrived, and it
516
+ // emits every time a lazy tab body mounts a nested group. Groups already
517
+ // wired therefore come back around - so without this the outer group
518
+ // accumulates one subscription per emission, and a single click on it opens
519
+ // that many stacked confirm dialogs.
520
+ if (this._confirmTabGroups.has(tabGroup)) {
521
+ return;
522
+ }
523
+ this._confirmTabGroups.add(tabGroup);
294
524
  const confirmTabGroup = tabGroup;
295
525
  if (!confirmTabGroup._originalHandleClick) {
296
526
  confirmTabGroup._originalHandleClick = tabGroup._handleClick;
@@ -319,71 +549,215 @@ class FsFormBaseDirective {
319
549
  }), takeUntil(this._destroy$))
320
550
  .subscribe();
321
551
  }
552
+ _registerCanDeactivateGuard() {
553
+ this._activatedRouteConfig = getActiveRoute(this._route)?.routeConfig;
554
+ if (!this._activatedRouteConfig) {
555
+ return;
556
+ }
557
+ this._form.registerFormDirective(this._activatedRouteConfig.component, this);
558
+ if (!Array.isArray(this._activatedRouteConfig.canDeactivate)) {
559
+ this._activatedRouteConfig.canDeactivate = [];
560
+ }
561
+ if (this._activatedRouteConfig.canDeactivate.indexOf(FormDeactivateGuard) === -1) {
562
+ this._activatedRouteConfig.canDeactivate.push(FormDeactivateGuard);
563
+ }
564
+ }
565
+ _cleanupCanDeactivate() {
566
+ if (!this._activatedRouteConfig) {
567
+ return;
568
+ }
569
+ const guardIndex = this._activatedRouteConfig.canDeactivate.indexOf(FormDeactivateGuard);
570
+ this._activatedRouteConfig.canDeactivate.splice(guardIndex, 1);
571
+ this._form.removeFormDirective(this._activatedRouteConfig.component);
572
+ }
322
573
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormBaseDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
323
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormBaseDirective, inputs: { confirmTabs: "confirmTabs", tabGroup: "tabGroup" }, queries: [{ propertyName: "_tabGroups", predicate: MatTabGroup, descendants: true }], ngImport: i0 });
574
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormBaseDirective, inputs: { confirmTabs: "confirmTabs", confirm: "confirm", confirmDialog: "confirmDialog", confirmDrawer: "confirmDrawer", confirmBrowser: "confirmBrowser", shortcuts: "shortcuts", deactivationGuard: "deactivationGuard" }, queries: [{ propertyName: "_tabGroups", predicate: MatTabGroup, descendants: true }], ngImport: i0 });
324
575
  }
325
576
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormBaseDirective, decorators: [{
326
577
  type: Directive
327
578
  }], propDecorators: { confirmTabs: [{
328
579
  type: Input
329
- }], tabGroup: [{
580
+ }], confirm: [{
581
+ type: Input
582
+ }], confirmDialog: [{
583
+ type: Input
584
+ }], confirmDrawer: [{
585
+ type: Input
586
+ }], confirmBrowser: [{
587
+ type: Input
588
+ }], shortcuts: [{
589
+ type: Input
590
+ }], deactivationGuard: [{
330
591
  type: Input
331
592
  }], _tabGroups: [{
332
593
  type: ContentChildren,
333
594
  args: [MatTabGroup, { descendants: true }]
334
595
  }] } });
335
596
 
336
- class FsFormGroupDirective extends FsFormBaseDirective {
337
- _formDirective;
338
- triggerSubmit() {
339
- return this._formDirective?.triggerSubmit();
340
- }
341
- triggerConfirm() {
342
- return this._formDirective?.triggerConfirm();
597
+ /**
598
+ * Bounds a set of forms without being one.
599
+ *
600
+ * An `fsForm` can only reach the forms nested inside it through DI, which forces
601
+ * a wrapper carrying `ngForm` around anything that needs to see them - and that
602
+ * wrapper then owns a real, empty `NgForm` whose control namespace every field
603
+ * below it falls into. A container has no `ngForm` and no controls. It only
604
+ * watches: forms register themselves as they mount, buttons resolve to it, and
605
+ * `fs-form-dialog-actions` reads Save-vs-Done off it. Put it on whatever element
606
+ * already frames the layout - or on an `<ng-container>` for no element at all.
607
+ *
608
+ * Forms in a container are siblings rather than nested, so on registration each
609
+ * one is linked into the first. That reproduces exactly the structure DI nesting
610
+ * builds, which is why validation, dirty state, the unsaved-changes confirm and
611
+ * the submit chain all span a container without any of that machinery knowing
612
+ * containers exist.
613
+ *
614
+ * A form marked `[link]="false"` never registers, so an instant-save panel stays
615
+ * out of the set the same way it does when nested.
616
+ */
617
+ class FsFormContainerDirective extends FsFormBaseDirective {
618
+ _forms = [];
619
+ _formsChange$ = new Subject();
620
+ /** The forms registered into this container, in registration order. */
621
+ get forms() {
622
+ return [...this._forms];
623
+ }
624
+ /**
625
+ * The form the rest of the set is linked into, or null while the container
626
+ * holds none - an open tab that has nothing to save, say.
627
+ */
628
+ get rootForm() {
629
+ return this._forms[0] || null;
630
+ }
631
+ /** Emits whenever a form registers or deregisters. */
632
+ get formsChange$() {
633
+ return this._formsChange$.asObservable();
634
+ }
635
+ get linkedForms() {
636
+ return this.rootForm?.linkedForms || [];
637
+ }
638
+ /**
639
+ * True when anything in the container has a submit handler - i.e. there is
640
+ * something for a Save button to do. `fs-form-dialog-actions` reads this to
641
+ * swap Save for Done as the mounted tab changes, so a dialog never has to keep
642
+ * a list of which tabs happen to be savable.
643
+ */
644
+ get submits() {
645
+ return this.linkedForms
646
+ .some((form) => form.hasSubmit);
647
+ }
648
+ get dirtyLinked() {
649
+ return this.rootForm?.dirtyLinked || false;
343
650
  }
344
651
  get submitting() {
345
- return this._formDirective?.submitting;
652
+ return this.rootForm?.submitting || false;
653
+ }
654
+ /**
655
+ * Emits whenever the set's dirty state may have moved, including when the set
656
+ * itself changes. Anything rendering off it - the dialog actions - subscribes
657
+ * once and outlives any particular form, so this has to follow the root across
658
+ * tab switches rather than bind to whichever form happened to be first.
659
+ */
660
+ get dirtyChange$() {
661
+ return this._formsChange$
662
+ .pipe(startWith(null), switchMap(() => (this.rootForm?.dirtyChange$ || EMPTY)
663
+ .pipe(startWith(null))), map(() => undefined));
664
+ }
665
+ // Resolved at subscribe time rather than switch-mapped: a submit in flight has
666
+ // to survive the set changing under it, which is routine when saving unmounts
667
+ // a tab.
668
+ get submitted$() {
669
+ return defer(() => this.rootForm?.submitted$ || EMPTY);
670
+ }
671
+ get status$() {
672
+ return defer(() => this.rootForm?.status$ || of(FormStatus.Valid));
673
+ }
674
+ /**
675
+ * Take a form into the container. Called by the form on init - consumers never
676
+ * call it directly.
677
+ */
678
+ registerForm(form) {
679
+ if (this._forms.indexOf(form) !== -1) {
680
+ return;
681
+ }
682
+ this._forms = [...this._forms, form];
683
+ if (this.rootForm !== form) {
684
+ form.linkTo(this.rootForm);
685
+ }
686
+ this._formsChange$.next();
687
+ }
688
+ /**
689
+ * Material attaches an incoming tab body before it detaches the outgoing one,
690
+ * so losing the root is a routine reshuffle rather than teardown. Re-elect and
691
+ * re-link the survivors instead of leaving them pointing at a dead root, and
692
+ * only let go of forms actually registered - clearing unconditionally would
693
+ * drop the form that just arrived.
694
+ */
695
+ deregisterForm(form) {
696
+ if (form && this._forms.indexOf(form) === -1) {
697
+ return;
698
+ }
699
+ const removing = form ? [form] : [...this._forms];
700
+ const rootRemoved = removing.indexOf(this.rootForm) !== -1;
701
+ removing.forEach((item) => item.unlinkFrom());
702
+ this._forms = this._forms
703
+ .filter((item) => removing.indexOf(item) === -1);
704
+ if (rootRemoved) {
705
+ const root = this.rootForm;
706
+ this._forms
707
+ .filter((item) => item !== root)
708
+ .forEach((item) => {
709
+ item.unlinkFrom();
710
+ item.linkTo(root);
711
+ });
712
+ }
713
+ this._formsChange$.next();
714
+ }
715
+ triggerSubmit() {
716
+ this.rootForm?.triggerSubmit();
346
717
  }
347
- get confirm() {
348
- return this._formDirective?.confirm;
718
+ triggerConfirm() {
719
+ return this.rootForm
720
+ ? this.rootForm.triggerConfirm()
721
+ : of(ConfirmResult.NoChanges);
349
722
  }
350
- registerForm(formDirective) {
351
- this._formDirective = formDirective;
723
+ submit$(event) {
724
+ return this.rootForm
725
+ ? this.rootForm.submit$(event)
726
+ : of(null);
352
727
  }
353
- deregisterForm() {
354
- this._formDirective = null;
728
+ reset() {
729
+ this.rootForm?.reset();
355
730
  }
356
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormGroupDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
357
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormGroupDirective, isStandalone: true, selector: "[fsFormGroup]", exportAs: ["fsFormGroup"], usesInheritance: true, ngImport: i0 });
731
+ validate() {
732
+ this.rootForm?.validate();
733
+ }
734
+ // The container's own element is a poor proxy for what it covers: it is
735
+ // typically layout chrome wrapping far more than the forms, and on an
736
+ // `<ng-container>` it is a comment node containing nothing at all. Scope to the
737
+ // registered forms instead.
738
+ _containsElement(el) {
739
+ return this._forms
740
+ .some((form) => this._nodeContains(form.element, el));
741
+ }
742
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormContainerDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
743
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormContainerDirective, isStandalone: true, selector: "fs-form-container,[fsFormContainer]", exportAs: ["fsFormContainer"], usesInheritance: true, ngImport: i0 });
358
744
  }
359
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormGroupDirective, decorators: [{
745
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormContainerDirective, decorators: [{
360
746
  type: Directive,
361
747
  args: [{
362
- selector: '[fsFormGroup]',
363
- exportAs: 'fsFormGroup',
748
+ selector: 'fs-form-container,[fsFormContainer]',
749
+ exportAs: 'fsFormContainer',
364
750
  standalone: true,
365
751
  }]
366
752
  }] });
367
753
 
368
754
  class FsFormDirective extends FsFormBaseDirective {
369
755
  ngForm = inject(NgForm);
370
- _form = inject(FsForm);
371
- _element = inject(ElementRef);
372
- _message = inject(FsMessage);
373
- _formGroup = inject(FsFormGroupDirective, { optional: true });
374
- _dialogRef = inject(MatDialogRef, { optional: true });
375
- _drawerRef = inject(DrawerRef, { optional: true });
376
- _route = inject(ActivatedRoute);
377
756
  wrapperSelector = '.fs-form-wrapper,.mat-mdc-form-field';
378
757
  messageSelector = '.fs-form-message,.mat-mdc-form-field-subscript-wrapper';
379
758
  hintSelector = '.fs-form-hint,.mat-mdc-form-field-hint-wrapper';
380
759
  labelSelector = '.fs-form-label,.mdc-floating-label>mat-label';
381
760
  autocomplete = false;
382
- shortcuts = true; // Ctrl + s
383
- confirm = true;
384
- confirmDialog = true;
385
- confirmDrawer = true;
386
- confirmBrowser = true;
387
761
  dirtySubmitButton = true;
388
762
  set submit(submit$) {
389
763
  this._submit$ = submit$;
@@ -393,7 +767,21 @@ class FsFormDirective extends FsFormBaseDirective {
393
767
  }
394
768
  successDelay = 0;
395
769
  errorDelay = 1000;
396
- deactivationGuard = true;
770
+ /**
771
+ * Whether this form joins the set around it - the `fsForm` it is nested inside,
772
+ * or the `fsFormContainer` enclosing it.
773
+ *
774
+ * Forms in a set behave as one submittable unit: the outermost form is what the
775
+ * submit buttons drive, and validation, dirty state, the unsaved-changes
776
+ * confirm and the pristine reset all span the set. That is what lets a form
777
+ * wrap only one tab's fields while the dialog footer stays outside it.
778
+ *
779
+ * Set `[link]="false"` for a panel that owns its own saving - an instant-save
780
+ * side panel, say. It keeps every fsForm feature for itself, but its dirty
781
+ * state never enables the outer Save button, its validation never blocks the
782
+ * outer submit, and the outer submit never touches it.
783
+ */
784
+ link = true;
397
785
  submitEvent = new EventEmitter();
398
786
  invalid = new EventEmitter();
399
787
  valid = new EventEmitter();
@@ -402,11 +790,79 @@ class FsFormDirective extends FsFormBaseDirective {
402
790
  cleared = new EventEmitter();
403
791
  fsFormClass = true;
404
792
  _registerControl;
405
- _dialogBackdropEscape = false;
406
793
  _snapshot = {};
407
- _activatedRouteConfig;
408
794
  _status$ = new BehaviorSubject(FormStatus.Valid);
409
795
  _submit$ = null;
796
+ _linkedForms = new Set();
797
+ _containerParent = null;
798
+ _dirtyChange$ = new Subject();
799
+ _message = inject(FsMessage);
800
+ // Both lookups walk the element injector, which reaches straight through a
801
+ // dialog opened with `viewContainerRef` into whatever opened it. Anything
802
+ // belonging to a different dialog is discarded, so a set never spans two.
803
+ _container = this._ownDialogOnly(inject(FsFormContainerDirective, { optional: true }));
804
+ _ancestorForm = this
805
+ ._ownDialogOnly(inject(FsFormDirective, { optional: true, skipSelf: true }));
806
+ /**
807
+ * Emits whenever the set's dirty state may have moved. Anything outside the
808
+ * form that renders off it - the dialog actions, say - can't watch
809
+ * `ngForm.valueChanges` on its own, because the edits happen in a nested form's
810
+ * NgForm, not this one's.
811
+ */
812
+ get dirtyChange$() {
813
+ return this._dirtyChange$.asObservable();
814
+ }
815
+ get submitted$() {
816
+ return this.submitted.asObservable();
817
+ }
818
+ get status$() {
819
+ return this._status$.asObservable();
820
+ }
821
+ get element() {
822
+ return this._element.nativeElement;
823
+ }
824
+ /** True when a Save button driving this form would have something to run. */
825
+ get hasSubmit() {
826
+ return !!this._submit$;
827
+ }
828
+ /**
829
+ * The form this one is linked into, or null when it is a root - either because
830
+ * nothing encloses it or because `[link]="false"` cut it loose. A container
831
+ * supplies the parent when the forms are siblings rather than nested.
832
+ */
833
+ get parentForm() {
834
+ if (!this.link) {
835
+ return null;
836
+ }
837
+ return this._ancestorForm || this._containerParent;
838
+ }
839
+ /** The outermost form of this set. Submit buttons drive this one. */
840
+ get rootForm() {
841
+ return this.parentForm?.rootForm || this;
842
+ }
843
+ /** This form plus every form linked into it, at any depth. */
844
+ get linkedForms() {
845
+ return [
846
+ this,
847
+ ...[...this._linkedForms]
848
+ .reduce((forms, form) => [...forms, ...form.linkedForms], []),
849
+ ];
850
+ }
851
+ /** True when this form or anything linked into it has unsaved changes. */
852
+ get dirtyLinked() {
853
+ return this.linkedForms
854
+ .some((form) => form.ngForm.dirty);
855
+ }
856
+ /**
857
+ * True when this form or anything linked into it has a submit handler - i.e.
858
+ * there is something for a Save button to do. A dialog footer can bind to this
859
+ * to swap Save for Done as the mounted tab changes, instead of hard-coding
860
+ * which tab names happen to be savable.
861
+ */
862
+ get submits() {
863
+ return this.linkedForms
864
+ .some((form) => form.hasSubmit);
865
+ }
410
866
  get submitting() {
411
867
  return this._status$.getValue() === FormStatus.Submitting;
412
868
  }
@@ -416,6 +872,15 @@ class FsFormDirective extends FsFormBaseDirective {
416
872
  get completing() {
417
873
  return this._status$.getValue() === FormStatus.Completing;
418
874
  }
875
+ /**
876
+ * True when nothing above this form speaks for the set it belongs to. Reads the
877
+ * raw injections rather than `parentForm`, so an unlinked panel inside a dialog
878
+ * still defers to whatever encloses it instead of registering a second backdrop
879
+ * confirm on the same dialog.
880
+ */
881
+ get _owns() {
882
+ return !this._ancestorForm && !this._container;
883
+ }
419
884
  get _submitEvent() {
420
885
  return {
421
886
  ngForm: this.ngForm,
@@ -430,8 +895,17 @@ class FsFormDirective extends FsFormBaseDirective {
430
895
  submitter: this._submitter,
431
896
  response: null,
432
897
  };
433
- const submit$ = this._submit$ ?
434
- this._submit$(this._submitEvent) : of(submittedEvent);
898
+ // Every form in the set gets to save. A form that only frames the layout - a
899
+ // dialog wrapper around a tab group - carries no submit of its own and simply
900
+ // defers to the tab form linked into it. When several are linked they run in
901
+ // nesting order, so an outer save lands before an inner one, and the response
902
+ // of the last is what the submit resolves with.
903
+ const submits = this.linkedForms
904
+ .filter((form) => form.hasSubmit)
905
+ .map((form) => form._submit$(this._submitEvent));
906
+ const submit$ = submits.length ?
907
+ concat(...submits).pipe(toArray(), map((responses) => responses[responses.length - 1])) :
908
+ of(submittedEvent);
435
909
  return submit$
436
910
  .pipe(map((response) => {
437
911
  submittedEvent.response = response;
@@ -451,22 +925,67 @@ class FsFormDirective extends FsFormBaseDirective {
451
925
  return throwError(() => new Error('Form validation error'));
452
926
  }
453
927
  get _submitter() {
454
- return this._getFormGroup().activeSubmitButton?.name;
928
+ return this._getOwner().activeSubmitButton?.name;
455
929
  }
456
930
  ngOnInit() {
457
- this._formGroup?.registerForm(this);
458
- if (this.deactivationGuard) {
459
- this._registerCanDeactivateGuard();
931
+ // A form already nested inside another links to that one, which is itself in
932
+ // the container - so it joins the set transitively and must not also register
933
+ // as a container sibling.
934
+ if (this.link) {
935
+ if (this._ancestorForm) {
936
+ this._ancestorForm.linkForm(this);
937
+ }
938
+ else {
939
+ this._container?.registerForm(this);
940
+ }
941
+ }
942
+ // The route guard, dialog backdrop, Ctrl+S and browser close prompt all act
943
+ // on the set as a whole, so exactly one thing registers them: a linked child
944
+ // defers to its parent, and every form in a container defers to the container.
945
+ if (this._owns) {
946
+ super.ngOnInit();
460
947
  }
461
- this._registerConfirmDialogBackdropEscape();
462
- this._listenHotKeys();
463
- this._listenWindowClose();
464
948
  this._listenSubmit();
465
949
  this._listenFormStatus();
466
950
  if (!this.autocomplete) {
467
951
  this._registerAutocomplete();
468
952
  }
469
953
  }
954
+ /**
955
+ * Take a nested form into this one's set. Called by the child on init, or by a
956
+ * container linking its siblings - consumers never call it directly.
957
+ */
958
+ linkForm(form) {
959
+ this._linkedForms.add(form);
960
+ Promise.resolve()
961
+ .then(() => {
962
+ this.rootForm._updateDirtySubmitButtons();
963
+ this._cdRef.markForCheck();
964
+ });
965
+ }
966
+ unlinkForm(form) {
967
+ this._linkedForms.delete(form);
968
+ Promise.resolve()
969
+ .then(() => {
970
+ this.rootForm._updateDirtySubmitButtons();
971
+ this._cdRef.markForCheck();
972
+ });
973
+ }
974
+ /**
975
+ * Adopt a parent that DI could not supply. Only a container calls this, to link
976
+ * forms that are siblings in the template into one set.
977
+ */
978
+ linkTo(form) {
979
+ if (!form || form === this) {
980
+ return;
981
+ }
982
+ this._containerParent = form;
983
+ form.linkForm(this);
984
+ }
985
+ unlinkFrom() {
986
+ this._containerParent?.unlinkForm(this);
987
+ this._containerParent = null;
988
+ }
470
989
  ngOnChanges(changes) {
471
990
  if (changes.confirm) {
472
991
  this._updateDirtySubmitButtons();
@@ -487,16 +1006,21 @@ class FsFormDirective extends FsFormBaseDirective {
487
1006
  });
488
1007
  }
489
1008
  ngAfterContentInit() {
490
- super.ngAfterContentInit();
1009
+ // Tab and drawer confirmation belong to whoever owns the set. A linked child
1010
+ // shares the same tab groups and the same drawer, so letting it register too
1011
+ // would put a second confirm on the same click.
1012
+ if (this._owns) {
1013
+ super.ngAfterContentInit();
1014
+ }
491
1015
  this._registerConfirm();
492
- this._registerConfirmDrawerClose();
493
- this._registerDrawerClose();
494
1016
  this._registerDirtySubmitButton();
495
1017
  }
496
1018
  ngOnDestroy() {
497
- this._formGroup?.deregisterForm();
1019
+ // Deregister first: it clears the container-supplied parent, so the unlink
1020
+ // below falls through to the DI ancestor rather than repeating the same work.
1021
+ this._container?.deregisterForm(this);
1022
+ this.parentForm?.unlinkForm(this);
498
1023
  super.ngOnDestroy();
499
- this._cleanupCanDeactivate();
500
1024
  }
501
1025
  createSnapshot() {
502
1026
  this._snapshot = this.ngForm.value;
@@ -505,11 +1029,14 @@ class FsFormDirective extends FsFormBaseDirective {
505
1029
  return this._snapshot || {};
506
1030
  }
507
1031
  reset() {
508
- this.ngForm.resetForm();
509
- Object.keys(this.ngForm.controls)
510
- .forEach((name) => {
511
- const control = this.ngForm.controls[name];
512
- control.reset(this._snapshot[name]);
1032
+ this.linkedForms
1033
+ .forEach((form) => {
1034
+ form.ngForm.resetForm();
1035
+ Object.keys(form.ngForm.controls)
1036
+ .forEach((name) => {
1037
+ const control = form.ngForm.controls[name];
1038
+ control.reset(form._snapshot[name]);
1039
+ });
513
1040
  });
514
1041
  this.reseted.emit();
515
1042
  }
@@ -539,24 +1066,29 @@ class FsFormDirective extends FsFormBaseDirective {
539
1066
  }
540
1067
  disable() {
541
1068
  this.ngForm.control.disable();
542
- this._getFormGroup()
1069
+ this._getOwner()
543
1070
  .buttons.forEach((button) => {
544
1071
  button.disable();
545
1072
  });
546
1073
  }
547
1074
  validate() {
548
- Object.values(this.ngForm.controls)
549
- .forEach((control) => {
550
- control.markAsDirty();
551
- control.markAsTouched();
552
- control.updateValueAndValidity();
1075
+ this.linkedForms
1076
+ .forEach((form) => {
1077
+ Object.values(form.ngForm.controls)
1078
+ .forEach((control) => {
1079
+ control.markAsDirty();
1080
+ control.markAsTouched();
1081
+ control.updateValueAndValidity();
1082
+ });
553
1083
  });
554
1084
  }
555
1085
  submit$(submitEvent) {
556
1086
  return of(submitEvent)
557
1087
  .pipe(tap(() => this._statusValidating()), tap(() => this.validate()), tap(() => this._statusSubmitting()), tap(() => this._setupActiveSubmitButton()), tap(() => this._disableButtons()), switchMap((data) => this._waitUntilStatusPending()
558
1088
  .pipe(map(() => data))), mergeMap(() => {
559
- if (this.ngForm.status === 'INVALID') {
1089
+ const invalid = this.linkedForms
1090
+ .some((form) => form.ngForm.status === 'INVALID');
1091
+ if (invalid) {
560
1092
  return this._formInvalidState$;
561
1093
  }
562
1094
  return this._formValidState$;
@@ -596,99 +1128,32 @@ class FsFormDirective extends FsFormBaseDirective {
596
1128
  }
597
1129
  });
598
1130
  }
599
- _listenWindowClose() {
600
- fromEvent(window, 'beforeunload')
601
- .pipe(takeUntil(this.destroy$))
602
- .subscribe((event) => {
603
- if (this.confirm && this.confirmBrowser && this.ngForm.dirty) {
604
- event.returnValue = false;
605
- }
606
- });
607
- }
608
- _activeDialog(el, dialog) {
609
- if (el.isSameNode(dialog)) {
610
- return true;
611
- }
612
- else if (el.parentElement) {
613
- return this._activeDialog(el.parentElement, dialog);
614
- }
615
- return false;
616
- }
617
- _listenHotKeys() {
618
- this._ngZone.runOutsideAngular(() => {
619
- fromEvent(document, 'keydown')
620
- .pipe(takeUntil(this.destroy$))
621
- .subscribe((event) => {
622
- if (this._dialogBackdropEscape && event.code === 'Escape') {
623
- const cdkOverlayPane = Array
624
- .from(document.querySelectorAll('.cdk-overlay-pane')).pop();
625
- const activeDialog = this
626
- ._activeDialog(document.getElementById(this._dialogRef.id), cdkOverlayPane);
627
- if (activeDialog) {
628
- this._ngZone.run(() => {
629
- this._formClose();
630
- });
631
- }
632
- }
633
- if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
634
- event.preventDefault();
635
- if (this.shortcuts) {
636
- if (this._elementInForm(document.activeElement)) {
637
- this.ngForm.ngSubmit.next(null);
638
- }
639
- }
640
- }
641
- });
642
- });
643
- }
644
- _formClose() {
645
- if (this.confirm && this.confirmDialog) {
646
- this.triggerConfirm()
647
- .pipe(filter((result) => confirmResultContinue(result)), switchMap((result) => {
648
- return result === ConfirmResult.NoChanges || result === ConfirmResult.Discard
649
- ? of(null)
650
- : this.submitted.asObservable();
651
- }), takeUntil(this.destroy$))
652
- .subscribe((result) => {
653
- this._dialogRef.close(result?.response);
654
- });
655
- }
656
- else {
657
- this._dialogRef.close(null);
658
- }
659
- }
660
1131
  _getActiveSubmitButton() {
661
- if (this._getFormGroup().activeSubmitButton) {
662
- return this._getFormGroup().activeSubmitButton;
1132
+ if (this._getOwner().activeSubmitButton) {
1133
+ return this._getOwner().activeSubmitButton;
663
1134
  }
664
- return this._getFormGroup()
1135
+ return this._getOwner()
665
1136
  .buttons
666
1137
  .filter((button) => button.submit)[0];
667
1138
  }
668
- _elementInForm(el) {
669
- if (el.isSameNode(this._element.nativeElement)) {
670
- return true;
671
- }
672
- else if (el.parentElement) {
673
- return this._elementInForm(el.parentElement);
674
- }
675
- return false;
676
- }
677
1139
  _completeSubmit(success, submitEvent) {
678
1140
  if (success) {
679
- this.ngForm.control.markAsPristine();
680
- this.createSnapshot();
1141
+ this.linkedForms
1142
+ .forEach((form) => {
1143
+ form.ngForm.control.markAsPristine();
1144
+ form.createSnapshot();
1145
+ });
681
1146
  this.submitted.emit(submitEvent);
682
1147
  }
683
1148
  else {
684
1149
  this._resetButtons();
685
1150
  }
686
- if (this._getFormGroup().activeSubmitButton) {
1151
+ if (this._getOwner().activeSubmitButton) {
687
1152
  if (success) {
688
- this._getFormGroup().activeSubmitButton.success();
1153
+ this._getOwner().activeSubmitButton.success();
689
1154
  }
690
1155
  else {
691
- this._getFormGroup().activeSubmitButton.error();
1156
+ this._getOwner().activeSubmitButton.error();
692
1157
  }
693
1158
  }
694
1159
  this._status$.next(FormStatus.Submitted);
@@ -703,22 +1168,39 @@ class FsFormDirective extends FsFormBaseDirective {
703
1168
  of(true)
704
1169
  .pipe(delay(resetDelay), first(), takeUntil(this.destroy$))
705
1170
  .subscribe(() => {
706
- if (this.ngForm.form.status === 'VALID') {
1171
+ const valid = this.linkedForms
1172
+ .every((form) => form.ngForm.form.status === 'VALID');
1173
+ if (valid) {
707
1174
  this._status$.next(FormStatus.Valid);
708
1175
  }
709
1176
  else {
710
1177
  this._status$.next(FormStatus.Invalid);
711
1178
  }
712
1179
  this._resetButtons();
713
- this._getFormGroup().activeSubmitButton = null;
1180
+ this._getOwner().activeSubmitButton = null;
714
1181
  this._updateDirtySubmitButtons();
715
1182
  });
716
1183
  }
717
- _getFormGroup() {
718
- return this._formGroup || this;
1184
+ /**
1185
+ * Whoever holds the submit buttons for this form. A container when one encloses
1186
+ * it, otherwise the root of its linked set - the button that saves a tab's form
1187
+ * typically sits outside it, down in the dialog footer.
1188
+ *
1189
+ * Only a form that actually joined the container may drive its buttons. DI
1190
+ * hands `_container` to every form inside, including one cut loose with
1191
+ * `[link]="false"`, and an instant-save panel is usually `[confirm]="false"`
1192
+ * too - so without this it would take the `!confirm` branch below and
1193
+ * re-enable the footer's Save button that the linked set had just disabled.
1194
+ */
1195
+ _getOwner() {
1196
+ return (this.link ? this._container : null) || this.rootForm;
1197
+ }
1198
+ /** Keeps a DI-resolved owner only when it belongs to this form's dialog. */
1199
+ _ownDialogOnly(owner) {
1200
+ return withinSameDialog(owner, this._dialogRef) ? owner : null;
719
1201
  }
720
1202
  _resetButtons() {
721
- this._getFormGroup()
1203
+ this._getOwner()
722
1204
  .buttons.forEach((button) => {
723
1205
  button.reset();
724
1206
  });
@@ -741,66 +1223,6 @@ class FsFormDirective extends FsFormBaseDirective {
741
1223
  }
742
1224
  });
743
1225
  }
744
- _registerDrawerClose() {
745
- if (this._drawerRef) {
746
- this._drawerRef.closeStart$
747
- .pipe(takeUntil(this.destroy$))
748
- .subscribe((subscriber) => {
749
- if (this.submitting) {
750
- this._status$
751
- .pipe(filter((status) => status === FormStatus.Success || status === FormStatus.Error), takeUntil(this.destroy$))
752
- .subscribe((status) => {
753
- if (status === FormStatus.Success) {
754
- subscriber.next(null);
755
- subscriber.complete();
756
- }
757
- else {
758
- subscriber.error();
759
- }
760
- });
761
- }
762
- else {
763
- subscriber.next(null);
764
- subscriber.complete();
765
- }
766
- });
767
- }
768
- }
769
- _registerConfirmDrawerClose() {
770
- if (this._drawerRef) {
771
- this._drawerRef.closeStart$
772
- .pipe(switchMap((subscriber) => {
773
- return iif(() => this.confirm && this.confirmDrawer, this.triggerConfirm()
774
- .pipe(map((result) => confirmResultContinue(result)), tap((result) => {
775
- if (result) {
776
- subscriber.next(null);
777
- subscriber.complete();
778
- }
779
- })), defer(() => {
780
- subscriber.next(null);
781
- subscriber.complete();
782
- return of(null);
783
- }));
784
- }), takeUntil(this.destroy$))
785
- .subscribe();
786
- }
787
- }
788
- _registerConfirmDialogBackdropEscape() {
789
- if (this._dialogRef) {
790
- this._dialogBackdropEscape = !this._dialogRef?.disableClose;
791
- if (this._dialogBackdropEscape) {
792
- this._dialogRef.backdropClick()
793
- .pipe(takeUntil(this.destroy$))
794
- .subscribe(() => {
795
- this._formClose();
796
- });
797
- this.destroy$
798
- .subscribe(() => {
799
- this._dialogRef.disableClose = false;
800
- });
801
- }
802
- }
803
- }
804
1226
  _registerAutocomplete() {
805
1227
  this._registerControl = this.ngForm.form.registerControl.bind(this.ngForm.form);
806
1228
  this.ngForm.form.registerControl = (name, control) => {
@@ -818,12 +1240,14 @@ class FsFormDirective extends FsFormBaseDirective {
818
1240
  if (!this.ngForm) {
819
1241
  return;
820
1242
  }
1243
+ // Report up: a linked child's edits have to reach the root, since that is
1244
+ // where the submit buttons live and what decides whether they are enabled.
821
1245
  this.ngForm.form.valueChanges
822
1246
  .pipe(takeUntil(this.destroy$))
823
1247
  .subscribe(() => {
824
- this._updateDirtySubmitButtons();
1248
+ this.rootForm._updateDirtySubmitButtons();
825
1249
  });
826
- this._getFormGroup()
1250
+ this._getOwner()
827
1251
  .buttons.changes
828
1252
  .pipe(takeUntil(this.destroy$))
829
1253
  .subscribe(() => {
@@ -841,11 +1265,12 @@ class FsFormDirective extends FsFormBaseDirective {
841
1265
  if (!this.ngForm) {
842
1266
  return;
843
1267
  }
844
- this._getFormGroup()
1268
+ this._dirtyChange$.next();
1269
+ this._getOwner()
845
1270
  .buttons
846
1271
  .filter((button) => button.submit)
847
1272
  .forEach((submitButton) => {
848
- if (!this.confirm || !this.dirtySubmitButton || this.ngForm.dirty || !submitButton.dirtySubmit) {
1273
+ if (!this.confirm || !this.dirtySubmitButton || this.dirtyLinked || !submitButton.dirtySubmit) {
849
1274
  submitButton.enable();
850
1275
  }
851
1276
  else {
@@ -860,22 +1285,26 @@ class FsFormDirective extends FsFormBaseDirective {
860
1285
  this._status$.next(FormStatus.Validating);
861
1286
  }
862
1287
  _setupActiveSubmitButton() {
863
- this._getFormGroup()
1288
+ this._getOwner()
864
1289
  .activeSubmitButton = this._getActiveSubmitButton();
865
1290
  this._resetButtons();
866
- if (this._getFormGroup().activeSubmitButton) {
867
- this._getFormGroup().activeSubmitButton.process();
1291
+ if (this._getOwner().activeSubmitButton) {
1292
+ this._getOwner().activeSubmitButton.process();
868
1293
  }
869
1294
  }
870
1295
  _disableButtons() {
871
- this._getFormGroup()
1296
+ this._getOwner()
872
1297
  .buttons.forEach((button) => {
873
1298
  button.disable();
874
1299
  });
875
1300
  }
876
1301
  _waitUntilStatusPending() {
877
- return this.ngForm.statusChanges
878
- .pipe(startWith(this.ngForm.status), first((state) => state !== 'PENDING'));
1302
+ // Async validators can be pending in any linked form, so hold the submit
1303
+ // until every one of them has settled - not just this form's.
1304
+ return combineLatest(this.linkedForms
1305
+ .map((form) => form.ngForm.statusChanges
1306
+ .pipe(startWith(form.ngForm.status))))
1307
+ .pipe(first((states) => states.every((state) => state !== 'PENDING')));
879
1308
  }
880
1309
  _handleError(e) {
881
1310
  console.log('%c Form Submit ', 'color: white; background-color: #D33F49', 'Error occured');
@@ -889,29 +1318,8 @@ class FsFormDirective extends FsFormBaseDirective {
889
1318
  console.groupEnd();
890
1319
  this._completeSubmit(false, null);
891
1320
  }
892
- _registerCanDeactivateGuard() {
893
- this._activatedRouteConfig = getActiveRoute(this._route).routeConfig;
894
- if (!this._activatedRouteConfig) {
895
- return;
896
- }
897
- this._form.registerFormDirective(this._activatedRouteConfig.component, this);
898
- if (!Array.isArray(this._activatedRouteConfig.canDeactivate)) {
899
- this._activatedRouteConfig.canDeactivate = [];
900
- }
901
- if (this._activatedRouteConfig.canDeactivate.indexOf(FormDeactivateGuard) === -1) {
902
- this._activatedRouteConfig.canDeactivate.push(FormDeactivateGuard);
903
- }
904
- }
905
- _cleanupCanDeactivate() {
906
- if (!this._activatedRouteConfig) {
907
- return;
908
- }
909
- const guardIndex = this._activatedRouteConfig.canDeactivate.indexOf(FormDeactivateGuard);
910
- this._activatedRouteConfig.canDeactivate.splice(guardIndex, 1);
911
- this._form.removeFormDirective(this._activatedRouteConfig.component);
912
- }
913
1321
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
914
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormDirective, isStandalone: true, selector: "[fsForm]", inputs: { wrapperSelector: "wrapperSelector", messageSelector: "messageSelector", hintSelector: "hintSelector", labelSelector: "labelSelector", autocomplete: "autocomplete", shortcuts: "shortcuts", confirm: "confirm", confirmDialog: "confirmDialog", confirmDrawer: "confirmDrawer", confirmBrowser: "confirmBrowser", dirtySubmitButton: "dirtySubmitButton", submit: "submit", successDelay: "successDelay", errorDelay: "errorDelay", deactivationGuard: "deactivationGuard" }, outputs: { submitEvent: "fsForm", invalid: "invalid", valid: "valid", submitted: "submitted", reseted: "reseted", cleared: "cleared" }, host: { properties: { "class.fs-form": "this.fsFormClass" } }, exportAs: ["fsForm"], usesInheritance: true, usesOnChanges: true, ngImport: i0 });
1322
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormDirective, isStandalone: true, selector: "[fsForm]", inputs: { wrapperSelector: "wrapperSelector", messageSelector: "messageSelector", hintSelector: "hintSelector", labelSelector: "labelSelector", autocomplete: "autocomplete", dirtySubmitButton: "dirtySubmitButton", submit: "submit", successDelay: "successDelay", errorDelay: "errorDelay", link: "link" }, outputs: { submitEvent: "fsForm", invalid: "invalid", valid: "valid", submitted: "submitted", reseted: "reseted", cleared: "cleared" }, host: { properties: { "class.fs-form": "this.fsFormClass" } }, exportAs: ["fsForm"], usesInheritance: true, usesOnChanges: true, ngImport: i0 });
915
1323
  }
916
1324
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormDirective, decorators: [{
917
1325
  type: Directive,
@@ -930,16 +1338,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
930
1338
  type: Input
931
1339
  }], autocomplete: [{
932
1340
  type: Input
933
- }], shortcuts: [{
934
- type: Input
935
- }], confirm: [{
936
- type: Input
937
- }], confirmDialog: [{
938
- type: Input
939
- }], confirmDrawer: [{
940
- type: Input
941
- }], confirmBrowser: [{
942
- type: Input
943
1341
  }], dirtySubmitButton: [{
944
1342
  type: Input
945
1343
  }], submit: [{
@@ -948,7 +1346,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
948
1346
  type: Input
949
1347
  }], errorDelay: [{
950
1348
  type: Input
951
- }], deactivationGuard: [{
1349
+ }], link: [{
952
1350
  type: Input
953
1351
  }], submitEvent: [{
954
1352
  type: Output,
@@ -984,20 +1382,26 @@ class FsButtonDirective {
984
1382
  _formDisabled = false;
985
1383
  _previousDisabled = false;
986
1384
  _destroy$ = new Subject();
987
- _formGroup = inject(FsFormGroupDirective, { optional: true });
988
- _form = inject(FsFormDirective, { optional: true });
1385
+ _dialogRef = inject(MatDialogRef, { optional: true });
1386
+ _container = this._ownDialogOnly(inject(FsFormContainerDirective, { optional: true }));
1387
+ _form = this._ownDialogOnly(inject(FsFormDirective, { optional: true }));
989
1388
  _matButton = inject(MatButton, { optional: true, host: true });
990
1389
  _elementRef = inject(ElementRef);
991
1390
  _cdRef = inject(ChangeDetectorRef);
992
1391
  _formBase;
993
1392
  ngOnInit() {
994
1393
  this.submit = this._elementRef.nativeElement.getAttribute('type') === 'submit';
995
- // Prefer the enclosing form group over the form: the form's own button
1394
+ // Prefer the enclosing container over the form: the form's own button
996
1395
  // management (dirty-submit tracking, activeSubmitButton, reset) resolves its
997
- // registry via `_formGroup || this`, so buttons must register with the same
998
- // owner. Registering with the form while the form reads the group leaves the
999
- // group's button list empty and the submit button never gets dirty-disabled.
1000
- this._formBase = this._formGroup || this._form;
1396
+ // registry the same way, so buttons must register with the same owner.
1397
+ // Registering with the form while the form reads the container leaves the
1398
+ // container's button list empty and the submit button never gets
1399
+ // dirty-disabled.
1400
+ //
1401
+ // Note this resolves the ROOT of the linked set, not the nearest form: the
1402
+ // button that saves a tab's form typically sits outside it, down in the
1403
+ // dialog footer, and drives the whole linked set from there.
1404
+ this._formBase = this._container || this._form?.rootForm;
1001
1405
  if (this._formBase) {
1002
1406
  this._formBase.addButton(this);
1003
1407
  if (this.submit) {
@@ -1076,6 +1480,15 @@ class FsButtonDirective {
1076
1480
  this._destroy$.complete();
1077
1481
  this._formBase?.removeButton(this);
1078
1482
  }
1483
+ /**
1484
+ * Keeps a DI-resolved owner only when it belongs to this button's dialog. A
1485
+ * dialog opened with `viewContainerRef` resolves through to whatever opened
1486
+ * it, so without this the inner dialog's Save would register with - and
1487
+ * submit - the outer dialog's form set.
1488
+ */
1489
+ _ownDialogOnly(owner) {
1490
+ return withinSameDialog(owner, this._dialogRef) ? owner : null;
1491
+ }
1079
1492
  // Effective disabled combines the consumer-controlled submitDisabled input with
1080
1493
  // the form's submit-lifecycle state, so form enable/disable never clobbers an
1081
1494
  // explicit [submitDisabled] and the two channels stay independent.
@@ -1176,30 +1589,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
1176
1589
  }] } });
1177
1590
 
1178
1591
  class FsFormDialogActionsComponent {
1179
- _form = inject(FsFormDirective, { optional: true });
1180
- _cdRef = inject(ChangeDetectorRef);
1181
- save = true;
1592
+ /** Leave unset inside an `fsFormContainer` to follow whether it has anything to save. */
1593
+ save;
1182
1594
  create = false;
1183
1595
  close = false;
1184
- done = false;
1596
+ /** Leave unset inside an `fsFormContainer` to fill in whenever Save does not apply. */
1597
+ done;
1185
1598
  closeData = null;
1186
1599
  name;
1187
1600
  dirty = false;
1188
1601
  _destroy$ = new Subject();
1602
+ _dialogRef = inject(MatDialogRef, { optional: true });
1603
+ // A dialog opened with `viewContainerRef` resolves through to whatever opened
1604
+ // it, so an owner from another dialog is discarded - these actions belong to
1605
+ // their own dialog's form set and no one else's.
1606
+ _container = this._ownDialogOnly(inject(FsFormContainerDirective, { optional: true }));
1607
+ _form = this._ownDialogOnly(inject(FsFormDirective, { optional: true }));
1608
+ _cdRef = inject(ChangeDetectorRef);
1609
+ /**
1610
+ * Inside a container these default to whether anything mounted can actually
1611
+ * save, so a footer left as a bare `<fs-form-dialog-actions>` swaps Save for
1612
+ * Done as tabs change without the dialog listing which tabs are savable. An
1613
+ * explicit binding always wins, and outside a container Save stays on as it
1614
+ * always did.
1615
+ */
1616
+ get showSave() {
1617
+ return this.save ?? (this._container ? this._container.submits : true);
1618
+ }
1619
+ get showDone() {
1620
+ return this.done ?? (this._container ? !this._container.submits && !this.create : false);
1621
+ }
1189
1622
  ngOnInit() {
1190
- if (this._form) {
1191
- this._form.ngForm.valueChanges
1192
- .pipe(filter(() => (!this.dirty)), takeUntil(this._destroy$))
1623
+ const owner = this._container || this._form?.rootForm;
1624
+ if (owner) {
1625
+ // Track the whole set, not one form's own controls: with a form scoped to a
1626
+ // tab's fields, the actions sit outside it in the dialog footer and that
1627
+ // form's NgForm holds nothing of its own. Recomputed on every emission
1628
+ // rather than latched, so a switch to a pristine tab clears it too.
1629
+ owner.dirtyChange$
1630
+ .pipe(takeUntil(this._destroy$))
1193
1631
  .subscribe(() => {
1194
- this.dirty = this._form.ngForm.dirty;
1632
+ this.dirty = owner.dirtyLinked;
1195
1633
  this._cdRef.markForCheck();
1196
1634
  });
1197
- this._form.submitted
1635
+ owner.submitted$
1198
1636
  .pipe(delay(50), takeUntil(this._destroy$))
1199
1637
  .subscribe(() => {
1200
1638
  this.dirty = false;
1201
1639
  this._cdRef.markForCheck();
1202
1640
  });
1641
+ }
1642
+ if (this._form) {
1203
1643
  this._form.reseted
1204
1644
  .pipe(takeUntil(this._destroy$))
1205
1645
  .subscribe(() => {
@@ -1212,8 +1652,12 @@ class FsFormDialogActionsComponent {
1212
1652
  this._destroy$.next(null);
1213
1653
  this._destroy$.complete();
1214
1654
  }
1655
+ /** Keeps a DI-resolved owner only when it belongs to these actions' dialog. */
1656
+ _ownDialogOnly(owner) {
1657
+ return withinSameDialog(owner, this._dialogRef) ? owner : null;
1658
+ }
1215
1659
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormDialogActionsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1216
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.7", type: FsFormDialogActionsComponent, isStandalone: true, selector: "fs-form-dialog-actions", inputs: { save: "save", create: "create", close: "close", done: "done", closeData: "closeData", name: "name" }, ngImport: i0, template: "<div\n class=\"form-buttons\"\n [ngClass]=\"{ 'save-create': save || create }\">\n @if (save || create) {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\"\n [name]=\"name\">\n {{ create ? 'Create' : 'Save' }}\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [disabled]=\"close && !dirty && !create\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (done) {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n @if (save || create) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n @if (close) {\n <button\n mat-button\n type=\"button\"\n class=\"close-button\"\n fsFormDialogClose\n [closeData]=\"closeData\"\n [color]=\"dirty ? 'basic' : 'primary'\">\n Close\n </button>\n }\n @if (!(save || create)) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n</div>\n<ng-template #content>\n <ng-content></ng-content>\n</ng-template>", styles: [":host{width:100%}.form-buttons.save-create .close-button{float:right}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit", "submitDisabled"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: FsFormDialogCloseDirective, selector: "[fsFormDialogClose],[fs-form-dialog-close]", inputs: ["closeData"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1660
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.7", type: FsFormDialogActionsComponent, isStandalone: true, selector: "fs-form-dialog-actions", inputs: { save: "save", create: "create", close: "close", done: "done", closeData: "closeData", name: "name" }, ngImport: i0, template: "<div\n class=\"form-buttons\"\n [ngClass]=\"{ 'save-create': showSave || create }\">\n @if (showSave || create) {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\"\n [name]=\"name\">\n {{ create ? 'Create' : 'Save' }}\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [disabled]=\"close && !dirty && !create\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (showDone) {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n @if (showSave || create) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n @if (close) {\n <button\n mat-button\n type=\"button\"\n class=\"close-button\"\n fsFormDialogClose\n [closeData]=\"closeData\"\n [color]=\"dirty ? 'basic' : 'primary'\">\n Close\n </button>\n }\n @if (!(showSave || create)) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n</div>\n<ng-template #content>\n <ng-content></ng-content>\n</ng-template>\n", styles: [":host{width:100%}.form-buttons.save-create .close-button{float:right}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "directive", type: FsButtonDirective, selector: "[mat-raised-button],[mat-button],[mat-flat-button],[mat-stroked-button]", inputs: ["name", "dirtySubmit", "submitDisabled"] }, { kind: "directive", type: MatDialogClose, selector: "[mat-dialog-close], [matDialogClose]", inputs: ["aria-label", "type", "mat-dialog-close", "matDialogClose"], exportAs: ["matDialogClose"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: FsFormDialogCloseDirective, selector: "[fsFormDialogClose],[fs-form-dialog-close]", inputs: ["closeData"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1217
1661
  }
1218
1662
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormDialogActionsComponent, decorators: [{
1219
1663
  type: Component,
@@ -1224,7 +1668,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
1224
1668
  MatDialogClose,
1225
1669
  NgTemplateOutlet,
1226
1670
  FsFormDialogCloseDirective,
1227
- ], template: "<div\n class=\"form-buttons\"\n [ngClass]=\"{ 'save-create': save || create }\">\n @if (save || create) {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\"\n [name]=\"name\">\n {{ create ? 'Create' : 'Save' }}\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [disabled]=\"close && !dirty && !create\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (done) {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n @if (save || create) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n @if (close) {\n <button\n mat-button\n type=\"button\"\n class=\"close-button\"\n fsFormDialogClose\n [closeData]=\"closeData\"\n [color]=\"dirty ? 'basic' : 'primary'\">\n Close\n </button>\n }\n @if (!(save || create)) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n</div>\n<ng-template #content>\n <ng-content></ng-content>\n</ng-template>", styles: [":host{width:100%}.form-buttons.save-create .close-button{float:right}\n"] }]
1671
+ ], template: "<div\n class=\"form-buttons\"\n [ngClass]=\"{ 'save-create': showSave || create }\">\n @if (showSave || create) {\n <button\n mat-button\n type=\"submit\"\n color=\"primary\"\n [name]=\"name\">\n {{ create ? 'Create' : 'Save' }}\n </button>\n <button\n mat-button\n type=\"button\"\n class=\"cancel-button\"\n [disabled]=\"close && !dirty && !create\"\n [mat-dialog-close]=\"null\">\n Cancel\n </button>\n }\n @if (showDone) {\n <button\n mat-button\n type=\"button\"\n color=\"primary\"\n [mat-dialog-close]=\"null\">\n Done\n </button>\n }\n @if (showSave || create) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n @if (close) {\n <button\n mat-button\n type=\"button\"\n class=\"close-button\"\n fsFormDialogClose\n [closeData]=\"closeData\"\n [color]=\"dirty ? 'basic' : 'primary'\">\n Close\n </button>\n }\n @if (!(showSave || create)) {\n <ng-template [ngTemplateOutlet]=\"content\"></ng-template>\n }\n</div>\n<ng-template #content>\n <ng-content></ng-content>\n</ng-template>\n", styles: [":host{width:100%}.form-buttons.save-create .close-button{float:right}\n"] }]
1228
1672
  }], propDecorators: { save: [{
1229
1673
  type: Input
1230
1674
  }], create: [{
@@ -1239,6 +1683,49 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
1239
1683
  type: Input
1240
1684
  }] } });
1241
1685
 
1686
+ /**
1687
+ * Brings a tab group into the enclosing form set's unsaved-changes confirm.
1688
+ *
1689
+ * An owner picks up tab groups with `@ContentChildren(MatTabGroup)`, which reaches
1690
+ * through its own projected content - including lazily mounted tab bodies - but
1691
+ * stops at a child component's view. So a tab group declared inside a child
1692
+ * component's own template is invisible to the dialog that contains it, and
1693
+ * switching between those tabs never prompts even though switching the outer ones
1694
+ * does. That is not a shape worth avoiding: a dialog whose Profile tab is its own
1695
+ * component, with its own tabs inside, is the normal way to keep a large dialog
1696
+ * readable.
1697
+ *
1698
+ * DI does cross the boundary a content query cannot, so put this on the nested
1699
+ * group and it registers itself with the owner above it:
1700
+ *
1701
+ * <mat-tab-group fsFormConfirmTabs [(selected)]="tab">
1702
+ *
1703
+ * Confirming stays gated on the owner's `confirm` and `confirmTabs` inputs, so
1704
+ * this only opts the group in - it never forces a prompt on.
1705
+ */
1706
+ class FsFormConfirmTabsDirective {
1707
+ _tabGroup = inject(MatTabGroup, { self: true });
1708
+ _dialogRef = inject(MatDialogRef, { optional: true });
1709
+ _container = this._ownDialogOnly(inject(FsFormContainerDirective, { optional: true }));
1710
+ _form = this._ownDialogOnly(inject(FsFormDirective, { optional: true }));
1711
+ ngOnInit() {
1712
+ const owner = this._container || this._form?.rootForm;
1713
+ owner?.registerTabGroup(this._tabGroup);
1714
+ }
1715
+ _ownDialogOnly(owner) {
1716
+ return withinSameDialog(owner, this._dialogRef) ? owner : null;
1717
+ }
1718
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormConfirmTabsDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1719
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.7", type: FsFormConfirmTabsDirective, isStandalone: true, selector: "mat-tab-group[fsFormConfirmTabs]", ngImport: i0 });
1720
+ }
1721
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormConfirmTabsDirective, decorators: [{
1722
+ type: Directive,
1723
+ args: [{
1724
+ selector: 'mat-tab-group[fsFormConfirmTabs]',
1725
+ standalone: true,
1726
+ }]
1727
+ }] });
1728
+
1242
1729
  const ERROR_MESSAGES = {
1243
1730
  required: 'This field is required',
1244
1731
  email: 'This is not a valid email address',
@@ -2523,7 +3010,8 @@ class FsFormModule {
2523
3010
  FsFormTemplateDirective,
2524
3011
  FsFormTemplateDirective,
2525
3012
  FsFormTemplateOutletDirective,
2526
- FsFormGroupDirective], exports: [FsFormDirective,
3013
+ FsFormContainerDirective,
3014
+ FsFormConfirmTabsDirective], exports: [FsFormDirective,
2527
3015
  FsControlDirective,
2528
3016
  FsFormRequiredDirective,
2529
3017
  FsFormMinDirective,
@@ -2553,7 +3041,8 @@ class FsFormModule {
2553
3041
  FsFormTemplateDirective,
2554
3042
  FsFormTemplateDirective,
2555
3043
  FsFormTemplateOutletDirective,
2556
- FsFormGroupDirective] });
3044
+ FsFormContainerDirective,
3045
+ FsFormConfirmTabsDirective] });
2557
3046
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "18.2.7", ngImport: i0, type: FsFormModule, providers: [
2558
3047
  {
2559
3048
  provide: ErrorStateMatcher,
@@ -2607,7 +3096,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
2607
3096
  FsFormTemplateDirective,
2608
3097
  FsFormTemplateDirective,
2609
3098
  FsFormTemplateOutletDirective,
2610
- FsFormGroupDirective,
3099
+ FsFormContainerDirective,
3100
+ FsFormConfirmTabsDirective,
2611
3101
  ],
2612
3102
  exports: [
2613
3103
  FsFormDirective,
@@ -2640,7 +3130,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
2640
3130
  FsFormTemplateDirective,
2641
3131
  FsFormTemplateDirective,
2642
3132
  FsFormTemplateOutletDirective,
2643
- FsFormGroupDirective,
3133
+ FsFormContainerDirective,
3134
+ FsFormConfirmTabsDirective,
2644
3135
  ],
2645
3136
  providers: [
2646
3137
  {
@@ -2657,5 +3148,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.7", ngImpor
2657
3148
  * Generated bundle index. Do not edit.
2658
3149
  */
2659
3150
 
2660
- export { ConfirmResult, FormDeactivateGuard, FormStatus, FsButtonDirective, FsControlDirective, FsForm, FsFormCompareDirective, FsFormDateRangeDirective, FsFormDialogActionsComponent, FsFormDialogCloseDirective, FsFormDirective, FsFormEmailDirective, FsFormEmailsDirective, FsFormFunctionDirective, FsFormGreaterDirective, FsFormGreaterEqualDirective, FsFormGroupDirective, FsFormIntegerDirective, FsFormLesserDirective, FsFormLesserEqualDirective, FsFormMaxDirective, FsFormMaxLengthDirective, FsFormMinDirective, FsFormMinLengthDirective, FsFormModule, FsFormNoFsValidatorsDirective, FsFormNumericDirective, FsFormPatternDirective, FsFormPhoneDirective, FsFormRequiredDirective, FsFormTemplateDirective, FsFormTemplateOutletDirective, FsFormUrlDirective, FsFormValidateDirective, FsSubmitButtonDirective, FsValidators };
3151
+ export { ConfirmResult, FormDeactivateGuard, FormStatus, FsButtonDirective, FsControlDirective, FsForm, FsFormCompareDirective, FsFormConfirmTabsDirective, FsFormContainerDirective, FsFormDateRangeDirective, FsFormDialogActionsComponent, FsFormDialogCloseDirective, FsFormDirective, FsFormEmailDirective, FsFormEmailsDirective, FsFormFunctionDirective, FsFormGreaterDirective, FsFormGreaterEqualDirective, FsFormIntegerDirective, FsFormLesserDirective, FsFormLesserEqualDirective, FsFormMaxDirective, FsFormMaxLengthDirective, FsFormMinDirective, FsFormMinLengthDirective, FsFormModule, FsFormNoFsValidatorsDirective, FsFormNumericDirective, FsFormPatternDirective, FsFormPhoneDirective, FsFormRequiredDirective, FsFormTemplateDirective, FsFormTemplateOutletDirective, FsFormUrlDirective, FsFormValidateDirective, FsSubmitButtonDirective, FsValidators };
2661
3152
  //# sourceMappingURL=firestitch-form.mjs.map