@mosaicoo/form-angular 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
- import { NgComponentOutlet, DOCUMENT } from '@angular/common';
1
+ import { NgComponentOutlet, NgTemplateOutlet, DOCUMENT } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
3
  import { InjectionToken, makeEnvironmentProviders, input, signal, inject, ChangeDetectorRef, reflectComponentType, effect, untracked, computed, ViewEncapsulation, ChangeDetectionStrategy, Component, output } from '@angular/core';
4
- import { maskValue, importForm, createLegacyImporter, createFormEngine, collectInputs } from '@mosaicoo/form-core';
4
+ import { maskValue, formatNumber, parseNumber, importForm, createLegacyImporter, createFormEngine, collectInputs } from '@mosaicoo/form-core';
5
5
 
6
6
  /** Multi-provider token: maps merge, later providers win on conflicts. */
7
7
  const MFORM_FIELD_COMPONENTS = new InjectionToken('MFORM_FIELD_COMPONENTS');
@@ -40,6 +40,13 @@ function provideMosaicooForm(config) {
40
40
  return makeEnvironmentProviders(providers);
41
41
  }
42
42
 
43
+ function formatBytes(bytes) {
44
+ if (bytes >= 1024 * 1024)
45
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
46
+ if (bytes >= 1024)
47
+ return `${Math.round(bytes / 1024)} KB`;
48
+ return `${bytes} B`;
49
+ }
43
50
  /**
44
51
  * Renders one schema node (input, container or static) and recurses into
45
52
  * children. State lives in the headless engine — this component only projects
@@ -123,6 +130,8 @@ class FormNodeComponent {
123
130
  /** Discards resolutions that arrive after a newer request started. */
124
131
  optionsRequestSeq = 0;
125
132
  constructor() {
133
+ // Autocomplete fields refresh their cached results on observed changes.
134
+ effect(() => this.watchReferenceRefresh());
126
135
  effect(() => {
127
136
  const node = this.node();
128
137
  const registry = this.providers();
@@ -237,10 +246,28 @@ class FormNodeComponent {
237
246
  return 'array';
238
247
  if (node.type === 'tabs')
239
248
  return 'tabs';
249
+ if (node.type === 'accordion')
250
+ return 'accordion';
240
251
  if (node.type === 'columns' && node.columns?.length)
241
252
  return 'columns';
242
253
  return 'block';
243
254
  }
255
+ /** Open accordion sections; the first one starts expanded. */
256
+ openSections = signal(null, ...(ngDevMode ? [{ debugName: "openSections" }] : /* istanbul ignore next */ []));
257
+ isSectionOpen(index) {
258
+ const open = this.openSections();
259
+ return open === null ? index === 0 : open.has(index);
260
+ }
261
+ toggleSection(index) {
262
+ this.openSections.update((current) => {
263
+ const next = new Set(current ?? [0]);
264
+ if (next.has(index))
265
+ next.delete(index);
266
+ else
267
+ next.add(index);
268
+ return next;
269
+ });
270
+ }
244
271
  columns() {
245
272
  const node = this.container();
246
273
  return (node.columns ?? []).map((column) => column.children
@@ -261,11 +288,29 @@ class FormNodeComponent {
261
288
  const value = this.value();
262
289
  if (value === undefined || value === null)
263
290
  return '';
264
- const mask = this.field().format?.mask;
265
- if (mask && typeof value === 'string')
266
- return maskValue(value, mask).masked;
291
+ const format = this.field().format;
292
+ if (format?.mask && typeof value === 'string')
293
+ return maskValue(value, format.mask).masked;
294
+ // Numbers show formatted while idle and raw while being edited, so the
295
+ // caret never fights the thousands separators.
296
+ if (format?.number && !this.focused())
297
+ return formatNumber(value, format.number);
267
298
  return String(value);
268
299
  }
300
+ /** Tracks focus so numeric formatting steps aside during typing. */
301
+ focused = signal(false, ...(ngDevMode ? [{ debugName: "focused" }] : /* istanbul ignore next */ []));
302
+ onFocus() {
303
+ if (this.field().format?.number)
304
+ this.focused.set(true);
305
+ }
306
+ onBlur() {
307
+ this.focused.set(false);
308
+ this.touch();
309
+ }
310
+ /** Numeric inputs stay `text` when formatted (a number input rejects `R$`). */
311
+ numericFormatted() {
312
+ return this.field().format?.number !== undefined;
313
+ }
269
314
  mapValue() {
270
315
  const value = this.value();
271
316
  return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -324,11 +369,16 @@ class FormNodeComponent {
324
369
  setString(event) {
325
370
  const target = event.target;
326
371
  const raw = target.value;
327
- const mask = this.field().format?.mask;
328
- if (mask) {
329
- const { masked, raw: stripped } = maskValue(raw, mask);
372
+ const format = this.field().format;
373
+ if (format?.mask) {
374
+ const { masked, raw: stripped } = maskValue(raw, format.mask);
330
375
  target.value = masked; // the control always shows the masked shape
331
- this.setValue(this.field().format?.keepMask ? masked : stripped);
376
+ this.setValue(format.keepMask ? masked : stripped);
377
+ return;
378
+ }
379
+ if (format?.number) {
380
+ // Locale-aware parse: `1.234,56` and `R$ 1.234,56` both land as a number.
381
+ this.setValue(parseNumber(raw, format.number));
332
382
  return;
333
383
  }
334
384
  if (this.field().dataType === 'number') {
@@ -340,7 +390,19 @@ class FormNodeComponent {
340
390
  }
341
391
  }
342
392
  setSelect(event) {
343
- this.setValue(event.target.value);
393
+ const select = event.target;
394
+ if (this.field().multiple) {
395
+ this.setValue(Array.from(select.selectedOptions, (option) => option.value));
396
+ return;
397
+ }
398
+ this.setValue(select.value);
399
+ }
400
+ /** Selection state for single and multi-value choice fields. */
401
+ isSelected(optionValue) {
402
+ const value = this.value();
403
+ if (Array.isArray(value))
404
+ return value.some((item) => `${item}` === `${optionValue}`);
405
+ return this.stringValue() === `${optionValue}`;
344
406
  }
345
407
  setChecked(event) {
346
408
  this.setValue(event.target.checked);
@@ -349,19 +411,48 @@ class FormNodeComponent {
349
411
  const checked = event.target.checked;
350
412
  this.setValue({ ...this.mapValue(), [option]: checked });
351
413
  }
414
+ /** Client-side rejection message (size/type), cleared on the next pick. */
415
+ fileRejection = signal(null, ...(ngDevMode ? [{ debugName: "fileRejection" }] : /* istanbul ignore next */ []));
416
+ acceptAttr() {
417
+ const accept = this.field().files?.accept;
418
+ if (accept)
419
+ return accept;
420
+ return this.field().type === 'image' ? 'image/*' : null;
421
+ }
352
422
  setFiles(event) {
353
- const files = event.target.files;
354
- const list = files ? Array.from(files) : [];
355
- if (!this.uploader || list.length === 0) {
423
+ const input = event.target;
424
+ const picked = input.files ? Array.from(input.files) : [];
425
+ this.fileRejection.set(null);
426
+ if (picked.length === 0)
427
+ return;
428
+ // Client-side guards give immediate feedback; the back end stays the
429
+ // authoritative check (file COUNT is a real rule via maxLength).
430
+ const maxSize = this.field().files?.maxSize;
431
+ const tooBig = maxSize ? picked.filter((file) => file.size > maxSize) : [];
432
+ const list = maxSize ? picked.filter((file) => file.size <= maxSize) : picked;
433
+ if (tooBig.length > 0) {
434
+ this.fileRejection.set(`${tooBig.map((f) => f.name).join(', ')} — above ${formatBytes(maxSize)}`);
435
+ }
436
+ if (list.length === 0) {
437
+ input.value = '';
438
+ return;
439
+ }
440
+ const previous = this.fileEntries();
441
+ const append = this.field().multiple !== false;
442
+ if (!this.uploader) {
356
443
  // no host uploader: only file names are stored
357
- this.setValue(list.map((f) => f.name));
444
+ const names = list.map((f) => f.name);
445
+ this.setValue(append ? [...previous.map((e) => e.name), ...names] : names);
358
446
  return;
359
447
  }
360
448
  const field = this.field();
361
449
  const path = this.path();
362
450
  this.uploading.set(true);
363
451
  Promise.all(list.map((file) => this.uploader(file, { field, path })))
364
- .then((refs) => this.setValue(refs))
452
+ .then((refs) => {
453
+ const current = append ? this.value() ?? [] : [];
454
+ this.setValue([...current, ...refs]);
455
+ })
365
456
  .catch(() => { }) // host upload failure keeps the previous value
366
457
  .finally(() => {
367
458
  this.uploading.set(false);
@@ -369,14 +460,37 @@ class FormNodeComponent {
369
460
  this.notify()();
370
461
  });
371
462
  }
372
- /** Names shown under a `file` control (refs or plain names). */
373
- fileNames() {
463
+ /** Normalized view of the stored value: name + optional preview url. */
464
+ fileEntries() {
374
465
  const value = this.value();
375
466
  if (!Array.isArray(value))
376
467
  return [];
377
- return value.map((item) => typeof item === 'object' && item !== null
378
- ? String(item.name ?? '')
379
- : String(item));
468
+ return value.map((item) => {
469
+ if (typeof item === 'object' && item !== null) {
470
+ const ref = item;
471
+ return {
472
+ name: String(ref.name ?? ''),
473
+ url: typeof ref.url === 'string' ? ref.url : null,
474
+ };
475
+ }
476
+ const name = String(item);
477
+ // A bare string that already looks like a URL doubles as the preview.
478
+ return { name, url: /^(https?:|data:image\/|blob:)/.test(name) ? name : null };
479
+ });
480
+ }
481
+ showThumbnails() {
482
+ const field = this.field();
483
+ if (field.files?.preview === false)
484
+ return false;
485
+ return field.type === 'image' || field.files?.preview === true;
486
+ }
487
+ removeFile(index) {
488
+ const value = this.value();
489
+ if (!Array.isArray(value))
490
+ return;
491
+ const next = [...value];
492
+ next.splice(index, 1);
493
+ this.setValue(next);
380
494
  }
381
495
  prefix() {
382
496
  const value = this.node().props?.['prefix'];
@@ -416,11 +530,41 @@ class FormNodeComponent {
416
530
  refTimer = null;
417
531
  /** Label of the chosen option (falls back to the raw value). */
418
532
  refLabel = signal(null, ...(ngDevMode ? [{ debugName: "refLabel" }] : /* istanbul ignore next */ []));
533
+ /** Last query typed, replayed when `refreshOn` invalidates the results. */
534
+ refQuery = '';
535
+ refRefreshKey = null;
536
+ /**
537
+ * `refreshOn` for autocomplete: when the observed data changes, cached
538
+ * results are dropped so the next search runs against the new context
539
+ * (e.g. properties filtered by the selected client).
540
+ */
541
+ watchReferenceRefresh() {
542
+ const field = this.field();
543
+ const source = field.optionsSource;
544
+ if (field.type !== 'reference' || source?.type !== 'provider' || !source.refreshOn)
545
+ return;
546
+ this.tick();
547
+ const engine = untracked(() => this.engine());
548
+ const key = source.refreshOn === 'data'
549
+ ? JSON.stringify(engine.getData())
550
+ : JSON.stringify(engine.getValue(source.refreshOn) ?? null);
551
+ if (this.refRefreshKey === null) {
552
+ this.refRefreshKey = key;
553
+ return;
554
+ }
555
+ if (key === this.refRefreshKey)
556
+ return;
557
+ this.refRefreshKey = key;
558
+ this.refOptions.set([]);
559
+ if (this.refOpen())
560
+ this.refResolve(this.refQuery);
561
+ }
419
562
  refDisplay() {
420
563
  return this.refLabel() ?? this.stringValue();
421
564
  }
422
565
  refSearch(event) {
423
566
  const query = event.target.value;
567
+ this.refQuery = query;
424
568
  this.refLabel.set(null);
425
569
  if (this.refTimer)
426
570
  clearTimeout(this.refTimer);
@@ -512,6 +656,42 @@ class FormNodeComponent {
512
656
  }
513
657
  </div>
514
658
  }
659
+ @case ('accordion') {
660
+ <div class="mform-accordion">
661
+ @for (section of container().children; track section.key; let i = $index) {
662
+ <section class="mform-acc-item" [class.is-open]="isSectionOpen(i)">
663
+ <h3 class="mform-acc-head">
664
+ <button
665
+ type="button"
666
+ class="mform-acc-toggle"
667
+ [attr.aria-expanded]="isSectionOpen(i)"
668
+ [attr.aria-controls]="controlId() + '-sec-' + i"
669
+ (click)="toggleSection(i)"
670
+ >
671
+ <span class="mform-acc-caret" aria-hidden="true">
672
+ {{ isSectionOpen(i) ? '▾' : '▸' }}
673
+ </span>
674
+ {{ section.label || 'Section ' + (i + 1) }}
675
+ </button>
676
+ </h3>
677
+ @if (isSectionOpen(i)) {
678
+ <div class="mform-acc-body" [id]="controlId() + '-sec-' + i" role="region">
679
+ <mform-node
680
+ [node]="section"
681
+ [engine]="engine()"
682
+ [tick]="tick()"
683
+ [providers]="providers()"
684
+ [mode]="mode()"
685
+ [notify]="notify()"
686
+ [scope]="childScope()"
687
+ [bare]="true"
688
+ />
689
+ </div>
690
+ }
691
+ </section>
692
+ }
693
+ </div>
694
+ }
515
695
  @case ('tabs') {
516
696
  <div class="mform-tabs">
517
697
  <div class="mform-tabbar" role="tablist">
@@ -625,17 +805,21 @@ class FormNodeComponent {
625
805
  class="mform-control"
626
806
  [id]="controlId()"
627
807
  [disabled]="controlDisabled()"
808
+ [multiple]="field().multiple === true"
809
+ [attr.size]="field().multiple ? 5 : null"
628
810
  [attr.aria-required]="required() || null"
629
811
  [attr.aria-invalid]="errors().length > 0 || null"
630
812
  [attr.aria-describedby]="describedBy()"
631
813
  (change)="setSelect($event)"
632
814
  (blur)="touch()"
633
815
  >
634
- <option value="" [selected]="stringValue() === ''"></option>
816
+ @if (!field().multiple) {
817
+ <option value="" [selected]="stringValue() === ''"></option>
818
+ }
635
819
  @for (option of options(); track option.value) {
636
820
  <option
637
821
  [value]="option.value"
638
- [selected]="stringValue() === '' + option.value"
822
+ [selected]="isSelected(option.value)"
639
823
  >
640
824
  {{ option.label }}
641
825
  </option>
@@ -720,23 +904,10 @@ class FormNodeComponent {
720
904
  </div>
721
905
  }
722
906
  @case ('file') {
723
- <input
724
- class="mform-control"
725
- type="file"
726
- multiple
727
- [id]="controlId()"
728
- [disabled]="controlDisabled() || uploading()"
729
- [attr.aria-busy]="uploading() || null"
730
- [attr.aria-describedby]="describedBy()"
731
- (change)="setFiles($event)"
732
- (blur)="touch()"
733
- />
734
- @if (uploading()) {
735
- <div class="mform-desc" role="status">Uploading…</div>
736
- }
737
- @for (name of fileNames(); track $index) {
738
- <div class="mform-file-name">📎 {{ name }}</div>
739
- }
907
+ <ng-container *ngTemplateOutlet="fileControl" />
908
+ }
909
+ @case ('image') {
910
+ <ng-container *ngTemplateOutlet="fileControl" />
740
911
  }
741
912
  @case ('reference') {
742
913
  <div class="mform-reference">
@@ -781,18 +952,19 @@ class FormNodeComponent {
781
952
  }
782
953
  <input
783
954
  class="mform-control"
784
- [type]="inputType()"
955
+ [type]="numericFormatted() ? 'text' : inputType()"
785
956
  [id]="controlId()"
786
957
  [value]="stringValue()"
787
958
  [placeholder]="field().placeholder || ''"
788
959
  [disabled]="controlDisabled()"
789
- [attr.step]="field().type === 'currency' ? '0.01' : null"
790
- [attr.inputmode]="field().type === 'currency' ? 'decimal' : null"
960
+ [attr.step]="field().type === 'currency' && !numericFormatted() ? '0.01' : null"
961
+ [attr.inputmode]="field().type === 'currency' || numericFormatted() ? 'decimal' : null"
791
962
  [attr.aria-required]="required() || null"
792
963
  [attr.aria-invalid]="errors().length > 0 || null"
793
964
  [attr.aria-describedby]="describedBy()"
794
965
  (input)="setString($event)"
795
- (blur)="touch()"
966
+ (focus)="onFocus()"
967
+ (blur)="onBlur()"
796
968
  />
797
969
  @if (suffix(); as s) {
798
970
  <span class="mform-suffix" aria-hidden="true">{{ s }}</span>
@@ -811,17 +983,56 @@ class FormNodeComponent {
811
983
  }
812
984
  }
813
985
  }
814
- `, isInline: true, dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers", "mode", "notify"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
986
+
987
+ <!-- File/image control: same markup for both types, thumbnails when the
988
+ stored reference points at an image. -->
989
+ <ng-template #fileControl>
990
+ <input
991
+ class="mform-control"
992
+ type="file"
993
+ [id]="controlId()"
994
+ [multiple]="field().multiple !== false"
995
+ [attr.accept]="acceptAttr()"
996
+ [disabled]="controlDisabled() || uploading()"
997
+ [attr.aria-busy]="uploading() || null"
998
+ [attr.aria-describedby]="describedBy()"
999
+ (change)="setFiles($event)"
1000
+ (blur)="touch()"
1001
+ />
1002
+ @if (uploading()) {
1003
+ <div class="mform-desc" role="status">Uploading…</div>
1004
+ }
1005
+ @if (fileRejection(); as rejection) {
1006
+ <div class="mform-error" role="alert">{{ rejection }}</div>
1007
+ }
1008
+ @if (fileEntries().length > 0) {
1009
+ <ul class="mform-files" [class.is-gallery]="showThumbnails()">
1010
+ @for (entry of fileEntries(); track $index; let i = $index) {
1011
+ <li class="mform-file">
1012
+ @if (showThumbnails() && entry.url) {
1013
+ <img class="mform-thumb" [src]="entry.url" [alt]="entry.name" loading="lazy" />
1014
+ } @else {
1015
+ <span class="mform-file-icon" aria-hidden="true">📎</span>
1016
+ }
1017
+ <span class="mform-file-name" [title]="entry.name">{{ entry.name }}</span>
1018
+ @if (interactive()) {
1019
+ <button
1020
+ type="button"
1021
+ class="mform-file-remove"
1022
+ (click)="removeFile(i)"
1023
+ [attr.aria-label]="'Remove ' + entry.name"
1024
+ >✕</button>
1025
+ }
1026
+ </li>
1027
+ }
1028
+ </ul>
1029
+ }
1030
+ </ng-template>
1031
+ `, isInline: true, styles: [".mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}@media(max-width:640px){.mform-columns{grid-template-columns:1fr!important}}.mform-affix{display:flex;align-items:center}.mform-affix.has-affix{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff)}.mform-affix.has-affix .mform-control{border:none;background:none;flex:1}.mform-affix.has-affix:focus-within{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px}.mform-prefix,.mform-suffix{padding:0 10px;color:var(--mform-muted, #6b6580);font-size:13px;white-space:nowrap}.mform-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.mform-tag{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-radius:999px;padding:3px 10px;font-size:12px;display:inline-flex;align-items:center;gap:6px}.mform-tag button{border:none;background:none;color:inherit;cursor:pointer;font-size:12px;padding:0;line-height:1}.mform-tag-input{flex:1;min-width:140px;width:auto}.mform-field.is-pending .mform-control{background-image:linear-gradient(90deg,transparent 0%,rgb(109 75 208 / 12%) 50%,transparent 100%);background-size:200% 100%;animation:mform-pending 1.2s linear infinite}@keyframes mform-pending{0%{background-position:200% 0}to{background-position:-200% 0}}.mform-files{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:4px}.mform-files.is-gallery{flex-direction:row;flex-wrap:wrap;gap:10px}.mform-file{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mform-muted, #6b6580)}.mform-files.is-gallery .mform-file{flex-direction:column;gap:4px;width:var(--mform-thumb-size, 96px);text-align:center;position:relative}.mform-thumb{width:var(--mform-thumb-size, 96px);height:var(--mform-thumb-size, 96px);object-fit:cover;border-radius:var(--mform-control-radius, 8px);border:1px solid var(--mform-border, #d9d5e6);background:var(--mform-bg, #f6f5fa)}.mform-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.mform-file-remove{border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer;font-size:12px;padding:0 4px}.mform-file-remove:hover{color:var(--mform-danger, #c62842)}.mform-files.is-gallery .mform-file-remove{position:absolute;top:2px;right:2px;background:var(--mform-surface, #fff);border-radius:50%;width:20px;height:20px;line-height:1;box-shadow:0 1px 4px #1f1b2e33}.mform-reference{position:relative}.mform-ref-list{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:10;margin:0;padding:4px;list-style:none;background:var(--mform-control-bg, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);box-shadow:0 8px 24px #1f1b2e1f;max-height:220px;overflow-y:auto}.mform-ref-list button{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:7px 10px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.mform-ref-list button:hover{background:var(--mform-bg, #f6f5fa)}.mform-accordion{display:flex;flex-direction:column;border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-acc-item+.mform-acc-item{border-top:1px solid var(--mform-border, #d9d5e6)}.mform-acc-head{margin:0}.mform-acc-toggle{display:flex;align-items:center;gap:8px;width:100%;font:inherit;font-size:14px;font-weight:600;text-align:left;padding:12px 16px;border:none;background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);cursor:pointer}.mform-acc-caret{font-size:11px;color:var(--mform-muted, #6b6580)}.mform-acc-body{padding:var(--mform-panel-padding, 16px);display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}\n"], dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers", "mode", "notify"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
815
1032
  }
816
1033
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormNodeComponent, decorators: [{
817
1034
  type: Component,
818
- args: [{
819
- selector: 'mform-node',
820
- changeDetection: ChangeDetectionStrategy.OnPush,
821
- encapsulation: ViewEncapsulation.None,
822
- host: { class: 'mform-node' },
823
- imports: [NgComponentOutlet],
824
- template: `
1035
+ args: [{ selector: 'mform-node', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: { class: 'mform-node' }, imports: [NgComponentOutlet, NgTemplateOutlet], template: `
825
1036
  @let n = node();
826
1037
  @if (visible()) {
827
1038
  @switch (n.kind) {
@@ -856,6 +1067,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
856
1067
  }
857
1068
  </div>
858
1069
  }
1070
+ @case ('accordion') {
1071
+ <div class="mform-accordion">
1072
+ @for (section of container().children; track section.key; let i = $index) {
1073
+ <section class="mform-acc-item" [class.is-open]="isSectionOpen(i)">
1074
+ <h3 class="mform-acc-head">
1075
+ <button
1076
+ type="button"
1077
+ class="mform-acc-toggle"
1078
+ [attr.aria-expanded]="isSectionOpen(i)"
1079
+ [attr.aria-controls]="controlId() + '-sec-' + i"
1080
+ (click)="toggleSection(i)"
1081
+ >
1082
+ <span class="mform-acc-caret" aria-hidden="true">
1083
+ {{ isSectionOpen(i) ? '▾' : '▸' }}
1084
+ </span>
1085
+ {{ section.label || 'Section ' + (i + 1) }}
1086
+ </button>
1087
+ </h3>
1088
+ @if (isSectionOpen(i)) {
1089
+ <div class="mform-acc-body" [id]="controlId() + '-sec-' + i" role="region">
1090
+ <mform-node
1091
+ [node]="section"
1092
+ [engine]="engine()"
1093
+ [tick]="tick()"
1094
+ [providers]="providers()"
1095
+ [mode]="mode()"
1096
+ [notify]="notify()"
1097
+ [scope]="childScope()"
1098
+ [bare]="true"
1099
+ />
1100
+ </div>
1101
+ }
1102
+ </section>
1103
+ }
1104
+ </div>
1105
+ }
859
1106
  @case ('tabs') {
860
1107
  <div class="mform-tabs">
861
1108
  <div class="mform-tabbar" role="tablist">
@@ -969,17 +1216,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
969
1216
  class="mform-control"
970
1217
  [id]="controlId()"
971
1218
  [disabled]="controlDisabled()"
1219
+ [multiple]="field().multiple === true"
1220
+ [attr.size]="field().multiple ? 5 : null"
972
1221
  [attr.aria-required]="required() || null"
973
1222
  [attr.aria-invalid]="errors().length > 0 || null"
974
1223
  [attr.aria-describedby]="describedBy()"
975
1224
  (change)="setSelect($event)"
976
1225
  (blur)="touch()"
977
1226
  >
978
- <option value="" [selected]="stringValue() === ''"></option>
1227
+ @if (!field().multiple) {
1228
+ <option value="" [selected]="stringValue() === ''"></option>
1229
+ }
979
1230
  @for (option of options(); track option.value) {
980
1231
  <option
981
1232
  [value]="option.value"
982
- [selected]="stringValue() === '' + option.value"
1233
+ [selected]="isSelected(option.value)"
983
1234
  >
984
1235
  {{ option.label }}
985
1236
  </option>
@@ -1064,23 +1315,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1064
1315
  </div>
1065
1316
  }
1066
1317
  @case ('file') {
1067
- <input
1068
- class="mform-control"
1069
- type="file"
1070
- multiple
1071
- [id]="controlId()"
1072
- [disabled]="controlDisabled() || uploading()"
1073
- [attr.aria-busy]="uploading() || null"
1074
- [attr.aria-describedby]="describedBy()"
1075
- (change)="setFiles($event)"
1076
- (blur)="touch()"
1077
- />
1078
- @if (uploading()) {
1079
- <div class="mform-desc" role="status">Uploading…</div>
1080
- }
1081
- @for (name of fileNames(); track $index) {
1082
- <div class="mform-file-name">📎 {{ name }}</div>
1083
- }
1318
+ <ng-container *ngTemplateOutlet="fileControl" />
1319
+ }
1320
+ @case ('image') {
1321
+ <ng-container *ngTemplateOutlet="fileControl" />
1084
1322
  }
1085
1323
  @case ('reference') {
1086
1324
  <div class="mform-reference">
@@ -1125,18 +1363,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1125
1363
  }
1126
1364
  <input
1127
1365
  class="mform-control"
1128
- [type]="inputType()"
1366
+ [type]="numericFormatted() ? 'text' : inputType()"
1129
1367
  [id]="controlId()"
1130
1368
  [value]="stringValue()"
1131
1369
  [placeholder]="field().placeholder || ''"
1132
1370
  [disabled]="controlDisabled()"
1133
- [attr.step]="field().type === 'currency' ? '0.01' : null"
1134
- [attr.inputmode]="field().type === 'currency' ? 'decimal' : null"
1371
+ [attr.step]="field().type === 'currency' && !numericFormatted() ? '0.01' : null"
1372
+ [attr.inputmode]="field().type === 'currency' || numericFormatted() ? 'decimal' : null"
1135
1373
  [attr.aria-required]="required() || null"
1136
1374
  [attr.aria-invalid]="errors().length > 0 || null"
1137
1375
  [attr.aria-describedby]="describedBy()"
1138
1376
  (input)="setString($event)"
1139
- (blur)="touch()"
1377
+ (focus)="onFocus()"
1378
+ (blur)="onBlur()"
1140
1379
  />
1141
1380
  @if (suffix(); as s) {
1142
1381
  <span class="mform-suffix" aria-hidden="true">{{ s }}</span>
@@ -1155,8 +1394,52 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1155
1394
  }
1156
1395
  }
1157
1396
  }
1158
- `,
1159
- }]
1397
+
1398
+ <!-- File/image control: same markup for both types, thumbnails when the
1399
+ stored reference points at an image. -->
1400
+ <ng-template #fileControl>
1401
+ <input
1402
+ class="mform-control"
1403
+ type="file"
1404
+ [id]="controlId()"
1405
+ [multiple]="field().multiple !== false"
1406
+ [attr.accept]="acceptAttr()"
1407
+ [disabled]="controlDisabled() || uploading()"
1408
+ [attr.aria-busy]="uploading() || null"
1409
+ [attr.aria-describedby]="describedBy()"
1410
+ (change)="setFiles($event)"
1411
+ (blur)="touch()"
1412
+ />
1413
+ @if (uploading()) {
1414
+ <div class="mform-desc" role="status">Uploading…</div>
1415
+ }
1416
+ @if (fileRejection(); as rejection) {
1417
+ <div class="mform-error" role="alert">{{ rejection }}</div>
1418
+ }
1419
+ @if (fileEntries().length > 0) {
1420
+ <ul class="mform-files" [class.is-gallery]="showThumbnails()">
1421
+ @for (entry of fileEntries(); track $index; let i = $index) {
1422
+ <li class="mform-file">
1423
+ @if (showThumbnails() && entry.url) {
1424
+ <img class="mform-thumb" [src]="entry.url" [alt]="entry.name" loading="lazy" />
1425
+ } @else {
1426
+ <span class="mform-file-icon" aria-hidden="true">📎</span>
1427
+ }
1428
+ <span class="mform-file-name" [title]="entry.name">{{ entry.name }}</span>
1429
+ @if (interactive()) {
1430
+ <button
1431
+ type="button"
1432
+ class="mform-file-remove"
1433
+ (click)="removeFile(i)"
1434
+ [attr.aria-label]="'Remove ' + entry.name"
1435
+ >✕</button>
1436
+ }
1437
+ </li>
1438
+ }
1439
+ </ul>
1440
+ }
1441
+ </ng-template>
1442
+ `, styles: [".mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}@media(max-width:640px){.mform-columns{grid-template-columns:1fr!important}}.mform-affix{display:flex;align-items:center}.mform-affix.has-affix{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff)}.mform-affix.has-affix .mform-control{border:none;background:none;flex:1}.mform-affix.has-affix:focus-within{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px}.mform-prefix,.mform-suffix{padding:0 10px;color:var(--mform-muted, #6b6580);font-size:13px;white-space:nowrap}.mform-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.mform-tag{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-radius:999px;padding:3px 10px;font-size:12px;display:inline-flex;align-items:center;gap:6px}.mform-tag button{border:none;background:none;color:inherit;cursor:pointer;font-size:12px;padding:0;line-height:1}.mform-tag-input{flex:1;min-width:140px;width:auto}.mform-field.is-pending .mform-control{background-image:linear-gradient(90deg,transparent 0%,rgb(109 75 208 / 12%) 50%,transparent 100%);background-size:200% 100%;animation:mform-pending 1.2s linear infinite}@keyframes mform-pending{0%{background-position:200% 0}to{background-position:-200% 0}}.mform-files{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:4px}.mform-files.is-gallery{flex-direction:row;flex-wrap:wrap;gap:10px}.mform-file{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mform-muted, #6b6580)}.mform-files.is-gallery .mform-file{flex-direction:column;gap:4px;width:var(--mform-thumb-size, 96px);text-align:center;position:relative}.mform-thumb{width:var(--mform-thumb-size, 96px);height:var(--mform-thumb-size, 96px);object-fit:cover;border-radius:var(--mform-control-radius, 8px);border:1px solid var(--mform-border, #d9d5e6);background:var(--mform-bg, #f6f5fa)}.mform-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.mform-file-remove{border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer;font-size:12px;padding:0 4px}.mform-file-remove:hover{color:var(--mform-danger, #c62842)}.mform-files.is-gallery .mform-file-remove{position:absolute;top:2px;right:2px;background:var(--mform-surface, #fff);border-radius:50%;width:20px;height:20px;line-height:1;box-shadow:0 1px 4px #1f1b2e33}.mform-reference{position:relative}.mform-ref-list{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:10;margin:0;padding:4px;list-style:none;background:var(--mform-control-bg, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);box-shadow:0 8px 24px #1f1b2e1f;max-height:220px;overflow-y:auto}.mform-ref-list button{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:7px 10px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.mform-ref-list button:hover{background:var(--mform-bg, #f6f5fa)}.mform-accordion{display:flex;flex-direction:column;border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-acc-item+.mform-acc-item{border-top:1px solid var(--mform-border, #d9d5e6)}.mform-acc-head{margin:0}.mform-acc-toggle{display:flex;align-items:center;gap:8px;width:100%;font:inherit;font-size:14px;font-weight:600;text-align:left;padding:12px 16px;border:none;background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);cursor:pointer}.mform-acc-caret{font-size:11px;color:var(--mform-muted, #6b6580)}.mform-acc-body{padding:var(--mform-panel-padding, 16px);display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}\n"] }]
1160
1443
  }], ctorParameters: () => [], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }], tick: [{ type: i0.Input, args: [{ isSignal: true, alias: "tick", required: true }] }], scope: [{ type: i0.Input, args: [{ isSignal: true, alias: "scope", required: false }] }], bare: [{ type: i0.Input, args: [{ isSignal: true, alias: "bare", required: false }] }], providers: [{ type: i0.Input, args: [{ isSignal: true, alias: "providers", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], notify: [{ type: i0.Input, args: [{ isSignal: true, alias: "notify", required: false }] }] } });
1161
1444
 
1162
1445
  /**
@@ -1284,19 +1567,29 @@ class FormRendererComponent {
1284
1567
  this.errorSummary.set([]);
1285
1568
  this.currentStep.update((step) => Math.max(0, step - 1));
1286
1569
  }
1287
- nextStep() {
1570
+ /**
1571
+ * Advances only when the step is valid INCLUDING async rules, so a
1572
+ * server-side check can still block the transition.
1573
+ */
1574
+ async nextStep() {
1288
1575
  const engine = this.engine();
1289
1576
  const step = this.steps()[this.currentStep()];
1290
- if (!engine || !step)
1291
- return;
1292
- const errors = engine.validateContainer(step.key);
1293
- if (errors.length > 0) {
1294
- this.errorSummary.set(errors);
1295
- this.focusFirstError(errors);
1577
+ if (!engine || !step || this.submitting())
1296
1578
  return;
1579
+ this.submitting.set(true);
1580
+ try {
1581
+ const errors = await engine.validateContainerAsync(step.key);
1582
+ if (errors.length > 0) {
1583
+ this.errorSummary.set(errors);
1584
+ this.focusFirstError(errors);
1585
+ return;
1586
+ }
1587
+ this.errorSummary.set([]);
1588
+ this.currentStep.update((index) => Math.min(this.steps().length - 1, index + 1));
1589
+ }
1590
+ finally {
1591
+ this.submitting.set(false);
1297
1592
  }
1298
- this.errorSummary.set([]);
1299
- this.currentStep.update((index) => Math.min(this.steps().length - 1, index + 1));
1300
1593
  }
1301
1594
  // -- submission -----------------------------------------------------------
1302
1595
  async onSubmit(event) {
@@ -1393,8 +1686,8 @@ class FormRendererComponent {
1393
1686
  </button>
1394
1687
  }
1395
1688
  @if (currentStep() < steps().length - 1) {
1396
- <button type="button" class="mform-btn" (click)="nextStep()">
1397
- {{ labels().next }}
1689
+ <button type="button" class="mform-btn" [disabled]="submitting()" (click)="nextStep()">
1690
+ {{ submitting() ? '…' : labels().next }}
1398
1691
  </button>
1399
1692
  } @else if (mode() === 'edit') {
1400
1693
  <button type="submit" class="mform-btn" [disabled]="submitting()">
@@ -1412,7 +1705,7 @@ class FormRendererComponent {
1412
1705
  }
1413
1706
  </form>
1414
1707
  }
1415
- `, isInline: true, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}@media(max-width:640px){.mform-columns{grid-template-columns:1fr!important}}.mform-affix{display:flex;align-items:center}.mform-affix.has-affix{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff)}.mform-affix.has-affix .mform-control{border:none;background:none;flex:1}.mform-affix.has-affix:focus-within{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px}.mform-prefix,.mform-suffix{padding:0 10px;color:var(--mform-muted, #6b6580);font-size:13px;white-space:nowrap}.mform-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.mform-tag{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-radius:999px;padding:3px 10px;font-size:12px;display:inline-flex;align-items:center;gap:6px}.mform-tag button{border:none;background:none;color:inherit;cursor:pointer;font-size:12px;padding:0;line-height:1}.mform-tag-input{flex:1;min-width:140px;width:auto}.mform-field.is-pending .mform-control{background-image:linear-gradient(90deg,transparent 0%,rgb(109 75 208 / 12%) 50%,transparent 100%);background-size:200% 100%;animation:mform-pending 1.2s linear infinite}@keyframes mform-pending{0%{background-position:200% 0}to{background-position:-200% 0}}.mform-file-name{font-size:12px;color:var(--mform-muted, #6b6580);padding:2px 0}.mform-reference{position:relative}.mform-ref-list{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:10;margin:0;padding:4px;list-style:none;background:var(--mform-control-bg, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);box-shadow:0 8px 24px #1f1b2e1f;max-height:220px;overflow-y:auto}.mform-ref-list button{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:7px 10px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.mform-ref-list button:hover{background:var(--mform-bg, #f6f5fa)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"], dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers", "mode", "notify"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1708
+ `, isInline: true, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"], dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers", "mode", "notify"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1416
1709
  }
1417
1710
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormRendererComponent, decorators: [{
1418
1711
  type: Component,
@@ -1460,8 +1753,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1460
1753
  </button>
1461
1754
  }
1462
1755
  @if (currentStep() < steps().length - 1) {
1463
- <button type="button" class="mform-btn" (click)="nextStep()">
1464
- {{ labels().next }}
1756
+ <button type="button" class="mform-btn" [disabled]="submitting()" (click)="nextStep()">
1757
+ {{ submitting() ? '…' : labels().next }}
1465
1758
  </button>
1466
1759
  } @else if (mode() === 'edit') {
1467
1760
  <button type="submit" class="mform-btn" [disabled]="submitting()">
@@ -1479,7 +1772,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImpo
1479
1772
  }
1480
1773
  </form>
1481
1774
  }
1482
- `, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}@media(max-width:640px){.mform-columns{grid-template-columns:1fr!important}}.mform-affix{display:flex;align-items:center}.mform-affix.has-affix{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff)}.mform-affix.has-affix .mform-control{border:none;background:none;flex:1}.mform-affix.has-affix:focus-within{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px}.mform-prefix,.mform-suffix{padding:0 10px;color:var(--mform-muted, #6b6580);font-size:13px;white-space:nowrap}.mform-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.mform-tag{background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border-radius:999px;padding:3px 10px;font-size:12px;display:inline-flex;align-items:center;gap:6px}.mform-tag button{border:none;background:none;color:inherit;cursor:pointer;font-size:12px;padding:0;line-height:1}.mform-tag-input{flex:1;min-width:140px;width:auto}.mform-field.is-pending .mform-control{background-image:linear-gradient(90deg,transparent 0%,rgb(109 75 208 / 12%) 50%,transparent 100%);background-size:200% 100%;animation:mform-pending 1.2s linear infinite}@keyframes mform-pending{0%{background-position:200% 0}to{background-position:-200% 0}}.mform-file-name{font-size:12px;color:var(--mform-muted, #6b6580);padding:2px 0}.mform-reference{position:relative}.mform-ref-list{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:10;margin:0;padding:4px;list-style:none;background:var(--mform-control-bg, #fff);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);box-shadow:0 8px 24px #1f1b2e1f;max-height:220px;overflow-y:auto}.mform-ref-list button{display:block;width:100%;text-align:left;font:inherit;font-size:13px;padding:7px 10px;border:none;border-radius:6px;background:none;color:inherit;cursor:pointer}.mform-ref-list button:hover{background:var(--mform-bg, #f6f5fa)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"] }]
1775
+ `, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"] }]
1483
1776
  }], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: false }] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }], initialData: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialData", required: false }] }], optionsProviders: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionsProviders", required: false }] }], messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }], validators: [{ type: i0.Input, args: [{ isSignal: true, alias: "validators", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], submitHandler: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitHandler", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], submitted: [{ type: i0.Output, args: ["submitted"] }], valueChanged: [{ type: i0.Output, args: ["valueChanged"] }] } });
1484
1777
 
1485
1778
  /*