@ng-simplicity/forms-core 1.1.3 → 1.1.5

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.
@@ -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
+ import { FormArray, FormGroup, UntypedFormGroup, FormControl, Validators, UntypedFormArray, FormsModule, ReactiveFormsModule } from '@angular/forms';
5
6
  import { toSignal } from '@angular/core/rxjs-interop';
6
- import { UntypedFormGroup, FormControl, 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
  };
@@ -65,6 +77,202 @@ function ngsDefaults(target, ...sources) {
65
77
  return result;
66
78
  }
67
79
 
80
+ /**
81
+ * Builds the object for `NgsFormsService.setServerErrorMessages()` from a flat list of paths and error keys, for
82
+ * APIs that return their errors that way. Array indexes create arrays, and rows without errors are filled with `{}`.
83
+ * Two errors on the same path become an array of keys.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * constructServerErrorMessageObject([
88
+ * { jsonPath: 'registration.userName', validator: 'taken' },
89
+ * { jsonPath: 'registration.contacts[2].phone', validator: 'invalidPhone' },
90
+ * ]);
91
+ * // { registration: { userName: 'taken', contacts: [{}, {}, { phone: 'invalidPhone' }] } }
92
+ * ```
93
+ */
94
+ function constructServerErrorMessageObject(errors) {
95
+ const result = {};
96
+ for (const { jsonPath, validator } of errors) {
97
+ const segments = parseJsonPath(jsonPath);
98
+ if (!segments.length || !validator)
99
+ continue;
100
+ let node = result;
101
+ segments.forEach((segment, i) => {
102
+ const isLast = i === segments.length - 1;
103
+ if (Array.isArray(node) && typeof segment === 'number') {
104
+ while (node.length < segment)
105
+ node.push({});
106
+ }
107
+ if (isLast) {
108
+ const existing = node[segment];
109
+ if (typeof existing === 'string') {
110
+ node[segment] = existing === validator ? existing : [existing, validator];
111
+ }
112
+ else if (Array.isArray(existing) && existing.every((e) => typeof e === 'string')) {
113
+ if (!existing.includes(validator))
114
+ existing.push(validator);
115
+ }
116
+ else {
117
+ node[segment] = validator;
118
+ }
119
+ return;
120
+ }
121
+ const nextIsIndex = typeof segments[i + 1] === 'number';
122
+ const existing = node[segment];
123
+ if (existing === undefined || existing === null || typeof existing !== 'object') {
124
+ node[segment] = nextIsIndex ? [] : {};
125
+ }
126
+ else if (nextIsIndex && !Array.isArray(existing) && Object.keys(existing).length === 0) {
127
+ // A `{}` placeholder for an array row that is itself an array.
128
+ node[segment] = [];
129
+ }
130
+ node = node[segment];
131
+ });
132
+ }
133
+ return result;
134
+ }
135
+ /** Splits `a.b[2]['c d']` into `['a', 'b', 2, 'c d']`. */
136
+ function parseJsonPath(jsonPath) {
137
+ const path = (jsonPath ?? '').trim().replace(/^\$\.?/, '');
138
+ const segments = [];
139
+ const pattern = /\[\s*(\d+)\s*\]|\[\s*(['"])(.*?)\2\s*\]|([^.[\]]+)/g;
140
+ let match;
141
+ while ((match = pattern.exec(path)) !== null) {
142
+ if (match[1] !== undefined)
143
+ segments.push(Number(match[1]));
144
+ else if (match[3] !== undefined)
145
+ segments.push(match[3]);
146
+ else if (match[4] !== undefined)
147
+ segments.push(match[4]);
148
+ }
149
+ return segments;
150
+ }
151
+ /**
152
+ * Marker on the error value of a server error (`control.errors[key][NGS_FORMS_SERVER_ERROR] === true`). When the
153
+ * key has no entry in `errorMessageMap`, the key itself is shown as the message, so servers can send message text.
154
+ */
155
+ const NGS_FORMS_SERVER_ERROR = 'ngsServerError';
156
+ /**
157
+ * Applies server errors to a form and clears each one when its control's value changes.
158
+ *
159
+ * @internal Used by `NgsFormsService`.
160
+ */
161
+ class NgsFormsServerErrorRegistry {
162
+ entries = new Map();
163
+ /**
164
+ * Validator that reports a control's server errors. Form items include it whenever they set their validators,
165
+ * so the errors survive re-validation until the control's value changes.
166
+ */
167
+ validator = (control) => this.validate(control);
168
+ /** `true` while at least one server error is set. */
169
+ get hasErrors() {
170
+ return this.entries.size > 0;
171
+ }
172
+ /** Replaces every server error with `errors`. Returns the paths that matched no control. */
173
+ set(root, errors) {
174
+ this.clear();
175
+ const unmatched = [];
176
+ this.apply(root, errors, '', unmatched);
177
+ return unmatched;
178
+ }
179
+ /** Removes every server error. */
180
+ clear() {
181
+ if (!this.entries.size)
182
+ return;
183
+ const controls = Array.from(this.entries.keys());
184
+ this.entries.clear();
185
+ controls.forEach((control) => control.updateValueAndValidity());
186
+ }
187
+ /** Forgets every server error without re-validating, for when the controls are being thrown away. */
188
+ reset() {
189
+ this.entries.clear();
190
+ }
191
+ apply(control, errors, path, unmatched) {
192
+ if (errors === null || errors === undefined)
193
+ return;
194
+ if (typeof errors === 'string' || (Array.isArray(errors) && errors.length > 0 && errors.every((e) => typeof e === 'string'))) {
195
+ this.applyKeys(control, typeof errors === 'string' ? [errors] : errors);
196
+ return;
197
+ }
198
+ if (Array.isArray(errors)) {
199
+ if (!(control instanceof FormArray)) {
200
+ if (errors.some((e) => !isEmpty(e)))
201
+ unmatched.push(path);
202
+ return;
203
+ }
204
+ errors.forEach((rowErrors, index) => {
205
+ if (isEmpty(rowErrors))
206
+ return;
207
+ const row = control.at(index);
208
+ const rowPath = `${path}[${index}]`;
209
+ if (!row) {
210
+ unmatched.push(rowPath);
211
+ return;
212
+ }
213
+ this.apply(row, rowErrors, rowPath, unmatched);
214
+ });
215
+ return;
216
+ }
217
+ if (!(control instanceof FormGroup)) {
218
+ if (!isEmpty(errors))
219
+ unmatched.push(path);
220
+ return;
221
+ }
222
+ for (const [name, childErrors] of Object.entries(errors)) {
223
+ if (isEmpty(childErrors))
224
+ continue;
225
+ const childPath = path ? `${path}.${name}` : name;
226
+ const child = control.get([name]);
227
+ if (!child) {
228
+ unmatched.push(childPath);
229
+ continue;
230
+ }
231
+ this.apply(child, childErrors, childPath, unmatched);
232
+ }
233
+ }
234
+ applyKeys(control, keys) {
235
+ this.entries.set(control, { keys, value: control.value });
236
+ if (!control.hasValidator(this.validator)) {
237
+ control.addValidators(this.validator);
238
+ }
239
+ // Show the message straight away, without waiting for the user to touch the field.
240
+ control.markAsTouched();
241
+ control.updateValueAndValidity();
242
+ }
243
+ validate(control) {
244
+ const entry = this.entries.get(control);
245
+ if (!entry)
246
+ return null;
247
+ if (!sameValue(control.value, entry.value)) {
248
+ this.entries.delete(control);
249
+ return null;
250
+ }
251
+ return Object.fromEntries(entry.keys.map((key) => [key, { [NGS_FORMS_SERVER_ERROR]: true }]));
252
+ }
253
+ }
254
+ function isEmpty(errors) {
255
+ if (errors === null || errors === undefined || errors === '')
256
+ return true;
257
+ if (Array.isArray(errors))
258
+ return errors.every(isEmpty);
259
+ if (typeof errors === 'object')
260
+ return Object.values(errors).every(isEmpty);
261
+ return false;
262
+ }
263
+ function sameValue(a, b) {
264
+ if (Object.is(a, b))
265
+ return true;
266
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
267
+ return false;
268
+ try {
269
+ return JSON.stringify(a) === JSON.stringify(b);
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
275
+
68
276
  /**
69
277
  * Root base class for every form item component. Reads the item's configuration from `NGS_FORMS_ITEM_DATA`.
70
278
  *
@@ -148,10 +356,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
148
356
  * ```
149
357
  */
150
358
  class NgsFormsService {
151
- /**
152
- * @internal
153
- */
154
- attempt = 0;
155
359
  /**
156
360
  * Emits the root form group's value every time it changes. Combine with `toSignal()` to read it in a template.
157
361
  *
@@ -161,8 +365,20 @@ class NgsFormsService {
161
365
  /** The latest value emitted by `formValue$`. */
162
366
  formValue = {};
163
367
  internalFormService;
164
- formGroupSubscriptions = [];
368
+ formGroupBinding;
165
369
  formGroup = new UntypedFormGroup({});
370
+ /** The config from the latest `setFormConfig()` call, re-applied when `<ngs-form>` (re)connects. */
371
+ lastConfig;
372
+ missingFormTimer;
373
+ destroyed = false;
374
+ /** Patches still being applied while the form renders, in call order. */
375
+ pendingPatches = [];
376
+ patchTimer;
377
+ /** The controls that existed when the pending patches were last applied. */
378
+ patchedStructure;
379
+ patchRounds = 0;
380
+ /** Render passes in a row in which no control was added or removed. */
381
+ stablePatchRounds = 0;
166
382
  /** `true` once the user has changed any control in the form. */
167
383
  get dirty() {
168
384
  return this.formGroup?.dirty ?? false;
@@ -180,27 +396,53 @@ class NgsFormsService {
180
396
  /**
181
397
  * Renders a form from `formConfig`, replacing any form that is already shown and resetting the submitted state.
182
398
  *
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.
399
+ * @remarks It is safe to call before `<ngs-form>` has initialised (for example in `ngOnInit`): the latest config
400
+ * is kept and applied as soon as the form component connects, and also when it is destroyed and created again
401
+ * (inside an `@if` or a tab, for example). An error is logged if no form has connected after half a second.
185
402
  */
186
403
  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);
404
+ this.lastConfig = formConfig;
405
+ // Patches made for the previous form do not carry over. Patches made after this call are kept until it renders.
406
+ this.cancelPendingPatches();
407
+ if (this.internalServiceIsSet) {
408
+ this.internalFormService.setFormData(formConfig);
194
409
  return;
195
410
  }
196
- this.internalFormService.setFormData(formConfig);
411
+ if (this.missingFormTimer === undefined && !this.destroyed) {
412
+ this.missingFormTimer = setTimeout(() => {
413
+ this.missingFormTimer = undefined;
414
+ if (!this.internalServiceIsSet && !this.destroyed) {
415
+ console.error('Internal form service is unavailable. Is the ngs-form root tag added to the page?');
416
+ }
417
+ }, 500);
418
+ }
197
419
  }
198
420
  /**
199
421
  * @internal
200
422
  */
201
423
  setInternalService(internalFormsService) {
424
+ this.formGroupBinding?.unsubscribe();
202
425
  this.internalFormService = internalFormsService;
203
426
  this.bindFormGroup();
427
+ if (this.lastConfig) {
428
+ internalFormsService.setFormData(this.lastConfig);
429
+ }
430
+ if (this.pendingPatches.length) {
431
+ this.schedulePatchRound();
432
+ }
433
+ }
434
+ /**
435
+ * @internal
436
+ * Disconnects `internalFormsService` when its `<ngs-form>` is destroyed. Does nothing if another form has
437
+ * connected since.
438
+ */
439
+ clearInternalService(internalFormsService) {
440
+ if (this.internalFormService !== internalFormsService)
441
+ return;
442
+ this.formGroupBinding?.unsubscribe();
443
+ this.formGroupBinding = undefined;
444
+ this.internalFormService = undefined;
445
+ this.formGroup = new UntypedFormGroup({});
204
446
  }
205
447
  /**
206
448
  * Marks the form as submitted (or not). While submitted, every invalid control shows its error message even if
@@ -212,13 +454,14 @@ class NgsFormsService {
212
454
  this.internalFormService.setIsSubmitted(isSubmitted);
213
455
  }
214
456
  bindFormGroup() {
215
- this.internalFormService.formGroup$.subscribe((fg) => {
457
+ this.formGroupBinding = this.internalFormService.formGroup$
458
+ .pipe(switchMap((fg) => {
216
459
  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
- }));
460
+ return fg.valueChanges;
461
+ }))
462
+ .subscribe((v) => {
463
+ this.formValue = v;
464
+ this.formValue$.next(v);
222
465
  });
223
466
  }
224
467
  /**
@@ -233,16 +476,106 @@ class NgsFormsService {
233
476
  this.internalFormService?.updateGlobalState(state);
234
477
  }
235
478
  ngOnDestroy() {
236
- this.formGroupSubscriptions.forEach((subscription) => subscription.unsubscribe());
479
+ this.destroyed = true;
480
+ clearTimeout(this.missingFormTimer);
481
+ this.missingFormTimer = undefined;
482
+ this.cancelPendingPatches();
483
+ this.formGroupBinding?.unsubscribe();
484
+ this.formValue$.complete();
485
+ }
486
+ /**
487
+ * Shows errors from the server on the matching controls, replacing any server errors shown before.
488
+ *
489
+ * @param errors - An object shaped like the form value (including the root group name) whose leaves are error
490
+ * keys, for example `{ registration: { userName: 'taken', contacts: [{}, {}, { phone: 'invalid' }] } }`. Each key
491
+ * is looked up in that control's `errorMessageMap`; when it has no entry the key itself is shown, so the server
492
+ * can also send the message text. Use `constructServerErrorMessageObject()` to build it from a list of paths.
493
+ * @returns The paths that matched no rendered control (a hidden item or a missing array row, for example), so you
494
+ * can show those errors elsewhere.
495
+ *
496
+ * @remarks The controls are marked as touched so the messages show at once, and they stay invalid until their
497
+ * value changes. All server errors are cleared by the next call, by `clearServerErrorMessages()`, by
498
+ * `setFormConfig()`, and when rows are added to or removed from a form array, because rows are matched by index.
499
+ */
500
+ setServerErrorMessages(errors) {
501
+ if (!this.internalFormService) {
502
+ console.error('setServerErrorMessages() was called before <ngs-form> connected.');
503
+ return [];
504
+ }
505
+ return this.internalFormService.serverErrors.set(this.formGroup, errors);
506
+ }
507
+ /** Removes every error set with `setServerErrorMessages()`. */
508
+ clearServerErrorMessages() {
509
+ this.internalFormService?.serverErrors.clear();
237
510
  }
238
511
  /**
239
512
  * Patches values into the form, using the same shape as `formValue$` (including the root group name).
240
513
  *
241
- * @remarks Only controls that are currently rendered receive values. Patch again after a conditional item becomes
242
- * visible, and make sure a form array has enough rows (see `initialItemCount`) before patching array values.
514
+ * @remarks It is safe to call straight after `setFormConfig()`, or before `<ngs-form>` has connected. The values
515
+ * are applied at once to the controls that exist, kept, and applied again each time new controls appear, until
516
+ * the form stops changing. That covers items that render later and items behind `visible$` that the patched values
517
+ * themselves reveal (and anything those reveal in turn). Later calls are applied after earlier ones. A form array
518
+ * only receives values for rows it has, so give it enough rows with `initialItemCount`. `setFormConfig()` drops
519
+ * patches that are still being applied to the previous form.
243
520
  */
244
521
  patchValue(entityData) {
245
- this.formGroup.patchValue(entityData);
522
+ this.pendingPatches.push(entityData);
523
+ this.applyPendingPatches();
524
+ this.patchRounds = 0;
525
+ this.stablePatchRounds = 0;
526
+ this.schedulePatchRound();
527
+ }
528
+ /** Maximum number of render passes a patch waits for new controls before it is dropped. */
529
+ static MAX_PATCH_ROUNDS = 20;
530
+ applyPendingPatches() {
531
+ for (const patch of this.pendingPatches) {
532
+ this.formGroup.patchValue(patch);
533
+ }
534
+ this.patchedStructure = this.internalServiceIsSet ? controlStructure(this.formGroup) : undefined;
535
+ }
536
+ /**
537
+ * Waits one render pass, then re-applies the pending patches if controls were added or removed. Stops once two
538
+ * passes in a row change nothing (rendering can take a pass to start), or after `MAX_PATCH_ROUNDS` passes. Waits
539
+ * without counting while no form is connected.
540
+ */
541
+ schedulePatchRound() {
542
+ clearTimeout(this.patchTimer);
543
+ if (this.destroyed || !this.pendingPatches.length)
544
+ return;
545
+ if (!this.internalServiceIsSet) {
546
+ // `setInternalService()` starts the rounds once <ngs-form> connects.
547
+ this.patchTimer = undefined;
548
+ return;
549
+ }
550
+ this.patchTimer = setTimeout(() => {
551
+ this.patchTimer = undefined;
552
+ if (this.destroyed || !this.internalServiceIsSet)
553
+ return;
554
+ const structure = controlStructure(this.formGroup);
555
+ if (structure === this.patchedStructure) {
556
+ if (++this.stablePatchRounds >= 2) {
557
+ this.cancelPendingPatches();
558
+ return;
559
+ }
560
+ this.schedulePatchRound();
561
+ return;
562
+ }
563
+ this.stablePatchRounds = 0;
564
+ this.applyPendingPatches();
565
+ if (++this.patchRounds >= NgsFormsService.MAX_PATCH_ROUNDS) {
566
+ this.cancelPendingPatches();
567
+ return;
568
+ }
569
+ this.schedulePatchRound();
570
+ });
571
+ }
572
+ cancelPendingPatches() {
573
+ clearTimeout(this.patchTimer);
574
+ this.patchTimer = undefined;
575
+ this.pendingPatches = [];
576
+ this.patchedStructure = undefined;
577
+ this.patchRounds = 0;
578
+ this.stablePatchRounds = 0;
246
579
  }
247
580
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
248
581
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService });
@@ -250,6 +583,18 @@ class NgsFormsService {
250
583
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsService, decorators: [{
251
584
  type: Injectable
252
585
  }] });
586
+ /** A string listing the paths of every control under `control`, so a change in the form's controls can be seen. */
587
+ function controlStructure(control, path = '') {
588
+ if (control instanceof FormGroup) {
589
+ return Object.keys(control.controls)
590
+ .map((name) => controlStructure(control.controls[name], `${path}.${name}`))
591
+ .join('|') + `|${path}{}`;
592
+ }
593
+ if (control instanceof FormArray) {
594
+ return control.controls.map((row, i) => controlStructure(row, `${path}[${i}]`)).join('|') + `|${path}[]`;
595
+ }
596
+ return path;
597
+ }
253
598
 
254
599
  /**
255
600
  * Per form state holder that connects `<ngs-form>`, its items and `NgsFormsService`.
@@ -262,18 +607,23 @@ class NgsFormsInternalService {
262
607
  formConfig$ = new BehaviorSubject(undefined);
263
608
  isSubmitted$ = new BehaviorSubject(false);
264
609
  state = new BehaviorSubject({});
610
+ /** Server errors set with `NgsFormsService.setServerErrorMessages()`. */
611
+ serverErrors = new NgsFormsServerErrorRegistry();
265
612
  setIsSubmitted(isSubmitted) {
266
613
  this.isSubmitted$.next(isSubmitted);
267
614
  }
615
+ /** `true` when no rendered control fails its validators. A form whose controls are all disabled counts as valid. */
268
616
  checkIsValid() {
269
- if (!this.formGroup$.value)
617
+ const formGroup = this.formGroup$.value;
618
+ if (!formGroup)
270
619
  return false;
271
- return this.formGroup$.value.valid;
620
+ return formGroup.valid || formGroup.disabled === true;
272
621
  }
273
622
  setFormData(formConfig) {
274
623
  const state = { ...(formConfig.initialState ?? {}) };
275
624
  state['global'] = { ...(formConfig.globalState ?? {}) };
276
625
  this.state.next(state);
626
+ this.serverErrors.reset();
277
627
  this.formGroup$.next(new UntypedFormGroup({}));
278
628
  this.formConfig$.next(formConfig);
279
629
  this.isSubmitted$.next(false);
@@ -302,18 +652,13 @@ class NgsFormsInternalService {
302
652
  */
303
653
  subscribeToState(uuid, name, initialState) {
304
654
  this.seedComponentState(uuid, initialState);
305
- let last = '';
306
- return this.state.pipe(map((allState) => {
655
+ // `defer` gives every subscriber its own comparison, so each one receives the current state first.
656
+ return defer(() => this.state.pipe(map((allState) => {
307
657
  const stateByUuid = allState[uuid] || {};
308
658
  const stateByName = name ? (allState[name] || {}) : {};
309
659
  const globalState = allState['global'] || {};
310
660
  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
- }));
661
+ }), distinctUntilChanged((previous, current) => JSON.stringify(previous) === JSON.stringify(current))));
317
662
  }
318
663
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
319
664
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsInternalService });
@@ -326,6 +671,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
326
671
  * Base class for items that own a form control. Creates the control (seeded with `value` when the config has one),
327
672
  * applies `validators` / `validators$` and `disabled` / `disabled$`, registers it with the parent group under
328
673
  * `config.name` and keeps `errorMessage` up to date.
674
+ *
675
+ * @typeParam T - The item's config type.
676
+ * @typeParam C - The type of `control`. Inputs use the default `UntypedFormControl`; groups and arrays set it in
677
+ * their constructor to an `UntypedFormGroup` or `UntypedFormArray`.
329
678
  */
330
679
  class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassFormComponent {
331
680
  /** The item's form control. Bind it with `[formControl]`. */
@@ -347,15 +696,24 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
347
696
  const initialValue = this.config.value;
348
697
  this.control = new FormControl(initialValue, { validators: [] });
349
698
  }
699
+ const control = this.control;
350
700
  this.bindValidators();
351
- this.formAddRemoveFns.add(this.control, this.config.name);
701
+ this.formAddRemoveFns.add(control, this.config.name);
352
702
  this.bindDisabled();
353
- // Listen to changes and update error message dynamically
354
- this.subscribe(merge(this.control.valueChanges, this.control.statusChanges, this.privateService.isSubmitted$), () => {
703
+ // Recompute the error message on value, status and touched changes, and when the form is submitted.
704
+ // `control.events` (Angular 18+) includes touched changes, so blurring an untouched field shows its error.
705
+ // Angular 17 has no `events`, so there the message updates on value and status changes only.
706
+ const controlEvents$ = control.events ?? merge(control.valueChanges, control.statusChanges);
707
+ this.subscribe(merge(controlEvents$, this.privateService.isSubmitted$), () => {
355
708
  this.updateErrorMessage();
356
709
  this.changeDetectorRef.markForCheck();
357
710
  });
358
711
  }
712
+ /** Whether the control currently has `Validators.required` or `Validators.requiredTrue`. */
713
+ get isRequired() {
714
+ const control = this.control;
715
+ return !!control && (control.hasValidator(Validators.required) || control.hasValidator(Validators.requiredTrue));
716
+ }
359
717
  updateErrorMessage() {
360
718
  if (!this.control) {
361
719
  this.errorMessage = '';
@@ -373,21 +731,34 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
373
731
  }
374
732
  const firstErrorKey = Object.keys(errors)[0];
375
733
  const map = this.config.errorMessageMap;
376
- this.errorMessage = map?.[firstErrorKey] || `Field is invalid: ${firstErrorKey}`;
734
+ const isServerError = errors[firstErrorKey]?.[NGS_FORMS_SERVER_ERROR] === true;
735
+ // A server error without a mapped message shows the server's text.
736
+ this.errorMessage = map?.[firstErrorKey] || (isServerError ? firstErrorKey : `Field is invalid: ${firstErrorKey}`);
377
737
  }
378
738
  ngOnDestroy() {
379
739
  super.ngOnDestroy();
380
740
  this.formAddRemoveFns.remove(this.control, this.config.name);
381
741
  }
382
742
  bindValidators() {
383
- if (!this.config.validators && !this.config.validators$)
743
+ const control = this.control;
744
+ // The server error validator is always kept, so server errors survive until the control's value changes.
745
+ // Optional so that tests which stub `NgsFormsInternalService` keep working.
746
+ const serverValidators = this.privateService.serverErrors ? [this.privateService.serverErrors.validator] : [];
747
+ if (!this.config.validators && !this.config.validators$) {
748
+ control.addValidators(serverValidators);
384
749
  return;
750
+ }
385
751
  if (this.config.validators) {
386
- this.control.setValidators(this.config.validators);
752
+ control.setValidators([...this.config.validators, ...serverValidators]);
753
+ // A group or array computed its status when it was created, before these validators were set.
754
+ control.updateValueAndValidity({ emitEvent: false });
387
755
  return;
388
756
  }
389
757
  if (this.config.validators$) {
390
- this.subscribe(this.config.validators$, (validators) => this.control.setValidators(validators));
758
+ this.subscribe(this.config.validators$, (validators) => {
759
+ control.setValidators([...validators, ...serverValidators]);
760
+ control.updateValueAndValidity();
761
+ });
391
762
  }
392
763
  }
393
764
  bindDisabled() {
@@ -399,14 +770,21 @@ class NgsFormsFormItemWithVisibleAndValidatorsBase extends NgsFormsBaseClassForm
399
770
  this.disabledFromConfig = !!disabled;
400
771
  this.applyDisabled(control);
401
772
  });
773
+ const reapply = () => {
774
+ if (this.disabledFromConfig && control.enabled) {
775
+ control.disable();
776
+ }
777
+ };
402
778
  // Child controls are added to groups and arrays after the container has been disabled, and adding an enabled
403
779
  // child re-enables its parent. Re-apply the configured state whenever a container's status changes.
404
780
  if (control instanceof FormGroup || control instanceof FormArray) {
405
- this.subscribe(control.statusChanges, () => {
406
- if (this.disabledFromConfig && control.enabled) {
407
- control.disable();
408
- }
409
- });
781
+ this.subscribe(control.statusChanges, reapply);
782
+ }
783
+ // Enabling a parent group or array enables every child in it. The parent emits its status after its children
784
+ // are enabled, so re-apply the configured state then. (Doing it on the child's own status change would be undone
785
+ // by the end of the child's `enable()`, which re-enables the bound DOM element.)
786
+ if (control.parent) {
787
+ this.subscribe(control.parent.statusChanges, reapply);
410
788
  }
411
789
  }
412
790
  applyDisabled(control) {
@@ -485,10 +863,13 @@ class NgsFormItemController extends NgsSubscriber {
485
863
  addRemoveControlFn;
486
864
  index;
487
865
  changeDetectorRef;
866
+ arrayRow;
488
867
  componentRef;
489
- parentVisibility = new BehaviorSubject(false);
490
868
  lastVisible;
491
- constructor(injector, viewContainerRef, itemData, addRemoveControlFn, index, changeDetectorRef) {
869
+ /** Incremented whenever a scheduled create becomes stale (hidden again, or destroyed). */
870
+ createToken = 0;
871
+ destroyed = false;
872
+ constructor(injector, viewContainerRef, itemData, addRemoveControlFn, index, changeDetectorRef, arrayRow) {
492
873
  super();
493
874
  this.injector = injector;
494
875
  this.viewContainerRef = viewContainerRef;
@@ -496,9 +877,12 @@ class NgsFormItemController extends NgsSubscriber {
496
877
  this.addRemoveControlFn = addRemoveControlFn;
497
878
  this.index = index;
498
879
  this.changeDetectorRef = changeDetectorRef;
880
+ this.arrayRow = arrayRow;
499
881
  this.bindVisibility();
500
882
  }
501
883
  destroy() {
884
+ this.destroyed = true;
885
+ this.createToken++;
502
886
  this.detach();
503
887
  super.ngOnDestroy();
504
888
  }
@@ -506,7 +890,7 @@ class NgsFormItemController extends NgsSubscriber {
506
890
  const config = this.itemData.config;
507
891
  const visible$ = config.visible$ ?? (config.visible !== undefined ? of(config.visible) : undefined);
508
892
  if (!visible$) {
509
- Promise.resolve().then(() => this.create());
893
+ this.scheduleCreate();
510
894
  return;
511
895
  }
512
896
  this.subscribe(visible$, (visible) => {
@@ -514,17 +898,37 @@ class NgsFormItemController extends NgsSubscriber {
514
898
  return;
515
899
  this.lastVisible = visible;
516
900
  if (visible) {
517
- Promise.resolve().then(() => this.create());
901
+ this.scheduleCreate();
518
902
  return;
519
903
  }
904
+ // Cancels a create that was scheduled but has not run yet.
905
+ this.createToken++;
520
906
  this.detach();
521
907
  });
522
908
  }
909
+ /**
910
+ * Creates the component in a microtask. A create that is overtaken by a later visibility change or by
911
+ * `destroy()` before it runs is dropped.
912
+ */
913
+ scheduleCreate() {
914
+ const token = ++this.createToken;
915
+ Promise.resolve().then(() => {
916
+ if (this.destroyed || token !== this.createToken)
917
+ return;
918
+ this.create();
919
+ });
920
+ }
523
921
  create() {
922
+ if (this.componentRef && !this.componentRef.hostView.destroyed) {
923
+ return;
924
+ }
524
925
  const providers = [{ provide: NGS_FORMS_ITEM_DATA, useValue: this.itemData }];
525
926
  if (this.index !== undefined) {
526
927
  providers.push({ provide: NGS_FORMS_ITEM_INDEX, useValue: this.index });
527
928
  }
929
+ if (this.arrayRow) {
930
+ providers.push({ provide: NGS_FORMS_ARRAY_ROW, useValue: this.arrayRow });
931
+ }
528
932
  if (this.addRemoveControlFn) {
529
933
  providers.push({ provide: NGS_FORMS_CONTROL_ADD_REMOVE_FN, useValue: this.addRemoveControlFn });
530
934
  }
@@ -540,11 +944,10 @@ class NgsFormItemController extends NgsSubscriber {
540
944
  this.componentRef = this.viewContainerRef.createComponent(componentType, { injector: myInjector });
541
945
  this.componentRef.changeDetectorRef?.detectChanges();
542
946
  this.changeDetectorRef?.markForCheck();
543
- this.parentVisibility.next(true);
544
947
  }
545
948
  detach() {
546
- this.parentVisibility.next(false);
547
949
  this.componentRef?.destroy();
950
+ this.componentRef = undefined;
548
951
  this.changeDetectorRef?.markForCheck();
549
952
  }
550
953
  }
@@ -563,7 +966,8 @@ class NgsFormsFormItemDirective {
563
966
  /** Form array that the item's control is added to. */
564
967
  formArray;
565
968
  /**
566
- * Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`.
969
+ * Index of the item inside a form array row, provided to children as `NGS_FORMS_ITEM_INDEX`. When it is set, the
970
+ * `ngs-form-group` is also provided to children as `NGS_FORMS_ARRAY_ROW`.
567
971
  */
568
972
  index;
569
973
  controller;
@@ -571,7 +975,24 @@ class NgsFormsFormItemDirective {
571
975
  injector = inject(Injector);
572
976
  changeDetectorRef = inject(ChangeDetectorRef);
573
977
  formComponentRegistryService = inject(NgsFormsComponentRegistryService);
978
+ initialized = false;
574
979
  ngOnInit() {
980
+ this.initialized = true;
981
+ this.build();
982
+ }
983
+ ngOnChanges(changes) {
984
+ if (!this.initialized) {
985
+ return;
986
+ }
987
+ // A new item, group or array means a different control, so the component is rebuilt. An index change on its own
988
+ // keeps the component: the row it belongs to is still the same, and `NGS_FORMS_ARRAY_ROW` follows it.
989
+ if (changes['itemData'] || changes['formGroup'] || changes['formArray']) {
990
+ this.controller?.destroy();
991
+ this.controller = undefined;
992
+ this.build();
993
+ }
994
+ }
995
+ build() {
575
996
  if (!this.itemData) {
576
997
  return console.error('Form item without form data');
577
998
  }
@@ -582,13 +1003,13 @@ class NgsFormsFormItemDirective {
582
1003
  if (this.formGroup) {
583
1004
  addRemoveFn = NgsFormsFormControlAddRemoveFunctions.formGroup(this.formGroup);
584
1005
  }
585
- this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef);
1006
+ this.controller = new NgsFormItemController(this.injector, this.viewContainerRef, this.itemData, addRemoveFn, this.index, this.changeDetectorRef, this.index !== undefined ? this.formGroup : undefined);
586
1007
  }
587
1008
  ngOnDestroy() {
588
1009
  this.controller?.destroy();
589
1010
  }
590
1011
  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 });
1012
+ 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
1013
  }
593
1014
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormItemDirective, decorators: [{
594
1015
  type: Directive,
@@ -633,6 +1054,9 @@ class NgsFormComponent {
633
1054
  constructor() {
634
1055
  this.ngsFormsService.setInternalService(this.ngsInternalFormsService);
635
1056
  }
1057
+ ngOnDestroy() {
1058
+ this.ngsFormsService.clearInternalService(this.ngsInternalFormsService);
1059
+ }
636
1060
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
637
1061
  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
1062
  }
@@ -754,8 +1178,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
754
1178
  class NgsFormsFormArrayInternalService {
755
1179
  config = inject(NGS_FORMS_ITEM_DATA).config;
756
1180
  formArray = new UntypedFormArray([]);
1181
+ formsInternalService = inject(NgsFormsInternalService, { optional: true });
757
1182
  /** Emits after a row is added or removed. */
758
1183
  itemsChanged = new Subject();
1184
+ /** Emits the index of a row right after it is removed. */
1185
+ rowRemoved = new Subject();
759
1186
  constructor() {
760
1187
  if (this.config.initialItemCount) {
761
1188
  for (let i = 0; i < this.config.initialItemCount; i++) {
@@ -764,16 +1191,21 @@ class NgsFormsFormArrayInternalService {
764
1191
  }
765
1192
  }
766
1193
  ngOnDestroy() {
1194
+ this.itemsChanged.complete();
1195
+ this.rowRemoved.complete();
767
1196
  }
768
1197
  /** Removes the row at `index`, unless that would go below `minItems`. */
769
1198
  removeAt(index) {
770
1199
  if (this.config?.minItems && this.formArray.controls.length <= this.config.minItems) {
771
1200
  return;
772
1201
  }
773
- if (!this.formArray.controls.length) {
1202
+ if (index < 0 || index >= this.formArray.controls.length) {
774
1203
  return;
775
1204
  }
1205
+ // Server errors for rows are matched by index, so they are cleared once the rows move.
1206
+ this.formsInternalService?.serverErrors?.clear();
776
1207
  this.formArray.removeAt(index);
1208
+ this.rowRemoved.next(index);
777
1209
  this.itemsChanged.next();
778
1210
  }
779
1211
  /** Appends an empty row, unless the array already has `maxItems` rows. */
@@ -781,6 +1213,7 @@ class NgsFormsFormArrayInternalService {
781
1213
  if (this.config?.maxItems && this.formArray.controls.length >= this.config.maxItems) {
782
1214
  return;
783
1215
  }
1216
+ this.formsInternalService?.serverErrors?.clear();
784
1217
  this.formArray.push(new UntypedFormGroup({}));
785
1218
  this.itemsChanged.next();
786
1219
  }
@@ -859,10 +1292,33 @@ class NgsFormsFormArrayListComponent extends NgsFormsBaseClassFormComponent {
859
1292
  internalArrayService = inject(NgsFormsFormArrayInternalService);
860
1293
  myFormArray = this.internalArrayService.formArray;
861
1294
  changeDetectorRef = inject(ChangeDetectorRef);
1295
+ elementRef = inject(ElementRef);
1296
+ injector = inject(Injector);
862
1297
  ngOnInit() {
863
1298
  this.subscribe(this.internalArrayService.itemsChanged, () => {
864
1299
  this.changeDetectorRef.markForCheck();
865
1300
  });
1301
+ this.subscribe(this.internalArrayService.rowRemoved, (index) => this.restoreFocusAfterRemove(index));
1302
+ }
1303
+ /**
1304
+ * When the focused element (usually the row's Remove button) was removed with its row, moves focus to the
1305
+ * same kind of control in the row that took its place, so keyboard users are not sent back to the top of the page.
1306
+ */
1307
+ restoreFocusAfterRemove(index) {
1308
+ afterNextRender(() => {
1309
+ const active = document.activeElement;
1310
+ if (active && active !== document.body) {
1311
+ return;
1312
+ }
1313
+ const host = this.elementRef.nativeElement;
1314
+ const rows = Array.from(host.querySelectorAll(':scope > [data-index]'));
1315
+ const row = rows[Math.min(index, rows.length - 1)];
1316
+ const focusable = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
1317
+ const target = row?.querySelector('ngs-forms-form-array-remove-item button:not([disabled])') ??
1318
+ row?.querySelector(focusable) ??
1319
+ host.closest('ngs-forms-form-array')?.querySelector(focusable);
1320
+ target?.focus();
1321
+ }, { injector: this.injector });
866
1322
  }
867
1323
  /** Creates a `form-array-list` item. */
868
1324
  static create(config) {
@@ -894,8 +1350,26 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
894
1350
  internalArrayService = inject(NgsFormsFormArrayInternalService);
895
1351
  elementRef = inject(ElementRef);
896
1352
  indexFromDi = inject(NGS_FORMS_ITEM_INDEX, { optional: true });
897
- /** Index of the row this button belongs to. */
1353
+ row = inject(NGS_FORMS_ARRAY_ROW, { optional: true });
1354
+ changeDetectorRef = inject(ChangeDetectorRef);
1355
+ /** Accessible name of the button, from `ariaLabel` or `${buttonText} item ${index + 1}`. */
1356
+ get ariaLabel() {
1357
+ const index = this.myIndex;
1358
+ const label = this.config.ariaLabel;
1359
+ if (typeof label === 'function')
1360
+ return label(index);
1361
+ if (label)
1362
+ return label;
1363
+ return `${this.config.buttonText || 'Remove'} item ${index + 1}`;
1364
+ }
1365
+ /** Current index of the row this button belongs to. */
898
1366
  get myIndex() {
1367
+ if (this.row) {
1368
+ const index = this.internalArrayService.formArray.controls.indexOf(this.row);
1369
+ if (index >= 0) {
1370
+ return index;
1371
+ }
1372
+ }
899
1373
  if (this.indexFromDi !== null && this.indexFromDi !== undefined) {
900
1374
  return this.indexFromDi;
901
1375
  }
@@ -912,6 +1386,10 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
912
1386
  constructor() {
913
1387
  super();
914
1388
  }
1389
+ ngOnInit() {
1390
+ // The row's position changes when earlier rows are removed, which changes the default accessible name.
1391
+ this.subscribe(this.internalArrayService.itemsChanged, () => this.changeDetectorRef.markForCheck());
1392
+ }
915
1393
  /** Creates a `form-array-remove-item` item. */
916
1394
  static create(config) {
917
1395
  return {
@@ -924,11 +1402,11 @@ class NgsFormsFormArrayRemoveItemComponent extends NgsFormsBaseClassFormComponen
924
1402
  this.internalArrayService.removeAt(this.myIndex);
925
1403
  }
926
1404
  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 });
1405
+ 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
1406
  }
929
1407
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormArrayRemoveItemComponent, decorators: [{
930
1408
  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" }]
1409
+ 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
1410
  }], ctorParameters: () => [] });
933
1411
 
934
1412
  /**
@@ -963,6 +1441,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
963
1441
  args: [{ selector: 'ngs-form-component-text-div', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"{{ config.classes }}\">{{ config.text }}</div>\n" }]
964
1442
  }] });
965
1443
 
1444
+ ;
1445
+
966
1446
  /**
967
1447
  * Renders an HTML string inside the form. The HTML is passed through Angular's sanitizer.
968
1448
  *
@@ -996,8 +1476,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
996
1476
  args: [{ selector: 'ngs-form-component-html-content', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div [innerHTML]=\"html\"></div>\n" }]
997
1477
  }], ctorParameters: () => [] });
998
1478
 
1479
+ /** @internal */
999
1480
  const sectionConfigDefaults = {
1000
1481
  titleClass: 'h3',
1482
+ headingLevel: 3,
1001
1483
  subtitleClass: 'lead',
1002
1484
  };
1003
1485
 
@@ -1006,10 +1488,13 @@ const sectionConfigDefaults = {
1006
1488
  * parent group.
1007
1489
  *
1008
1490
  * @remarks Registry key: `section`. Config: `title`, `subtitle`, `titleClass` (default `h3`),
1009
- * `subtitleClass` (default `lead`) and `items`.
1491
+ * `headingLevel` (default 3), `subtitleClass` (default `lead`) and `items`. The section is exposed to assistive
1492
+ * technology as a group named by its title.
1010
1493
  */
1011
1494
  class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
1012
1495
  static key = 'section';
1496
+ /** Element id of the title, used to name the section's group. */
1497
+ titleId = `${this.itemData.uuid || v4()}-section-title`;
1013
1498
  /** Creates a `section` item. */
1014
1499
  static create(config) {
1015
1500
  return {
@@ -1022,11 +1507,11 @@ class NgsFormsFormSectionComponent extends NgsFormsBaseClassItemsContainerBase {
1022
1507
  this.config = ngsDefaults(this.config, sectionConfigDefaults);
1023
1508
  }
1024
1509
  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 });
1510
+ 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
1511
  }
1027
1512
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NgsFormsFormSectionComponent, decorators: [{
1028
1513
  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" }]
1514
+ 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
1515
  }] });
1031
1516
 
1032
1517
  // --
@@ -1066,5 +1551,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImpo
1066
1551
  * Generated bundle index. Do not edit.
1067
1552
  */
1068
1553
 
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 };
1554
+ export { NGS_FORMS_ARRAY_ROW, NGS_FORMS_CONTROL_ADD_REMOVE_FN, NGS_FORMS_ITEM_DATA, NGS_FORMS_ITEM_INDEX, NGS_FORMS_SERVER_ERROR, NgsFormComponent, NgsFormsBaseClassFormComponent, NgsFormsBaseClassFormInputComponent, NgsFormsBaseClassFormItemInputWithOptionsComponent, NgsFormsBaseClassItemsContainerBase, NgsFormsColumnComponent, NgsFormsComponentRegistryService, NgsFormsCoreModule, NgsFormsFormArrayAddItemComponent, NgsFormsFormArrayContainerComponent, NgsFormsFormArrayInternalService, NgsFormsFormArrayListComponent, NgsFormsFormArrayRemoveItemComponent, NgsFormsFormControlAddRemoveFunctions, NgsFormsFormGroupComponent, NgsFormsFormItemDirective, NgsFormsFormItemWithVisibleAndValidatorsBase, NgsFormsFormSectionComponent, NgsFormsHtmlContentComponent, NgsFormsInternalService, NgsFormsRowComponent, NgsFormsServerErrorRegistry, NgsFormsService, NgsFormsTextDivComponent, NgsSubscriber, constructServerErrorMessageObject, ngsDefaults, sectionConfigDefaults };
1070
1555
  //# sourceMappingURL=ng-simplicity-forms-core.mjs.map