@vectoriox/iox-ui 4.28.0 → 4.29.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.
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs +82 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +392 -93
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/forms-core/index.d.ts +42 -2
- package/index.d.ts +228 -36
- package/package.json +1 -1
|
@@ -3123,78 +3123,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3123
3123
|
type: Input
|
|
3124
3124
|
}] } });
|
|
3125
3125
|
|
|
3126
|
-
/** Which of the form's four states the canvas / runtime is showing. */
|
|
3127
|
-
var FormRenderState;
|
|
3128
|
-
(function (FormRenderState) {
|
|
3129
|
-
FormRenderState["Idle"] = "idle";
|
|
3130
|
-
FormRenderState["Submitting"] = "submitting";
|
|
3131
|
-
FormRenderState["Success"] = "success";
|
|
3132
|
-
FormRenderState["Error"] = "error";
|
|
3133
|
-
})(FormRenderState || (FormRenderState = {}));
|
|
3134
|
-
/**
|
|
3135
|
-
* Directive-host Form: the real `<form>` IS the host — no wrapper, no inner/outer split. Children
|
|
3136
|
-
* (fields, layout, the submit button, and the two state blocks) render into it via `childrenHost`.
|
|
3137
|
-
*
|
|
3138
|
-
* Scope is ANCESTRY, not naming (decision D1): a Field belongs to its nearest ancestor Form, so
|
|
3139
|
-
* there is no form-id to pick and nothing to mis-wire. The runtime finds its fields by walking the
|
|
3140
|
-
* DOM it owns.
|
|
3141
|
-
*
|
|
3142
|
-
* `renderState` drives which children are visible. In the builder it is set by the canvas state
|
|
3143
|
-
* switcher so an author can DESIGN the success and error blocks; at runtime the same input is
|
|
3144
|
-
* driven by the submit lifecycle. One mechanism, two drivers — the author designs exactly what a
|
|
3145
|
-
* visitor will see.
|
|
3146
|
-
*
|
|
3147
|
-
* ⚠️ `novalidate` is deliberate: the browser's native bubbles would pre-empt our own messages,
|
|
3148
|
-
* which are styleable, translated and announced. Validation still runs — ours does it.
|
|
3149
|
-
*/
|
|
3150
|
-
class IoxFormComponent {
|
|
3151
|
-
constructor() {
|
|
3152
|
-
this.nodeId = '';
|
|
3153
|
-
/** Stable id the submit endpoint resolves the saved spec by. */
|
|
3154
|
-
this.formId = '';
|
|
3155
|
-
/** Author-facing name — what the submissions inbox groups by. */
|
|
3156
|
-
this.name = '';
|
|
3157
|
-
this.renderState = FormRenderState.Idle;
|
|
3158
|
-
/**
|
|
3159
|
-
* Emitted instead of navigating. The host (builder canvas or engine runtime) owns the transport;
|
|
3160
|
-
* this component owns only the DOM contract, so the same component works in both renderers.
|
|
3161
|
-
*/
|
|
3162
|
-
this.formSubmit = new EventEmitter();
|
|
3163
|
-
}
|
|
3164
|
-
onSubmit(event) {
|
|
3165
|
-
// Never let the browser navigate: a full page POST would lose the designed Success state
|
|
3166
|
-
// and every value the visitor typed.
|
|
3167
|
-
event.preventDefault();
|
|
3168
|
-
this.formSubmit.emit(event);
|
|
3169
|
-
}
|
|
3170
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3171
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: IoxFormComponent, isStandalone: false, selector: "form[iox-form]", inputs: { nodeId: "nodeId", formId: "formId", name: "name", renderState: "renderState" }, outputs: { formSubmit: "formSubmit" }, host: { listeners: { "submit": "onSubmit($event)" }, properties: { "class": "'iox-form iox-node-' + nodeId", "attr.novalidate": "''", "attr.data-iox-form": "formId || null", "attr.data-iox-state": "renderState", "attr.aria-busy": "renderState === 'submitting' ? 'true' : null" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{box-sizing:border-box}:host ::ng-deep .iox-form-success,:host ::ng-deep .iox-form-error{display:none}:host([data-iox-state=\"success\"]) ::ng-deep .iox-form-success{display:block}:host([data-iox-state=\"error\"]) ::ng-deep .iox-form-error{display:block}:host([data-iox-state=\"success\"]) ::ng-deep .iox-field,:host([data-iox-state=\"success\"]) ::ng-deep .iox-field-group,:host([data-iox-state=\"success\"]) ::ng-deep .iox-submit{display:none}\n"] }); }
|
|
3172
|
-
}
|
|
3173
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormComponent, decorators: [{
|
|
3174
|
-
type: Component,
|
|
3175
|
-
args: [{ selector: 'form[iox-form]', template: `<ng-container #childrenHost></ng-container>`, host: {
|
|
3176
|
-
'[class]': "'iox-form iox-node-' + nodeId",
|
|
3177
|
-
'[attr.novalidate]': "''",
|
|
3178
|
-
'[attr.data-iox-form]': 'formId || null',
|
|
3179
|
-
'[attr.data-iox-state]': 'renderState',
|
|
3180
|
-
'[attr.aria-busy]': "renderState === 'submitting' ? 'true' : null",
|
|
3181
|
-
'(submit)': 'onSubmit($event)',
|
|
3182
|
-
}, standalone: false, styles: [":host{box-sizing:border-box}:host ::ng-deep .iox-form-success,:host ::ng-deep .iox-form-error{display:none}:host([data-iox-state=\"success\"]) ::ng-deep .iox-form-success{display:block}:host([data-iox-state=\"error\"]) ::ng-deep .iox-form-error{display:block}:host([data-iox-state=\"success\"]) ::ng-deep .iox-field,:host([data-iox-state=\"success\"]) ::ng-deep .iox-field-group,:host([data-iox-state=\"success\"]) ::ng-deep .iox-submit{display:none}\n"] }]
|
|
3183
|
-
}], propDecorators: { nodeId: [{
|
|
3184
|
-
type: Input
|
|
3185
|
-
}], formId: [{
|
|
3186
|
-
type: Input
|
|
3187
|
-
}], name: [{
|
|
3188
|
-
type: Input
|
|
3189
|
-
}], renderState: [{
|
|
3190
|
-
type: Input
|
|
3191
|
-
}], formSubmit: [{
|
|
3192
|
-
type: Output
|
|
3193
|
-
}], childrenHost: [{
|
|
3194
|
-
type: ViewChild,
|
|
3195
|
-
args: ['childrenHost', { read: ViewContainerRef, static: true }]
|
|
3196
|
-
}] } });
|
|
3197
|
-
|
|
3198
3126
|
// The persisted shape of a form. Pure types + the one derived constant each renderer needs.
|
|
3199
3127
|
//
|
|
3200
3128
|
// A FormSpec is DERIVED from the node tree (see `collect.ts`), never hand-maintained beside it —
|
|
@@ -3656,6 +3584,87 @@ function normaliseActions(raw) {
|
|
|
3656
3584
|
return [STORE_ACTION, ...declared.filter(a => a.type !== STORE_ACTION.type)];
|
|
3657
3585
|
}
|
|
3658
3586
|
|
|
3587
|
+
// When a field's error is allowed to be VISIBLE — the pure half of the form runtime.
|
|
3588
|
+
//
|
|
3589
|
+
// Validity and visibility are different questions. A field can be invalid from the moment the page
|
|
3590
|
+
// loads (an empty required field always is), but showing that immediately means greeting a visitor
|
|
3591
|
+
// with a wall of red before they have typed anything. This module owns the second question, and it
|
|
3592
|
+
// owns it in one place so the builder canvas, the SSR engine and any future runtime agree.
|
|
3593
|
+
//
|
|
3594
|
+
// Framework-free, like the rest of forms-core.
|
|
3595
|
+
const INITIAL_FORM_STATE = Object.freeze({
|
|
3596
|
+
touched: new Set(),
|
|
3597
|
+
submitted: false,
|
|
3598
|
+
errors: {},
|
|
3599
|
+
submitting: false,
|
|
3600
|
+
});
|
|
3601
|
+
/**
|
|
3602
|
+
* Should this field's error be on screen right now?
|
|
3603
|
+
*
|
|
3604
|
+
* - `Submit` — only after a submit attempt. Quietest; nothing moves while typing.
|
|
3605
|
+
* - `Live` — as soon as it is invalid. Loudest; shouts at a half-typed email address.
|
|
3606
|
+
* - `Smart` — the default, and the reason the author gets one dropdown instead of a decision per
|
|
3607
|
+
* field: stay quiet until the visitor has either left the field or tried to submit.
|
|
3608
|
+
* After that, correct live, so fixing an error clears it as they type.
|
|
3609
|
+
*/
|
|
3610
|
+
function shouldShowError(fieldName, state, timing = FeedbackTiming.Smart) {
|
|
3611
|
+
if (!state.errors[fieldName])
|
|
3612
|
+
return false;
|
|
3613
|
+
switch (timing) {
|
|
3614
|
+
case FeedbackTiming.Live: return true;
|
|
3615
|
+
case FeedbackTiming.Submit: return state.submitted;
|
|
3616
|
+
default: return state.submitted || state.touched.has(fieldName);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
/**
|
|
3620
|
+
* Re-validate one field after a change. Only that field is re-checked, EXCEPT that a `matches` rule
|
|
3621
|
+
* makes another field's validity depend on this one — so a confirm-email field stops being wrong
|
|
3622
|
+
* the moment you fix the address it points at, rather than waiting for its own blur.
|
|
3623
|
+
*/
|
|
3624
|
+
function onFieldChange(state, fieldName, values, spec) {
|
|
3625
|
+
const errors = { ...state.errors };
|
|
3626
|
+
const affected = [fieldName, ...dependentsOf(fieldName, spec)];
|
|
3627
|
+
for (const name of affected) {
|
|
3628
|
+
const field = (spec.fields ?? []).find(f => f.name === name);
|
|
3629
|
+
if (!field)
|
|
3630
|
+
continue;
|
|
3631
|
+
const result = validateField(values?.[name], field, values ?? {});
|
|
3632
|
+
if (result.valid)
|
|
3633
|
+
delete errors[name];
|
|
3634
|
+
else
|
|
3635
|
+
errors[name] = { messageKey: result.messageKey, params: result.params };
|
|
3636
|
+
}
|
|
3637
|
+
return { ...state, errors };
|
|
3638
|
+
}
|
|
3639
|
+
/** Mark a field touched (on blur) and re-check it, so Smart timing can start showing its error. */
|
|
3640
|
+
function onFieldBlur(state, fieldName, values, spec) {
|
|
3641
|
+
const touched = new Set(state.touched);
|
|
3642
|
+
touched.add(fieldName);
|
|
3643
|
+
return { ...onFieldChange(state, fieldName, values, spec), touched };
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* A submit attempt. Validates everything and flips `submitted`, so from here on every error shows
|
|
3647
|
+
* whatever the timing setting is — a visitor who pressed the button is owed the full picture.
|
|
3648
|
+
*/
|
|
3649
|
+
function onSubmitAttempt(state, values, spec) {
|
|
3650
|
+
const { errors } = validateForm(values ?? {}, spec);
|
|
3651
|
+
return { ...state, submitted: true, errors };
|
|
3652
|
+
}
|
|
3653
|
+
/** Server-side rejection: adopt the server's errors verbatim. It is the authority, not the client. */
|
|
3654
|
+
function applyServerErrors(state, errors) {
|
|
3655
|
+
return { ...state, submitted: true, submitting: false, errors: { ...errors } };
|
|
3656
|
+
}
|
|
3657
|
+
/** A clean slate after a successful submit, for a form configured to clear itself. */
|
|
3658
|
+
function resetFormState() {
|
|
3659
|
+
return { touched: new Set(), submitted: false, errors: {}, submitting: false };
|
|
3660
|
+
}
|
|
3661
|
+
/** Field names whose validity depends on `fieldName` through a `matches` rule. */
|
|
3662
|
+
function dependentsOf(fieldName, spec) {
|
|
3663
|
+
return (spec.fields ?? [])
|
|
3664
|
+
.filter(f => (f.rules ?? []).some(r => r.params?.['field'] === fieldName))
|
|
3665
|
+
.map(f => f.name);
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3659
3668
|
/*
|
|
3660
3669
|
* @vectoriox/iox-ui/forms-core — the shared, FRAMEWORK-FREE core of the forms feature.
|
|
3661
3670
|
*
|
|
@@ -3668,6 +3677,229 @@ function normaliseActions(raw) {
|
|
|
3668
3677
|
* disagree. See iox-ai-guidance `architecture/builder/forms-and-inputs-plan.md` §5.
|
|
3669
3678
|
*/
|
|
3670
3679
|
|
|
3680
|
+
const IOX_FORM_MESSAGES = new InjectionToken('IOX_FORM_MESSAGES', {
|
|
3681
|
+
providedIn: 'root',
|
|
3682
|
+
factory: () => defaultMessageResolver,
|
|
3683
|
+
});
|
|
3684
|
+
/** `{{n}}` / `{{field}}` interpolation over the built-in English catalog. */
|
|
3685
|
+
const defaultMessageResolver = (key, params) => {
|
|
3686
|
+
const template = DEFAULT_EN_MESSAGES[key] ?? key;
|
|
3687
|
+
if (!params)
|
|
3688
|
+
return template;
|
|
3689
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? ''));
|
|
3690
|
+
};
|
|
3691
|
+
/**
|
|
3692
|
+
* Per-form runtime. **Provided by `IoxFormComponent`**, so every Field created into that form's
|
|
3693
|
+
* `childrenHost` resolves THIS instance through its element injector — which is exactly how a field
|
|
3694
|
+
* finds its form without a form-id, matching the ancestry rule (decision D1). A Field outside any
|
|
3695
|
+
* form injects nothing and simply renders.
|
|
3696
|
+
*
|
|
3697
|
+
* All the decisions live in the pure `forms-core` runtime; this class only holds the mutable state,
|
|
3698
|
+
* the registered fields and the Angular-shaped API. Nothing here decides *when* an error shows.
|
|
3699
|
+
*/
|
|
3700
|
+
class FormRuntimeService {
|
|
3701
|
+
constructor() {
|
|
3702
|
+
/** Registered fields, in DOM order — the order the spec and focus-first-invalid depend on. */
|
|
3703
|
+
this.fields = [];
|
|
3704
|
+
this._state = INITIAL_FORM_STATE;
|
|
3705
|
+
this._spec = null;
|
|
3706
|
+
this._timing = FeedbackTiming.Smart;
|
|
3707
|
+
}
|
|
3708
|
+
get state() { return this._state; }
|
|
3709
|
+
get submitting() { return this._state.submitting; }
|
|
3710
|
+
/** Set by the Form from its own traits. */
|
|
3711
|
+
configure(spec, timing) {
|
|
3712
|
+
this._spec = spec;
|
|
3713
|
+
this._timing = timing;
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* A Field announces itself here on init. It passes FUNCTIONS rather than values so the runtime
|
|
3717
|
+
* always reads the field's current spec and value — a trait edited on the canvas takes effect
|
|
3718
|
+
* without re-registering anything.
|
|
3719
|
+
*/
|
|
3720
|
+
register(field) {
|
|
3721
|
+
this.fields.push(field);
|
|
3722
|
+
return () => {
|
|
3723
|
+
const index = this.fields.indexOf(field);
|
|
3724
|
+
if (index >= 0)
|
|
3725
|
+
this.fields.splice(index, 1);
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
/** The live spec, derived from what is registered right now. */
|
|
3729
|
+
specNow() {
|
|
3730
|
+
const base = this._spec ?? {
|
|
3731
|
+
formId: '', name: '', feedback: FeedbackTiming.Smart,
|
|
3732
|
+
fields: [], actions: [], antiSpam: { honeypot: true, minFillMs: 1200 },
|
|
3733
|
+
};
|
|
3734
|
+
return { ...base, fields: this.fields.map(f => f.spec()) };
|
|
3735
|
+
}
|
|
3736
|
+
/** Current values, keyed by submitted field name. */
|
|
3737
|
+
values() {
|
|
3738
|
+
const out = {};
|
|
3739
|
+
for (const field of this.fields)
|
|
3740
|
+
out[field.spec().name] = field.getValue();
|
|
3741
|
+
return out;
|
|
3742
|
+
}
|
|
3743
|
+
onChange(name) {
|
|
3744
|
+
this._state = onFieldChange(this._state, name, this.values(), this.specNow());
|
|
3745
|
+
}
|
|
3746
|
+
onBlur(name) {
|
|
3747
|
+
this._state = onFieldBlur(this._state, name, this.values(), this.specNow());
|
|
3748
|
+
}
|
|
3749
|
+
/** Validate everything. Returns whether the submission may proceed. */
|
|
3750
|
+
validateAll() {
|
|
3751
|
+
this._state = onSubmitAttempt(this._state, this.values(), this.specNow());
|
|
3752
|
+
return Object.keys(this._state.errors).length === 0;
|
|
3753
|
+
}
|
|
3754
|
+
setSubmitting(submitting) {
|
|
3755
|
+
this._state = { ...this._state, submitting };
|
|
3756
|
+
}
|
|
3757
|
+
/** The server rejected it — its verdict replaces ours. */
|
|
3758
|
+
applyErrors(errors) {
|
|
3759
|
+
this._state = { ...this._state, submitted: true, submitting: false, errors: { ...errors } };
|
|
3760
|
+
}
|
|
3761
|
+
reset() {
|
|
3762
|
+
this._state = resetFormState();
|
|
3763
|
+
}
|
|
3764
|
+
/** Whether this field's error is allowed on screen right now. */
|
|
3765
|
+
showsError(name) {
|
|
3766
|
+
return shouldShowError(name, this._state, this._timing);
|
|
3767
|
+
}
|
|
3768
|
+
errorFor(name) {
|
|
3769
|
+
return this._state.errors[name];
|
|
3770
|
+
}
|
|
3771
|
+
/** The first invalid field in registration (DOM) order — what submit should focus. */
|
|
3772
|
+
firstInvalidName() {
|
|
3773
|
+
for (const field of this.fields) {
|
|
3774
|
+
const name = field.spec().name;
|
|
3775
|
+
if (this._state.errors[name])
|
|
3776
|
+
return name;
|
|
3777
|
+
}
|
|
3778
|
+
return null;
|
|
3779
|
+
}
|
|
3780
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: FormRuntimeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
3781
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: FormRuntimeService }); }
|
|
3782
|
+
}
|
|
3783
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: FormRuntimeService, decorators: [{
|
|
3784
|
+
type: Injectable
|
|
3785
|
+
}] });
|
|
3786
|
+
|
|
3787
|
+
/** Which of the form's four states the canvas / runtime is showing. */
|
|
3788
|
+
var FormRenderState;
|
|
3789
|
+
(function (FormRenderState) {
|
|
3790
|
+
FormRenderState["Idle"] = "idle";
|
|
3791
|
+
FormRenderState["Submitting"] = "submitting";
|
|
3792
|
+
FormRenderState["Success"] = "success";
|
|
3793
|
+
FormRenderState["Error"] = "error";
|
|
3794
|
+
})(FormRenderState || (FormRenderState = {}));
|
|
3795
|
+
/**
|
|
3796
|
+
* Directive-host Form: the real `<form>` IS the host — no wrapper, no inner/outer split. Children
|
|
3797
|
+
* (fields, layout, the submit button, and the two state blocks) render into it via `childrenHost`.
|
|
3798
|
+
*
|
|
3799
|
+
* Scope is ANCESTRY, not naming (decision D1): a Field belongs to its nearest ancestor Form, so
|
|
3800
|
+
* there is no form-id to pick and nothing to mis-wire. The runtime finds its fields by walking the
|
|
3801
|
+
* DOM it owns.
|
|
3802
|
+
*
|
|
3803
|
+
* `renderState` drives which children are visible. In the builder it is set by the canvas state
|
|
3804
|
+
* switcher so an author can DESIGN the success and error blocks; at runtime the same input is
|
|
3805
|
+
* driven by the submit lifecycle. One mechanism, two drivers — the author designs exactly what a
|
|
3806
|
+
* visitor will see.
|
|
3807
|
+
*
|
|
3808
|
+
* ⚠️ `novalidate` is deliberate: the browser's native bubbles would pre-empt our own messages,
|
|
3809
|
+
* which are styleable, translated and announced. Validation still runs — ours does it.
|
|
3810
|
+
*/
|
|
3811
|
+
class IoxFormComponent {
|
|
3812
|
+
/**
|
|
3813
|
+
* Constructor injection rather than `inject()`, for the same reason as Field: this library's
|
|
3814
|
+
* specs construct components directly. `runtime` is provided by THIS component (see
|
|
3815
|
+
* `providers`), so every Field created into `childrenHost` resolves this instance by ancestry.
|
|
3816
|
+
*/
|
|
3817
|
+
constructor(runtime, host) {
|
|
3818
|
+
this.runtime = runtime;
|
|
3819
|
+
this.host = host;
|
|
3820
|
+
this.nodeId = '';
|
|
3821
|
+
/** Stable id the submit endpoint resolves the saved spec by. */
|
|
3822
|
+
this.formId = '';
|
|
3823
|
+
/** Author-facing name — what the submissions inbox groups by. */
|
|
3824
|
+
this.name = '';
|
|
3825
|
+
this.renderState = FormRenderState.Idle;
|
|
3826
|
+
this.feedback = FeedbackTiming.Smart;
|
|
3827
|
+
/**
|
|
3828
|
+
* Emitted ONLY when client-side validation passes, carrying the collected values. The host
|
|
3829
|
+
* (builder canvas or engine runtime) owns the transport — posting, retries and the server's
|
|
3830
|
+
* verdict — so the same component serves both renderers.
|
|
3831
|
+
*/
|
|
3832
|
+
this.formSubmit = new EventEmitter();
|
|
3833
|
+
/** Emitted when a submit attempt was refused locally, so a host can react (analytics, focus). */
|
|
3834
|
+
this.validationFailed = new EventEmitter();
|
|
3835
|
+
}
|
|
3836
|
+
ngOnChanges() {
|
|
3837
|
+
// Traits are edited by DIRECT ASSIGNMENT on the canvas, which does not call ngOnChanges —
|
|
3838
|
+
// but `feedback` is also read live by `runtime.showsError`, so re-configuring here covers
|
|
3839
|
+
// the setInput path and the getter covers the rest.
|
|
3840
|
+
this.runtime.configure(this.runtime.specNow(), this.feedback);
|
|
3841
|
+
}
|
|
3842
|
+
onSubmit(event) {
|
|
3843
|
+
// Never let the browser navigate: a full page POST would lose the designed Success state
|
|
3844
|
+
// and every value the visitor typed.
|
|
3845
|
+
event.preventDefault();
|
|
3846
|
+
this.runtime.configure(this.runtime.specNow(), this.feedback);
|
|
3847
|
+
if (!this.runtime.validateAll()) {
|
|
3848
|
+
// Move the visitor TO the problem rather than leaving them to hunt for red text —
|
|
3849
|
+
// the single most valuable thing a failed submit can do.
|
|
3850
|
+
this.focusFirstInvalid();
|
|
3851
|
+
this.validationFailed.emit(this.runtime.state.errors);
|
|
3852
|
+
return;
|
|
3853
|
+
}
|
|
3854
|
+
this.formSubmit.emit({ values: this.runtime.values(), event });
|
|
3855
|
+
}
|
|
3856
|
+
focusFirstInvalid() {
|
|
3857
|
+
const name = this.runtime.firstInvalidName();
|
|
3858
|
+
if (!name)
|
|
3859
|
+
return;
|
|
3860
|
+
// Compare the attribute rather than interpolating the name into a selector: `CSS.escape` is
|
|
3861
|
+
// a browser-only global (undefined on the server and under the unit-test runner), and an
|
|
3862
|
+
// author-overridden field key can contain characters a raw selector would choke on.
|
|
3863
|
+
const fields = this.host.nativeElement.querySelectorAll?.('[data-iox-field]') ?? [];
|
|
3864
|
+
for (const element of Array.from(fields)) {
|
|
3865
|
+
if (element.getAttribute('data-iox-field') !== name)
|
|
3866
|
+
continue;
|
|
3867
|
+
element.querySelector('.iox-field__control')?.focus();
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormComponent, deps: [{ token: FormRuntimeService }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3872
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: IoxFormComponent, isStandalone: false, selector: "form[iox-form]", inputs: { nodeId: "nodeId", formId: "formId", name: "name", renderState: "renderState", feedback: "feedback" }, outputs: { formSubmit: "formSubmit", validationFailed: "validationFailed" }, host: { listeners: { "submit": "onSubmit($event)" }, properties: { "class": "'iox-form iox-node-' + nodeId", "attr.novalidate": "''", "attr.data-iox-form": "formId || null", "attr.data-iox-state": "renderState", "attr.aria-busy": "renderState === 'submitting' ? 'true' : null" } }, providers: [FormRuntimeService], viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #childrenHost></ng-container>`, isInline: true, styles: [":host{box-sizing:border-box}:host ::ng-deep .iox-form-success,:host ::ng-deep .iox-form-error{display:none}:host([data-iox-state=\"success\"]) ::ng-deep .iox-form-success{display:block}:host([data-iox-state=\"error\"]) ::ng-deep .iox-form-error{display:block}:host([data-iox-state=\"success\"]) ::ng-deep .iox-field,:host([data-iox-state=\"success\"]) ::ng-deep .iox-field-group,:host([data-iox-state=\"success\"]) ::ng-deep .iox-submit{display:none}\n"] }); }
|
|
3873
|
+
}
|
|
3874
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormComponent, decorators: [{
|
|
3875
|
+
type: Component,
|
|
3876
|
+
args: [{ selector: 'form[iox-form]', template: `<ng-container #childrenHost></ng-container>`, host: {
|
|
3877
|
+
'[class]': "'iox-form iox-node-' + nodeId",
|
|
3878
|
+
'[attr.novalidate]': "''",
|
|
3879
|
+
'[attr.data-iox-form]': 'formId || null',
|
|
3880
|
+
'[attr.data-iox-state]': 'renderState',
|
|
3881
|
+
'[attr.aria-busy]': "renderState === 'submitting' ? 'true' : null",
|
|
3882
|
+
'(submit)': 'onSubmit($event)',
|
|
3883
|
+
}, providers: [FormRuntimeService], standalone: false, styles: [":host{box-sizing:border-box}:host ::ng-deep .iox-form-success,:host ::ng-deep .iox-form-error{display:none}:host([data-iox-state=\"success\"]) ::ng-deep .iox-form-success{display:block}:host([data-iox-state=\"error\"]) ::ng-deep .iox-form-error{display:block}:host([data-iox-state=\"success\"]) ::ng-deep .iox-field,:host([data-iox-state=\"success\"]) ::ng-deep .iox-field-group,:host([data-iox-state=\"success\"]) ::ng-deep .iox-submit{display:none}\n"] }]
|
|
3884
|
+
}], ctorParameters: () => [{ type: FormRuntimeService }, { type: i0.ElementRef }], propDecorators: { nodeId: [{
|
|
3885
|
+
type: Input
|
|
3886
|
+
}], formId: [{
|
|
3887
|
+
type: Input
|
|
3888
|
+
}], name: [{
|
|
3889
|
+
type: Input
|
|
3890
|
+
}], renderState: [{
|
|
3891
|
+
type: Input
|
|
3892
|
+
}], feedback: [{
|
|
3893
|
+
type: Input
|
|
3894
|
+
}], formSubmit: [{
|
|
3895
|
+
type: Output
|
|
3896
|
+
}], validationFailed: [{
|
|
3897
|
+
type: Output
|
|
3898
|
+
}], childrenHost: [{
|
|
3899
|
+
type: ViewChild,
|
|
3900
|
+
args: ['childrenHost', { read: ViewContainerRef, static: true }]
|
|
3901
|
+
}] } });
|
|
3902
|
+
|
|
3671
3903
|
let fieldSeq = 0;
|
|
3672
3904
|
/**
|
|
3673
3905
|
* The compound Field: label + control + help + error + required mark, as ONE node (decision D2).
|
|
@@ -3685,8 +3917,19 @@ let fieldSeq = 0;
|
|
|
3685
3917
|
* and choosing `email` also chooses email validation.
|
|
3686
3918
|
*/
|
|
3687
3919
|
class IoxFieldComponent {
|
|
3688
|
-
|
|
3920
|
+
/**
|
|
3921
|
+
* Classic constructor injection, not `inject()`: this library's specs construct components
|
|
3922
|
+
* directly (its vitest setup cannot run `TestBed.createComponent`), and a field initializer
|
|
3923
|
+
* calling `inject()` throws NG0203 outside an injection context.
|
|
3924
|
+
*
|
|
3925
|
+
* `runtime` is optional because a Field dropped OUTSIDE any Form must still render — it simply
|
|
3926
|
+
* has nothing to submit to. That is the same ancestry rule from the other side.
|
|
3927
|
+
*/
|
|
3928
|
+
constructor(runtime, resolveMessage) {
|
|
3929
|
+
this.runtime = runtime;
|
|
3930
|
+
this.resolveMessage = resolveMessage;
|
|
3689
3931
|
this.nodeId = '';
|
|
3932
|
+
/** The submitted key. Blank means "derive it from the label" — authors never invent keys. */
|
|
3690
3933
|
this.name = '';
|
|
3691
3934
|
this.label = '';
|
|
3692
3935
|
this.control = ControlType.Text;
|
|
@@ -3700,10 +3943,15 @@ class IoxFieldComponent {
|
|
|
3700
3943
|
this.accept = '';
|
|
3701
3944
|
this.options = [];
|
|
3702
3945
|
this.value = '';
|
|
3703
|
-
/**
|
|
3704
|
-
this.
|
|
3705
|
-
/**
|
|
3706
|
-
|
|
3946
|
+
/** Rules beyond `required` — carried on the node, interpreted by forms-core. */
|
|
3947
|
+
this.rules = [];
|
|
3948
|
+
/**
|
|
3949
|
+
* Forces the error appearance without a real failure — how the builder lets an author DESIGN
|
|
3950
|
+
* the error state (decision D5). At runtime the state comes from the runtime service instead.
|
|
3951
|
+
*/
|
|
3952
|
+
this.forceInvalid = false;
|
|
3953
|
+
/** Placeholder shown while designing the error state, so the block has something to style. */
|
|
3954
|
+
this.designErrorText = 'Please check this field';
|
|
3707
3955
|
/** Only after a successful check — never on an untouched field. */
|
|
3708
3956
|
this.showValid = false;
|
|
3709
3957
|
this.valueChange = new EventEmitter();
|
|
@@ -3711,6 +3959,47 @@ class IoxFieldComponent {
|
|
|
3711
3959
|
/** Stable per instance so `for`/`id` and `aria-describedby` always pair up. */
|
|
3712
3960
|
this.controlId = `iox-field-${++fieldSeq}`;
|
|
3713
3961
|
}
|
|
3962
|
+
ngOnInit() {
|
|
3963
|
+
this.unregister = this.runtime?.register({
|
|
3964
|
+
// Functions, not values: a trait edited on the canvas takes effect with no re-register.
|
|
3965
|
+
spec: () => this.toSpec(),
|
|
3966
|
+
getValue: () => this.value,
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
ngOnDestroy() {
|
|
3970
|
+
this.unregister?.();
|
|
3971
|
+
}
|
|
3972
|
+
/** The submitted key: the author's override, else derived from the label. */
|
|
3973
|
+
get fieldName() {
|
|
3974
|
+
return this.name || deriveFieldName(this.label);
|
|
3975
|
+
}
|
|
3976
|
+
/** Failing AND allowed to say so — or forced, while an author designs the state. */
|
|
3977
|
+
get invalid() {
|
|
3978
|
+
if (this.forceInvalid)
|
|
3979
|
+
return true;
|
|
3980
|
+
return !!this.runtime?.showsError(this.fieldName);
|
|
3981
|
+
}
|
|
3982
|
+
/** The message a visitor reads. Translation is the consumer's, via IOX_FORM_MESSAGES. */
|
|
3983
|
+
get errorText() {
|
|
3984
|
+
if (this.forceInvalid)
|
|
3985
|
+
return this.designErrorText;
|
|
3986
|
+
const error = this.runtime?.errorFor(this.fieldName);
|
|
3987
|
+
return error ? this.resolveMessage(error.messageKey, error.params) : '';
|
|
3988
|
+
}
|
|
3989
|
+
toSpec() {
|
|
3990
|
+
const spec = {
|
|
3991
|
+
name: this.fieldName,
|
|
3992
|
+
control: this.control ?? ControlType.Text,
|
|
3993
|
+
label: this.label,
|
|
3994
|
+
};
|
|
3995
|
+
if (this.required)
|
|
3996
|
+
spec.required = true;
|
|
3997
|
+
if (this.rules?.length)
|
|
3998
|
+
spec.rules = this.rules;
|
|
3999
|
+
if (this.options?.length)
|
|
4000
|
+
spec.options = this.options;
|
|
4001
|
+
return spec;
|
|
4002
|
+
}
|
|
3714
4003
|
/** `file`, `date`, `number`… map straight through; everything else is a text input. */
|
|
3715
4004
|
get inputType() {
|
|
3716
4005
|
const passthrough = ['email', 'tel', 'url', 'number', 'password', 'date', 'time', 'file', 'hidden'];
|
|
@@ -3727,13 +4016,16 @@ class IoxFieldComponent {
|
|
|
3727
4016
|
}
|
|
3728
4017
|
emit(value) {
|
|
3729
4018
|
this.value = value;
|
|
4019
|
+
this.runtime?.onChange(this.fieldName);
|
|
3730
4020
|
this.valueChange.emit(value);
|
|
3731
4021
|
}
|
|
3732
4022
|
touch() {
|
|
4023
|
+
// Blur is what lets Smart timing start speaking for this field.
|
|
4024
|
+
this.runtime?.onBlur(this.fieldName);
|
|
3733
4025
|
this.touched.emit();
|
|
3734
4026
|
}
|
|
3735
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
3736
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.31", type: IoxFieldComponent, isStandalone: false, selector: "div[iox-field]", inputs: { nodeId: "nodeId", name: "name", label: "label", control: "control", placeholder: "placeholder", help: "help", required: "required", disabled: "disabled", labelHidden: "labelHidden", requiredMark: "requiredMark", rows: "rows", accept: "accept", options: "options", value: "value",
|
|
4027
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFieldComponent, deps: [{ token: FormRuntimeService, optional: true }, { token: IOX_FORM_MESSAGES }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
4028
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.31", type: IoxFieldComponent, isStandalone: false, selector: "div[iox-field]", inputs: { nodeId: "nodeId", name: "name", label: "label", control: "control", placeholder: "placeholder", help: "help", required: "required", disabled: "disabled", labelHidden: "labelHidden", requiredMark: "requiredMark", rows: "rows", accept: "accept", options: "options", value: "value", rules: "rules", forceInvalid: "forceInvalid", designErrorText: "designErrorText", showValid: "showValid" }, outputs: { valueChange: "valueChange", touched: "touched" }, host: { properties: { "class": "'iox-field iox-node-' + nodeId", "class.iox-invalid": "invalid", "class.iox-valid": "showValid", "attr.data-iox-field": "fieldName || null", "hidden": "control === 'hidden' ? true : null" } }, ngImport: i0, template: `
|
|
3737
4029
|
<label class="iox-field__label"
|
|
3738
4030
|
[class.iox-field__label--hidden]="labelHidden"
|
|
3739
4031
|
[attr.for]="controlId">
|
|
@@ -3747,7 +4039,7 @@ class IoxFieldComponent {
|
|
|
3747
4039
|
@case ('textarea') {
|
|
3748
4040
|
<textarea class="iox-field__control"
|
|
3749
4041
|
[id]="controlId"
|
|
3750
|
-
[name]="
|
|
4042
|
+
[name]="fieldName"
|
|
3751
4043
|
[rows]="rows"
|
|
3752
4044
|
[attr.placeholder]="placeholder || null"
|
|
3753
4045
|
[attr.required]="required ? '' : null"
|
|
@@ -3761,7 +4053,7 @@ class IoxFieldComponent {
|
|
|
3761
4053
|
@case ('select') {
|
|
3762
4054
|
<select class="iox-field__control"
|
|
3763
4055
|
[id]="controlId"
|
|
3764
|
-
[name]="
|
|
4056
|
+
[name]="fieldName"
|
|
3765
4057
|
[attr.required]="required ? '' : null"
|
|
3766
4058
|
[attr.aria-invalid]="invalid ? 'true' : null"
|
|
3767
4059
|
[attr.aria-describedby]="describedBy"
|
|
@@ -3778,7 +4070,7 @@ class IoxFieldComponent {
|
|
|
3778
4070
|
<input class="iox-field__control iox-field__control--checkbox"
|
|
3779
4071
|
type="checkbox"
|
|
3780
4072
|
[id]="controlId"
|
|
3781
|
-
[name]="
|
|
4073
|
+
[name]="fieldName"
|
|
3782
4074
|
[attr.required]="required ? '' : null"
|
|
3783
4075
|
[attr.aria-invalid]="invalid ? 'true' : null"
|
|
3784
4076
|
[attr.aria-describedby]="describedBy"
|
|
@@ -3792,7 +4084,7 @@ class IoxFieldComponent {
|
|
|
3792
4084
|
@for (option of options; track option.value) {
|
|
3793
4085
|
<label class="iox-field__option">
|
|
3794
4086
|
<input type="radio"
|
|
3795
|
-
[name]="
|
|
4087
|
+
[name]="fieldName"
|
|
3796
4088
|
[value]="option.value"
|
|
3797
4089
|
[checked]="option.value === value"
|
|
3798
4090
|
[attr.disabled]="disabled ? '' : null"
|
|
@@ -3806,7 +4098,7 @@ class IoxFieldComponent {
|
|
|
3806
4098
|
@default {
|
|
3807
4099
|
<input class="iox-field__control"
|
|
3808
4100
|
[id]="controlId"
|
|
3809
|
-
[name]="
|
|
4101
|
+
[name]="fieldName"
|
|
3810
4102
|
[type]="inputType"
|
|
3811
4103
|
[attr.placeholder]="placeholder || null"
|
|
3812
4104
|
[attr.required]="required ? '' : null"
|
|
@@ -3844,7 +4136,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3844
4136
|
@case ('textarea') {
|
|
3845
4137
|
<textarea class="iox-field__control"
|
|
3846
4138
|
[id]="controlId"
|
|
3847
|
-
[name]="
|
|
4139
|
+
[name]="fieldName"
|
|
3848
4140
|
[rows]="rows"
|
|
3849
4141
|
[attr.placeholder]="placeholder || null"
|
|
3850
4142
|
[attr.required]="required ? '' : null"
|
|
@@ -3858,7 +4150,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3858
4150
|
@case ('select') {
|
|
3859
4151
|
<select class="iox-field__control"
|
|
3860
4152
|
[id]="controlId"
|
|
3861
|
-
[name]="
|
|
4153
|
+
[name]="fieldName"
|
|
3862
4154
|
[attr.required]="required ? '' : null"
|
|
3863
4155
|
[attr.aria-invalid]="invalid ? 'true' : null"
|
|
3864
4156
|
[attr.aria-describedby]="describedBy"
|
|
@@ -3875,7 +4167,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3875
4167
|
<input class="iox-field__control iox-field__control--checkbox"
|
|
3876
4168
|
type="checkbox"
|
|
3877
4169
|
[id]="controlId"
|
|
3878
|
-
[name]="
|
|
4170
|
+
[name]="fieldName"
|
|
3879
4171
|
[attr.required]="required ? '' : null"
|
|
3880
4172
|
[attr.aria-invalid]="invalid ? 'true' : null"
|
|
3881
4173
|
[attr.aria-describedby]="describedBy"
|
|
@@ -3889,7 +4181,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3889
4181
|
@for (option of options; track option.value) {
|
|
3890
4182
|
<label class="iox-field__option">
|
|
3891
4183
|
<input type="radio"
|
|
3892
|
-
[name]="
|
|
4184
|
+
[name]="fieldName"
|
|
3893
4185
|
[value]="option.value"
|
|
3894
4186
|
[checked]="option.value === value"
|
|
3895
4187
|
[attr.disabled]="disabled ? '' : null"
|
|
@@ -3903,7 +4195,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3903
4195
|
@default {
|
|
3904
4196
|
<input class="iox-field__control"
|
|
3905
4197
|
[id]="controlId"
|
|
3906
|
-
[name]="
|
|
4198
|
+
[name]="fieldName"
|
|
3907
4199
|
[type]="inputType"
|
|
3908
4200
|
[attr.placeholder]="placeholder || null"
|
|
3909
4201
|
[attr.required]="required ? '' : null"
|
|
@@ -3927,10 +4219,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3927
4219
|
'[class]': "'iox-field iox-node-' + nodeId",
|
|
3928
4220
|
'[class.iox-invalid]': 'invalid',
|
|
3929
4221
|
'[class.iox-valid]': 'showValid',
|
|
3930
|
-
'[attr.data-iox-field]': '
|
|
4222
|
+
'[attr.data-iox-field]': 'fieldName || null',
|
|
3931
4223
|
'[hidden]': "control === 'hidden' ? true : null",
|
|
3932
4224
|
}, standalone: false, styles: [":host{box-sizing:border-box;display:block}.iox-field__label--hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}\n"] }]
|
|
3933
|
-
}],
|
|
4225
|
+
}], ctorParameters: () => [{ type: FormRuntimeService, decorators: [{
|
|
4226
|
+
type: Optional
|
|
4227
|
+
}] }, { type: undefined, decorators: [{
|
|
4228
|
+
type: Inject,
|
|
4229
|
+
args: [IOX_FORM_MESSAGES]
|
|
4230
|
+
}] }], propDecorators: { nodeId: [{
|
|
3934
4231
|
type: Input
|
|
3935
4232
|
}], name: [{
|
|
3936
4233
|
type: Input
|
|
@@ -3958,9 +4255,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
|
|
|
3958
4255
|
type: Input
|
|
3959
4256
|
}], value: [{
|
|
3960
4257
|
type: Input
|
|
3961
|
-
}],
|
|
4258
|
+
}], rules: [{
|
|
4259
|
+
type: Input
|
|
4260
|
+
}], forceInvalid: [{
|
|
3962
4261
|
type: Input
|
|
3963
|
-
}],
|
|
4262
|
+
}], designErrorText: [{
|
|
3964
4263
|
type: Input
|
|
3965
4264
|
}], showValid: [{
|
|
3966
4265
|
type: Input
|