@vectoriox/iox-ui 4.26.0 → 4.28.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.
@@ -3123,6 +3123,976 @@ 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
+ // The persisted shape of a form. Pure types + the one derived constant each renderer needs.
3199
+ //
3200
+ // A FormSpec is DERIVED from the node tree (see `collect.ts`), never hand-maintained beside it —
3201
+ // there is one source of truth for "what fields does this form have", and it is the tree the author
3202
+ // edited. The builder writes the derived spec into the saved page so the SERVER can re-validate
3203
+ // against it without re-implementing the builder's tree walk.
3204
+ /** What the visitor actually types into. Chosen by a trait; it also implies a validation rule. */
3205
+ var ControlType;
3206
+ (function (ControlType) {
3207
+ ControlType["Text"] = "text";
3208
+ ControlType["Email"] = "email";
3209
+ ControlType["Tel"] = "tel";
3210
+ ControlType["Url"] = "url";
3211
+ ControlType["Number"] = "number";
3212
+ ControlType["Password"] = "password";
3213
+ ControlType["Textarea"] = "textarea";
3214
+ ControlType["Select"] = "select";
3215
+ ControlType["Checkbox"] = "checkbox";
3216
+ ControlType["Radio"] = "radio";
3217
+ ControlType["Date"] = "date";
3218
+ ControlType["Time"] = "time";
3219
+ ControlType["File"] = "file";
3220
+ /** Not shown; carries a constant or a bound value (campaign, source page, …). */
3221
+ ControlType["Hidden"] = "hidden";
3222
+ })(ControlType || (ControlType = {}));
3223
+ /** The v1 rule grammar. Phase 3's conditional logic reuses these same names for its conditions. */
3224
+ var RuleName;
3225
+ (function (RuleName) {
3226
+ RuleName["Required"] = "required";
3227
+ RuleName["MinLength"] = "minLength";
3228
+ RuleName["MaxLength"] = "maxLength";
3229
+ RuleName["Min"] = "min";
3230
+ RuleName["Max"] = "max";
3231
+ RuleName["Pattern"] = "pattern";
3232
+ /** Cross-field: this value must equal another field's (confirm email / password). */
3233
+ RuleName["Matches"] = "matches";
3234
+ /** A checkbox that must be ticked — terms, consent. */
3235
+ RuleName["Accepted"] = "accepted";
3236
+ RuleName["FileType"] = "fileType";
3237
+ RuleName["FileSize"] = "fileSize";
3238
+ })(RuleName || (RuleName = {}));
3239
+ /** Named regexes so an author picks "Email" rather than writing one. */
3240
+ var PatternPreset;
3241
+ (function (PatternPreset) {
3242
+ PatternPreset["Email"] = "email";
3243
+ PatternPreset["Phone"] = "phone";
3244
+ PatternPreset["Url"] = "url";
3245
+ PatternPreset["Digits"] = "digits";
3246
+ PatternPreset["Letters"] = "letters";
3247
+ PatternPreset["Slug"] = "slug";
3248
+ })(PatternPreset || (PatternPreset = {}));
3249
+ /**
3250
+ * Deliberately permissive. A validator that rejects `user+tag@sub.domain.co.uk` or a real-world
3251
+ * phone number is worse than one that lets a typo through: the address gets verified by sending to
3252
+ * it, and a false rejection is a lost lead with no recourse.
3253
+ */
3254
+ const PATTERN_PRESETS = Object.freeze({
3255
+ [PatternPreset.Email]: /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/,
3256
+ [PatternPreset.Phone]: /^\+?[\d\s().-]{6,20}$/,
3257
+ [PatternPreset.Url]: /^(https?:\/\/)?[^\s.]+\.[^\s]{2,}$/i,
3258
+ [PatternPreset.Digits]: /^\d+$/,
3259
+ [PatternPreset.Letters]: /^[\p{L}\s'-]+$/u,
3260
+ [PatternPreset.Slug]: /^[a-z0-9]+(-[a-z0-9]+)*$/,
3261
+ });
3262
+ /** When a field is validated, once the author has stopped deciding it per field. */
3263
+ var FeedbackTiming;
3264
+ (function (FeedbackTiming) {
3265
+ /** Validate on submit, then live per-field once that field has been touched or has failed. */
3266
+ FeedbackTiming["Smart"] = "smart";
3267
+ FeedbackTiming["Submit"] = "submit";
3268
+ FeedbackTiming["Live"] = "live";
3269
+ })(FeedbackTiming || (FeedbackTiming = {}));
3270
+ /**
3271
+ * The rule a control type implies with no configuration. Choosing "Email" IS choosing email
3272
+ * validation — the single biggest reason the panel can stay empty and still validate well.
3273
+ */
3274
+ const IMPLIED_PATTERN = Object.freeze({
3275
+ [ControlType.Email]: PatternPreset.Email,
3276
+ [ControlType.Tel]: PatternPreset.Phone,
3277
+ [ControlType.Url]: PatternPreset.Url,
3278
+ });
3279
+ /** Controls whose value is a list rather than a scalar. */
3280
+ const MULTI_VALUE_CONTROLS = new Set([ControlType.File]);
3281
+
3282
+ // The default message for every rule, as an i18n KEY — never a rendered string.
3283
+ //
3284
+ // This is what lets the Validation panel stay empty and still produce good errors: an author who
3285
+ // configures nothing gets "Enter a valid email address", not "Invalid". The author's own `message`
3286
+ // on a rule always wins; this is the fallback, and it is the only place a default lives.
3287
+ //
3288
+ // Consumers translate the key with their own i18n (the CMS client via ngx-translate, the engine via
3289
+ // the page's locale bundle). The backend returns the key too, so a server-side rejection renders in
3290
+ // the visitor's language rather than in the server's.
3291
+ /** Namespace for every forms validation key. Keep in sync with `en.json` / `he.json`. */
3292
+ const MESSAGE_NAMESPACE = 'forms.validation';
3293
+ /**
3294
+ * Rule → i18n key. Params travel beside the key (`{ n: 8 }`, `{ field: 'Email' }`) so a translation
3295
+ * can place the number wherever its grammar needs it, rather than having it baked into the string.
3296
+ */
3297
+ const RULE_MESSAGE_KEYS = Object.freeze({
3298
+ [RuleName.Required]: `${MESSAGE_NAMESPACE}.required`,
3299
+ [RuleName.MinLength]: `${MESSAGE_NAMESPACE}.minLength`,
3300
+ [RuleName.MaxLength]: `${MESSAGE_NAMESPACE}.maxLength`,
3301
+ [RuleName.Min]: `${MESSAGE_NAMESPACE}.min`,
3302
+ [RuleName.Max]: `${MESSAGE_NAMESPACE}.max`,
3303
+ [RuleName.Pattern]: `${MESSAGE_NAMESPACE}.pattern`,
3304
+ [RuleName.Matches]: `${MESSAGE_NAMESPACE}.matches`,
3305
+ [RuleName.Accepted]: `${MESSAGE_NAMESPACE}.accepted`,
3306
+ [RuleName.FileType]: `${MESSAGE_NAMESPACE}.fileType`,
3307
+ [RuleName.FileSize]: `${MESSAGE_NAMESPACE}.fileSize`,
3308
+ });
3309
+ /**
3310
+ * A pattern failure deserves better than "Invalid format". Each preset gets its own key so the
3311
+ * message can say what a valid value looks like, which is the difference between a visitor fixing
3312
+ * a typo and a visitor giving up.
3313
+ */
3314
+ const PATTERN_MESSAGE_KEYS = Object.freeze({
3315
+ email: `${MESSAGE_NAMESPACE}.pattern.email`,
3316
+ phone: `${MESSAGE_NAMESPACE}.pattern.phone`,
3317
+ url: `${MESSAGE_NAMESPACE}.pattern.url`,
3318
+ digits: `${MESSAGE_NAMESPACE}.pattern.digits`,
3319
+ letters: `${MESSAGE_NAMESPACE}.pattern.letters`,
3320
+ slug: `${MESSAGE_NAMESPACE}.pattern.slug`,
3321
+ });
3322
+ /** Form-level outcomes the visitor sees in the Error state. One designed block, five reasons. */
3323
+ const FORM_ERROR_KEYS = Object.freeze({
3324
+ Network: `${MESSAGE_NAMESPACE}.form.network`,
3325
+ Rejected: `${MESSAGE_NAMESPACE}.form.rejected`,
3326
+ Spam: `${MESSAGE_NAMESPACE}.form.spam`,
3327
+ RateLimited: `${MESSAGE_NAMESPACE}.form.rateLimited`,
3328
+ Server: `${MESSAGE_NAMESPACE}.form.server`,
3329
+ });
3330
+ /** The English defaults, so a consumer that has not added the keys yet still shows real text. */
3331
+ const DEFAULT_EN_MESSAGES = Object.freeze({
3332
+ [RULE_MESSAGE_KEYS[RuleName.Required]]: 'This field is required',
3333
+ [RULE_MESSAGE_KEYS[RuleName.MinLength]]: 'Use at least {{n}} characters',
3334
+ [RULE_MESSAGE_KEYS[RuleName.MaxLength]]: 'Use no more than {{n}} characters',
3335
+ [RULE_MESSAGE_KEYS[RuleName.Min]]: 'Enter {{n}} or more',
3336
+ [RULE_MESSAGE_KEYS[RuleName.Max]]: 'Enter {{n}} or less',
3337
+ [RULE_MESSAGE_KEYS[RuleName.Pattern]]: 'Check the format of this value',
3338
+ [RULE_MESSAGE_KEYS[RuleName.Matches]]: 'This must match {{field}}',
3339
+ [RULE_MESSAGE_KEYS[RuleName.Accepted]]: 'Please tick this to continue',
3340
+ [RULE_MESSAGE_KEYS[RuleName.FileType]]: 'That file type isn’t accepted',
3341
+ [RULE_MESSAGE_KEYS[RuleName.FileSize]]: 'That file is larger than {{n}}MB',
3342
+ [PATTERN_MESSAGE_KEYS['email']]: 'Enter a valid email address',
3343
+ [PATTERN_MESSAGE_KEYS['phone']]: 'Enter a valid phone number',
3344
+ [PATTERN_MESSAGE_KEYS['url']]: 'Enter a valid web address',
3345
+ [PATTERN_MESSAGE_KEYS['digits']]: 'Use digits only',
3346
+ [PATTERN_MESSAGE_KEYS['letters']]: 'Use letters only',
3347
+ [PATTERN_MESSAGE_KEYS['slug']]: 'Use lowercase letters, numbers and hyphens',
3348
+ [FORM_ERROR_KEYS.Network]: 'We couldn’t reach the server — check your connection and try again',
3349
+ [FORM_ERROR_KEYS.Rejected]: 'Some answers need fixing',
3350
+ [FORM_ERROR_KEYS.Spam]: 'We couldn’t accept this submission',
3351
+ [FORM_ERROR_KEYS.RateLimited]: 'Too many attempts — please wait a moment and try again',
3352
+ [FORM_ERROR_KEYS.Server]: 'Something went wrong on our side — your answers are still here',
3353
+ });
3354
+
3355
+ // The validator. ONE implementation, run by the builder canvas, the SSR engine and `iox-cms`.
3356
+ //
3357
+ // Client-side validation is UX; server-side validation is truth. Because both call this function,
3358
+ // a visitor can never be told a value is fine and then have the server reject it, and a hand-crafted
3359
+ // POST can never bypass a rule the author configured.
3360
+ /**
3361
+ * Validate one field's value.
3362
+ *
3363
+ * Order is deliberate and is part of the contract:
3364
+ * 1. `required` (or `accepted` for a checkbox) — the only rules an EMPTY value can fail.
3365
+ * 2. Everything else, in the order the author arranged them, but ONLY for a non-empty value.
3366
+ *
3367
+ * That second point is what stops an optional "Phone" field from shouting "enter a valid phone
3368
+ * number" at someone who simply left it blank — the single most common validation-UX bug.
3369
+ * The FIRST failure wins: a visitor fixes one thing at a time.
3370
+ */
3371
+ function validateField(value, spec, form = {}) {
3372
+ const rules = effectiveRules(spec);
3373
+ const empty = isEmpty(value, spec.control);
3374
+ for (const rule of rules) {
3375
+ const appliesToEmpty = rule.name === RuleName.Required || rule.name === RuleName.Accepted;
3376
+ if (empty && !appliesToEmpty)
3377
+ continue;
3378
+ const failure = check(rule, value, spec, form);
3379
+ if (failure) {
3380
+ return {
3381
+ valid: false,
3382
+ messageKey: rule.message ?? failure.messageKey,
3383
+ params: failure.params,
3384
+ };
3385
+ }
3386
+ }
3387
+ return { valid: true };
3388
+ }
3389
+ /**
3390
+ * Validate a whole submission. Every field is checked (not short-circuited at the first bad one) so
3391
+ * the visitor sees every problem at once instead of fixing them one reload at a time.
3392
+ */
3393
+ function validateForm(values, spec) {
3394
+ const errors = {};
3395
+ for (const field of spec.fields ?? []) {
3396
+ const result = validateField(values?.[field.name], field, values ?? {});
3397
+ if (!result.valid) {
3398
+ errors[field.name] = { messageKey: result.messageKey, params: result.params };
3399
+ }
3400
+ }
3401
+ return { valid: Object.keys(errors).length === 0, errors };
3402
+ }
3403
+ /** The name of the first field with an error, in spec order — what the browser should focus. */
3404
+ function firstInvalidField(spec, errors) {
3405
+ for (const field of spec.fields ?? [])
3406
+ if (errors[field.name])
3407
+ return field.name;
3408
+ return null;
3409
+ }
3410
+ // ─── internals ───────────────────────────────────────────────────────────────
3411
+ /**
3412
+ * The author's rules, plus the ones the field's own configuration implies: `required` from the
3413
+ * switch (rather than making the author add a rule for the commonest case) and the pattern implied
3414
+ * by an Email/Tel/Url control. An explicitly declared rule of the same name always wins, so an
3415
+ * author who sets their own `pattern` on an Email field is not fighting an invisible second one.
3416
+ */
3417
+ function effectiveRules(spec) {
3418
+ const declared = spec.rules ?? [];
3419
+ const has = (name) => declared.some(r => r.name === name);
3420
+ const implied = [];
3421
+ if (spec.required && !has(RuleName.Required)) {
3422
+ // A checkbox's "required" means TICKED, which is a different message and a different check.
3423
+ implied.push(spec.control === ControlType.Checkbox
3424
+ ? { name: RuleName.Accepted }
3425
+ : { name: RuleName.Required });
3426
+ }
3427
+ const preset = IMPLIED_PATTERN[spec.control];
3428
+ if (preset && !has(RuleName.Pattern)) {
3429
+ implied.push({ name: RuleName.Pattern, params: { preset } });
3430
+ }
3431
+ return [...implied, ...declared];
3432
+ }
3433
+ /** Empty means "the visitor gave us nothing" — per control type, not a blanket falsy check. */
3434
+ function isEmpty(value, control) {
3435
+ if (value === null || value === undefined)
3436
+ return true;
3437
+ if (control === ControlType.Checkbox)
3438
+ return value === false;
3439
+ if (Array.isArray(value))
3440
+ return value.length === 0;
3441
+ if (typeof value === 'string')
3442
+ return value.trim() === '';
3443
+ return false;
3444
+ }
3445
+ function check(rule, value, spec, form) {
3446
+ const fail = (messageKey, params) => ({ messageKey, params });
3447
+ const defaultKey = RULE_MESSAGE_KEYS[rule.name];
3448
+ const n = Number(rule.params?.['n']);
3449
+ switch (rule.name) {
3450
+ case RuleName.Required:
3451
+ return isEmpty(value, spec.control) ? fail(defaultKey) : null;
3452
+ case RuleName.Accepted:
3453
+ return value === true ? null : fail(defaultKey);
3454
+ case RuleName.MinLength:
3455
+ return text(value).length < n ? fail(defaultKey, { n }) : null;
3456
+ case RuleName.MaxLength:
3457
+ return text(value).length > n ? fail(defaultKey, { n }) : null;
3458
+ case RuleName.Min: {
3459
+ const num = numeric(value);
3460
+ return num === null || num < n ? fail(defaultKey, { n }) : null;
3461
+ }
3462
+ case RuleName.Max: {
3463
+ const num = numeric(value);
3464
+ return num === null || num > n ? fail(defaultKey, { n }) : null;
3465
+ }
3466
+ case RuleName.Pattern: {
3467
+ const regex = patternFor(rule);
3468
+ if (!regex)
3469
+ return null; // an unparsable custom regex must not block a submission
3470
+ if (regex.test(text(value)))
3471
+ return null;
3472
+ const preset = rule.params?.['preset'];
3473
+ return fail((preset && PATTERN_MESSAGE_KEYS[preset]) || defaultKey);
3474
+ }
3475
+ case RuleName.Matches: {
3476
+ const other = String(rule.params?.['field'] ?? '');
3477
+ return text(value) === text(form?.[other]) ? null : fail(defaultKey, { field: other });
3478
+ }
3479
+ case RuleName.FileType: {
3480
+ const accepted = list(rule.params?.['types']).map(t => t.toLowerCase().replace(/^\./, ''));
3481
+ if (!accepted.length)
3482
+ return null;
3483
+ const bad = files(value).some(f => {
3484
+ const ext = (f.name ?? '').split('.').pop()?.toLowerCase() ?? '';
3485
+ const mime = (f.type ?? '').toLowerCase();
3486
+ return !accepted.some(t => t === ext || mime === t || mime.startsWith(t + '/'));
3487
+ });
3488
+ return bad ? fail(defaultKey, { types: accepted.join(', ') }) : null;
3489
+ }
3490
+ case RuleName.FileSize: {
3491
+ const maxBytes = n * 1024 * 1024;
3492
+ return files(value).some(f => (f.size ?? 0) > maxBytes) ? fail(defaultKey, { n }) : null;
3493
+ }
3494
+ default:
3495
+ return null;
3496
+ }
3497
+ }
3498
+ function patternFor(rule) {
3499
+ const preset = rule.params?.['preset'];
3500
+ if (preset && PATTERN_PRESETS[preset])
3501
+ return PATTERN_PRESETS[preset];
3502
+ const custom = rule.params?.['regex'];
3503
+ if (typeof custom !== 'string' || !custom)
3504
+ return null;
3505
+ // A malformed regex is the AUTHOR's mistake; failing open is the only safe reading, because
3506
+ // failing closed would make a live form permanently unsubmittable for every visitor.
3507
+ try {
3508
+ return new RegExp(custom);
3509
+ }
3510
+ catch {
3511
+ return null;
3512
+ }
3513
+ }
3514
+ const text = (value) => value === null || value === undefined ? '' : String(value).trim();
3515
+ function numeric(value) {
3516
+ const parsed = typeof value === 'number' ? value : parseFloat(text(value));
3517
+ return Number.isFinite(parsed) ? parsed : null;
3518
+ }
3519
+ const list = (value) => Array.isArray(value) ? value.map(String)
3520
+ : typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean)
3521
+ : [];
3522
+ const files = (value) => Array.isArray(value) ? value
3523
+ : value && typeof value === 'object' ? [value]
3524
+ : [];
3525
+
3526
+ // Derive a FormSpec from a node tree.
3527
+ //
3528
+ // The tree the author edited is the SINGLE source of truth for "what fields does this form have".
3529
+ // The spec is derived from it — never maintained beside it — so a field cannot exist in one and not
3530
+ // the other. The builder runs this on save and stores the result in the page document; the backend
3531
+ // reads THAT stored spec to re-validate a submission (it must never trust a spec sent by the
3532
+ // browser). See iox-ai-guidance `architecture/builder/forms-and-inputs-plan.md` §5.1 and D11.
3533
+ const FORM_NODE_TYPE = 'Form';
3534
+ const FIELD_NODE_TYPE = 'Field';
3535
+ /** Always present, never removable — the safety net that makes "store first, deliver after" true. */
3536
+ const STORE_ACTION = Object.freeze({ type: 'store' });
3537
+ /**
3538
+ * Turn a label into the key that arrives in the submitted data — `Full name` → `fullName`.
3539
+ * Authors never have to invent keys (decision: auto-derive, with an editable override).
3540
+ *
3541
+ * Diacritics are stripped rather than dropped, so a Hebrew or accented label still yields a usable
3542
+ * ASCII key instead of an empty one; a label with no usable characters falls back to `field`.
3543
+ */
3544
+ function deriveFieldName(label) {
3545
+ const words = (label ?? '')
3546
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
3547
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
3548
+ .trim()
3549
+ .split(/\s+/)
3550
+ .filter(Boolean);
3551
+ if (!words.length)
3552
+ return 'field';
3553
+ const [first, ...rest] = words;
3554
+ const name = first.toLowerCase() + rest.map(w => w[0].toUpperCase() + w.slice(1).toLowerCase()).join('');
3555
+ // A key must not start with a digit — it ends up as an object key and a form control id.
3556
+ return /^\d/.test(name) ? `field${name}` : name;
3557
+ }
3558
+ /** Make `name` unique within `taken`, appending 2, 3, … — two "Email" fields must not collide. */
3559
+ function uniqueFieldName(name, taken) {
3560
+ if (!taken.has(name))
3561
+ return name;
3562
+ let n = 2;
3563
+ while (taken.has(`${name}${n}`))
3564
+ n++;
3565
+ return `${name}${n}`;
3566
+ }
3567
+ /**
3568
+ * Walk a Form subtree and produce its spec.
3569
+ *
3570
+ * Nested forms are not collected: a Field belongs to its NEAREST ancestor Form (decision D1), so the
3571
+ * walk stops at any inner Form node rather than stealing its fields.
3572
+ */
3573
+ function collectFormSpec(node) {
3574
+ if (!node || node.type !== FORM_NODE_TYPE)
3575
+ return null;
3576
+ const props = node.props ?? {};
3577
+ const fields = [];
3578
+ const taken = new Set();
3579
+ for (const fieldNode of collectFieldNodes(node.children ?? [])) {
3580
+ const spec = toFieldSpec(fieldNode, taken);
3581
+ taken.add(spec.name);
3582
+ fields.push(spec);
3583
+ }
3584
+ return {
3585
+ formId: props['formId'] ?? '',
3586
+ name: props['name'] ?? 'Form',
3587
+ feedback: props['feedback'] ?? FeedbackTiming.Smart,
3588
+ fields,
3589
+ actions: normaliseActions(props['actions']),
3590
+ antiSpam: {
3591
+ honeypot: props['honeypot'] !== false,
3592
+ minFillMs: Number(props['minFillMs'] ?? 1200),
3593
+ ...(props['turnstile'] ? { turnstile: true } : {}),
3594
+ },
3595
+ ...(props['retentionDays'] ? { retention: { days: Number(props['retentionDays']) } } : {}),
3596
+ };
3597
+ }
3598
+ /** Every Field in this subtree, in document order, not descending into a nested Form. */
3599
+ function collectFieldNodes(nodes) {
3600
+ const out = [];
3601
+ for (const node of nodes ?? []) {
3602
+ if (!node?.type)
3603
+ continue;
3604
+ if (node.type === FORM_NODE_TYPE)
3605
+ continue; // a nested form owns its own fields
3606
+ if (node.type === FIELD_NODE_TYPE) {
3607
+ out.push(node);
3608
+ continue;
3609
+ }
3610
+ out.push(...collectFieldNodes(node.children ?? []));
3611
+ }
3612
+ return out;
3613
+ }
3614
+ function toFieldSpec(node, taken) {
3615
+ const props = node.props ?? {};
3616
+ const label = props['label'] ?? '';
3617
+ // The author's explicit override wins; otherwise derive from the label.
3618
+ const name = uniqueFieldName(props['name'] || deriveFieldName(label), taken);
3619
+ const spec = {
3620
+ name,
3621
+ control: props['control'] ?? ControlType.Text,
3622
+ label,
3623
+ };
3624
+ if (props['required'])
3625
+ spec.required = true;
3626
+ const rules = normaliseRules(props['rules']);
3627
+ if (rules.length)
3628
+ spec.rules = rules;
3629
+ if (Array.isArray(props['options']) && props['options'].length)
3630
+ spec.options = props['options'];
3631
+ if (props['optionsSource'])
3632
+ spec.optionsSource = props['optionsSource'];
3633
+ if (props['defaultValue'] !== undefined && props['defaultValue'] !== '') {
3634
+ spec.defaultValue = props['defaultValue'];
3635
+ }
3636
+ return spec;
3637
+ }
3638
+ /** Drop anything that isn't a rule we know — a hand-edited page must not break validation. */
3639
+ function normaliseRules(raw) {
3640
+ if (!Array.isArray(raw))
3641
+ return [];
3642
+ const known = new Set(Object.values(RuleName));
3643
+ return raw
3644
+ .filter(r => r && known.has(r.name))
3645
+ .map(r => ({
3646
+ name: r.name,
3647
+ ...(r.params ? { params: r.params } : {}),
3648
+ ...(r.message ? { message: r.message } : {}),
3649
+ }));
3650
+ }
3651
+ /** `store` is always first and always present, however the page was edited. */
3652
+ function normaliseActions(raw) {
3653
+ const declared = Array.isArray(raw)
3654
+ ? raw.filter(a => a && typeof a.type === 'string')
3655
+ : [];
3656
+ return [STORE_ACTION, ...declared.filter(a => a.type !== STORE_ACTION.type)];
3657
+ }
3658
+
3659
+ /*
3660
+ * @vectoriox/iox-ui/forms-core — the shared, FRAMEWORK-FREE core of the forms feature.
3661
+ *
3662
+ * ⚠️ Nothing in this entry point may import Angular, RxJS, the DOM, or Node. It is consumed by
3663
+ * THREE runtimes: the builder canvas (Angular), the SSR engine (Angular), and `iox-cms` (NestJS,
3664
+ * plain Node). The main `@vectoriox/iox-ui` entry pulls in `@angular/core`, which Nest must never
3665
+ * load — that is the entire reason this secondary entry point exists.
3666
+ *
3667
+ * Client-side validation is UX; server-side validation is truth. Both run THIS code, so they cannot
3668
+ * disagree. See iox-ai-guidance `architecture/builder/forms-and-inputs-plan.md` §5.
3669
+ */
3670
+
3671
+ let fieldSeq = 0;
3672
+ /**
3673
+ * The compound Field: label + control + help + error + required mark, as ONE node (decision D2).
3674
+ *
3675
+ * This is the biggest divergence from Webflow, and it is deliberate. There, a form field is four
3676
+ * loose elements an author can delete — and they do delete the label, which breaks the
3677
+ * `for`/`id` pairing and with it screen-reader support. Here the structure is fixed and
3678
+ * accessibility is guaranteed by construction: the label can be visually hidden, never removed.
3679
+ *
3680
+ * The PARTS are styled through the virtual-trait mechanism (`__labelStyles`, `__errorStyles`, …)
3681
+ * that already compiles `::marker` rules for List — so per-part styling invents no new concept in
3682
+ * the style engine. Each part carries a stable class the compiler targets.
3683
+ *
3684
+ * ⚠️ Control type is a TRAIT, not a component per type (decision D4) — one component, one config,
3685
+ * and choosing `email` also chooses email validation.
3686
+ */
3687
+ class IoxFieldComponent {
3688
+ constructor() {
3689
+ this.nodeId = '';
3690
+ this.name = '';
3691
+ this.label = '';
3692
+ this.control = ControlType.Text;
3693
+ this.placeholder = '';
3694
+ this.help = '';
3695
+ this.required = false;
3696
+ this.disabled = false;
3697
+ this.labelHidden = false;
3698
+ this.requiredMark = '*';
3699
+ this.rows = 4;
3700
+ this.accept = '';
3701
+ this.options = [];
3702
+ this.value = '';
3703
+ /** The resolved, already-translated message. Resolving the key is the host's job, not ours. */
3704
+ this.errorText = '';
3705
+ /** Design-time or runtime failure. The style compiler targets `.iox-invalid`. */
3706
+ this.invalid = false;
3707
+ /** Only after a successful check — never on an untouched field. */
3708
+ this.showValid = false;
3709
+ this.valueChange = new EventEmitter();
3710
+ this.touched = new EventEmitter();
3711
+ /** Stable per instance so `for`/`id` and `aria-describedby` always pair up. */
3712
+ this.controlId = `iox-field-${++fieldSeq}`;
3713
+ }
3714
+ /** `file`, `date`, `number`… map straight through; everything else is a text input. */
3715
+ get inputType() {
3716
+ const passthrough = ['email', 'tel', 'url', 'number', 'password', 'date', 'time', 'file', 'hidden'];
3717
+ return passthrough.includes(String(this.control)) ? String(this.control) : 'text';
3718
+ }
3719
+ /** Help and error are BOTH announced, in that order, when both are present. */
3720
+ get describedBy() {
3721
+ const ids = [];
3722
+ if (this.help)
3723
+ ids.push(`${this.controlId}-help`);
3724
+ if (this.invalid && this.errorText)
3725
+ ids.push(`${this.controlId}-error`);
3726
+ return ids.length ? ids.join(' ') : null;
3727
+ }
3728
+ emit(value) {
3729
+ this.value = value;
3730
+ this.valueChange.emit(value);
3731
+ }
3732
+ touch() {
3733
+ this.touched.emit();
3734
+ }
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", errorText: "errorText", invalid: "invalid", 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": "name || null", "hidden": "control === 'hidden' ? true : null" } }, ngImport: i0, template: `
3737
+ <label class="iox-field__label"
3738
+ [class.iox-field__label--hidden]="labelHidden"
3739
+ [attr.for]="controlId">
3740
+ {{ label }}
3741
+ @if (required) {
3742
+ <span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>
3743
+ }
3744
+ </label>
3745
+
3746
+ @switch (control) {
3747
+ @case ('textarea') {
3748
+ <textarea class="iox-field__control"
3749
+ [id]="controlId"
3750
+ [name]="name"
3751
+ [rows]="rows"
3752
+ [attr.placeholder]="placeholder || null"
3753
+ [attr.required]="required ? '' : null"
3754
+ [attr.aria-invalid]="invalid ? 'true' : null"
3755
+ [attr.aria-describedby]="describedBy"
3756
+ [attr.disabled]="disabled ? '' : null"
3757
+ [value]="value ?? ''"
3758
+ (input)="emit($any($event.target).value)"
3759
+ (blur)="touch()"></textarea>
3760
+ }
3761
+ @case ('select') {
3762
+ <select class="iox-field__control"
3763
+ [id]="controlId"
3764
+ [name]="name"
3765
+ [attr.required]="required ? '' : null"
3766
+ [attr.aria-invalid]="invalid ? 'true' : null"
3767
+ [attr.aria-describedby]="describedBy"
3768
+ [attr.disabled]="disabled ? '' : null"
3769
+ (change)="emit($any($event.target).value)"
3770
+ (blur)="touch()">
3771
+ <option value="" [selected]="!value">{{ placeholder || '' }}</option>
3772
+ @for (option of options; track option.value) {
3773
+ <option [value]="option.value" [selected]="option.value === value">{{ option.label }}</option>
3774
+ }
3775
+ </select>
3776
+ }
3777
+ @case ('checkbox') {
3778
+ <input class="iox-field__control iox-field__control--checkbox"
3779
+ type="checkbox"
3780
+ [id]="controlId"
3781
+ [name]="name"
3782
+ [attr.required]="required ? '' : null"
3783
+ [attr.aria-invalid]="invalid ? 'true' : null"
3784
+ [attr.aria-describedby]="describedBy"
3785
+ [attr.disabled]="disabled ? '' : null"
3786
+ [checked]="value === true"
3787
+ (change)="emit($any($event.target).checked)"
3788
+ (blur)="touch()">
3789
+ }
3790
+ @case ('radio') {
3791
+ <div class="iox-field__group" role="radiogroup" [attr.aria-labelledby]="controlId + '-label'">
3792
+ @for (option of options; track option.value) {
3793
+ <label class="iox-field__option">
3794
+ <input type="radio"
3795
+ [name]="name"
3796
+ [value]="option.value"
3797
+ [checked]="option.value === value"
3798
+ [attr.disabled]="disabled ? '' : null"
3799
+ (change)="emit(option.value)"
3800
+ (blur)="touch()">
3801
+ <span>{{ option.label }}</span>
3802
+ </label>
3803
+ }
3804
+ </div>
3805
+ }
3806
+ @default {
3807
+ <input class="iox-field__control"
3808
+ [id]="controlId"
3809
+ [name]="name"
3810
+ [type]="inputType"
3811
+ [attr.placeholder]="placeholder || null"
3812
+ [attr.required]="required ? '' : null"
3813
+ [attr.aria-invalid]="invalid ? 'true' : null"
3814
+ [attr.aria-describedby]="describedBy"
3815
+ [attr.disabled]="disabled ? '' : null"
3816
+ [attr.accept]="control === 'file' ? (accept || null) : null"
3817
+ [value]="control === 'file' ? '' : (value ?? '')"
3818
+ (input)="emit($any($event.target).value)"
3819
+ (blur)="touch()">
3820
+ }
3821
+ }
3822
+
3823
+ @if (help) {
3824
+ <p class="iox-field__help" [id]="controlId + '-help'">{{ help }}</p>
3825
+ }
3826
+ @if (invalid && errorText) {
3827
+ <p class="iox-field__error" [id]="controlId + '-error'" role="alert">{{ errorText }}</p>
3828
+ }
3829
+ `, isInline: true, 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"] }); }
3830
+ }
3831
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFieldComponent, decorators: [{
3832
+ type: Component,
3833
+ args: [{ selector: 'div[iox-field]', template: `
3834
+ <label class="iox-field__label"
3835
+ [class.iox-field__label--hidden]="labelHidden"
3836
+ [attr.for]="controlId">
3837
+ {{ label }}
3838
+ @if (required) {
3839
+ <span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>
3840
+ }
3841
+ </label>
3842
+
3843
+ @switch (control) {
3844
+ @case ('textarea') {
3845
+ <textarea class="iox-field__control"
3846
+ [id]="controlId"
3847
+ [name]="name"
3848
+ [rows]="rows"
3849
+ [attr.placeholder]="placeholder || null"
3850
+ [attr.required]="required ? '' : null"
3851
+ [attr.aria-invalid]="invalid ? 'true' : null"
3852
+ [attr.aria-describedby]="describedBy"
3853
+ [attr.disabled]="disabled ? '' : null"
3854
+ [value]="value ?? ''"
3855
+ (input)="emit($any($event.target).value)"
3856
+ (blur)="touch()"></textarea>
3857
+ }
3858
+ @case ('select') {
3859
+ <select class="iox-field__control"
3860
+ [id]="controlId"
3861
+ [name]="name"
3862
+ [attr.required]="required ? '' : null"
3863
+ [attr.aria-invalid]="invalid ? 'true' : null"
3864
+ [attr.aria-describedby]="describedBy"
3865
+ [attr.disabled]="disabled ? '' : null"
3866
+ (change)="emit($any($event.target).value)"
3867
+ (blur)="touch()">
3868
+ <option value="" [selected]="!value">{{ placeholder || '' }}</option>
3869
+ @for (option of options; track option.value) {
3870
+ <option [value]="option.value" [selected]="option.value === value">{{ option.label }}</option>
3871
+ }
3872
+ </select>
3873
+ }
3874
+ @case ('checkbox') {
3875
+ <input class="iox-field__control iox-field__control--checkbox"
3876
+ type="checkbox"
3877
+ [id]="controlId"
3878
+ [name]="name"
3879
+ [attr.required]="required ? '' : null"
3880
+ [attr.aria-invalid]="invalid ? 'true' : null"
3881
+ [attr.aria-describedby]="describedBy"
3882
+ [attr.disabled]="disabled ? '' : null"
3883
+ [checked]="value === true"
3884
+ (change)="emit($any($event.target).checked)"
3885
+ (blur)="touch()">
3886
+ }
3887
+ @case ('radio') {
3888
+ <div class="iox-field__group" role="radiogroup" [attr.aria-labelledby]="controlId + '-label'">
3889
+ @for (option of options; track option.value) {
3890
+ <label class="iox-field__option">
3891
+ <input type="radio"
3892
+ [name]="name"
3893
+ [value]="option.value"
3894
+ [checked]="option.value === value"
3895
+ [attr.disabled]="disabled ? '' : null"
3896
+ (change)="emit(option.value)"
3897
+ (blur)="touch()">
3898
+ <span>{{ option.label }}</span>
3899
+ </label>
3900
+ }
3901
+ </div>
3902
+ }
3903
+ @default {
3904
+ <input class="iox-field__control"
3905
+ [id]="controlId"
3906
+ [name]="name"
3907
+ [type]="inputType"
3908
+ [attr.placeholder]="placeholder || null"
3909
+ [attr.required]="required ? '' : null"
3910
+ [attr.aria-invalid]="invalid ? 'true' : null"
3911
+ [attr.aria-describedby]="describedBy"
3912
+ [attr.disabled]="disabled ? '' : null"
3913
+ [attr.accept]="control === 'file' ? (accept || null) : null"
3914
+ [value]="control === 'file' ? '' : (value ?? '')"
3915
+ (input)="emit($any($event.target).value)"
3916
+ (blur)="touch()">
3917
+ }
3918
+ }
3919
+
3920
+ @if (help) {
3921
+ <p class="iox-field__help" [id]="controlId + '-help'">{{ help }}</p>
3922
+ }
3923
+ @if (invalid && errorText) {
3924
+ <p class="iox-field__error" [id]="controlId + '-error'" role="alert">{{ errorText }}</p>
3925
+ }
3926
+ `, host: {
3927
+ '[class]': "'iox-field iox-node-' + nodeId",
3928
+ '[class.iox-invalid]': 'invalid',
3929
+ '[class.iox-valid]': 'showValid',
3930
+ '[attr.data-iox-field]': 'name || null',
3931
+ '[hidden]': "control === 'hidden' ? true : null",
3932
+ }, 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
+ }], propDecorators: { nodeId: [{
3934
+ type: Input
3935
+ }], name: [{
3936
+ type: Input
3937
+ }], label: [{
3938
+ type: Input
3939
+ }], control: [{
3940
+ type: Input
3941
+ }], placeholder: [{
3942
+ type: Input
3943
+ }], help: [{
3944
+ type: Input
3945
+ }], required: [{
3946
+ type: Input
3947
+ }], disabled: [{
3948
+ type: Input
3949
+ }], labelHidden: [{
3950
+ type: Input
3951
+ }], requiredMark: [{
3952
+ type: Input
3953
+ }], rows: [{
3954
+ type: Input
3955
+ }], accept: [{
3956
+ type: Input
3957
+ }], options: [{
3958
+ type: Input
3959
+ }], value: [{
3960
+ type: Input
3961
+ }], errorText: [{
3962
+ type: Input
3963
+ }], invalid: [{
3964
+ type: Input
3965
+ }], showValid: [{
3966
+ type: Input
3967
+ }], valueChange: [{
3968
+ type: Output
3969
+ }], touched: [{
3970
+ type: Output
3971
+ }] } });
3972
+
3973
+ /**
3974
+ * Directive-host SubmitButton: the real `<button type="submit">` IS the host.
3975
+ *
3976
+ * It carries no destination configuration of its own — "what happens with the data" is an ordered
3977
+ * action list on the FORM (decision D9), so there is nothing here to wire and nothing to get out of
3978
+ * sync. The button owns only its own appearance and its busy state.
3979
+ *
3980
+ * While submitting it shows `loadingLabel` and is disabled, which is the whole double-submit
3981
+ * defence on the client side (the server's rate limit is the real one).
3982
+ */
3983
+ class IoxSubmitButtonComponent {
3984
+ constructor() {
3985
+ this.nodeId = '';
3986
+ this.label = 'Submit';
3987
+ this.loadingLabel = 'Sending…';
3988
+ this.disableWhileSubmitting = true;
3989
+ /** Driven by the form's lifecycle, not by the author. */
3990
+ this.submitting = false;
3991
+ }
3992
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxSubmitButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
3993
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: IoxSubmitButtonComponent, isStandalone: false, selector: "button[iox-submit]", inputs: { nodeId: "nodeId", label: "label", loadingLabel: "loadingLabel", disableWhileSubmitting: "disableWhileSubmitting", submitting: "submitting" }, host: { properties: { "class": "'iox-submit iox-node-' + nodeId", "attr.type": "'submit'", "attr.disabled": "submitting && disableWhileSubmitting ? \"\" : null", "attr.aria-busy": "submitting ? \"true\" : null" } }, ngImport: i0, template: `{{ submitting && loadingLabel ? loadingLabel : label }}`, isInline: true, styles: [":host{box-sizing:border-box;cursor:pointer}\n"] }); }
3994
+ }
3995
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxSubmitButtonComponent, decorators: [{
3996
+ type: Component,
3997
+ args: [{ selector: 'button[iox-submit]', template: `{{ submitting && loadingLabel ? loadingLabel : label }}`, host: {
3998
+ '[class]': "'iox-submit iox-node-' + nodeId",
3999
+ '[attr.type]': "'submit'",
4000
+ '[attr.disabled]': 'submitting && disableWhileSubmitting ? "" : null',
4001
+ '[attr.aria-busy]': 'submitting ? "true" : null',
4002
+ }, standalone: false, styles: [":host{box-sizing:border-box;cursor:pointer}\n"] }]
4003
+ }], propDecorators: { nodeId: [{
4004
+ type: Input
4005
+ }], label: [{
4006
+ type: Input
4007
+ }], loadingLabel: [{
4008
+ type: Input
4009
+ }], disableWhileSubmitting: [{
4010
+ type: Input
4011
+ }], submitting: [{
4012
+ type: Input
4013
+ }] } });
4014
+
4015
+ /**
4016
+ * The Success and Error blocks — real, fully styleable container nodes that the Form auto-creates
4017
+ * and the author cannot delete (decision D7). Hiding one is allowed; removing it is not, because
4018
+ * "the author deleted the success block and the form went quiet" is Webflow's commonest form
4019
+ * failure and we designed it out.
4020
+ *
4021
+ * ⚠️ VISIBILITY IS NOT AN INPUT. Which block shows is decided by CSS keyed on the parent form's
4022
+ * `data-iox-state` attribute (see `iox-form-states.css` / the engine's `styles.scss`):
4023
+ *
4024
+ * .iox-form-success { display: none }
4025
+ * .iox-form[data-iox-state="success"] .iox-form-success { display: block }
4026
+ *
4027
+ * That is deliberate. It means the builder's state switcher and the runtime's submit lifecycle
4028
+ * drive the SAME mechanism through one attribute, with no input plumbing down the tree and no
4029
+ * chance of the canvas and the live site disagreeing about what a visitor sees. The selector
4030
+ * out-specifies `.iox-node-{id}`, so the state machine owns visibility and the author owns
4031
+ * everything else about the block.
4032
+ */
4033
+ class IoxFormSuccessComponent {
4034
+ constructor() {
4035
+ this.nodeId = '';
4036
+ }
4037
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormSuccessComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4038
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.31", type: IoxFormSuccessComponent, isStandalone: false, selector: "div[iox-form-success]", inputs: { nodeId: "nodeId" }, host: { properties: { "class": "'iox-form-success iox-node-' + nodeId", "attr.role": "'status'", "attr.aria-live": "'polite'" } }, 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}\n"] }); }
4039
+ }
4040
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormSuccessComponent, decorators: [{
4041
+ type: Component,
4042
+ args: [{ selector: 'div[iox-form-success]', template: `<ng-container #childrenHost></ng-container>`, host: {
4043
+ '[class]': "'iox-form-success iox-node-' + nodeId",
4044
+ '[attr.role]': "'status'",
4045
+ '[attr.aria-live]': "'polite'",
4046
+ }, standalone: false, styles: [":host{box-sizing:border-box}\n"] }]
4047
+ }], propDecorators: { nodeId: [{
4048
+ type: Input
4049
+ }], childrenHost: [{
4050
+ type: ViewChild,
4051
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
4052
+ }] } });
4053
+ /**
4054
+ * The Error block. One design, five reasons: the renderer writes the resolved reason text into
4055
+ * `message`, so an author designs the block once and every failure mode (network, rejected, spam,
4056
+ * rate-limited, server) renders through it.
4057
+ *
4058
+ * `role="alert"` rather than `status`: a failed submit must interrupt, because the visitor is
4059
+ * about to walk away believing they sent it.
4060
+ */
4061
+ class IoxFormErrorComponent {
4062
+ constructor() {
4063
+ this.nodeId = '';
4064
+ /** Already-translated reason. Resolving the key is the host's job. */
4065
+ this.message = '';
4066
+ }
4067
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormErrorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4068
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.31", type: IoxFormErrorComponent, isStandalone: false, selector: "div[iox-form-error]", inputs: { nodeId: "nodeId", message: "message" }, host: { properties: { "class": "'iox-form-error iox-node-' + nodeId", "attr.role": "'alert'", "attr.aria-live": "'assertive'" } }, viewQueries: [{ propertyName: "childrenHost", first: true, predicate: ["childrenHost"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: `
4069
+ @if (message) {
4070
+ <p class="iox-form-error__message">{{ message }}</p>
4071
+ }
4072
+ <ng-container #childrenHost></ng-container>
4073
+ `, isInline: true, styles: [":host{box-sizing:border-box}\n"] }); }
4074
+ }
4075
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFormErrorComponent, decorators: [{
4076
+ type: Component,
4077
+ args: [{ selector: 'div[iox-form-error]', template: `
4078
+ @if (message) {
4079
+ <p class="iox-form-error__message">{{ message }}</p>
4080
+ }
4081
+ <ng-container #childrenHost></ng-container>
4082
+ `, host: {
4083
+ '[class]': "'iox-form-error iox-node-' + nodeId",
4084
+ '[attr.role]': "'alert'",
4085
+ '[attr.aria-live]': "'assertive'",
4086
+ }, standalone: false, styles: [":host{box-sizing:border-box}\n"] }]
4087
+ }], propDecorators: { nodeId: [{
4088
+ type: Input
4089
+ }], message: [{
4090
+ type: Input
4091
+ }], childrenHost: [{
4092
+ type: ViewChild,
4093
+ args: ['childrenHost', { read: ViewContainerRef, static: true }]
4094
+ }] } });
4095
+
3126
4096
  const COMPONENTS = [
3127
4097
  BuilderImageComponent,
3128
4098
  BuilderSpacerComponent,
@@ -3143,6 +4113,11 @@ const COMPONENTS = [
3143
4113
  ClientListComponent,
3144
4114
  ClientListItemComponent,
3145
4115
  ClientGalleryComponent,
4116
+ IoxFormComponent,
4117
+ IoxFieldComponent,
4118
+ IoxSubmitButtonComponent,
4119
+ IoxFormSuccessComponent,
4120
+ IoxFormErrorComponent,
3146
4121
  ];
3147
4122
  class IoxBuilderComponentsModule {
3148
4123
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
@@ -3164,7 +4139,12 @@ class IoxBuilderComponentsModule {
3164
4139
  ClientSliderSlideComponent,
3165
4140
  ClientListComponent,
3166
4141
  ClientListItemComponent,
3167
- ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
4142
+ ClientGalleryComponent,
4143
+ IoxFormComponent,
4144
+ IoxFieldComponent,
4145
+ IoxSubmitButtonComponent,
4146
+ IoxFormSuccessComponent,
4147
+ IoxFormErrorComponent], imports: [CommonModule], exports: [BuilderImageComponent,
3168
4148
  BuilderSpacerComponent,
3169
4149
  CardComponent,
3170
4150
  BuilderDividerComponent,
@@ -3182,7 +4162,12 @@ class IoxBuilderComponentsModule {
3182
4162
  ClientSliderSlideComponent,
3183
4163
  ClientListComponent,
3184
4164
  ClientListItemComponent,
3185
- ClientGalleryComponent] }); }
4165
+ ClientGalleryComponent,
4166
+ IoxFormComponent,
4167
+ IoxFieldComponent,
4168
+ IoxSubmitButtonComponent,
4169
+ IoxFormSuccessComponent,
4170
+ IoxFormErrorComponent] }); }
3186
4171
  static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
3187
4172
  }
3188
4173
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
@@ -3230,6 +4215,13 @@ class IoxComponentRegistryService {
3230
4215
  ['List', ClientListComponent],
3231
4216
  ['ListItem', ClientListItemComponent],
3232
4217
  ['Gallery', ClientGalleryComponent],
4218
+ // Forms. FieldGroup is config-only (it reuses ContainerComponent), like List/ListItem.
4219
+ ['Form', IoxFormComponent],
4220
+ ['Field', IoxFieldComponent],
4221
+ ['FieldGroup', ContainerComponent],
4222
+ ['SubmitButton', IoxSubmitButtonComponent],
4223
+ ['FormSuccess', IoxFormSuccessComponent],
4224
+ ['FormError', IoxFormErrorComponent],
3233
4225
  ]);
3234
4226
  }
3235
4227
  getComponent(type) {
@@ -4586,5 +5578,5 @@ function effectiveTransitionId(anim) {
4586
5578
  * Generated bundle index. Do not edit.
4587
5579
  */
4588
5580
 
4589
- export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
5581
+ export { AOSService, AnalyticsService, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CONTEXT_ACTION_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, ContextStore, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, DeclarationContext, ENVIRONMENT, FormRenderState, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_RESPONSIVE_TRAITS, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, activeActions, applyBindingPipes, applyFeel, breakpointDef, breakpointForWidth, breakpointQueryFor, buildDataSourceFilter, buildRelatedFilter, buildSwiperBreakpoints, buildTargetedStateSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, collectContextSlotKeys, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, contextActionEffect, contextSlotKeys, countOverrides, dataSourcePlanToRequest, declaredInputNames, effectiveTransitionId, excludeSelf, feelFor, getByPath, initialSlotValues, isActionEnabled, isContextAction, isInputOverriddenAt, isOverriddenAt, isReservedAlias, isValidSlotKey, legacyEnterToTransition, matchRouteParams, mediaQueryFor, mergeSwiperBreakpoints, nearestCommonAncestorId, nodeCssId, nodeUsesContext, normalizeCollectionResult, normalizeGalleryImage, parentBreakpoint, parseUrlList, planDataSource, planResponsiveRules, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveContextValue, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, resolveStateStyles, resolveStyleProps, resolveValueChain, rewriteViewportUnits, safeSetInput, setInputOverride, setOverride, stateNamesIn, stripSuppressedProps, styleKeyToKebab, variableAliases, variableItemsFrom, withContextSlots, wrapInBreakpointQuery, wrapInMediaQuery };
4590
5582
  //# sourceMappingURL=vectoriox-iox-ui.mjs.map