@ng-simplicity/forms-core 1.1.3 → 1.1.4

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/README.md CHANGED
@@ -15,7 +15,7 @@ This core package is extended by styling integration packages that provide prebu
15
15
 
16
16
  | Package | Package version | Angular support |
17
17
  |---|---:|---|
18
- | `@ng-simplicity/forms-core` | `1.1.3` | `17.x`–`22.x` |
18
+ | `@ng-simplicity/forms-core` | `1.1.4` | `17.x`–`22.x` |
19
19
 
20
20
  ---
21
21
 
@@ -397,10 +397,17 @@ import { NgsFormsFormArrayRemoveItemComponent } from '@ng-simplicity/forms-core'
397
397
  const removeContactBtn = NgsFormsFormArrayRemoveItemComponent.create({
398
398
  buttonText: 'Delete',
399
399
  buttonClass: 'btn btn-danger',
400
- buttonIcon: 'trash-icon'
400
+ buttonIcon: 'trash-icon',
401
+ // Optional accessible name. Defaults to `${buttonText} item ${index + 1}`, for example "Delete item 2".
402
+ ariaLabel: (index) => `Delete contact ${index + 1}`,
401
403
  });
402
404
  ```
403
405
 
406
+ The button always removes its own row, even after earlier rows have been removed. When the removed row held the focus, focus moves to the Remove button of the row that takes its place. Custom components inside a row can inject `NGS_FORMS_ARRAY_ROW` (the row's `UntypedFormGroup`) and use `formArray.controls.indexOf(row)` to find the row's current index; `NGS_FORMS_ITEM_INDEX` is the index the row had when it was created.
407
+
408
+ ```typescript
409
+ ```
410
+
404
411
  ### 5. Section (`section`)
405
412
  A visual layout grouping with a title and subtitle.
406
413
  - **Component**: `NgsFormsFormSectionComponent`
@@ -413,6 +420,7 @@ import { NgsFormsFormSectionComponent } from '@ng-simplicity/forms-core';
413
420
  const personalSection = NgsFormsFormSectionComponent.create({
414
421
  title: 'Personal Information',
415
422
  titleClass: 'custom-title-class', // Optional, defaults to 'h3'
423
+ headingLevel: 2, // Optional heading level announced by screen readers, defaults to 3
416
424
  subtitle: 'Tell us a bit about yourself',
417
425
  subtitleClass: 'custom-subtitle-class', // Optional, defaults to 'lead'
418
426
  items: [
@@ -1,9 +1,9 @@
1
- import { Subject, takeUntil, BehaviorSubject, map, filter, merge, of } from 'rxjs';
1
+ import { Subject, takeUntil, BehaviorSubject, switchMap, defer, map, distinctUntilChanged, merge, of } from 'rxjs';
2
2
  import * as i0 from '@angular/core';
3
- import { Directive, InjectionToken, inject, Injectable, ChangeDetectorRef, Injector, ViewContainerRef, Input, computed, ChangeDetectionStrategy, Component, ElementRef, Sanitizer, SecurityContext, NgModule } from '@angular/core';
3
+ import { Directive, InjectionToken, inject, Injectable, ChangeDetectorRef, Injector, ViewContainerRef, Input, computed, ChangeDetectionStrategy, Component, ElementRef, afterNextRender, Sanitizer, SecurityContext, NgModule } from '@angular/core';
4
4
  import { v4 } from 'uuid';
5
5
  import { toSignal } from '@angular/core/rxjs-interop';
6
- import { UntypedFormGroup, FormControl, FormGroup, FormArray, UntypedFormArray, FormsModule, ReactiveFormsModule } from '@angular/forms';
6
+ import { UntypedFormGroup, FormControl, Validators, FormGroup, FormArray, UntypedFormArray, FormsModule, ReactiveFormsModule } from '@angular/forms';
7
7
  import { CommonModule } from '@angular/common';
8
8
 
9
9
  /** Base class that ties RxJS subscriptions to the component's lifetime. */
@@ -32,8 +32,18 @@ const NGS_FORMS_ITEM_DATA = new InjectionToken('ngs-forms-item-data');
32
32
  * @internal
33
33
  */
34
34
  const NGS_FORMS_CONTROL_ADD_REMOVE_FN = new InjectionToken('ngs-form-control-remove-fn');
35
- /** Injection token for the row index when a component is rendered inside a form array list. */
35
+ /**
36
+ * Injection token for the row index when a component is rendered inside a form array list.
37
+ *
38
+ * @remarks This is the index the row had when the component was created. Rows before it can be removed later,
39
+ * so to find a row's current position use `NGS_FORMS_ARRAY_ROW` with `formArray.controls.indexOf(row)`.
40
+ */
36
41
  const NGS_FORMS_ITEM_INDEX = new InjectionToken('ngs-forms-item-index');
42
+ /**
43
+ * Injection token for the `UntypedFormGroup` of the form array row a component is rendered in. Unlike
44
+ * `NGS_FORMS_ITEM_INDEX`, it stays correct when earlier rows are removed.
45
+ */
46
+ const NGS_FORMS_ARRAY_ROW = new InjectionToken('ngs-forms-array-row');
37
47
  /**
38
48
  * @internal
39
49
  */
@@ -43,10 +53,12 @@ const NgsFormsFormControlAddRemoveFunctions = {
43
53
  remove: (control, controlName) => formGroup.removeControl(controlName),
44
54
  }),
45
55
  formArray: (formArray) => ({
46
- add: (control, controlName) => formArray.controls.push(control),
47
- remove: (control, controlName) => {
56
+ add: (control) => formArray.push(control),
57
+ remove: (control) => {
48
58
  const index = formArray.controls.indexOf(control);
49
- formArray.controls.splice(index, 1);
59
+ if (index >= 0) {
60
+ formArray.removeAt(index);
61
+ }
50
62
  },
51
63
  }),
52
64
  };
@@ -148,10 +160,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
148
160
  * ```
149
161
  */
150
162
  class NgsFormsService {
151
- /**
152
- * @internal
153
- */
154
- attempt = 0;
155
163
  /**
156
164
  * Emits the root form group's value every time it changes. Combine with `toSignal()` to read it in a template.
157
165
  *
@@ -161,8 +169,12 @@ class NgsFormsService {
161
169
  /** The latest value emitted by `formValue$`. */
162
170
  formValue = {};
163
171
  internalFormService;
164
- formGroupSubscriptions = [];
172
+ formGroupBinding;
165
173
  formGroup = new UntypedFormGroup({});
174
+ /** The config from the latest `setFormConfig()` call, re-applied when `<ngs-form>` (re)connects. */
175
+ lastConfig;
176
+ missingFormTimer;
177
+ destroyed = false;
166
178
  /** `true` once the user has changed any control in the form. */
167
179
  get dirty() {
168
180
  return this.formGroup?.dirty ?? false;
@@ -180,27 +192,48 @@ class NgsFormsService {
180
192
  /**
181
193
  * Renders a form from `formConfig`, replacing any form that is already shown and resetting the submitted state.
182
194
  *
183
- * @remarks It is safe to call before `<ngs-form>` has initialised (for example in `ngOnInit`): the call is retried
184
- * every 25 ms for about half a second until the form component is available.
195
+ * @remarks It is safe to call before `<ngs-form>` has initialised (for example in `ngOnInit`): the latest config
196
+ * is kept and applied as soon as the form component connects, and also when it is destroyed and created again
197
+ * (inside an `@if` or a tab, for example). An error is logged if no form has connected after half a second.
185
198
  */
186
199
  setFormConfig(formConfig) {
187
- if (!this.internalServiceIsSet) {
188
- if (this.attempt > 20) {
189
- console.error('Internal form service is unavailable. Is the ngs-form root tag added to the page?');
190
- return;
191
- }
192
- this.attempt++;
193
- setTimeout(() => this.setFormConfig(formConfig), 25);
200
+ this.lastConfig = formConfig;
201
+ if (this.internalServiceIsSet) {
202
+ this.internalFormService.setFormData(formConfig);
194
203
  return;
195
204
  }
196
- this.internalFormService.setFormData(formConfig);
205
+ if (this.missingFormTimer === undefined && !this.destroyed) {
206
+ this.missingFormTimer = setTimeout(() => {
207
+ this.missingFormTimer = undefined;
208
+ if (!this.internalServiceIsSet && !this.destroyed) {
209
+ console.error('Internal form service is unavailable. Is the ngs-form root tag added to the page?');
210
+ }
211
+ }, 500);
212
+ }
197
213
  }
198
214
  /**
199
215
  * @internal
200
216
  */
201
217
  setInternalService(internalFormsService) {
218
+ this.formGroupBinding?.unsubscribe();
202
219
  this.internalFormService = internalFormsService;
203
220
  this.bindFormGroup();
221
+ if (this.lastConfig) {
222
+ internalFormsService.setFormData(this.lastConfig);
223
+ }
224
+ }
225
+ /**
226
+ * @internal
227
+ * Disconnects `internalFormsService` when its `<ngs-form>` is destroyed. Does nothing if another form has
228
+ * connected since.
229
+ */
230
+ clearInternalService(internalFormsService) {
231
+ if (this.internalFormService !== internalFormsService)
232
+ return;
233
+ this.formGroupBinding?.unsubscribe();
234
+ this.formGroupBinding = undefined;
235
+ this.internalFormService = undefined;
236
+ this.formGroup = new UntypedFormGroup({});
204
237
  }
205
238
  /**
206
239
  * Marks the form as submitted (or not). While submitted, every invalid control shows its error message even if
@@ -212,13 +245,14 @@ class NgsFormsService {
212
245
  this.internalFormService.setIsSubmitted(isSubmitted);
213
246
  }
214
247
  bindFormGroup() {
215
- this.internalFormService.formGroup$.subscribe((fg) => {
248
+ this.formGroupBinding = this.internalFormService.formGroup$
249
+ .pipe(switchMap((fg) => {
216
250
  this.formGroup = fg;
217
- this.formGroupSubscriptions.forEach((subscription) => subscription.unsubscribe());
218
- this.formGroupSubscriptions.push(this.formGroup.valueChanges.subscribe((v) => {
219
- this.formValue = v;
220
- this.formValue$.next(v);
221
- }));
251
+ return fg.valueChanges;
252
+ }))
253
+ .subscribe((v) => {
254
+ this.formValue = v;
255
+ this.formValue$.next(v);
222
256
  });
223
257
  }
224
258
  /**
@@ -233,7 +267,11 @@ class NgsFormsService {
233
267
  this.internalFormService?.updateGlobalState(state);
234
268
  }
235
269
  ngOnDestroy() {
236
- this.formGroupSubscriptions.forEach((subscription) => subscription.unsubscribe());
270
+ this.destroyed = true;
271
+ clearTimeout(this.missingFormTimer);
272
+ this.missingFormTimer = undefined;
273
+ this.formGroupBinding?.unsubscribe();
274
+ this.formValue$.complete();
237
275
  }
238
276
  /**
239
277
  * Patches values into the form, using the same shape as `formValue$` (including the root group name).
@@ -265,10 +303,12 @@ class NgsFormsInternalService {
265
303
  setIsSubmitted(isSubmitted) {
266
304
  this.isSubmitted$.next(isSubmitted);
267
305
  }
306
+ /** `true` when no rendered control fails its validators. A form whose controls are all disabled counts as valid. */
268
307
  checkIsValid() {
269
- if (!this.formGroup$.value)
308
+ const formGroup = this.formGroup$.value;
309
+ if (!formGroup)
270
310
  return false;
271
- return this.formGroup$.value.valid;
311
+ return formGroup.valid || formGroup.disabled === true;
272
312
  }
273
313
  setFormData(formConfig) {
274
314
  const state = { ...(formConfig.initialState ?? {}) };
@@ -302,18 +342,13 @@ class NgsFormsInternalService {
302
342
  */
303
343
  subscribeToState(uuid, name, initialState) {
304
344
  this.seedComponentState(uuid, initialState);
305
- let last = '';
306
- return this.state.pipe(map((allState) => {
345
+ // `defer` gives every subscriber its own comparison, so each one receives the current state first.
346
+ return defer(() => this.state.pipe(map((allState) => {
307
347
  const stateByUuid = allState[uuid] || {};
308
348
  const stateByName = name ? (allState[name] || {}) : {};
309
349
  const globalState = allState['global'] || {};
310
350
  return ngsDefaults(stateByUuid, ngsDefaults(stateByName, globalState));
311
- }), filter((currentState) => {
312
- const stringifiedState = JSON.stringify(currentState);
313
- const isUpdated = stringifiedState !== last;
314
- last = stringifiedState;
315
- return isUpdated;
316
- }));
351
+ }), distinctUntilChanged((previous, current) => JSON.stringify(previous) === JSON.stringify(current))));
317
352
  }
318
353
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
319
354
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService });
@@ -326,6 +361,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
326
361
  * Base class for items that own a form control. Creates the control (seeded with `value` when the config has one),
327
362
  * applies `validators` / `validators$` and `disabled` / `disabled$`, registers it with the parent group under
328
363
  * `config.name` and keeps `errorMessage` up to date.
364
+ *
365
+ * @typeParam T - The item's config type.
366
+ * @typeParam C - The type of `control`. Inputs use the default `UntypedFormControl`; groups and arrays set it in
367
+ * their constructor to an `UntypedFormGroup` or `UntypedFormArray`.
329
368
  */
330
369
  class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassFormComponent {
331
370
  /** The item's form control. Bind it with `[formControl]`. */
@@ -347,15 +386,24 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
347
386
  const initialValue = this.config.value;
348
387
  this.control = new FormControl(initialValue, { validators: [] });
349
388
  }
389
+ const control = this.control;
350
390
  this.bindValidators();
351
- this.formAddRemoveFns.add(this.control, this.config.name);
391
+ this.formAddRemoveFns.add(control, this.config.name);
352
392
  this.bindDisabled();
353
- // Listen to changes and update error message dynamically
354
- this.subscribe(merge(this.control.valueChanges, this.control.statusChanges, this.privateService.isSubmitted$), () => {
393
+ // Recompute the error message on value, status and touched changes, and when the form is submitted.
394
+ // `control.events` (Angular 18+) includes touched changes, so blurring an untouched field shows its error.
395
+ // Angular 17 has no `events`, so there the message updates on value and status changes only.
396
+ const controlEvents$ = control.events ?? merge(control.valueChanges, control.statusChanges);
397
+ this.subscribe(merge(controlEvents$, this.privateService.isSubmitted$), () => {
355
398
  this.updateErrorMessage();
356
399
  this.changeDetectorRef.markForCheck();
357
400
  });
358
401
  }
402
+ /** Whether the control currently has `Validators.required` or `Validators.requiredTrue`. */
403
+ get isRequired() {
404
+ const control = this.control;
405
+ return !!control && (control.hasValidator(Validators.required) || control.hasValidator(Validators.requiredTrue));
406
+ }
359
407
  updateErrorMessage() {
360
408
  if (!this.control) {
361
409
  this.errorMessage = '';
@@ -382,12 +430,18 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
382
430
  bindValidators() {
383
431
  if (!this.config.validators && !this.config.validators$)
384
432
  return;
433
+ const control = this.control;
385
434
  if (this.config.validators) {
386
- this.control.setValidators(this.config.validators);
435
+ control.setValidators(this.config.validators);
436
+ // A group or array computed its status when it was created, before these validators were set.
437
+ control.updateValueAndValidity({ emitEvent: false });
387
438
  return;
388
439
  }
389
440
  if (this.config.validators$) {
390
- this.subscribe(this.config.validators$, (validators) => this.control.setValidators(validators));
441
+ this.subscribe(this.config.validators$, (validators) => {
442
+ control.setValidators(validators);
443
+ control.updateValueAndValidity();
444
+ });
391
445
  }
392
446
  }
393
447
  bindDisabled() {
@@ -399,14 +453,21 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
399
453
  this.disabledFromConfig = !!disabled;
400
454
  this.applyDisabled(control);
401
455
  });
456
+ const reapply = () => {
457
+ if (this.disabledFromConfig && control.enabled) {
458
+ control.disable();
459
+ }
460
+ };
402
461
  // Child controls are added to groups and arrays after the container has been disabled, and adding an enabled
403
462
  // child re-enables its parent. Re-apply the configured state whenever a container's status changes.
404
463
  if (control instanceof FormGroup || control instanceof FormArray) {
405
- this.subscribe(control.statusChanges, () => {
406
- if (this.disabledFromConfig && control.enabled) {
407
- control.disable();
408
- }
409
- });
464
+ this.subscribe(control.statusChanges, reapply);
465
+ }
466
+ // Enabling a parent group or array enables every child in it. The parent emits its status after its children
467
+ // are enabled, so re-apply the configured state then. (Doing it on the child's own status change would be undone
468
+ // by the end of the child's `enable()`, which re-enables the bound DOM element.)
469
+ if (control.parent) {
470
+ this.subscribe(control.parent.statusChanges, reapply);
410
471
  }
411
472
  }
412
473
  applyDisabled(control) {
@@ -485,10 +546,13 @@ class NgsFormItemController extends NgsSubscriber {
485
546
  addRemoveControlFn;
486
547
  index;
487
548
  changeDetectorRef;
549
+ arrayRow;
488
550
  componentRef;
489
- parentVisibility = new BehaviorSubject(false);
490
551
  lastVisible;
491
- constructor(injector, viewContainerRef, itemData, addRemoveControlFn, index, changeDetectorRef) {
552
+ /** Incremented whenever a scheduled create becomes stale (hidden again, or destroyed). */
553
+ createToken = 0;
554
+ destroyed = false;
555
+ constructor(injector, viewContainerRef, itemData, addRemoveControlFn, index, changeDetectorRef, arrayRow) {
492
556
  super();
493
557
  this.injector = injector;
494
558
  this.viewContainerRef = viewContainerRef;
@@ -496,9 +560,12 @@ class NgsFormItemController extends NgsSubscriber {
496
560
  this.addRemoveControlFn = addRemoveControlFn;
497
561
  this.index = index;
498
562
  this.changeDetectorRef = changeDetectorRef;
563
+ this.arrayRow = arrayRow;
499
564
  this.bindVisibility();
500
565
  }
501
566
  destroy() {
567
+ this.destroyed = true;
568
+ this.createToken++;
502
569
  this.detach();
503
570
  super.ngOnDestroy();
504
571
  }
@@ -506,7 +573,7 @@ class NgsFormItemController extends NgsSubscriber {
506
573
  const config = this.itemData.config;
507
574
  const visible$ = config.visible$ ?? (config.visible !== undefined ? of(config.visible) : undefined);
508
575
  if (!visible$) {
509
- Promise.resolve().then(() => this.create());
576
+ this.scheduleCreate();
510
577
  return;
511
578
  }
512
579
  this.subscribe(visible$, (visible) => {
@@ -514,17 +581,37 @@ class NgsFormItemController extends NgsSubscriber {
514
581
  return;
515
582
  this.lastVisible = visible;
516
583
  if (visible) {
517
- Promise.resolve().then(() => this.create());
584
+ this.scheduleCreate();
518
585
  return;
519
586
  }
587
+ // Cancels a create that was scheduled but has not run yet.
588
+ this.createToken++;
520
589
  this.detach();
521
590
  });
522
591
  }
592
+ /**
593
+ * Creates the component in a microtask. A create that is overtaken by a later visibility change or by
594
+ * `destroy()` before it runs is dropped.
595
+ */
596
+ scheduleCreate() {
597
+ const token = ++this.createToken;
598
+ Promise.resolve().then(() => {
599
+ if (this.destroyed || token !== this.createToken)
600
+ return;
601
+ this.create();
602
+ });
603
+ }
523
604
  create() {
605
+ if (this.componentRef && !this.componentRef.hostView.destroyed) {
606
+ return;
607
+ }
524
608
  const providers = [{ provide: NGS_FORMS_ITEM_DATA, useValue: this.itemData }];
525
609
  if (this.index !== undefined) {
526
610
  providers.push({ provide: NGS_FORMS_ITEM_INDEX, useValue: this.index });
527
611
  }
612
+ if (this.arrayRow) {
613
+ providers.push({ provide: NGS_FORMS_ARRAY_ROW, useValue: this.arrayRow });
614
+ }
528
615
  if (this.addRemoveControlFn) {
529
616
  providers.push({ provide: NGS_FORMS_CONTROL_ADD_REMOVE_FN, useValue: this.addRemoveControlFn });
530
617
  }
@@ -540,11 +627,10 @@ class NgsFormItemController extends NgsSubscriber {
540
627
  this.componentRef = this.viewContainerRef.createComponent(componentType, { injector: myInjector });
541
628
  this.componentRef.changeDetectorRef?.detectChanges();
542
629
  this.changeDetectorRef?.markForCheck();
543
- this.parentVisibility.next(true);
544
630
  }
545
631
  detach() {
546
- this.parentVisibility.next(false);
547
632
  this.componentRef?.destroy();
633
+ this.componentRef = undefined;
548
634
  this.changeDetectorRef?.markForCheck();
549
635
  }
550
636
  }
@@ -563,7 +649,8 @@ class NgsFormsFormItemDirective {
563
649
  /** Form array that the item's control is added to. */
564
650
  formArray;
565
651
  /**
566
- * Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`.
652
+ * Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`. When it is set, the
653
+ * `ngs-form-group` is also provided to children as `NGS_FORMS_ARRAY_ROW`.
567
654
  */
568
655
  index;
569
656
  controller;
@@ -571,7 +658,24 @@ class NgsFormsFormItemDirective {
571
658
  injector = inject(Injector);
572
659
  changeDetectorRef = inject(ChangeDetectorRef);
573
660
  formComponentRegistryService = inject(NgsFormsComponentRegistryService);
661
+ initialized = false;
574
662
  ngOnInit() {
663
+ this.initialized = true;
664
+ this.build();
665
+ }
666
+ ngOnChanges(changes) {
667
+ if (!this.initialized) {
668
+ return;
669
+ }
670
+ // A new item, group or array means a different control, so the component is rebuilt. An index change on its own
671
+ // keeps the component: the row it belongs to is still the same, and `NGS_FORMS_ARRAY_ROW` follows it.
672
+ if (changes['itemData'] || changes['formGroup'] || changes['formArray']) {
673
+ this.controller?.destroy();
674
+ this.controller = undefined;
675
+ this.build();
676
+ }
677
+ }
678
+ build() {
575
679
  if (!this.itemData) {
576
680
  return console.error('Form item without form data');
577
681
  }
@@ -582,13 +686,13 @@ class NgsFormsFormItemDirective {
582
686
  if (this.formGroup) {
583
687
  addRemoveFn = NgsFormsFormControlAddRemoveFunctions.formGroup(this.formGroup);
584
688
  }
585
- this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef);
689
+ this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef, this.index !== undefined ? this.formGroup : undefined);
586
690
  }
587
691
  ngOnDestroy() {
588
692
  this.controller?.destroy();
589
693
  }
590
694
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormItemDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
591
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.19", type: NgsFormsFormItemDirective, isStandalone: true, selector: "[ngs-form-item]", inputs: { itemData: ["ngs-form-item", "itemData"], formGroup: ["ngs-form-group", "formGroup"], formArray: ["ngs-form-array", "formArray"], index: ["ngs-form-index", "index"] }, ngImport: i0 });
695
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.19", type: NgsFormsFormItemDirective, isStandalone: true, selector: "[ngs-form-item]", inputs: { itemData: ["ngs-form-item", "itemData"], formGroup: ["ngs-form-group", "formGroup"], formArray: ["ngs-form-array", "formArray"], index: ["ngs-form-index", "index"] }, usesOnChanges: true, ngImport: i0 });
592
696
  }
593
697
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormItemDirective, decorators: [{
594
698
  type: Directive,
@@ -633,6 +737,9 @@ class NgsFormComponent {
633
737
  constructor() {
634
738
  this.ngsFormsService.setInternalService(this.ngsInternalFormsService);
635
739
  }
740
+ ngOnDestroy() {
741
+ this.ngsFormsService.clearInternalService(this.ngsInternalFormsService);
742
+ }
636
743
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
637
744
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormComponent, isStandalone: true, selector: "ngs-form", providers: [NgsFormsInternalService], ngImport: i0, template: "<!-- Tracking by form group re-creates the tree whenever setFormConfig() is called again, since each call creates a new group. -->\n@for (entry of rendered(); track entry.group) {\n <ng-container [ngs-form-group]=\"entry.group\" [ngs-form-item]=\"entry.root\"></ng-container>\n}\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
638
745
  }
@@ -756,6 +863,8 @@ class NgsFormsFormArrayInternalService {
756
863
  formArray = new UntypedFormArray([]);
757
864
  /** Emits after a row is added or removed. */
758
865
  itemsChanged = new Subject();
866
+ /** Emits the index of a row right after it is removed. */
867
+ rowRemoved = new Subject();
759
868
  constructor() {
760
869
  if (this.config.initialItemCount) {
761
870
  for (let i = 0; i < this.config.initialItemCount; i++) {
@@ -764,16 +873,19 @@ class NgsFormsFormArrayInternalService {
764
873
  }
765
874
  }
766
875
  ngOnDestroy() {
876
+ this.itemsChanged.complete();
877
+ this.rowRemoved.complete();
767
878
  }
768
879
  /** Removes the row at `index`, unless that would go below `minItems`. */
769
880
  removeAt(index) {
770
881
  if (this.config?.minItems && this.formArray.controls.length <= this.config.minItems) {
771
882
  return;
772
883
  }
773
- if (!this.formArray.controls.length) {
884
+ if (index < 0 || index >= this.formArray.controls.length) {
774
885
  return;
775
886
  }
776
887
  this.formArray.removeAt(index);
888
+ this.rowRemoved.next(index);
777
889
  this.itemsChanged.next();
778
890
  }
779
891
  /** Appends an empty row, unless the array already has `maxItems` rows. */
@@ -859,10 +971,33 @@ class NgsFormsFormArrayListComponent extends NgsFormsBaseClassFormComponent {
859
971
  internalArrayService = inject(NgsFormsFormArrayInternalService);
860
972
  myFormArray = this.internalArrayService.formArray;
861
973
  changeDetectorRef = inject(ChangeDetectorRef);
974
+ elementRef = inject(ElementRef);
975
+ injector = inject(Injector);
862
976
  ngOnInit() {
863
977
  this.subscribe(this.internalArrayService.itemsChanged, () => {
864
978
  this.changeDetectorRef.markForCheck();
865
979
  });
980
+ this.subscribe(this.internalArrayService.rowRemoved, (index) => this.restoreFocusAfterRemove(index));
981
+ }
982
+ /**
983
+ * When the focused element (usually the row's Remove button) was removed with its row, moves focus to the
984
+ * same kind of control in the row that took its place, so keyboard users are not sent back to the top of the page.
985
+ */
986
+ restoreFocusAfterRemove(index) {
987
+ afterNextRender(() => {
988
+ const active = document.activeElement;
989
+ if (active && active !== document.body) {
990
+ return;
991
+ }
992
+ const host = this.elementRef.nativeElement;
993
+ const rows = Array.from(host.querySelectorAll(':scope > [data-index]'));
994
+ const row = rows[Math.min(index, rows.length - 1)];
995
+ const focusable = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
996
+ const target = row?.querySelector('ngs-forms-form-array-remove-item button:not([disabled])') ??
997
+ row?.querySelector(focusable) ??
998
+ host.closest('ngs-forms-form-array')?.querySelector(focusable);
999
+ target?.focus();
1000
+ }, { injector: this.injector });
866
1001
  }
867
1002
  /** Creates a `form-array-list` item. */
868
1003
  static create(config) {
@@ -894,8 +1029,26 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
894
1029
  internalArrayService = inject(NgsFormsFormArrayInternalService);
895
1030
  elementRef = inject(ElementRef);
896
1031
  indexFromDi = inject(NGS_FORMS_ITEM_INDEX, { optional: true });
897
- /** Index of the row this button belongs to. */
1032
+ row = inject(NGS_FORMS_ARRAY_ROW, { optional: true });
1033
+ changeDetectorRef = inject(ChangeDetectorRef);
1034
+ /** Accessible name of the button, from `ariaLabel` or `${buttonText} item ${index + 1}`. */
1035
+ get ariaLabel() {
1036
+ const index = this.myIndex;
1037
+ const label = this.config.ariaLabel;
1038
+ if (typeof label === 'function')
1039
+ return label(index);
1040
+ if (label)
1041
+ return label;
1042
+ return `${this.config.buttonText || 'Remove'} item ${index + 1}`;
1043
+ }
1044
+ /** Current index of the row this button belongs to. */
898
1045
  get myIndex() {
1046
+ if (this.row) {
1047
+ const index = this.internalArrayService.formArray.controls.indexOf(this.row);
1048
+ if (index >= 0) {
1049
+ return index;
1050
+ }
1051
+ }
899
1052
  if (this.indexFromDi !== null && this.indexFromDi !== undefined) {
900
1053
  return this.indexFromDi;
901
1054
  }
@@ -912,6 +1065,10 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
912
1065
  constructor() {
913
1066
  super();
914
1067
  }
1068
+ ngOnInit() {
1069
+ // The row's position changes when earlier rows are removed, which changes the default accessible name.
1070
+ this.subscribe(this.internalArrayService.itemsChanged, () => this.changeDetectorRef.markForCheck());
1071
+ }
915
1072
  /** Creates a `form-array-remove-item` item. */
916
1073
  static create(config) {
917
1074
  return {
@@ -924,11 +1081,11 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
924
1081
  this.internalArrayService.removeAt(this.myIndex);
925
1082
  }
926
1083
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormArrayRemoveItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
927
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormArrayRemoveItemComponent, isStandalone: true, selector: "ngs-forms-form-array-remove-item", usesInheritance: true, ngImport: i0, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
1084
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormArrayRemoveItemComponent, isStandalone: true, selector: "ngs-forms-form-array-remove-item", usesInheritance: true, ngImport: i0, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n [attr.aria-label]=\"ariaLabel\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush });
928
1085
  }
929
1086
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormArrayRemoveItemComponent, decorators: [{
930
1087
  type: Component,
931
- args: [{ selector: 'ngs-forms-form-array-remove-item', changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n" }]
1088
+ args: [{ selector: 'ngs-forms-form-array-remove-item', changeDetection: ChangeDetectionStrategy.OnPush, template: "<button\n (click)=\"removeItem()\"\n [class]=\"config.buttonClass || 'btn btn-outline-danger btn-sm'\"\n [attr.aria-label]=\"ariaLabel\"\n type=\"button\">\n @if (config.buttonIcon) {\n <i [class]=\"config.buttonIcon\" aria-hidden=\"true\"></i>\n }\n {{ config.buttonText || 'Remove' }}\n</button>\n" }]
932
1089
  }], ctorParameters: () => [] });
933
1090
 
934
1091
  /**
@@ -963,6 +1120,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
963
1120
  args: [{ selector: 'ngs-form-component-text-div', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"{{ config.classes }}\">{{ config.text }}</div>\n" }]
964
1121
  }] });
965
1122
 
1123
+ ;
1124
+
966
1125
  /**
967
1126
  * Renders an HTML string inside the form. The HTML is passed through Angular's sanitizer.
968
1127
  *
@@ -996,8 +1155,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
996
1155
  args: [{ selector: 'ngs-form-component-html-content', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div [innerHTML]=\"html\"></div>\n" }]
997
1156
  }], ctorParameters: () => [] });
998
1157
 
1158
+ /** @internal */
999
1159
  const sectionConfigDefaults = {
1000
1160
  titleClass: 'h3',
1161
+ headingLevel: 3,
1001
1162
  subtitleClass: 'lead',
1002
1163
  };
1003
1164
 
@@ -1006,10 +1167,13 @@ const sectionConfigDefaults = {
1006
1167
  * parent group.
1007
1168
  *
1008
1169
  * @remarks Registry key: `section`. Config: `title`, `subtitle`, `titleClass` (default `h3`),
1009
- * `subtitleClass` (default `lead`) and `items`.
1170
+ * `headingLevel` (default 3), `subtitleClass` (default `lead`) and `items`. The section is exposed to assistive
1171
+ * technology as a group named by its title.
1010
1172
  */
1011
1173
  class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
1012
1174
  static key = 'section';
1175
+ /** Element id of the title, used to name the section's group. */
1176
+ titleId = `${this.itemData.uuid || v4()}-section-title`;
1013
1177
  /** Creates a `section` item. */
1014
1178
  static create(config) {
1015
1179
  return {
@@ -1022,11 +1186,11 @@ class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
1022
1186
  this.config = ngsDefaults(this.config, sectionConfigDefaults);
1023
1187
  }
1024
1188
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormSectionComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
1025
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormSectionComponent, isStandalone: true, selector: "ngs-forms-form-section", usesInheritance: true, ngImport: i0, template: "<div class=\"form-section\">\n <div class=\"form-section-title {{config.titleClass}}\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1189
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: NgsFormsFormSectionComponent, isStandalone: true, selector: "ngs-forms-form-section", usesInheritance: true, ngImport: i0, template: "<div class=\"form-section\" role=\"group\" [attr.aria-labelledby]=\"titleId\">\n <div class=\"form-section-title {{config.titleClass}}\" role=\"heading\" [attr.aria-level]=\"config.headingLevel\" [id]=\"titleId\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n", dependencies: [{ kind: "directive", type: NgsFormsFormItemDirective, selector: "[ngs-form-item]", inputs: ["ngs-form-item", "ngs-form-group", "ngs-form-array", "ngs-form-index"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1026
1190
  }
1027
1191
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormSectionComponent, decorators: [{
1028
1192
  type: Component,
1029
- args: [{ selector: 'ngs-forms-form-section', imports: [NgsFormsFormItemDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-section\">\n <div class=\"form-section-title {{config.titleClass}}\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n" }]
1193
+ args: [{ selector: 'ngs-forms-form-section', imports: [NgsFormsFormItemDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"form-section\" role=\"group\" [attr.aria-labelledby]=\"titleId\">\n <div class=\"form-section-title {{config.titleClass}}\" role=\"heading\" [attr.aria-level]=\"config.headingLevel\" [id]=\"titleId\">{{ config.title }}</div>\n @if (config.subtitle) {\n <div class=\"form-section-subtitle {{config.subtitleClass}}\">{{ config.subtitle }}</div>\n }\n @if (config.items.length) {\n <div class=\"form-section-items\">\n @for (item of config.items; track item.uuid) {\n <ng-container [ngs-form-item]=\"item\"></ng-container>\n }\n </div>\n }\n</div>\n" }]
1030
1194
  }] });
1031
1195
 
1032
1196
  // --
@@ -1066,5 +1230,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
1066
1230
  * Generated bundle index. Do not edit.
1067
1231
  */
1068
1232
 
1069
- export { NGS_FORMS_CONTROL_ADD_REMOVE_FN, NGS_FORMS_ITEM_DATA, NGS_FORMS_ITEM_INDEX, NgsFormComponent, NgsFormsBaseClassFormComponent, NgsFormsBaseClassFormInputComponent, NgsFormsBaseClassFormItemInputWithOptionsComponent, NgsFormsBaseClassItemsContainerBase, NgsFormsColumnComponent, NgsFormsComponentRegistryService, NgsFormsCoreModule, NgsFormsFormArrayAddItemComponent, NgsFormsFormArrayContainerComponent, NgsFormsFormArrayInternalService, NgsFormsFormArrayListComponent, NgsFormsFormArrayRemoveItemComponent, NgsFormsFormControlAddRemoveFunctions, NgsFormsFormGroupComponent, NgsFormsFormItemDirective, NgsFormsFormItemWithVisibleAndValidatorsBase, NgsFormsFormSectionComponent, NgsFormsHtmlContentComponent, NgsFormsInternalService, NgsFormsRowComponent, NgsFormsService, NgsFormsTextDivComponent, NgsSubscriber, ngsDefaults };
1233
+ export { NGS_FORMS_ARRAY_ROW, NGS_FORMS_CONTROL_ADD_REMOVE_FN, NGS_FORMS_ITEM_DATA, NGS_FORMS_ITEM_INDEX, NgsFormComponent, NgsFormsBaseClassFormComponent, NgsFormsBaseClassFormInputComponent, NgsFormsBaseClassFormItemInputWithOptionsComponent, NgsFormsBaseClassItemsContainerBase, NgsFormsColumnComponent, NgsFormsComponentRegistryService, NgsFormsCoreModule, NgsFormsFormArrayAddItemComponent, NgsFormsFormArrayContainerComponent, NgsFormsFormArrayInternalService, NgsFormsFormArrayListComponent, NgsFormsFormArrayRemoveItemComponent, NgsFormsFormControlAddRemoveFunctions, NgsFormsFormGroupComponent, NgsFormsFormItemDirective, NgsFormsFormItemWithVisibleAndValidatorsBase, NgsFormsFormSectionComponent, NgsFormsHtmlContentComponent, NgsFormsInternalService, NgsFormsRowComponent, NgsFormsService, NgsFormsTextDivComponent, NgsSubscriber, ngsDefaults, sectionConfigDefaults };
1070
1234
  //# sourceMappingURL=ng-simplicity-forms-core.mjs.map