ngx-json-render 0.2.1 → 0.3.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,9 +1,9 @@
1
+ import { NgComponentOutlet } from '@angular/common';
1
2
  import * as i0 from '@angular/core';
2
- import { signal, Injectable, inject, computed, untracked, effect, isDevMode, input, output, ChangeDetectionStrategy, Component, DestroyRef, InjectionToken, Injector, forwardRef } from '@angular/core';
3
- import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
4
- export { createStateStore, nestedToFlat } from '@json-render/core';
3
+ import { signal, Injectable, inject, computed, untracked, effect, isDevMode, DestroyRef, InjectionToken, input, output, viewChild, afterNextRender, ChangeDetectionStrategy, Component, Injector, forwardRef } from '@angular/core';
4
+ import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, autoFixSpec, validateSpec, formatSpecIssues, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
5
+ export { autoFixSpec, createStateStore, formatSpecIssues, nestedToFlat, validateSpec } from '@json-render/core';
5
6
  import { flattenToPointers } from '@json-render/core/store-utils';
6
- import { NgComponentOutlet } from '@angular/common';
7
7
 
8
8
  const EMPTY = signal(undefined, ...(ngDevMode ? [{ debugName: "EMPTY" }] : /* istanbul ignore next */ []));
9
9
  /**
@@ -273,29 +273,73 @@ function validationConfigEqual(a, b) {
273
273
  }
274
274
  return true;
275
275
  }
276
+ /**
277
+ * Owner of a registration made through the service's own API rather than by
278
+ * a field component. Manual registrations release together, which is the
279
+ * behaviour a caller of `registerField(path, config)` would expect.
280
+ */
281
+ const MANUAL_OWNER = { manual: true };
276
282
  /**
277
283
  * Form validation state of a `<json-render>` subtree. Fields register their
278
284
  * {@link ValidationConfig}; the built-in `validateForm` action validates all
279
285
  * registered fields and writes the result to state.
286
+ *
287
+ * Registrations are owner-scoped: a path stays registered while at least one
288
+ * owner holds it, and its config and state are dropped when the last one
289
+ * releases. A spec that swaps screens or hides fields therefore stops
290
+ * validating what is no longer on screen.
280
291
  */
281
292
  class JsonRenderValidationService {
282
293
  root = inject(JsonRenderRootContext);
283
294
  state = inject(JsonRenderStateService);
284
295
  _fieldStates = signal({}, ...(ngDevMode ? [{ debugName: "_fieldStates" }] : /* istanbul ignore next */ []));
285
296
  _fieldConfigs = signal({}, ...(ngDevMode ? [{ debugName: "_fieldConfigs" }] : /* istanbul ignore next */ []));
297
+ /** Live owners per path — the reference count behind a registration. */
298
+ fieldOwners = new Map();
286
299
  /** Validation state per registered field path. */
287
300
  fieldStates = this._fieldStates.asReadonly();
288
301
  get customFunctions() {
289
302
  return untracked(this.root.validationFunctions) ?? {};
290
303
  }
291
- /** Register (or update) a field's validation config. */
292
- registerField(path, config) {
304
+ /**
305
+ * Register (or update) a field's validation config.
306
+ *
307
+ * @param owner Identity holding the registration — pass the same value to
308
+ * {@link unregisterField} to release it. Field components pass themselves,
309
+ * so two components bound to one path each hold their own claim.
310
+ */
311
+ registerField(path, config, owner = MANUAL_OWNER) {
312
+ const owners = this.fieldOwners.get(path);
313
+ if (owners)
314
+ owners.add(owner);
315
+ else
316
+ this.fieldOwners.set(path, new Set([owner]));
293
317
  const prev = untracked(this._fieldConfigs);
294
318
  const existing = prev[path];
295
319
  if (existing && validationConfigEqual(existing, config))
296
320
  return;
297
321
  this._fieldConfigs.set({ ...prev, [path]: config });
298
322
  }
323
+ /**
324
+ * Release one owner's registration of a field. The config and validation
325
+ * state survive while another owner still holds the path; once the last
326
+ * one lets go, `validateAll` stops seeing the field.
327
+ */
328
+ unregisterField(path, owner = MANUAL_OWNER) {
329
+ const owners = this.fieldOwners.get(path);
330
+ if (!owners)
331
+ return;
332
+ owners.delete(owner);
333
+ if (owners.size > 0)
334
+ return;
335
+ this.fieldOwners.delete(path);
336
+ const prev = untracked(this._fieldConfigs);
337
+ if (path in prev) {
338
+ const { [path]: _removed, ...rest } = prev;
339
+ this._fieldConfigs.set(rest);
340
+ }
341
+ this.clear(path);
342
+ }
299
343
  /** Validate a single field and record the result. */
300
344
  validate(path, config) {
301
345
  const currentState = this.state.getSnapshot();
@@ -368,18 +412,39 @@ function injectValidation() {
368
412
  /**
369
413
  * Field-level validation helper for catalog input components: registers the
370
414
  * config and exposes the field's validation state as signals.
415
+ *
416
+ * The registration follows the component: it moves when the bound path
417
+ * changes and is released when the component is destroyed, so a field that
418
+ * has left the screen no longer takes part in `validateForm`.
371
419
  */
372
420
  function injectFieldValidation(path, config) {
373
421
  const validation = injectValidation();
374
422
  const getPath = typeof path === 'function' ? path : () => path;
375
423
  const getConfig = typeof config === 'function' ? config : () => config;
424
+ // Identity of this field's claim on a path. Two components bound to the
425
+ // same path hold separate claims, so one being destroyed does not
426
+ // deregister the other.
427
+ const owner = {};
428
+ let registeredPath = null;
376
429
  effect(() => {
377
430
  const p = getPath();
378
431
  const c = getConfig();
379
- if (p && c) {
380
- validation.registerField(p, c);
432
+ const next = p && c ? p : null;
433
+ if (registeredPath !== null && registeredPath !== next) {
434
+ validation.unregisterField(registeredPath, owner);
435
+ registeredPath = null;
436
+ }
437
+ if (next && c) {
438
+ validation.registerField(next, c, owner);
439
+ registeredPath = next;
381
440
  }
382
441
  });
442
+ inject(DestroyRef).onDestroy(() => {
443
+ if (registeredPath === null)
444
+ return;
445
+ validation.unregisterField(registeredPath, owner);
446
+ registeredPath = null;
447
+ });
383
448
  const state = computed(() => {
384
449
  const current = validation.fieldStates()[getPath()];
385
450
  return current ?? { touched: false, validated: false, result: null };
@@ -432,7 +497,7 @@ function deepResolveValue(value, get) {
432
497
  * Carries a stable `name` so callers can tell a cancellation apart from a
433
498
  * handler failure without matching on the message.
434
499
  *
435
- * @internal Not part of the public API; check `error.name` instead.
500
+ * @internal Not part of the public API; use {@link isActionCancelled}.
436
501
  */
437
502
  class ActionCancelledError extends Error {
438
503
  constructor() {
@@ -448,9 +513,9 @@ function isActionCancelled(error) {
448
513
  * Action dispatcher of a `<json-render>` subtree.
449
514
  *
450
515
  * Executes {@link ActionBinding}s: built-in actions (`setState`, `pushState`,
451
- * `removeState`, `push`, `pop`, `validateForm`) are handled internally;
452
- * everything else is routed to the host-provided `handlers` (or the
453
- * `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
516
+ * `removeState`, `push`, `pop`, `validateForm`, `submitForm`) are handled
517
+ * internally; everything else is routed to the host-provided `handlers` (or
518
+ * the `onAction` catch-all), honoring `confirm`, `onSuccess`, and `onError`.
454
519
  */
455
520
  class JsonRenderActionsService {
456
521
  root = inject(JsonRenderRootContext);
@@ -557,19 +622,36 @@ class JsonRenderActionsService {
557
622
  return;
558
623
  }
559
624
  if (resolved.action === 'validateForm') {
560
- if (!this.validation) {
625
+ if (this.checkForm(resolved) === null) {
561
626
  console.warn('validateForm action was dispatched but no JsonRenderValidationService is available.');
627
+ }
628
+ return;
629
+ }
630
+ if (resolved.action === 'submitForm') {
631
+ const valid = this.checkForm(resolved);
632
+ if (valid === null) {
633
+ console.warn('submitForm action was dispatched but no JsonRenderValidationService is available; nothing was submitted.');
562
634
  return;
563
635
  }
564
- const valid = this.validation.validateAll();
565
- const errors = {};
566
- for (const [path, fs] of Object.entries(untracked(this.validation.fieldStates))) {
567
- if (fs.result && !fs.result.valid) {
568
- errors[path] = fs.result.errors;
569
- }
636
+ // An invalid form stops here, with the errors already written to
637
+ // state and every field marked validated, so they are on screen.
638
+ if (!valid)
639
+ return;
640
+ const target = resolved.params?.['action'];
641
+ if (typeof target !== 'string' || !target) {
642
+ console.warn('submitForm needs the action to submit to: { "action": "submitForm", "params": { "action": "saveUser" } }.');
643
+ return;
570
644
  }
571
- const statePath = resolved.params?.['statePath'] || '/formValidation';
572
- set(statePath, { valid, errors });
645
+ // Dispatched, not called directly, so the submitted action goes
646
+ // through the same lookup, confirmation, loading state and observers
647
+ // as any other — submitForm gates it, it does not replace it.
648
+ await this.execute({
649
+ action: target,
650
+ params: deepResolveValue(resolved.params?.['params'], get),
651
+ confirm: resolved.confirm,
652
+ onSuccess: resolved.onSuccess,
653
+ onError: resolved.onError,
654
+ });
573
655
  return;
574
656
  }
575
657
  const handler = this.lookupHandler(resolved.action);
@@ -622,6 +704,28 @@ class JsonRenderActionsService {
622
704
  cancel() {
623
705
  untracked(this._pendingConfirmation)?.reject();
624
706
  }
707
+ /**
708
+ * Validate every registered field and write `{ valid, errors }` to the
709
+ * action's `statePath` (default `/formValidation`). Returns null when there
710
+ * is no validation service to ask.
711
+ *
712
+ * Shared by `validateForm` and `submitForm`: the two must report the same
713
+ * thing about the same form, and one implementation is how that stays true.
714
+ */
715
+ checkForm(resolved) {
716
+ if (!this.validation)
717
+ return null;
718
+ const valid = this.validation.validateAll();
719
+ const errors = {};
720
+ for (const [path, fieldState] of Object.entries(untracked(this.validation.fieldStates))) {
721
+ if (fieldState.result && !fieldState.result.valid) {
722
+ errors[path] = fieldState.result.errors;
723
+ }
724
+ }
725
+ const statePath = resolved.params?.['statePath'] || '/formValidation';
726
+ this.state.set(statePath, { valid, errors });
727
+ return valid;
728
+ }
625
729
  lookupHandler(name) {
626
730
  const direct = this.handlers[name];
627
731
  if (direct)
@@ -674,91 +778,178 @@ function injectAction(binding) {
674
778
  };
675
779
  }
676
780
 
781
+ /**
782
+ * Labels for the default confirmation dialog's buttons, when the spec does not
783
+ * supply `confirmLabel` / `cancelLabel` of its own.
784
+ *
785
+ * They are the only strings this package puts on screen, and they were English
786
+ * with no way to change them. Override once, in an app's providers:
787
+ *
788
+ * ```ts
789
+ * { provide: JR_CONFIRM_LABELS, useValue: { confirm: 'Подтвердить', cancel: 'Отмена' } }
790
+ * ```
791
+ */
792
+ const JR_CONFIRM_LABELS = new InjectionToken('ngx-json-render JR_CONFIRM_LABELS', {
793
+ providedIn: 'root',
794
+ factory: () => ({ confirm: 'Confirm', cancel: 'Cancel' }),
795
+ });
796
+ /** The confirmation a replacement dialog component is rendered for. */
797
+ const CONFIRM_CONTEXT = new InjectionToken('ngx-json-render CONFIRM_CONTEXT');
798
+ /**
799
+ * The component `<json-render>` should render for a `confirm` on an action.
800
+ * Unset means the packaged {@link JrConfirmDialog}.
801
+ *
802
+ * The component is rendered with a {@link CONFIRM_CONTEXT} in its injector, so
803
+ * it takes no inputs and emits no outputs:
804
+ *
805
+ * ```ts
806
+ * @Component({ template: `<my-modal (ok)="ctx.confirm()" (dismiss)="ctx.cancel()">…` })
807
+ * export class AppConfirm {
808
+ * readonly ctx = injectConfirmContext();
809
+ * }
810
+ * // providers: [{ provide: JR_CONFIRM_DIALOG, useValue: AppConfirm }]
811
+ * ```
812
+ */
813
+ const JR_CONFIRM_DIALOG = new InjectionToken('ngx-json-render JR_CONFIRM_DIALOG');
814
+ /** Inject the confirmation a replacement dialog component is rendering for. */
815
+ function injectConfirmContext() {
816
+ const ctx = inject(CONFIRM_CONTEXT, { optional: true });
817
+ if (!ctx) {
818
+ throw new Error('injectConfirmContext() must be used inside a component provided through JR_CONFIRM_DIALOG');
819
+ }
820
+ return ctx;
821
+ }
822
+
823
+ let dialogIdCounter = 0;
677
824
  /**
678
825
  * Default confirmation dialog shown for action bindings with a `confirm`
679
826
  * field. Rendered automatically by `<json-render>`; can also be used
680
827
  * standalone with a custom action flow.
828
+ *
829
+ * It is a real dialog: `role="dialog"` with `aria-modal`, labelled by its own
830
+ * title and described by its message, focus moved in on open and returned to
831
+ * wherever it came from on close, Tab kept inside it and Escape cancelling.
832
+ * A generated UI can ask for confirmation of something destructive, so a
833
+ * keyboard or screen-reader user has to be able to answer it.
834
+ *
835
+ * Colours come from CSS custom properties with light and dark defaults, so an
836
+ * app can theme it — `--jr-confirm-surface`, `--jr-confirm-ink`,
837
+ * `--jr-confirm-muted`, `--jr-confirm-border`, `--jr-confirm-scrim`,
838
+ * `--jr-confirm-accent`, `--jr-confirm-danger`, `--jr-confirm-on-accent`,
839
+ * `--jr-confirm-radius` — and replace it outright through
840
+ * {@link JR_CONFIRM_DIALOG} when that is not enough.
681
841
  */
682
842
  class JrConfirmDialog {
683
843
  config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
684
844
  confirmed = output();
685
845
  cancelled = output();
846
+ labels = inject(JR_CONFIRM_LABELS);
847
+ id = dialogIdCounter++;
848
+ titleId = `jr-confirm-title-${this.id}`;
849
+ messageId = `jr-confirm-message-${this.id}`;
850
+ cancelButton = viewChild('cancel', ...(ngDevMode ? [{ debugName: "cancelButton" }] : /* istanbul ignore next */ []));
851
+ confirmButton = viewChild('confirm', ...(ngDevMode ? [{ debugName: "confirmButton" }] : /* istanbul ignore next */ []));
686
852
  isDanger = computed(() => this.config().variant === 'danger', ...(ngDevMode ? [{ debugName: "isDanger" }] : /* istanbul ignore next */ []));
853
+ constructor() {
854
+ // Whatever was focused when the action fired — usually the control that
855
+ // dispatched it, which is still in the document behind the dialog.
856
+ const previous = typeof document !== 'undefined'
857
+ ? document.activeElement
858
+ : null;
859
+ // Cancel takes focus rather than confirm: a dialog exists to interrupt,
860
+ // and landing on the destructive answer makes a stray Enter destructive.
861
+ afterNextRender(() => this.cancelButton()?.nativeElement.focus());
862
+ inject(DestroyRef).onDestroy(() => previous?.focus?.());
863
+ }
864
+ /**
865
+ * Keep Tab inside the dialog. With exactly two buttons the whole trap is
866
+ * wrapping the ends; a modal that lets focus wander behind it is a modal
867
+ * only for people using a mouse.
868
+ */
869
+ onKeydown(event) {
870
+ if (event.key !== 'Tab')
871
+ return;
872
+ const cancel = this.cancelButton()?.nativeElement;
873
+ const confirm = this.confirmButton()?.nativeElement;
874
+ if (!cancel || !confirm)
875
+ return;
876
+ const active = event.target;
877
+ if (event.shiftKey && active === cancel) {
878
+ event.preventDefault();
879
+ confirm.focus();
880
+ }
881
+ else if (!event.shiftKey && active === confirm) {
882
+ event.preventDefault();
883
+ cancel.focus();
884
+ }
885
+ }
687
886
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
688
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.22", type: JrConfirmDialog, isStandalone: true, selector: "jr-confirm-dialog", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { confirmed: "confirmed", cancelled: "cancelled" }, ngImport: i0, template: `
689
- <div
690
- style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
691
- (click)="cancelled.emit()"
692
- >
887
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.22", type: JrConfirmDialog, isStandalone: true, selector: "jr-confirm-dialog", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { confirmed: "confirmed", cancelled: "cancelled" }, host: { listeners: { "keydown.escape": "cancelled.emit()" } }, viewQueries: [{ propertyName: "cancelButton", first: true, predicate: ["cancel"], descendants: true, isSignal: true }, { propertyName: "confirmButton", first: true, predicate: ["confirm"], descendants: true, isSignal: true }], ngImport: i0, template: `
888
+ <div class="jr-confirm-scrim" (click)="cancelled.emit()">
693
889
  <div
694
- style="background-color: white; border-radius: 8px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1)"
890
+ class="jr-confirm-panel"
891
+ role="dialog"
892
+ aria-modal="true"
893
+ [attr.aria-labelledby]="titleId"
894
+ [attr.aria-describedby]="messageId"
695
895
  (click)="$event.stopPropagation()"
896
+ (keydown)="onKeydown($event)"
696
897
  >
697
- <h3 style="margin: 0 0 8px 0; font-size: 18px; font-weight: 600">
698
- {{ config().title }}
699
- </h3>
700
- <p style="margin: 0 0 24px 0; color: #6b7280">{{ config().message }}</p>
701
- <div style="display: flex; gap: 12px; justify-content: flex-end">
702
- <button
703
- type="button"
704
- (click)="cancelled.emit()"
705
- style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
706
- >
707
- {{ config().cancelLabel ?? 'Cancel' }}
898
+ <h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
899
+ <p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
900
+ <div class="jr-confirm-actions">
901
+ <button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
902
+ {{ config().cancelLabel ?? labels.cancel }}
708
903
  </button>
709
904
  <button
905
+ #confirm
710
906
  type="button"
907
+ class="jr-confirm-confirm"
908
+ [class.jr-confirm-danger]="isDanger()"
711
909
  (click)="confirmed.emit()"
712
- [style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
713
- style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
714
910
  >
715
- {{ config().confirmLabel ?? 'Confirm' }}
911
+ {{ config().confirmLabel ?? labels.confirm }}
716
912
  </button>
717
913
  </div>
718
914
  </div>
719
915
  </div>
720
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
916
+ `, isInline: true, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
721
917
  }
722
918
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JrConfirmDialog, decorators: [{
723
919
  type: Component,
724
- args: [{
725
- selector: 'jr-confirm-dialog',
726
- changeDetection: ChangeDetectionStrategy.OnPush,
727
- template: `
728
- <div
729
- style="position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 50"
730
- (click)="cancelled.emit()"
731
- >
920
+ args: [{ selector: 'jr-confirm-dialog', changeDetection: ChangeDetectionStrategy.OnPush, host: {
921
+ '(keydown.escape)': 'cancelled.emit()',
922
+ }, template: `
923
+ <div class="jr-confirm-scrim" (click)="cancelled.emit()">
732
924
  <div
733
- style="background-color: white; border-radius: 8px; padding: 24px; max-width: 400px; width: 100%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1)"
925
+ class="jr-confirm-panel"
926
+ role="dialog"
927
+ aria-modal="true"
928
+ [attr.aria-labelledby]="titleId"
929
+ [attr.aria-describedby]="messageId"
734
930
  (click)="$event.stopPropagation()"
931
+ (keydown)="onKeydown($event)"
735
932
  >
736
- <h3 style="margin: 0 0 8px 0; font-size: 18px; font-weight: 600">
737
- {{ config().title }}
738
- </h3>
739
- <p style="margin: 0 0 24px 0; color: #6b7280">{{ config().message }}</p>
740
- <div style="display: flex; gap: 12px; justify-content: flex-end">
741
- <button
742
- type="button"
743
- (click)="cancelled.emit()"
744
- style="padding: 8px 16px; border-radius: 6px; border: 1px solid #d1d5db; background-color: white; cursor: pointer"
745
- >
746
- {{ config().cancelLabel ?? 'Cancel' }}
933
+ <h3 class="jr-confirm-title" [id]="titleId">{{ config().title }}</h3>
934
+ <p class="jr-confirm-message" [id]="messageId">{{ config().message }}</p>
935
+ <div class="jr-confirm-actions">
936
+ <button #cancel type="button" class="jr-confirm-cancel" (click)="cancelled.emit()">
937
+ {{ config().cancelLabel ?? labels.cancel }}
747
938
  </button>
748
939
  <button
940
+ #confirm
749
941
  type="button"
942
+ class="jr-confirm-confirm"
943
+ [class.jr-confirm-danger]="isDanger()"
750
944
  (click)="confirmed.emit()"
751
- [style.background-color]="isDanger() ? '#dc2626' : '#3b82f6'"
752
- style="padding: 8px 16px; border-radius: 6px; border: none; color: white; cursor: pointer"
753
945
  >
754
- {{ config().confirmLabel ?? 'Confirm' }}
946
+ {{ config().confirmLabel ?? labels.confirm }}
755
947
  </button>
756
948
  </div>
757
949
  </div>
758
950
  </div>
759
- `,
760
- }]
761
- }], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } });
951
+ `, styles: [":host{--jr-confirm-scrim-default: rgba(0, 0, 0, .5);--jr-confirm-surface-default: #ffffff;--jr-confirm-ink-default: #111827;--jr-confirm-muted-default: #6b7280;--jr-confirm-border-default: #d1d5db;--jr-confirm-accent-default: #3b82f6;--jr-confirm-danger-default: #dc2626;--jr-confirm-on-accent-default: #ffffff}@media(prefers-color-scheme:dark){:host{--jr-confirm-scrim-default: rgba(0, 0, 0, .65);--jr-confirm-surface-default: #1f2937;--jr-confirm-ink-default: #f3f4f6;--jr-confirm-muted-default: #9ca3af;--jr-confirm-border-default: #4b5563;--jr-confirm-accent-default: #60a5fa;--jr-confirm-danger-default: #f87171;--jr-confirm-on-accent-default: #111827}}.jr-confirm-scrim{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background-color:var(--jr-confirm-scrim, var(--jr-confirm-scrim-default))}.jr-confirm-panel{width:100%;max-width:400px;padding:24px;border-radius:var(--jr-confirm-radius, 8px);background-color:var(--jr-confirm-surface, var(--jr-confirm-surface-default));color:var(--jr-confirm-ink, var(--jr-confirm-ink-default));box-shadow:0 20px 25px -5px #00000040}.jr-confirm-title{margin:0 0 8px;font-size:18px;font-weight:600}.jr-confirm-message{margin:0 0 24px;color:var(--jr-confirm-muted, var(--jr-confirm-muted-default))}.jr-confirm-actions{display:flex;gap:12px;justify-content:flex-end}.jr-confirm-actions button{padding:8px 16px;border-radius:6px;font:inherit;cursor:pointer}.jr-confirm-cancel{border:1px solid var(--jr-confirm-border, var(--jr-confirm-border-default));background-color:transparent;color:inherit}.jr-confirm-confirm{border:none;background-color:var(--jr-confirm-accent, var(--jr-confirm-accent-default));color:var(--jr-confirm-on-accent, var(--jr-confirm-on-accent-default))}.jr-confirm-confirm.jr-confirm-danger{background-color:var(--jr-confirm-danger, var(--jr-confirm-danger-default))}\n"] }]
952
+ }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], confirmed: [{ type: i0.Output, args: ["confirmed"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }], cancelButton: [{ type: i0.ViewChild, args: ['cancel', { isSignal: true }] }], confirmButton: [{ type: i0.ViewChild, args: ['confirm', { isSignal: true }] }] } });
762
953
 
763
954
  /**
764
955
  * Reactive mirror of the json-render devtools-active flag. When active, the
@@ -776,6 +967,11 @@ function injectDevtoolsActive() {
776
967
  * Provided by the renderer for every catalog component instance.
777
968
  */
778
969
  const RENDER_CONTEXT = new InjectionToken('ngx-json-render RENDER_CONTEXT');
970
+ /**
971
+ * The spec key of the element currently being rendered. Provided alongside
972
+ * {@link RENDER_CONTEXT} for every catalog component instance.
973
+ */
974
+ const ELEMENT_KEY = new InjectionToken('ngx-json-render ELEMENT_KEY');
779
975
  /**
780
976
  * The current repeat scope. Present only for elements rendered inside a
781
977
  * `repeat` block.
@@ -802,6 +998,27 @@ function injectRenderContext() {
802
998
  }
803
999
  return ctx;
804
1000
  }
1001
+ /**
1002
+ * Inject the spec key of the element this component renders.
1003
+ *
1004
+ * Catalog components that collect their children — a tab group discovering
1005
+ * its tabs, for instance — need it to order registrations by the spec's
1006
+ * `children` array rather than by the order the children happened to
1007
+ * announce themselves in.
1008
+ *
1009
+ * @example
1010
+ * ```ts
1011
+ * const key = injectElementKey();
1012
+ * const index = computed(() => parent.element().children?.indexOf(key()) ?? -1);
1013
+ * ```
1014
+ */
1015
+ function injectElementKey() {
1016
+ const key = inject(ELEMENT_KEY, { optional: true });
1017
+ if (!key) {
1018
+ throw new Error('injectElementKey() must be used inside a component rendered by <json-render>');
1019
+ }
1020
+ return key;
1021
+ }
805
1022
  /**
806
1023
  * Inject the current repeat scope, or `null` when the component is not
807
1024
  * rendered inside a `repeat` block.
@@ -899,7 +1116,10 @@ class JrElement {
899
1116
  },
900
1117
  };
901
1118
  outletInjector = Injector.create({
902
- providers: [{ provide: RENDER_CONTEXT, useValue: this.renderCtx }],
1119
+ providers: [
1120
+ { provide: RENDER_CONTEXT, useValue: this.renderCtx },
1121
+ { provide: ELEMENT_KEY, useValue: this.elementKey },
1122
+ ],
903
1123
  parent: inject(Injector),
904
1124
  });
905
1125
  constructor() {
@@ -1047,6 +1267,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1047
1267
  `, styles: [":host{display:contents}\n"] }]
1048
1268
  }], ctorParameters: () => [], propDecorators: { elementKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "elementKey", required: true }] }] } });
1049
1269
 
1270
+ const CLEAN = {
1271
+ spec: null,
1272
+ issues: [],
1273
+ fixes: [],
1274
+ hasErrors: false,
1275
+ };
1276
+ /**
1277
+ * Check a spec the way `validate` asks for.
1278
+ *
1279
+ * Only lossless fixes are applied — `visible`, `on` and `repeat` that a model
1280
+ * put inside `props`, which the renderer would otherwise ignore, moved back
1281
+ * where they belong. The lossy ones prune content, and core's own guidance is
1282
+ * to re-prompt rather than accept that; a renderer that silently deleted
1283
+ * elements would be the worse failure.
1284
+ */
1285
+ function checkSpec(spec, mode) {
1286
+ if (!spec)
1287
+ return CLEAN;
1288
+ if (mode === 'off') {
1289
+ return { spec, issues: [], fixes: [], hasErrors: false };
1290
+ }
1291
+ const fixed = autoFixSpec(spec, { lossy: false });
1292
+ const { issues } = validateSpec(fixed.spec);
1293
+ return {
1294
+ spec: fixed.spec,
1295
+ issues,
1296
+ fixes: fixed.fixes,
1297
+ hasErrors: issues.some((issue) => issue.severity === 'error'),
1298
+ };
1299
+ }
1300
+ /**
1301
+ * Report a check to the console: fixes as information, issues at the severity
1302
+ * the mode gives them. `strict` refuses to render, so its errors are reported
1303
+ * as errors; under `warn` the spec still renders and the same finding is a
1304
+ * warning.
1305
+ */
1306
+ function reportSpecCheck(check, mode) {
1307
+ if (mode === 'off')
1308
+ return;
1309
+ if (check.fixes.length > 0) {
1310
+ console.info(`[ngx-json-render] Fixed ${check.fixes.length} spec issue(s) before rendering:\n` +
1311
+ check.fixes.map((fix) => ` - ${fix}`).join('\n'));
1312
+ }
1313
+ if (check.issues.length === 0)
1314
+ return;
1315
+ const report = `[ngx-json-render] Spec validation:\n${formatSpecIssues([...check.issues])}`;
1316
+ if (mode === 'strict' && check.hasErrors) {
1317
+ console.error(`${report}\nNothing is rendered: validate="strict".`);
1318
+ }
1319
+ else {
1320
+ console.warn(report);
1321
+ }
1322
+ }
1323
+
1050
1324
  /**
1051
1325
  * Renders a json-render {@link Spec} using a registry of Angular components.
1052
1326
  *
@@ -1075,6 +1349,19 @@ class JsonRenderer {
1075
1349
  loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1076
1350
  /** Fallback component for unknown types. */
1077
1351
  fallback = input(null, ...(ngDevMode ? [{ debugName: "fallback" }] : /* istanbul ignore next */ []));
1352
+ /**
1353
+ * Whether to check the spec's structure before rendering it, and what to do
1354
+ * about what turns up. Off by default.
1355
+ *
1356
+ * `'warn'` reports the issues and renders anyway; `'strict'` renders nothing
1357
+ * when the spec has errors. Both first apply the lossless fixes — `visible`,
1358
+ * `on` and `repeat` misplaced inside `props` are moved back to the element,
1359
+ * where they take effect instead of being ignored.
1360
+ *
1361
+ * The check is skipped while `loading` is true: a spec that is still
1362
+ * arriving is expected to reference children that have not streamed in yet.
1363
+ */
1364
+ validate = input('off', ...(ngDevMode ? [{ debugName: "validate" }] : /* istanbul ignore next */ []));
1078
1365
  /**
1079
1366
  * Initial state model (uncontrolled mode). Defaults to `spec.state`.
1080
1367
  * Ignored when `store` is provided.
@@ -1100,9 +1387,17 @@ class JsonRenderer {
1100
1387
  stateStore;
1101
1388
  /** The action dispatcher of this renderer. */
1102
1389
  actions;
1390
+ /**
1391
+ * The spec as checked, and what the check found. While `loading`, the mode
1392
+ * is forced off: the spec is still arriving, and the missing children it
1393
+ * refers to are the normal state of a stream, not a defect.
1394
+ */
1395
+ checked = computed(() => checkSpec(this.spec(), this.loading() ? 'off' : this.validate()), ...(ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []));
1103
1396
  constructor() {
1104
1397
  const root = inject(JsonRenderRootContext);
1105
- root.spec = this.spec;
1398
+ // The fixed spec, not the input: a `visible` the check moved out of
1399
+ // `props` has to be the one the tree renders, or the fix is cosmetic.
1400
+ root.spec = computed(() => this.checked().spec, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
1106
1401
  root.registry = this.registry;
1107
1402
  root.loading = this.loading;
1108
1403
  root.fallback = this.fallback;
@@ -1122,14 +1417,52 @@ class JsonRenderer {
1122
1417
  this.stateStore = inject(JsonRenderStateService);
1123
1418
  inject(JsonRenderValidationService);
1124
1419
  this.actions = inject(JsonRenderActionsService);
1420
+ // Reports once per distinct check rather than once per render: `checked`
1421
+ // only recomputes when the spec, the mode or the loading flag change.
1422
+ effect(() => reportSpecCheck(this.checked(), this.validate()));
1125
1423
  }
1126
1424
  rootKey = computed(() => {
1127
- const spec = this.spec();
1425
+ const checked = this.checked();
1426
+ // A spec whose errors survived the fixes does not render under `strict`.
1427
+ // Reporting it and drawing half of it anyway is the behaviour the mode
1428
+ // exists to refuse.
1429
+ if (this.validate() === 'strict' && checked.hasErrors)
1430
+ return null;
1431
+ const spec = checked.spec;
1128
1432
  return spec?.root && spec.elements?.[spec.root] ? spec.root : null;
1129
1433
  }, ...(ngDevMode ? [{ debugName: "rootKey" }] : /* istanbul ignore next */ []));
1130
1434
  pendingConfirm = computed(() => this.actions.pendingConfirmation()?.action.confirm ?? null, ...(ngDevMode ? [{ debugName: "pendingConfirm" }] : /* istanbul ignore next */ []));
1435
+ /**
1436
+ * The app's own dialog, if it provided one. Read once: which component
1437
+ * answers a confirmation is an application decision, not something that
1438
+ * changes between two confirmations.
1439
+ */
1440
+ customDialog = inject(JR_CONFIRM_DIALOG, {
1441
+ optional: true,
1442
+ });
1443
+ injector = inject(Injector);
1444
+ /**
1445
+ * A replacement dialog takes no inputs and emits no outputs — it injects
1446
+ * everything it needs, the way catalog components do. That keeps the seam
1447
+ * one token wide instead of a component contract the renderer would have to
1448
+ * bind to.
1449
+ */
1450
+ confirmInjector = computed(() => {
1451
+ const config = this.pendingConfirm();
1452
+ if (!config)
1453
+ return null;
1454
+ const context = {
1455
+ config,
1456
+ confirm: () => this.actions.confirm(),
1457
+ cancel: () => this.actions.cancel(),
1458
+ };
1459
+ return Injector.create({
1460
+ providers: [{ provide: CONFIRM_CONTEXT, useValue: context }],
1461
+ parent: this.injector,
1462
+ });
1463
+ }, ...(ngDevMode ? [{ debugName: "confirmInjector" }] : /* istanbul ignore next */ []));
1131
1464
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, deps: [], target: i0.ɵɵFactoryTarget.Component });
1132
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
1465
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: JsonRenderer, isStandalone: true, selector: "json-render", inputs: { spec: { classPropertyName: "spec", publicName: "spec", isSignal: true, isRequired: true, transformFunction: null }, registry: { classPropertyName: "registry", publicName: "registry", isSignal: true, isRequired: true, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, fallback: { classPropertyName: "fallback", publicName: "fallback", isSignal: true, isRequired: false, transformFunction: null }, validate: { classPropertyName: "validate", publicName: "validate", isSignal: true, isRequired: false, transformFunction: null }, state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: false, transformFunction: null }, store: { classPropertyName: "store", publicName: "store", isSignal: true, isRequired: false, transformFunction: null }, handlers: { classPropertyName: "handlers", publicName: "handlers", isSignal: true, isRequired: false, transformFunction: null }, onAction: { classPropertyName: "onAction", publicName: "onAction", isSignal: true, isRequired: false, transformFunction: null }, navigate: { classPropertyName: "navigate", publicName: "navigate", isSignal: true, isRequired: false, transformFunction: null }, validationFunctions: { classPropertyName: "validationFunctions", publicName: "validationFunctions", isSignal: true, isRequired: false, transformFunction: null }, functions: { classPropertyName: "functions", publicName: "functions", isSignal: true, isRequired: false, transformFunction: null }, directives: { classPropertyName: "directives", publicName: "directives", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { stateChange: "stateChange" }, providers: [
1133
1466
  JsonRenderRootContext,
1134
1467
  JsonRenderStateService,
1135
1468
  JsonRenderValidationService,
@@ -1139,17 +1472,23 @@ class JsonRenderer {
1139
1472
  <jr-element [elementKey]="key" />
1140
1473
  }
1141
1474
  @if (pendingConfirm(); as confirm) {
1142
- <jr-confirm-dialog
1143
- [config]="confirm"
1144
- (confirmed)="actions.confirm()"
1145
- (cancelled)="actions.cancel()"
1146
- />
1475
+ @if (customDialog; as dialog) {
1476
+ <ng-container
1477
+ *ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
1478
+ />
1479
+ } @else {
1480
+ <jr-confirm-dialog
1481
+ [config]="confirm"
1482
+ (confirmed)="actions.confirm()"
1483
+ (cancelled)="actions.cancel()"
1484
+ />
1485
+ }
1147
1486
  }
1148
- `, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1487
+ `, isInline: true, styles: [":host{display:contents}\n"], dependencies: [{ kind: "component", type: JrElement, selector: "jr-element", inputs: ["elementKey"] }, { kind: "component", type: JrConfirmDialog, selector: "jr-confirm-dialog", inputs: ["config"], outputs: ["confirmed", "cancelled"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1149
1488
  }
1150
1489
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: JsonRenderer, decorators: [{
1151
1490
  type: Component,
1152
- args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog], providers: [
1491
+ args: [{ selector: 'json-render', changeDetection: ChangeDetectionStrategy.OnPush, imports: [JrElement, JrConfirmDialog, NgComponentOutlet], providers: [
1153
1492
  JsonRenderRootContext,
1154
1493
  JsonRenderStateService,
1155
1494
  JsonRenderValidationService,
@@ -1159,14 +1498,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1159
1498
  <jr-element [elementKey]="key" />
1160
1499
  }
1161
1500
  @if (pendingConfirm(); as confirm) {
1162
- <jr-confirm-dialog
1163
- [config]="confirm"
1164
- (confirmed)="actions.confirm()"
1165
- (cancelled)="actions.cancel()"
1166
- />
1501
+ @if (customDialog; as dialog) {
1502
+ <ng-container
1503
+ *ngComponentOutlet="dialog; injector: confirmInjector() ?? undefined"
1504
+ />
1505
+ } @else {
1506
+ <jr-confirm-dialog
1507
+ [config]="confirm"
1508
+ (confirmed)="actions.confirm()"
1509
+ (cancelled)="actions.cancel()"
1510
+ />
1511
+ }
1167
1512
  }
1168
1513
  `, styles: [":host{display:contents}\n"] }]
1169
- }], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
1514
+ }], ctorParameters: () => [], propDecorators: { spec: [{ type: i0.Input, args: [{ isSignal: true, alias: "spec", required: true }] }], registry: [{ type: i0.Input, args: [{ isSignal: true, alias: "registry", required: true }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], fallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "fallback", required: false }] }], validate: [{ type: i0.Input, args: [{ isSignal: true, alias: "validate", required: false }] }], state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: false }] }], store: [{ type: i0.Input, args: [{ isSignal: true, alias: "store", required: false }] }], handlers: [{ type: i0.Input, args: [{ isSignal: true, alias: "handlers", required: false }] }], onAction: [{ type: i0.Input, args: [{ isSignal: true, alias: "onAction", required: false }] }], navigate: [{ type: i0.Input, args: [{ isSignal: true, alias: "navigate", required: false }] }], validationFunctions: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationFunctions", required: false }] }], functions: [{ type: i0.Input, args: [{ isSignal: true, alias: "functions", required: false }] }], directives: [{ type: i0.Input, args: [{ isSignal: true, alias: "directives", required: false }] }], stateChange: [{ type: i0.Output, args: ["stateChange"] }] } });
1170
1515
 
1171
1516
  /**
1172
1517
  * Provides the repeat scope (item, index, absolute base path) to the elements
@@ -1891,6 +2236,8 @@ function injectUIStream(options) {
1891
2236
  const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1892
2237
  const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
1893
2238
  const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
2239
+ const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
2240
+ const validate = options.validate ?? 'off';
1894
2241
  const session = createStreamSession();
1895
2242
  inject(DestroyRef).onDestroy(() => session.cancel());
1896
2243
  const stop = () => {
@@ -1905,6 +2252,7 @@ function injectUIStream(options) {
1905
2252
  error.set(null);
1906
2253
  usage.set(null);
1907
2254
  rawLines.set([]);
2255
+ issues.set([]);
1908
2256
  };
1909
2257
  const send = async (prompt, sendOptions) => {
1910
2258
  const gate = session.begin();
@@ -1912,6 +2260,7 @@ function injectUIStream(options) {
1912
2260
  error.set(null);
1913
2261
  usage.set(null);
1914
2262
  rawLines.set([]);
2263
+ issues.set([]);
1915
2264
  const previousSpec = sendOptions?.previousSpec;
1916
2265
  let currentSpec = previousSpec && previousSpec.root
1917
2266
  ? { ...previousSpec, elements: { ...previousSpec.elements } }
@@ -1942,7 +2291,26 @@ function injectUIStream(options) {
1942
2291
  fetch: options.fetch,
1943
2292
  signal: gate.signal,
1944
2293
  }, createLineConsumer(handleLine));
1945
- gate.commit(() => options.onComplete?.(currentSpec));
2294
+ gate.commit(() => {
2295
+ const check = checkSpec(currentSpec, validate);
2296
+ reportSpecCheck(check, validate);
2297
+ if (check.spec) {
2298
+ currentSpec = check.spec;
2299
+ spec.set(currentSpec);
2300
+ }
2301
+ issues.set(check.issues);
2302
+ // Under `strict` a broken generation is a failure, not a result: the
2303
+ // app's onComplete is where a spec usually gets persisted, and handing
2304
+ // it one that cannot render is the outcome this mode is asked to
2305
+ // prevent. The spec stays put so the app can show or discard it.
2306
+ if (validate === 'strict' && check.hasErrors) {
2307
+ const invalid = new Error(`Generated spec failed validation:\n${formatSpecIssues([...check.issues])}`);
2308
+ error.set(invalid);
2309
+ options.onError?.(invalid);
2310
+ return;
2311
+ }
2312
+ options.onComplete?.(currentSpec);
2313
+ });
1946
2314
  }
1947
2315
  catch (err) {
1948
2316
  if (isAbortError(err))
@@ -1963,6 +2331,7 @@ function injectUIStream(options) {
1963
2331
  error: error.asReadonly(),
1964
2332
  usage: usage.asReadonly(),
1965
2333
  rawLines: rawLines.asReadonly(),
2334
+ issues: issues.asReadonly(),
1966
2335
  send,
1967
2336
  stop,
1968
2337
  clear,
@@ -1990,6 +2359,8 @@ function injectChatUI(options) {
1990
2359
  const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
1991
2360
  const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
1992
2361
  const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
2362
+ const issues = signal([], ...(ngDevMode ? [{ debugName: "issues" }] : /* istanbul ignore next */ []));
2363
+ const validate = options.validate ?? 'off';
1993
2364
  const session = createStreamSession();
1994
2365
  inject(DestroyRef).onDestroy(() => session.cancel());
1995
2366
  const stop = () => {
@@ -2000,6 +2371,7 @@ function injectChatUI(options) {
2000
2371
  stop();
2001
2372
  messages.set([]);
2002
2373
  error.set(null);
2374
+ issues.set([]);
2003
2375
  };
2004
2376
  const send = async (text) => {
2005
2377
  if (!text.trim())
@@ -2073,13 +2445,33 @@ function injectChatUI(options) {
2073
2445
  fetch: options.fetch,
2074
2446
  signal: gate.signal,
2075
2447
  }, parser);
2076
- const finalMessage = {
2077
- id: assistantId,
2078
- role: 'assistant',
2079
- text: accumulatedText,
2080
- spec: hasSpec ? currentSpec : null,
2081
- };
2082
- gate.commit(() => options.onComplete?.(finalMessage));
2448
+ gate.commit(() => {
2449
+ // A reply that only talked has no spec to check, and reporting
2450
+ // "missing root" for a sentence would be nonsense.
2451
+ const check = checkSpec(hasSpec ? currentSpec : null, validate);
2452
+ if (hasSpec) {
2453
+ reportSpecCheck(check, validate);
2454
+ if (check.spec)
2455
+ currentSpec = check.spec;
2456
+ }
2457
+ issues.set(check.issues);
2458
+ const finalMessage = {
2459
+ id: assistantId,
2460
+ role: 'assistant',
2461
+ text: accumulatedText,
2462
+ spec: hasSpec ? currentSpec : null,
2463
+ };
2464
+ // The reply keeps the fixed spec, so what renders in the bubble is
2465
+ // what was checked.
2466
+ messages.update((prev) => prev.map((m) => (m.id === assistantId ? finalMessage : m)));
2467
+ if (validate === 'strict' && check.hasErrors) {
2468
+ const invalid = new Error(`Generated spec failed validation:\n${formatSpecIssues([...check.issues])}`);
2469
+ error.set(invalid);
2470
+ options.onError?.(invalid);
2471
+ return;
2472
+ }
2473
+ options.onComplete?.(finalMessage);
2474
+ });
2083
2475
  }
2084
2476
  catch (err) {
2085
2477
  // Unconditional: however this turn ended, its own bubble goes with it if
@@ -2103,6 +2495,7 @@ function injectChatUI(options) {
2103
2495
  messages: messages.asReadonly(),
2104
2496
  isStreaming: isStreaming.asReadonly(),
2105
2497
  error: error.asReadonly(),
2498
+ issues: issues.asReadonly(),
2106
2499
  send,
2107
2500
  stop,
2108
2501
  clear,
@@ -2186,6 +2579,10 @@ const schema = defineSchema((s) => ({
2186
2579
  name: 'validateForm',
2187
2580
  description: 'Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.',
2188
2581
  },
2582
+ {
2583
+ name: 'submitForm',
2584
+ description: "Validate all registered form fields and, only if every one passes, dispatch another action. Params: { action: string, params?: object, statePath?: string }. Writes the same { valid, errors } result as validateForm. Use this on a form's submit button: it is validateForm and the submit in one binding, and the submit is skipped when the form is invalid.",
2585
+ },
2189
2586
  ],
2190
2587
  defaultRules: [
2191
2588
  // Element integrity
@@ -2214,5 +2611,5 @@ const schema = defineSchema((s) => ({
2214
2611
  * Generated bundle index. Do not edit.
2215
2612
  */
2216
2613
 
2217
- export { JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectDevtoolsActive, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, jsonRenderMessage, schema };
2614
+ export { CONFIRM_CONTEXT, ELEMENT_KEY, JR_CONFIRM_DIALOG, JR_CONFIRM_LABELS, JrChildren, JrConfirmDialog, JrElement, JrRepeatScope, JsonRenderActionsService, JsonRenderRootContext, JsonRenderStateService, JsonRenderValidationService, JsonRenderer, RENDER_CONTEXT, REPEAT_SCOPE, applyPatch, buildSpecFromParts, checkSpec, createStoreSetState, defineRegistry, flatToTree, getTextFromParts, injectAction, injectActions, injectBoundProp, injectChatUI, injectConfirmContext, injectDevtoolsActive, injectElementKey, injectFieldValidation, injectRenderContext, injectRepeatScope, injectStateBinding, injectStateStore, injectStateValue, injectUIStream, injectValidation, isActionCancelled, jsonRenderMessage, schema };
2218
2615
  //# sourceMappingURL=ngx-json-render.mjs.map