@vectoriox/iox-ui 5.82.2 → 5.83.1

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.
Files changed (32) hide show
  1. package/design-core/index.d.ts +200 -0
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +215 -1
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-forms-core.mjs +733 -56
  5. package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
  6. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +40 -4
  7. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
  8. package/fesm2022/vectoriox-iox-ui.mjs +1559 -135
  9. package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
  10. package/forms-core/index.d.ts +366 -18
  11. package/generation-core/index.d.ts +3 -0
  12. package/index.d.ts +307 -25
  13. package/package.json +1 -1
  14. package/src/design-core/contrast-audit.ts +4 -0
  15. package/src/design-core/flavor-spec.generated.ts +209 -1
  16. package/src/forms-core/channel.ts +85 -0
  17. package/src/forms-core/collect.ts +63 -30
  18. package/src/forms-core/dates.ts +299 -0
  19. package/src/forms-core/field-markup.ts +159 -0
  20. package/src/forms-core/form-presets.ts +51 -2
  21. package/src/forms-core/form.model.ts +34 -2
  22. package/src/forms-core/messages.ts +31 -0
  23. package/src/forms-core/public-api.ts +3 -0
  24. package/src/forms-core/rule-catalog.ts +3 -2
  25. package/src/forms-core/submit-lifecycle.ts +42 -12
  26. package/src/forms-core/validate.ts +90 -4
  27. package/src/generation-core/audit-site.ts +9 -1
  28. package/src/generation-core/compose-site.ts +22 -3
  29. package/src/generation-core/node-baselines.generated.ts +10 -0
  30. package/src/generation-core/plan-wire.ts +2 -2
  31. package/styles/iox-design.css +10 -0
  32. package/styles/iox-forms.css +358 -10
@@ -3757,6 +3757,11 @@ var ControlType;
3757
3757
  /** A slider over a numeric range — min/max/step ride the field. */
3758
3758
  ControlType["Range"] = "range";
3759
3759
  ControlType["Radio"] = "radio";
3760
+ /** One or several of a few visible choices — chips (the default) or a list of radios / checkboxes (premium N1).
3761
+ * Its `multiple` trait picks the value's shape: a string, or a list of strings. */
3762
+ ControlType["Choices"] = "choices";
3763
+ /** A date the visitor picks from the look's calendar (premium N8) — a button that reads the date in words and a
3764
+ * hidden input carrying the ISO `YYYY-MM-DD` value. Never the browser's own date box. */
3760
3765
  ControlType["Date"] = "date";
3761
3766
  ControlType["Time"] = "time";
3762
3767
  ControlType["File"] = "file";
@@ -3778,7 +3783,20 @@ var RuleName;
3778
3783
  RuleName["Accepted"] = "accepted";
3779
3784
  RuleName["FileType"] = "fileType";
3780
3785
  RuleName["FileSize"] = "fileSize";
3786
+ /** IMPLIED, never authored: an answer of a Choices / Radio / Dropdown must be one of the field's own options
3787
+ * (premium N1) — a hand-crafted POST cannot file a value the form never offered. */
3788
+ RuleName["OneOf"] = "oneOf";
3789
+ /** IMPLIED, never authored: a Date's answer is a real `YYYY-MM-DD` day (premium N8). */
3790
+ RuleName["ValidDate"] = "validDate";
3781
3791
  })(RuleName || (RuleName = {}));
3792
+ /** How a Choices field draws its options (premium N1). */
3793
+ var ChoiceAppearance;
3794
+ (function (ChoiceAppearance) {
3795
+ /** Each option a chip — the label is the chip; the native input inside it is visually hidden. */
3796
+ ChoiceAppearance["Chips"] = "chips";
3797
+ /** The options as rows of the look's own radio / checkbox (DS9.1). */
3798
+ ChoiceAppearance["List"] = "list";
3799
+ })(ChoiceAppearance || (ChoiceAppearance = {}));
3782
3800
  /** Named regexes so an author picks "Email" rather than writing one. */
3783
3801
  var PatternPreset;
3784
3802
  (function (PatternPreset) {
@@ -3819,8 +3837,21 @@ const IMPLIED_PATTERN = Object.freeze({
3819
3837
  [ControlType.Tel]: PatternPreset.Phone,
3820
3838
  [ControlType.Url]: PatternPreset.Url,
3821
3839
  });
3822
- /** Controls whose value is a list rather than a scalar. */
3823
- const MULTI_VALUE_CONTROLS = new Set([ControlType.File]);
3840
+ /**
3841
+ * Whether this field's answer is a LIST rather than one value — a file list, or a Choices field that takes several
3842
+ * (premium N1). Per FIELD, not per control: the same Choices control is single or multiple by its `multiple` trait.
3843
+ * ONE reading for collect, validate, the runtimes' transport and the server (replaced the control-only
3844
+ * `MULTI_VALUE_CONTROLS`, which nothing read).
3845
+ */
3846
+ function isMultiValueField(field) {
3847
+ if (!field)
3848
+ return false;
3849
+ if (field.control === ControlType.File)
3850
+ return true;
3851
+ return field.control === ControlType.Choices && field.multiple === true;
3852
+ }
3853
+ /** The controls whose answers must be one of the field's own options (the implied `oneOf` rule). */
3854
+ const OPTION_CONTROLS = new Set([ControlType.Choices, ControlType.Radio, ControlType.Select]);
3824
3855
 
3825
3856
  // The default message for every rule, as an i18n KEY — never a rendered string.
3826
3857
  //
@@ -3851,6 +3882,26 @@ const RULE_MESSAGE_KEYS = Object.freeze({
3851
3882
  [RuleName.Accepted]: `${MESSAGE_NAMESPACE}.accepted`,
3852
3883
  [RuleName.FileType]: `${MESSAGE_NAMESPACE}.fileType`,
3853
3884
  [RuleName.FileSize]: `${MESSAGE_NAMESPACE}.fileSize`,
3885
+ [RuleName.OneOf]: `${MESSAGE_NAMESPACE}.oneOf`,
3886
+ [RuleName.ValidDate]: `${MESSAGE_NAMESPACE}.validDate`,
3887
+ });
3888
+ /**
3889
+ * What "required" says on a field you CHOOSE rather than type into (premium N1 / N8): "Choose an option", "Choose at
3890
+ * least one", "Choose a date" — never "This field is required" beside a row of chips.
3891
+ */
3892
+ const CHOICE_MESSAGE_KEYS = Object.freeze({
3893
+ One: `${MESSAGE_NAMESPACE}.choose`,
3894
+ Some: `${MESSAGE_NAMESPACE}.chooseSome`,
3895
+ Date: `${MESSAGE_NAMESPACE}.chooseDate`,
3896
+ });
3897
+ /** A Date outside its Earliest / Latest. The calendar refuses those days, so this is a stale page or a hand POST. */
3898
+ const DATE_MESSAGE_KEYS = Object.freeze({
3899
+ Min: `${MESSAGE_NAMESPACE}.dateMin`,
3900
+ Max: `${MESSAGE_NAMESPACE}.dateMax`,
3901
+ });
3902
+ /** The submit button's own words (premium N2): what it says once its form has sent, when the author wrote none. */
3903
+ const SUBMIT_MESSAGE_KEYS = Object.freeze({
3904
+ Sent: 'forms.submit.sent',
3854
3905
  });
3855
3906
  /**
3856
3907
  * A pattern failure deserves better than "Invalid format". Each preset gets its own key so the
@@ -3896,8 +3947,266 @@ const DEFAULT_EN_MESSAGES = Object.freeze({
3896
3947
  [FORM_ERROR_KEYS.Spam]: 'We couldn’t accept this submission',
3897
3948
  [FORM_ERROR_KEYS.RateLimited]: 'Too many attempts — please wait a moment and try again',
3898
3949
  [FORM_ERROR_KEYS.Server]: 'Something went wrong on our side — your answers are still here',
3950
+ [RULE_MESSAGE_KEYS[RuleName.OneOf]]: 'Choose one of the options',
3951
+ [RULE_MESSAGE_KEYS[RuleName.ValidDate]]: 'Choose a date from the calendar',
3952
+ [CHOICE_MESSAGE_KEYS.One]: 'Choose an option',
3953
+ [CHOICE_MESSAGE_KEYS.Some]: 'Choose at least one',
3954
+ [CHOICE_MESSAGE_KEYS.Date]: 'Choose a date',
3955
+ [DATE_MESSAGE_KEYS.Min]: 'Choose a later date',
3956
+ [DATE_MESSAGE_KEYS.Max]: 'Choose an earlier date',
3957
+ [SUBMIT_MESSAGE_KEYS.Sent]: 'Sent',
3899
3958
  });
3900
3959
 
3960
+ /**
3961
+ * DATES — the date picker's pure half (premium components N8) and the date rules' one reading.
3962
+ *
3963
+ * A Date field's value is an ISO day, `YYYY-MM-DD`, compared as a string (the format sorts as the calendar does).
3964
+ * Its `min` / `max` rules take an ISO day OR an offset in days from today (`{ offsetDays: 0 }` = today), so "from
3965
+ * today on" stays true tomorrow without a re-save. ONE reading for the validator (client AND iox-cms), the calendar
3966
+ * (which days it refuses) and the builder's Earliest / Latest controls.
3967
+ *
3968
+ * The calendar model (six rows of seven, roving focus, the APG keys) lives here too, so the DOM runtime only draws
3969
+ * what this decides — the canvas, the engine's component path and its string path draw the SAME month.
3970
+ *
3971
+ * ⚠️ Framework-free, like the rest of forms-core. Local-time `Date` objects are used only to walk the calendar; a
3972
+ * day leaves this file as an ISO string.
3973
+ */
3974
+ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
3975
+ const OFFSET = /^[+-]?\d+$/;
3976
+ const DAYS_IN_WEEK = 7;
3977
+ /** Always six rows, so the panel never changes size between months (APG; Emil 74 — one object, one size). */
3978
+ const CALENDAR_ROWS = 6;
3979
+ const pad = (n, width = 2) => String(n).padStart(width, '0');
3980
+ /** The ISO day of a local `Date`. */
3981
+ function isoOfDate(d) {
3982
+ return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
3983
+ }
3984
+ /** A local `Date` at midnight for an ISO day, or null when it is not one (2026-02-30 is not). */
3985
+ function dateOfIso(iso) {
3986
+ const m = typeof iso === 'string' ? ISO_DAY.exec(iso.trim()) : null;
3987
+ if (!m)
3988
+ return null;
3989
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
3990
+ const date = new Date(y, mo - 1, d);
3991
+ date.setFullYear(y);
3992
+ return date.getFullYear() === y && date.getMonth() === mo - 1 && date.getDate() === d ? date : null;
3993
+ }
3994
+ /** True for a real calendar day written `YYYY-MM-DD`. */
3995
+ function isIsoDate(value) {
3996
+ return dateOfIso(value) !== null;
3997
+ }
3998
+ /** Today, as the device that runs this sees it (the visitor's local day in a browser; the server's on iox-cms). */
3999
+ function todayIso(now = new Date()) {
4000
+ return isoOfDate(now);
4001
+ }
4002
+ /** `iso` moved by `days` (negative goes back). */
4003
+ function addDaysIso(iso, days) {
4004
+ const d = dateOfIso(iso);
4005
+ if (!d)
4006
+ return iso;
4007
+ return isoOfDate(new Date(d.getFullYear(), d.getMonth(), d.getDate() + days));
4008
+ }
4009
+ /** `iso` moved by `months`, its day clamped to the target month's last (31 Jan + 1 month = 28/29 Feb). */
4010
+ function addMonthsIso(iso, months) {
4011
+ const d = dateOfIso(iso);
4012
+ if (!d)
4013
+ return iso;
4014
+ const first = new Date(d.getFullYear(), d.getMonth() + months, 1);
4015
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
4016
+ return isoOfDate(new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last)));
4017
+ }
4018
+ /** `iso` held inside [min, max] (either may be absent). */
4019
+ function clampIso(iso, min, max) {
4020
+ if (min && iso < min)
4021
+ return min;
4022
+ if (max && iso > max)
4023
+ return max;
4024
+ return iso;
4025
+ }
4026
+ /**
4027
+ * A Field's `earliest` / `latest` trait → the rule parameter. The trait is one string so the panel and a saved page
4028
+ * stay simple: `''` = any day · an integer = that many days from today (`'0'` = today, `'30'` = a month ahead) · an
4029
+ * ISO day. Anything else reads as no bound.
4030
+ */
4031
+ function parseDateBound(raw) {
4032
+ if (typeof raw === 'number' && Number.isInteger(raw))
4033
+ return { offsetDays: raw };
4034
+ if (typeof raw !== 'string')
4035
+ return null;
4036
+ const text = raw.trim();
4037
+ if (!text)
4038
+ return null;
4039
+ if (OFFSET.test(text))
4040
+ return { offsetDays: Number(text) };
4041
+ return isIsoDate(text) ? { date: text } : null;
4042
+ }
4043
+ /** The `min` / `max` rules a Date field's Earliest / Latest traits give it (none for "any"). */
4044
+ function dateBoundRules(earliest, latest) {
4045
+ const rules = [];
4046
+ const min = parseDateBound(earliest);
4047
+ const max = parseDateBound(latest);
4048
+ if (min)
4049
+ rules.push({ name: RuleName.Min, params: { ...min } });
4050
+ if (max)
4051
+ rules.push({ name: RuleName.Max, params: { ...max } });
4052
+ return rules;
4053
+ }
4054
+ /** True when a rule's params are a DATE bound (an ISO day or an offset), never a number bound. */
4055
+ function isDateBoundParams(params) {
4056
+ return !!params && (typeof params['date'] === 'string' || Number.isInteger(params['offsetDays']));
4057
+ }
4058
+ /** The ISO day a bound names today, or null when it names none. */
4059
+ function resolveDateBound(params, today) {
4060
+ if (!params)
4061
+ return null;
4062
+ if (typeof params['date'] === 'string' && isIsoDate(params['date']))
4063
+ return params['date'];
4064
+ if (Number.isInteger(params['offsetDays']))
4065
+ return addDaysIso(today, params['offsetDays']);
4066
+ return null;
4067
+ }
4068
+ /** The first and last day a Date field accepts today — the calendar's refused days and the validator's range. */
4069
+ function fieldDateRange(field, today) {
4070
+ let min = null;
4071
+ let max = null;
4072
+ for (const rule of field?.rules ?? []) {
4073
+ if (rule.name === RuleName.Min)
4074
+ min = resolveDateBound(rule.params, today) ?? min;
4075
+ if (rule.name === RuleName.Max)
4076
+ max = resolveDateBound(rule.params, today) ?? max;
4077
+ }
4078
+ return { min, max };
4079
+ }
4080
+ /** True when the field is a date picker (the control that reads these rules as days). */
4081
+ function isDateField(field) {
4082
+ return field?.control === ControlType.Date;
4083
+ }
4084
+ // ─── the week, the month, the keys ───────────────────────────────────────────
4085
+ /** Regions whose week starts on Sunday when the runtime cannot say (Intl.Locale#getWeekInfo is not everywhere). */
4086
+ const SUNDAY_REGIONS = new Set(['US', 'CA', 'IL', 'JP', 'BR', 'MX', 'PH', 'IN', 'KR', 'TW', 'ZA', 'AU']);
4087
+ const SUNDAY_LANGUAGES = new Set(['he', 'iw', 'ja', 'ko']);
4088
+ /**
4089
+ * The first day of the week for a locale, 0 = Sunday … 6 = Saturday. `Intl.Locale(…).getWeekInfo()` (or the older
4090
+ * `weekInfo` getter) when the runtime has it, else a small region table; a bare `en` reads as the US.
4091
+ */
4092
+ function weekStartFor(locale) {
4093
+ const tag = locale || 'en';
4094
+ try {
4095
+ const Ctor = Intl.Locale;
4096
+ if (Ctor) {
4097
+ const loc = new Ctor(tag);
4098
+ const info = typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : loc.weekInfo;
4099
+ const first = Number(info?.firstDay);
4100
+ if (first >= 1 && first <= 7)
4101
+ return first % DAYS_IN_WEEK;
4102
+ }
4103
+ }
4104
+ catch { /* an unknown tag falls through to the table */ }
4105
+ const [language, region] = tag.split(/[-_]/);
4106
+ if (region && SUNDAY_REGIONS.has(region.toUpperCase()))
4107
+ return 0;
4108
+ if (!region && (language === 'en' || SUNDAY_LANGUAGES.has(language)))
4109
+ return 0;
4110
+ return 1;
4111
+ }
4112
+ /** The month `focus` sits in, as six rows of seven — the ONE grid every renderer draws. */
4113
+ function calendarMonth(focus, options) {
4114
+ const at = dateOfIso(focus) ?? dateOfIso(options.today) ?? new Date();
4115
+ const year = at.getFullYear();
4116
+ const month = at.getMonth();
4117
+ const lead = (new Date(year, month, 1).getDay() - options.weekStart + DAYS_IN_WEEK) % DAYS_IN_WEEK;
4118
+ const days = new Date(year, month + 1, 0).getDate();
4119
+ const weeks = [];
4120
+ for (let r = 0; r < CALENDAR_ROWS; r++) {
4121
+ const row = [];
4122
+ for (let c = 0; c < DAYS_IN_WEEK; c++) {
4123
+ const n = r * DAYS_IN_WEEK + c - lead + 1;
4124
+ if (n < 1 || n > days) {
4125
+ row.push(null);
4126
+ continue;
4127
+ }
4128
+ const iso = isoOfDate(new Date(year, month, n));
4129
+ row.push({
4130
+ iso, day: n,
4131
+ today: iso === options.today,
4132
+ selected: iso === options.selected,
4133
+ disabled: (!!options.min && iso < options.min) || (!!options.max && iso > options.max),
4134
+ focus: iso === focus,
4135
+ });
4136
+ }
4137
+ weeks.push(row);
4138
+ }
4139
+ const monthKey = (iso) => (iso ? iso.slice(0, 7) : null);
4140
+ const here = monthKey(isoOfDate(new Date(year, month, 1)));
4141
+ const minMonth = monthKey(options.min);
4142
+ const maxMonth = monthKey(options.max);
4143
+ return {
4144
+ year, month, weeks,
4145
+ weekdays: Array.from({ length: DAYS_IN_WEEK }, (_, i) => (options.weekStart + i) % DAYS_IN_WEEK),
4146
+ canPrev: !minMonth || here > minMonth,
4147
+ canNext: !maxMonth || here < maxMonth,
4148
+ };
4149
+ }
4150
+ /** The keys the grid answers (APG date picker dialog). */
4151
+ var CalendarKey;
4152
+ (function (CalendarKey) {
4153
+ CalendarKey["Left"] = "ArrowLeft";
4154
+ CalendarKey["Right"] = "ArrowRight";
4155
+ CalendarKey["Up"] = "ArrowUp";
4156
+ CalendarKey["Down"] = "ArrowDown";
4157
+ CalendarKey["Home"] = "Home";
4158
+ CalendarKey["End"] = "End";
4159
+ CalendarKey["PageUp"] = "PageUp";
4160
+ CalendarKey["PageDown"] = "PageDown";
4161
+ })(CalendarKey || (CalendarKey = {}));
4162
+ const KEY_MOVES = {
4163
+ // The arrows follow the READING direction: in RTL "→" is a day back (APG; mirrored).
4164
+ [CalendarKey.Right]: (iso, c) => addDaysIso(iso, c.ahead),
4165
+ [CalendarKey.Left]: (iso, c) => addDaysIso(iso, -c.ahead),
4166
+ [CalendarKey.Down]: iso => addDaysIso(iso, DAYS_IN_WEEK),
4167
+ [CalendarKey.Up]: iso => addDaysIso(iso, -DAYS_IN_WEEK),
4168
+ [CalendarKey.Home]: (iso, c) => addDaysIso(iso, -c.dow),
4169
+ [CalendarKey.End]: (iso, c) => addDaysIso(iso, DAYS_IN_WEEK - 1 - c.dow),
4170
+ // A month; with Shift, a year.
4171
+ [CalendarKey.PageUp]: (iso, c) => addMonthsIso(iso, c.shift ? -12 : -1),
4172
+ [CalendarKey.PageDown]: (iso, c) => addMonthsIso(iso, c.shift ? 12 : 1),
4173
+ };
4174
+ /**
4175
+ * Where a key moves the focused day, held inside [min, max] — or null for a key the grid does not answer (Enter,
4176
+ * Space and Escape are the runtime's: they pick or close, they never move).
4177
+ */
4178
+ function calendarKeyTarget(key, focus, options) {
4179
+ const move = KEY_MOVES[key];
4180
+ const at = dateOfIso(focus);
4181
+ if (!move || !at)
4182
+ return null;
4183
+ const dow = (at.getDay() - options.weekStart + DAYS_IN_WEEK) % DAYS_IN_WEEK;
4184
+ return clampIso(move(focus, { ahead: options.rtl ? -1 : 1, dow, shift: !!options.shift }), options.min, options.max);
4185
+ }
4186
+ // ─── words ───────────────────────────────────────────────────────────────────
4187
+ /** The date in words, in the site's language: "Thu, 20 November" — the year only when it is not this year's. */
4188
+ function formatDateInWords(iso, locale, today) {
4189
+ const d = dateOfIso(iso);
4190
+ if (!d)
4191
+ return '';
4192
+ const sameYear = iso.slice(0, 4) === today.slice(0, 4);
4193
+ try {
4194
+ return new Intl.DateTimeFormat(locale || undefined, { weekday: 'short', day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(d);
4195
+ }
4196
+ catch {
4197
+ return iso;
4198
+ }
4199
+ }
4200
+ const DATE_PICKER_WORDS = Object.freeze({
4201
+ en: { choose: 'Choose a date', previousMonth: 'Previous month', nextMonth: 'Next month', close: 'Close' },
4202
+ he: { choose: 'בחרו תאריך', previousMonth: 'החודש הקודם', nextMonth: 'החודש הבא', close: 'סגירה' },
4203
+ });
4204
+ /** The words for a language tag (`he-IL` → Hebrew); English for anything else. */
4205
+ function datePickerWords(lang) {
4206
+ const language = String(lang ?? '').toLowerCase().split(/[-_]/)[0];
4207
+ return language === 'he' || language === 'iw' ? DATE_PICKER_WORDS.he : DATE_PICKER_WORDS.en;
4208
+ }
4209
+
3901
4210
  // The validator. ONE implementation, run by the builder canvas, the SSR engine and `iox-cms`.
3902
4211
  //
3903
4212
  // Client-side validation is UX; server-side validation is truth. Because both call this function,
@@ -3914,14 +4223,14 @@ const DEFAULT_EN_MESSAGES = Object.freeze({
3914
4223
  * number" at someone who simply left it blank — the single most common validation-UX bug.
3915
4224
  * The FIRST failure wins: a visitor fixes one thing at a time.
3916
4225
  */
3917
- function validateField(value, spec, form = {}) {
4226
+ function validateField(value, spec, form = {}, options = {}) {
3918
4227
  const rules = effectiveRules(spec);
3919
4228
  const empty = isEmpty(value, spec.control);
3920
4229
  for (const rule of rules) {
3921
4230
  const appliesToEmpty = rule.name === RuleName.Required || rule.name === RuleName.Accepted;
3922
4231
  if (empty && !appliesToEmpty)
3923
4232
  continue;
3924
- const failure = check(rule, value, spec, form);
4233
+ const failure = check(rule, value, spec, form, options);
3925
4234
  if (failure) {
3926
4235
  return {
3927
4236
  valid: false,
@@ -3936,16 +4245,33 @@ function validateField(value, spec, form = {}) {
3936
4245
  * Validate a whole submission. Every field is checked (not short-circuited at the first bad one) so
3937
4246
  * the visitor sees every problem at once instead of fixing them one reload at a time.
3938
4247
  */
3939
- function validateForm(values, spec) {
4248
+ function validateForm(values, spec, options = {}) {
3940
4249
  const errors = {};
3941
4250
  for (const field of spec.fields ?? []) {
3942
- const result = validateField(values?.[field.name], field, values ?? {});
4251
+ const result = validateField(values?.[field.name], field, values ?? {}, options);
3943
4252
  if (!result.valid) {
3944
4253
  errors[field.name] = { messageKey: result.messageKey, params: result.params };
3945
4254
  }
3946
4255
  }
3947
4256
  return { valid: Object.keys(errors).length === 0, errors };
3948
4257
  }
4258
+ /**
4259
+ * The answers in the shape each field takes (premium N1): a field that takes several answers gets a LIST (one answer
4260
+ * posted on its own is a list of one; nothing is an empty list). ONE reading for a host's transport and the server,
4261
+ * before validation — `isMultiValueField` decides. A single-answer field is left as posted: two answers to it is the
4262
+ * validator's to refuse, never quietly cut to one.
4263
+ */
4264
+ function normaliseAnswers(values, spec) {
4265
+ const out = { ...(values ?? {}) };
4266
+ for (const field of spec.fields ?? []) {
4267
+ if (!isMultiValueField(field) || field.control === ControlType.File)
4268
+ continue;
4269
+ if (!(field.name in out))
4270
+ continue;
4271
+ out[field.name] = chosen(out[field.name]);
4272
+ }
4273
+ return out;
4274
+ }
3949
4275
  /** The name of the first field with an error, in spec order — what the browser should focus. */
3950
4276
  function firstInvalidField(spec, errors) {
3951
4277
  for (const field of spec.fields ?? [])
@@ -3974,8 +4300,54 @@ function effectiveRules(spec) {
3974
4300
  if (preset && !has(RuleName.Pattern)) {
3975
4301
  implied.push({ name: RuleName.Pattern, params: { preset } });
3976
4302
  }
4303
+ // An answer to a field of CHOICES must be one the form offered (premium N1) — only when the options are the
4304
+ // field's own: options from a data source are not known here, so they are not judged.
4305
+ if (OPTION_CONTROLS.has(spec.control) && !spec.optionsSource && (spec.options ?? []).length) {
4306
+ implied.push({ name: RuleName.OneOf });
4307
+ }
4308
+ // A Date's answer is a real day before any bound is asked about it (premium N8).
4309
+ if (spec.control === ControlType.Date)
4310
+ implied.push({ name: RuleName.ValidDate });
3977
4311
  return [...implied, ...declared];
3978
4312
  }
4313
+ /** "Required" in the words of the control: choose, not fill in. */
4314
+ function requiredMessageKey(spec) {
4315
+ if (spec.control === ControlType.Date)
4316
+ return CHOICE_MESSAGE_KEYS.Date;
4317
+ if (isMultiValueField(spec) && spec.control === ControlType.Choices)
4318
+ return CHOICE_MESSAGE_KEYS.Some;
4319
+ if (OPTION_CONTROLS.has(spec.control))
4320
+ return CHOICE_MESSAGE_KEYS.One;
4321
+ return RULE_MESSAGE_KEYS[RuleName.Required];
4322
+ }
4323
+ /** The answers a field of choices gave, as a list — a single answer is a list of one, nothing is an empty list. */
4324
+ function chosen(value) {
4325
+ if (Array.isArray(value))
4326
+ return value;
4327
+ return value === null || value === undefined || value === '' ? [] : [value];
4328
+ }
4329
+ /** The `oneOf` verdict: every answer is one of the options, and a single-answer field got at most one. */
4330
+ function isOneOf(value, spec) {
4331
+ const offered = new Set((spec.options ?? []).map(o => String(o.value)));
4332
+ const answers = chosen(value);
4333
+ if (!isMultiValueField(spec) && answers.length > 1)
4334
+ return false;
4335
+ return answers.every(a => (typeof a === 'string' || typeof a === 'number') && offered.has(String(a)));
4336
+ }
4337
+ /** A Date bound, as a failure or null — the bound resolved against today, widened by the server's slack. */
4338
+ function dateBoundFailure(rule, value, options) {
4339
+ const today = options.today ?? todayIso();
4340
+ const slack = Math.max(0, Math.trunc(options.dateSlackDays ?? 0));
4341
+ const bound = resolveDateBound(rule.params, today);
4342
+ if (!bound || !isIsoDate(value))
4343
+ return null;
4344
+ const day = String(value);
4345
+ if (rule.name === RuleName.Min && day < addDaysIso(bound, -slack))
4346
+ return { messageKey: DATE_MESSAGE_KEYS.Min };
4347
+ if (rule.name === RuleName.Max && day > addDaysIso(bound, slack))
4348
+ return { messageKey: DATE_MESSAGE_KEYS.Max };
4349
+ return null;
4350
+ }
3979
4351
  /** Empty means "the visitor gave us nothing" — per control type, not a blanket falsy check. */
3980
4352
  function isEmpty(value, control) {
3981
4353
  if (value === null || value === undefined)
@@ -3988,15 +4360,24 @@ function isEmpty(value, control) {
3988
4360
  return value.trim() === '';
3989
4361
  return false;
3990
4362
  }
3991
- function check(rule, value, spec, form) {
4363
+ function check(rule, value, spec, form, options) {
3992
4364
  const fail = (messageKey, params) => ({ messageKey, params });
3993
4365
  const defaultKey = RULE_MESSAGE_KEYS[rule.name];
3994
4366
  const n = Number(rule.params?.['n']);
4367
+ // A Date's min / max are DAYS (an ISO day or an offset from today), never a number to parseFloat — `2026-11-06`
4368
+ // read as 2026 is what the numeric reading did.
4369
+ if ((rule.name === RuleName.Min || rule.name === RuleName.Max) && spec.control === ControlType.Date && isDateBoundParams(rule.params)) {
4370
+ return dateBoundFailure(rule, value, options);
4371
+ }
3995
4372
  switch (rule.name) {
3996
4373
  case RuleName.Required:
3997
- return isEmpty(value, spec.control) ? fail(defaultKey) : null;
4374
+ return isEmpty(value, spec.control) ? fail(requiredMessageKey(spec)) : null;
3998
4375
  case RuleName.Accepted:
3999
4376
  return value === true ? null : fail(defaultKey);
4377
+ case RuleName.OneOf:
4378
+ return isOneOf(value, spec) ? null : fail(defaultKey);
4379
+ case RuleName.ValidDate:
4380
+ return isIsoDate(value) ? null : fail(defaultKey);
4000
4381
  case RuleName.MinLength:
4001
4382
  return text(value).length < n ? fail(defaultKey, { n }) : null;
4002
4383
  case RuleName.MaxLength:
@@ -4140,6 +4521,68 @@ function uniqueFieldName(name, taken) {
4140
4521
  n++;
4141
4522
  return `${name}${n}`;
4142
4523
  }
4524
+ /** A Field's own key, before the form makes it unique: the author's override, else derived from the label. */
4525
+ function baseFieldName(props) {
4526
+ const own = typeof props?.name === 'string' ? props.name : '';
4527
+ return own || deriveFieldName(String(props?.label ?? ''));
4528
+ }
4529
+ /**
4530
+ * THE naming rule (vectoriox/iox-ui#43): a form's fields, in document order, each its base name made unique against
4531
+ * the names before it — `email`, `email2`. The spec (`collectFormSpec`), the component path's runtime and the engine's
4532
+ * string renderer all name a field from here, so the input's `name` and the saved spec's key can never differ.
4533
+ */
4534
+ function uniqueFieldNames(baseNames) {
4535
+ const taken = new Set();
4536
+ return baseNames.map(base => {
4537
+ const name = uniqueFieldName(base, taken);
4538
+ taken.add(name);
4539
+ return name;
4540
+ });
4541
+ }
4542
+ /** The submitted name of every Field of a Form, in document order — `uniqueFieldNames` over its fields. */
4543
+ function formFieldNames(node) {
4544
+ if (!node || node.type !== FORM_NODE_TYPE)
4545
+ return [];
4546
+ return uniqueFieldNames(collectFieldNodes(node.children ?? []).map(f => baseFieldName(f.props)));
4547
+ }
4548
+ /** Each Field node of a Form → the name it submits under (`formFieldNames`, by node). */
4549
+ function formFieldNameMap(node) {
4550
+ const out = new Map();
4551
+ if (!node || node.type !== FORM_NODE_TYPE)
4552
+ return out;
4553
+ const fields = collectFieldNodes(node.children ?? []);
4554
+ uniqueFieldNames(fields.map(f => baseFieldName(f.props))).forEach((name, i) => out.set(fields[i], name));
4555
+ return out;
4556
+ }
4557
+ /**
4558
+ * A Field's spec from its props and the name its form gave it — ONE reading for the saved spec and the component
4559
+ * path's runtime (it was written twice: `IoxFieldComponent.toSpec` never carried the options source, the default or a
4560
+ * Date's bounds). A Date's Earliest / Latest (`earliest` / `latest`) become its `min` / `max` rules (premium N8).
4561
+ */
4562
+ function fieldSpecOf(props, name) {
4563
+ const p = props ?? {};
4564
+ const control = p['control'] || ControlType.Text;
4565
+ const spec = { name, control, label: p['label'] ?? '' };
4566
+ if (p['required'])
4567
+ spec.required = true;
4568
+ const declared = normaliseRules(p['rules']);
4569
+ const bounds = control === ControlType.Date
4570
+ ? dateBoundRules(p['earliest'], p['latest']).filter(b => !declared.some(r => r.name === b.name))
4571
+ : [];
4572
+ const rules = [...declared, ...bounds];
4573
+ if (rules.length)
4574
+ spec.rules = rules;
4575
+ const options = fieldOptionsOf(p);
4576
+ if (options.length)
4577
+ spec.options = options;
4578
+ if (p['optionsSource'])
4579
+ spec.optionsSource = p['optionsSource'];
4580
+ if (p['defaultValue'] !== undefined && p['defaultValue'] !== '')
4581
+ spec.defaultValue = p['defaultValue'];
4582
+ if (control === ControlType.Choices && p['multiple'] === true)
4583
+ spec.multiple = true;
4584
+ return spec;
4585
+ }
4143
4586
  /**
4144
4587
  * Walk a Form subtree and produce its spec.
4145
4588
  *
@@ -4150,13 +4593,9 @@ function collectFormSpec(node) {
4150
4593
  if (!node || node.type !== FORM_NODE_TYPE)
4151
4594
  return null;
4152
4595
  const props = node.props ?? {};
4153
- const fields = [];
4154
- const taken = new Set();
4155
- for (const fieldNode of collectFieldNodes(node.children ?? [])) {
4156
- const spec = toFieldSpec(fieldNode, taken);
4157
- taken.add(spec.name);
4158
- fields.push(spec);
4159
- }
4596
+ const fieldNodes = collectFieldNodes(node.children ?? []);
4597
+ const names = uniqueFieldNames(fieldNodes.map(f => baseFieldName(f.props)));
4598
+ const fields = fieldNodes.map((f, i) => fieldSpecOf(f.props, names[i]));
4160
4599
  return {
4161
4600
  formId: props['formId'] ?? '',
4162
4601
  name: props['name'] ?? 'Form',
@@ -4187,31 +4626,6 @@ function collectFieldNodes(nodes) {
4187
4626
  }
4188
4627
  return out;
4189
4628
  }
4190
- function toFieldSpec(node, taken) {
4191
- const props = node.props ?? {};
4192
- const label = props['label'] ?? '';
4193
- // The author's explicit override wins; otherwise derive from the label.
4194
- const name = uniqueFieldName(props['name'] || deriveFieldName(label), taken);
4195
- const spec = {
4196
- name,
4197
- control: props['control'] ?? ControlType.Text,
4198
- label,
4199
- };
4200
- if (props['required'])
4201
- spec.required = true;
4202
- const rules = normaliseRules(props['rules']);
4203
- if (rules.length)
4204
- spec.rules = rules;
4205
- const options = fieldOptionsOf(props);
4206
- if (options.length)
4207
- spec.options = options;
4208
- if (props['optionsSource'])
4209
- spec.optionsSource = props['optionsSource'];
4210
- if (props['defaultValue'] !== undefined && props['defaultValue'] !== '') {
4211
- spec.defaultValue = props['defaultValue'];
4212
- }
4213
- return spec;
4214
- }
4215
4629
  /** Drop anything that isn't a rule we know — a hand-edited page must not break validation. */
4216
4630
  function normaliseRules(raw) {
4217
4631
  if (!Array.isArray(raw))
@@ -4340,8 +4754,9 @@ const TEXTUAL = [
4340
4754
  ControlType.Text, ControlType.Email, ControlType.Tel, ControlType.Url,
4341
4755
  ControlType.Password, ControlType.Textarea,
4342
4756
  ];
4343
- /** Controls with a comparable magnitude. */
4344
- const NUMERIC = [ControlType.Number, ControlType.Date, ControlType.Time, ControlType.Range];
4757
+ /** Controls with a comparable magnitude. A Date is not one of them: its bounds are DAYS (an ISO day or "N days from
4758
+ * today", `dates.ts`), set by the Field's own Earliest / Latest — a number box here read `2026-11-06` as 2026. */
4759
+ const NUMERIC = [ControlType.Number, ControlType.Time, ControlType.Range];
4345
4760
  const RULE_DESCRIPTORS = Object.freeze([
4346
4761
  {
4347
4762
  name: RuleName.MinLength, labelKey: 'forms.rules.minLength',
@@ -4528,12 +4943,16 @@ function parseClientFormSpec(raw) {
4528
4943
  return null;
4529
4944
  }
4530
4945
  }
4531
- /** While its form sends, a submit button is busy, and disabled unless its author turned that off — ONE rule for the
4532
- * canvas's Sending state, the component path and the string path. Its "Sending…" label is the shared CSS's, keyed on
4533
- * the same `data-iox-state`. */
4946
+ /**
4947
+ * While its form sends, a submit button is busy, and disabled unless its author turned that off; once the form has
4948
+ * SENT it stays disabled, reading "Sent" with its check — nothing sends a lead twice (premium N2, the success in place).
4949
+ * ONE rule for the canvas's Sending / Success states, the component path and the string path. Its labels are the shared
4950
+ * CSS's, keyed on the same `data-iox-state`.
4951
+ */
4534
4952
  function submitButtonView(state, disableWhileSubmitting = true) {
4535
4953
  const busy = state === FormRenderState.Submitting;
4536
- return { busy, disabled: busy && disableWhileSubmitting !== false };
4954
+ const sent = state === FormRenderState.Success;
4955
+ return { busy, disabled: sent || (busy && disableWhileSubmitting !== false) };
4537
4956
  }
4538
4957
  /** On a submit button whose author turned "Disable while sending" off. */
4539
4958
  const SUBMIT_KEEP_ENABLED_ATTRIBUTE = 'data-iox-keep-enabled';
@@ -4542,17 +4961,30 @@ var SubmitLabelClass;
4542
4961
  SubmitLabelClass["Labels"] = "iox-submit__labels";
4543
4962
  SubmitLabelClass["Idle"] = "iox-submit__idle";
4544
4963
  SubmitLabelClass["Busy"] = "iox-submit__busy";
4964
+ SubmitLabelClass["Sent"] = "iox-submit__sent";
4965
+ SubmitLabelClass["Check"] = "iox-submit__check";
4545
4966
  })(SubmitLabelClass || (SubmitLabelClass = {}));
4546
- /**
4547
- * A submit button's words, as markup: its label, or — when it has a "While sending" label — both, stacked in one
4548
- * cell (the button keeps the longer one's width and never jumps). The string renderer's copy of
4549
- * `IoxSubmitButtonComponent`'s template; `escape` is the renderer's.
4550
- */
4551
- function submitButtonLabelsHtml(label, loadingLabel, escape) {
4552
- if (!loadingLabel)
4553
- return escape(label);
4554
- return `<span class="${SubmitLabelClass.Labels}"><span class="${SubmitLabelClass.Idle}">${escape(label)}</span>` +
4555
- `<span class="${SubmitLabelClass.Busy}">${escape(loadingLabel)}</span></span>`;
4967
+ /** The check the Sent label draws once (premium N2 — the rare-tier beat, Emil 135/136): one stroke, `pathLength` 100 so
4968
+ * the shared CSS draws it by `stroke-dashoffset` whatever its real length. */
4969
+ const SUBMIT_CHECK_SVG = `<svg class="${SubmitLabelClass.Check}" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false">` +
4970
+ '<path d="M5 12.5l4.5 4.5L19 7.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
4971
+ /** "Sent" in the site's language, for a button whose author wrote no Sent words (a string-rendered site has no
4972
+ * translation service — the document's `lang` picks, like the date picker's words). */
4973
+ const SENT_WORDS = Object.freeze({ en: 'Sent', he: 'נשלח' });
4974
+ function sentWord(lang) {
4975
+ const language = String(lang ?? '').toLowerCase().split(/[-_]/)[0];
4976
+ return SENT_WORDS[language === 'iw' ? 'he' : language] ?? SENT_WORDS['en'];
4977
+ }
4978
+ /**
4979
+ * A submit button's words, as markup: its three labels stacked in one grid cell (the button keeps the longest one's
4980
+ * width and never jumps), each shown by the form's state through the shared CSS — swapped in place behind a 2px blur
4981
+ * (Emil 87/139). The string renderer's copy of `IoxSubmitButtonComponent`'s template; `escape` is the renderer's.
4982
+ */
4983
+ function submitButtonLabelsHtml(labels, escape) {
4984
+ const busy = labels.busy || labels.idle;
4985
+ return `<span class="${SubmitLabelClass.Labels}"><span class="${SubmitLabelClass.Idle}">${escape(labels.idle)}</span>` +
4986
+ `<span class="${SubmitLabelClass.Busy}">${escape(busy)}</span>` +
4987
+ `<span class="${SubmitLabelClass.Sent}">${SUBMIT_CHECK_SVG}<span>${escape(labels.sent)}</span></span></span>`;
4556
4988
  }
4557
4989
  const FORM_HONEYPOT_CLASS = 'iox-form__hp';
4558
4990
  const FORM_ANNOUNCER_ATTRIBUTE = 'data-iox-form-announcer';
@@ -4580,7 +5012,14 @@ var FormPresetId;
4580
5012
  FormPresetId["Newsletter"] = "newsletter";
4581
5013
  FormPresetId["Application"] = "application";
4582
5014
  FormPresetId["Booking"] = "booking";
5015
+ /** Premium N2: modelled on Doron's iox.studio brief — name, a phone that is also WhatsApp, the business, what they
5016
+ * need and a budget as chips, Send with WhatsApp beside it. */
5017
+ FormPresetId["Lead"] = "lead";
4583
5018
  })(FormPresetId || (FormPresetId = {}));
5019
+ /** The presets a generated plan may name (the plan wire's `form` enum) — every preset not held back by `inPlan`. */
5020
+ function planFormPresetIds() {
5021
+ return FORM_PRESETS.filter(p => p.inPlan !== false).map(p => p.id);
5022
+ }
4584
5023
  // forms-core is imported by both renderers (the field runtime), and a renderer that never reads the presets
4585
5024
 
4586
5025
  // to it by form-presets.spec) — a read of `FormPresetId.X` counts as a side effect to a bundler and kept the
@@ -4636,7 +5075,8 @@ const FORM_PRESETS = /* @__PURE__ */ Object.freeze([
4636
5075
  fields: [
4637
5076
  { label: 'Name', control: 'text', required: true },
4638
5077
  { label: 'Email', control: 'email', required: true },
4639
- { label: 'Date', control: 'date', required: true },
5078
+ // The first day a booking may ask for is today (N8): the calendar refuses the past.
5079
+ { label: 'Date', control: 'date', required: true, earliest: '0' },
4640
5080
  { label: 'People', control: 'number', required: true },
4641
5081
  { label: 'Notes', control: 'textarea', rows: 3, help: 'Allergies, access needs, anything useful' },
4642
5082
  ],
@@ -4644,6 +5084,27 @@ const FORM_PRESETS = /* @__PURE__ */ Object.freeze([
4644
5084
  successText: 'Request received — we’ll confirm by email.',
4645
5085
  errorText: 'We couldn’t send your request. Please try again.',
4646
5086
  },
5087
+ {
5088
+ id: 'lead', name: 'Lead', icon: 'ph-thin ph-handshake',
5089
+ description: 'Name, phone, the business and what they need, as chips. Stored, with WhatsApp beside Send.',
5090
+ formName: 'Lead form',
5091
+ fields: [
5092
+ { label: 'Name', control: 'text', required: true },
5093
+ { label: 'Phone / WhatsApp', control: 'tel', required: true },
5094
+ { label: 'What is your business?', control: 'text' },
5095
+ // The words ARE the values: what arrives in the inbox and the email reads as the visitor chose it.
5096
+ { label: 'What do you need?', control: 'choices', multiple: true,
5097
+ optionsText: 'Website\nBranding\nPhotos & video\nNot sure yet' },
5098
+ // Neutral sizes the author renames — never a price or a currency a studio could be held to.
5099
+ { label: 'Estimated budget', control: 'choices',
5100
+ optionsText: 'Small project\nMedium\nLarge\nNot sure yet' },
5101
+ ],
5102
+ submitLabel: 'Send',
5103
+ successText: 'Thanks — we’ll get back to you soon.',
5104
+ errorText: 'We couldn’t send your details. Please try again — your answers are still here.',
5105
+ channel: { kind: 'whatsapp', label: 'Message us on WhatsApp' },
5106
+ inPlan: false,
5107
+ },
4647
5108
  ]);
4648
5109
  /** The preset with this id, or undefined. */
4649
5110
  function formPresetById(id) {
@@ -4724,6 +5185,222 @@ function findFormContentInHosts(roots) {
4724
5185
  return out;
4725
5186
  }
4726
5187
 
5188
+ /**
5189
+ * THE SECOND CHANNEL (premium components N2) — "or message us on WhatsApp", the button a form keeps beside its submit,
5190
+ * before AND after it sends.
5191
+ *
5192
+ * It is an ordinary node in the form's actions row (a ButtonBlock that links) carrying the ROLE `form.channel`, so the
5193
+ * shared CSS keeps it on screen when the form succeeds and the Publishable gate can find it. Its address is the
5194
+ * OWNER's fact: the builder builds it from what the author types (a number, an address, a link) and never invents one —
5195
+ * inserted empty, it blocks publishing until it is filled (generation-core `auditSite`, `empty-channel`).
5196
+ *
5197
+ * Framework-free, like the rest of forms-core.
5198
+ */
5199
+ /** The role the channel's node carries (`iox-role-form-channel`). */
5200
+ const FORM_CHANNEL_ROLE = 'form.channel';
5201
+ /** The role of the row that holds the submit button and the channel (`iox-role-form-actions`). */
5202
+ const FORM_ACTIONS_ROLE = 'form.actions';
5203
+ var ChannelKind;
5204
+ (function (ChannelKind) {
5205
+ ChannelKind["WhatsApp"] = "whatsapp";
5206
+ ChannelKind["Phone"] = "phone";
5207
+ ChannelKind["Email"] = "email";
5208
+ ChannelKind["Link"] = "link";
5209
+ })(ChannelKind || (ChannelKind = {}));
5210
+ const CHANNEL_KINDS = [ChannelKind.WhatsApp, ChannelKind.Phone, ChannelKind.Email, ChannelKind.Link];
5211
+ const WHATSAPP_PREFIX = 'https://wa.me/';
5212
+ const TEL_PREFIX = 'tel:';
5213
+ const MAILTO_PREFIX = 'mailto:';
5214
+ const HTTP = /^https?:\/\//i;
5215
+ const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
5216
+ /** A phone number's own characters; anything else is typing noise (spaces, dashes, brackets). */
5217
+ const DIGITS = /\d/g;
5218
+ /** WhatsApp's click-to-chat wants the full international number: digits only, no `+`, no leading `00`. */
5219
+ const INTERNATIONAL_00 = /^00/;
5220
+ /** A number shorter than this is not a number anyone can be reached on. */
5221
+ const MIN_DIGITS = 6;
5222
+ /**
5223
+ * The address a channel links to, built from what the author typed — or `''` when it gives nothing a visitor could
5224
+ * use (no digits, not an email). Never a guess: a WhatsApp number keeps exactly the digits typed (the panel asks for
5225
+ * the country code), a link without a scheme gets `https://`.
5226
+ */
5227
+ function channelHref(kind, input) {
5228
+ const raw = typeof input === 'string' ? input.trim() : '';
5229
+ if (!raw)
5230
+ return '';
5231
+ switch (kind) {
5232
+ case ChannelKind.WhatsApp: {
5233
+ const digits = (raw.match(DIGITS) ?? []).join('').replace(INTERNATIONAL_00, '');
5234
+ return digits.length >= MIN_DIGITS ? `${WHATSAPP_PREFIX}${digits}` : '';
5235
+ }
5236
+ case ChannelKind.Phone: {
5237
+ const digits = (raw.match(DIGITS) ?? []).join('');
5238
+ if (digits.length < MIN_DIGITS)
5239
+ return '';
5240
+ return `${TEL_PREFIX}${raw.startsWith('+') ? '+' : ''}${digits}`;
5241
+ }
5242
+ case ChannelKind.Email:
5243
+ return EMAIL.test(raw) ? `${MAILTO_PREFIX}${raw}` : '';
5244
+ case ChannelKind.Link:
5245
+ return HTTP.test(raw) ? raw : `https://${raw.replace(/^\/+/, '')}`;
5246
+ default:
5247
+ return '';
5248
+ }
5249
+ }
5250
+ /** A channel's address back into what the panel shows: its kind and the value the author typed. */
5251
+ function parseChannelHref(href) {
5252
+ const text = typeof href === 'string' ? href.trim() : '';
5253
+ if (text.toLowerCase().startsWith(WHATSAPP_PREFIX))
5254
+ return { kind: ChannelKind.WhatsApp, value: text.slice(WHATSAPP_PREFIX.length).split(/[?#]/)[0] };
5255
+ if (text.toLowerCase().startsWith(TEL_PREFIX))
5256
+ return { kind: ChannelKind.Phone, value: text.slice(TEL_PREFIX.length) };
5257
+ if (text.toLowerCase().startsWith(MAILTO_PREFIX))
5258
+ return { kind: ChannelKind.Email, value: text.slice(MAILTO_PREFIX.length).split('?')[0] };
5259
+ if (!text)
5260
+ return { kind: ChannelKind.WhatsApp, value: '' };
5261
+ return { kind: ChannelKind.Link, value: text };
5262
+ }
5263
+ /** True when the channel goes somewhere a visitor can use — the gate's "filled". */
5264
+ function isChannelFilled(href) {
5265
+ const { kind, value } = parseChannelHref(href);
5266
+ return !!value && channelHref(kind, value) !== '';
5267
+ }
5268
+ /** A WhatsApp or web channel opens in a new tab; a call or an email hands over to the device. */
5269
+ function channelOpensNewTab(kind) {
5270
+ return kind === ChannelKind.WhatsApp || kind === ChannelKind.Link;
5271
+ }
5272
+
5273
+ /**
5274
+ * A Field as MARKUP — the string renderer's copy of `IoxFieldComponent`'s template, in ONE place (it lived in the
5275
+ * engine's `layout-renderer.service.ts`, a second copy beside the component). The engine's string path writes it; the
5276
+ * component mirrors it element for element, so hydration replaces like with like and the shared CSS (iox-forms.css,
5277
+ * the contract's field baseline) reaches both.
5278
+ *
5279
+ * Choices (premium N1): a `<fieldset>` whose `<legend>` is the label, its options native radios (one answer — the
5280
+ * arrow keys move and pick, with no script) or checkboxes (several), each inside a `<label>` that IS the chip; the
5281
+ * input is visually hidden, never removed. Date (N8): a `<button>` that says the date in words and opens the look's
5282
+ * calendar, with a hidden input carrying the ISO day — the raw SSR is the final markup (nothing shifts on hydrate).
5283
+ *
5284
+ * Framework-free, like the rest of forms-core: `escape` is the caller's.
5285
+ */
5286
+ /** The classes the markup carries — the runtimes and the shared CSS key on these, never on a literal. */
5287
+ var FieldClass;
5288
+ (function (FieldClass) {
5289
+ FieldClass["Root"] = "iox-field";
5290
+ FieldClass["Label"] = "iox-field__label";
5291
+ FieldClass["LabelHidden"] = "iox-field__label--hidden";
5292
+ FieldClass["Required"] = "iox-field__required";
5293
+ FieldClass["Control"] = "iox-field__control";
5294
+ FieldClass["Help"] = "iox-field__help";
5295
+ FieldClass["Error"] = "iox-field__error";
5296
+ FieldClass["Group"] = "iox-field__group";
5297
+ FieldClass["Option"] = "iox-field__option";
5298
+ FieldClass["Choices"] = "iox-field__choices";
5299
+ FieldClass["ChoicesChips"] = "iox-field__choices--chips";
5300
+ FieldClass["ChoicesList"] = "iox-field__choices--list";
5301
+ FieldClass["Chips"] = "iox-field__chips";
5302
+ FieldClass["Chip"] = "iox-field__chip";
5303
+ FieldClass["ChipInput"] = "iox-field__control--chip";
5304
+ FieldClass["DateButton"] = "iox-field__control--date";
5305
+ FieldClass["DateValue"] = "iox-field__date-value";
5306
+ FieldClass["DateIcon"] = "iox-field__date-icon";
5307
+ FieldClass["DateInput"] = "iox-field__date-input";
5308
+ })(FieldClass || (FieldClass = {}));
5309
+ /** On the date button: the raw Earliest / Latest (`'0'`, an ISO day), resolved by the visitor's own today. */
5310
+ const DATE_MIN_ATTRIBUTE = 'data-iox-date-min';
5311
+ const DATE_MAX_ATTRIBUTE = 'data-iox-date-max';
5312
+ /** On the date's words while no day is chosen (the placeholder's colour keys on it). */
5313
+ const DATE_EMPTY_ATTRIBUTE = 'data-iox-empty';
5314
+ /** The calendar glyph inside the date button: one path set, drawn in currentColor, never announced. */
5315
+ const DATE_ICON_SVG = '<svg class="iox-field__date-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
5316
+ '<rect x="3.5" y="5" width="17" height="15.5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/>' +
5317
+ '<path d="M3.5 10h17M8 3v4M16 3v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
5318
+ /** The controls that are a plain `<input type=…>`; anything else unknown is a text input. */
5319
+ const INPUT_TYPES = new Set(['email', 'tel', 'url', 'number', 'password', 'time', 'file', 'hidden']);
5320
+ /** The `<input type>` of a control that renders as a plain input. */
5321
+ function inputTypeOf(control) {
5322
+ return INPUT_TYPES.has(control) ? control : 'text';
5323
+ }
5324
+ /** A Choices field's appearance; anything unknown reads as chips (the default). */
5325
+ function choiceAppearanceOf(props) {
5326
+ return props?.['appearance'] === ChoiceAppearance.List ? ChoiceAppearance.List : ChoiceAppearance.Chips;
5327
+ }
5328
+ /** A Field's whole element, as the string renderer writes it. */
5329
+ function fieldHtml(props, options) {
5330
+ const p = props ?? {};
5331
+ const { name, id, escape: esc } = options;
5332
+ const control = String(p['control'] ?? ControlType.Text);
5333
+ const required = !!p['required'];
5334
+ const help = p['help'] ? `<p class="${FieldClass.Help}" id="${id}-help">${esc(String(p['help']))}</p>` : '';
5335
+ const describedBy = p['help'] ? ` aria-describedby="${id}-help"` : '';
5336
+ const labelClass = p['labelHidden'] ? `${FieldClass.Label} ${FieldClass.LabelHidden}` : FieldClass.Label;
5337
+ const mark = required ? `<span class="${FieldClass.Required}" aria-hidden="true">*</span>` : '';
5338
+ const text = esc(String(p['label'] ?? ''));
5339
+ const label = `<label class="${labelClass}" id="${id}-label" for="${id}">${text}${mark}</label>`;
5340
+ const hidden = control === ControlType.Hidden ? ' hidden' : '';
5341
+ const open = `<div class="${FieldClass.Root} ${options.cls}" data-iox-field="${esc(name)}"${hidden}${options.attrs ?? ''}>`;
5342
+ if (control === ControlType.Choices) {
5343
+ return `${open}${choicesHtml(p, options, `<legend class="${labelClass}" id="${id}-label">${text}${mark}</legend>`, describedBy)}${help}</div>`;
5344
+ }
5345
+ return `${open}${label}${controlHtml(p, control, options, describedBy)}${help}</div>`;
5346
+ }
5347
+ function controlHtml(p, control, options, describedBy) {
5348
+ const { id, escape: esc } = options;
5349
+ const name = esc(options.name);
5350
+ const required = p['required'] ? ' required' : '';
5351
+ const placeholder = p['placeholder'] ? ` placeholder="${esc(String(p['placeholder']))}"` : '';
5352
+ const choices = fieldOptionsOf(p);
5353
+ const option = (o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`;
5354
+ const num = (v, d) => (Number.isFinite(Number(v)) && v !== '' && v !== null ? Number(v) : d);
5355
+ switch (control) {
5356
+ case ControlType.Textarea:
5357
+ return `<textarea class="${FieldClass.Control}" id="${id}" name="${name}" rows="${num(p['rows'], 4)}"${placeholder}${required}${describedBy}></textarea>`;
5358
+ case ControlType.Select:
5359
+ return `<select class="${FieldClass.Control}" id="${id}" name="${name}"${required}${describedBy}>` +
5360
+ `<option value="">${esc(String(p['placeholder'] ?? ''))}</option>${choices.map(option).join('')}</select>`;
5361
+ case ControlType.Switch:
5362
+ return `<input class="${FieldClass.Control} iox-field__control--switch" type="checkbox" role="switch" id="${id}" name="${name}"${describedBy}>`;
5363
+ case ControlType.Range:
5364
+ return `<input class="${FieldClass.Control} iox-field__control--range" type="range" id="${id}" name="${name}" ` +
5365
+ `min="${num(p['rangeMin'], 0)}" max="${num(p['rangeMax'], 100)}" step="${num(p['rangeStep'], 1)}"${describedBy}>`;
5366
+ case ControlType.Radio:
5367
+ return `<div class="${FieldClass.Group}" role="radiogroup" aria-labelledby="${id}-label">` +
5368
+ choices.map(o => `<label class="${FieldClass.Option}"><input class="${FieldClass.Control} iox-field__control--radio" type="radio" name="${name}" ` +
5369
+ `value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`).join('') + `</div>`;
5370
+ case ControlType.Checkbox:
5371
+ return `<input class="${FieldClass.Control} iox-field__control--checkbox" type="checkbox" id="${id}" name="${name}"${required}${describedBy}>`;
5372
+ case ControlType.Date:
5373
+ return dateHtml(p, options, describedBy);
5374
+ default:
5375
+ return `<input class="${FieldClass.Control}" id="${id}" name="${name}" type="${inputTypeOf(control)}"${placeholder}${required}${describedBy}>`;
5376
+ }
5377
+ }
5378
+ /** A Choices field's fieldset: the legend, then the options as chips or as a list. */
5379
+ function choicesHtml(p, options, legend, describedBy) {
5380
+ const { escape: esc } = options;
5381
+ const name = esc(options.name);
5382
+ const multiple = p['multiple'] === true;
5383
+ const type = multiple ? 'checkbox' : 'radio';
5384
+ const chips = choiceAppearanceOf(p) === ChoiceAppearance.Chips;
5385
+ const items = fieldOptionsOf(p).map(o => chips
5386
+ ? `<label class="${FieldClass.Chip}"><input class="${FieldClass.Control} ${FieldClass.ChipInput}" type="${type}" name="${name}" value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`
5387
+ : `<label class="${FieldClass.Option}"><input class="${FieldClass.Control}" type="${type}" name="${name}" value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`).join('');
5388
+ const look = chips ? FieldClass.ChoicesChips : FieldClass.ChoicesList;
5389
+ const row = chips ? FieldClass.Chips : FieldClass.Group;
5390
+ return `<fieldset class="${FieldClass.Choices} ${look}"${describedBy}>${legend}<div class="${row}">${items}</div></fieldset>`;
5391
+ }
5392
+ /** The date picker's button + hidden input. The calendar itself is the runtime's, opened on press. */
5393
+ function dateHtml(p, options, describedBy) {
5394
+ const { id, escape: esc } = options;
5395
+ const words = datePickerWords(options.lang);
5396
+ const empty = esc(String(p['placeholder'] || words.choose));
5397
+ const bound = (attr, raw) => (typeof raw === 'string' && raw.trim() !== '') || typeof raw === 'number' ? ` ${attr}="${esc(String(raw).trim())}"` : '';
5398
+ return `<button type="button" class="${FieldClass.Control} ${FieldClass.DateButton}" id="${id}" aria-haspopup="dialog" aria-expanded="false" ` +
5399
+ `aria-labelledby="${id}-label ${id}-value"${describedBy}${bound(DATE_MIN_ATTRIBUTE, p['earliest'])}${bound(DATE_MAX_ATTRIBUTE, p['latest'])}>` +
5400
+ `<span class="${FieldClass.DateValue}" id="${id}-value" ${DATE_EMPTY_ATTRIBUTE}>${empty}</span>${DATE_ICON_SVG}</button>` +
5401
+ `<input type="hidden" class="${FieldClass.DateInput}" name="${esc(options.name)}" value="">`;
5402
+ }
5403
+
4727
5404
  /*
4728
5405
  * @vectoriox/iox-ui/forms-core — the shared, FRAMEWORK-FREE core of the forms feature.
4729
5406
  *
@@ -4758,7 +5435,7 @@ const defaultMessageResolver = (key, params) => {
4758
5435
  */
4759
5436
  class FormRuntimeService {
4760
5437
  constructor() {
4761
- /** Registered fields, in DOM order — the order the spec and focus-first-invalid depend on. */
5438
+ /** Registered fields, in DOM order — the order the spec, the unique names and focus-first-invalid depend on. */
4762
5439
  this.fields = [];
4763
5440
  this._state = INITIAL_FORM_STATE;
4764
5441
  this._spec = null;
@@ -4784,19 +5461,29 @@ class FormRuntimeService {
4784
5461
  this.fields.splice(index, 1);
4785
5462
  };
4786
5463
  }
5464
+ /** Every registered field's submitted name, in order — unique within the form (iox-ui#43). */
5465
+ names() {
5466
+ return uniqueFieldNames(this.fields.map(f => (f.baseName ? f.baseName() : f.spec('').name)));
5467
+ }
5468
+ /** The name the form gives one registered field, or null when it is not this form's. */
5469
+ nameOf(field) {
5470
+ const index = this.fields.indexOf(field);
5471
+ return index < 0 ? null : this.names()[index];
5472
+ }
4787
5473
  /** The live spec, derived from what is registered right now. */
4788
5474
  specNow() {
4789
5475
  const base = this._spec ?? {
4790
5476
  formId: '', name: '', feedback: FeedbackTiming.Smart,
4791
5477
  fields: [], actions: [], antiSpam: { honeypot: true, minFillMs: 1200 },
4792
5478
  };
4793
- return { ...base, fields: this.fields.map(f => f.spec()) };
5479
+ const names = this.names();
5480
+ return { ...base, fields: this.fields.map((f, i) => ({ ...f.spec(names[i]), name: names[i] })) };
4794
5481
  }
4795
5482
  /** Current values, keyed by submitted field name. */
4796
5483
  values() {
4797
5484
  const out = {};
4798
- for (const field of this.fields)
4799
- out[field.spec().name] = field.getValue();
5485
+ const names = this.names();
5486
+ this.fields.forEach((field, i) => { out[names[i]] = field.getValue(); });
4800
5487
  return out;
4801
5488
  }
4802
5489
  onChange(name) {
@@ -4829,8 +5516,7 @@ class FormRuntimeService {
4829
5516
  }
4830
5517
  /** The first invalid field in registration (DOM) order — what submit should focus. */
4831
5518
  firstInvalidName() {
4832
- for (const field of this.fields) {
4833
- const name = field.spec().name;
5519
+ for (const name of this.names()) {
4834
5520
  if (this._state.errors[name])
4835
5521
  return name;
4836
5522
  }
@@ -4843,6 +5529,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
4843
5529
  type: Injectable
4844
5530
  }] });
4845
5531
 
5532
+ /**
5533
+ * The shake a failing field gives after a refused submit (premium components N2) — Emil 134: the named effect for
5534
+ * rejected input. ONE function for the component path, the string path and the canvas (a field put in its error state
5535
+ * on the canvas shakes once, so the author sees the motion they are designing).
5536
+ *
5537
+ * WAAPI, `composite: 'add'`, so it moves the field from wherever its own transform puts it. A second refused submit
5538
+ * CANCELS the running shake and starts clean — never two stacked (Emil 52/62). Under reduced motion there is no shake:
5539
+ * focus and the message still happen (Emil 192 — the meaning survives without the movement).
5540
+ */
5541
+ /** ±6 → ±4 → rest: a decaying side-to-side (the prototype's, Doron-approved). */
5542
+ const SHAKE_KEYFRAMES = Object.freeze([
5543
+ { transform: 'translateX(0px)' },
5544
+ { transform: 'translateX(-6px)' },
5545
+ { transform: 'translateX(6px)' },
5546
+ { transform: 'translateX(-4px)' },
5547
+ { transform: 'translateX(4px)' },
5548
+ { transform: 'translateX(0px)' },
5549
+ ]);
5550
+ /** Under Emil's UI budget (28) and long enough to read as "no" (134). */
5551
+ const SHAKE_DURATION_MS = 320;
5552
+ /** Emil 18 — the strong ease-out. */
5553
+ const SHAKE_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';
5554
+ const RUNNING = '__ioxShake';
5555
+ /** Shake `el` once; returns the animation, or null when nothing plays (no element, no WAAPI, reduced motion). */
5556
+ function shakeElement(el, reducedMotion = prefersReducedMotion()) {
5557
+ if (!el)
5558
+ return null;
5559
+ const host = el;
5560
+ host[RUNNING]?.cancel();
5561
+ delete host[RUNNING];
5562
+ if (reducedMotion || typeof el.animate !== 'function')
5563
+ return null;
5564
+ const animation = el.animate(SHAKE_KEYFRAMES, { duration: SHAKE_DURATION_MS, easing: SHAKE_EASING, composite: 'add' });
5565
+ host[RUNNING] = animation;
5566
+ animation.onfinish = () => { if (host[RUNNING] === animation)
5567
+ delete host[RUNNING]; };
5568
+ return animation;
5569
+ }
5570
+
4846
5571
  const SUBMIT_BUTTON_SELECTOR = '.iox-submit';
4847
5572
  const FORM_SELECTOR = 'form, .iox-form';
4848
5573
  /**
@@ -4963,6 +5688,9 @@ class IoxFormComponent {
4963
5688
  // Never let the browser navigate: a full page POST would lose the designed Success state
4964
5689
  // and every value the visitor typed.
4965
5690
  event.preventDefault();
5691
+ // Sent is final (premium N2): the button reads "Sent" and is disabled — an Enter in a field sends nothing twice.
5692
+ if (this.renderState === FormRenderState.Success)
5693
+ return;
4966
5694
  this.runtime.configure(this.runtime.specNow(), this.feedback);
4967
5695
  if (!this.runtime.validateAll()) {
4968
5696
  this.announce(this.runtime.state.errors);
@@ -5051,6 +5779,7 @@ class IoxFormComponent {
5051
5779
  // A microtask is enough for the empty value to be committed before the new one lands.
5052
5780
  Promise.resolve().then(() => { this.announcement = next; });
5053
5781
  }
5782
+ /** Focus the first field that failed and shake it once (premium N2 — Emil 134; none under reduced motion). */
5054
5783
  focusFirstInvalid() {
5055
5784
  const name = this.runtime.firstInvalidName();
5056
5785
  if (!name)
@@ -5063,6 +5792,7 @@ class IoxFormComponent {
5063
5792
  if (element.getAttribute('data-iox-field') !== name)
5064
5793
  continue;
5065
5794
  element.querySelector('.iox-field__control')?.focus();
5795
+ shakeElement(element);
5066
5796
  return;
5067
5797
  }
5068
5798
  }
@@ -5138,6 +5868,449 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5138
5868
  args: ['childrenHost', { read: ViewContainerRef, static: true }]
5139
5869
  }] } });
5140
5870
 
5871
+ /**
5872
+ * placeFloating — where a panel anchored to a control goes (premium components N8, reused by M5's tooltip and popover).
5873
+ *
5874
+ * ONE pure decision for every floating panel: under its anchor, aligned to the anchor's reading start (the left edge in
5875
+ * LTR, the right in RTL); above it when there is no room below and more above; held inside the viewport with a margin;
5876
+ * and the transform-origin AT the anchor, so the panel grows from the control that opened it (Emil 47) — even when the
5877
+ * clamp moved it sideways.
5878
+ *
5879
+ * Lives in the main entry: nothing server-side places a panel, and the date picker (now) and the floating family (M5)
5880
+ * are DOM runtimes of the main entry. Pure — no DOM read here: the caller measures, this decides, the caller writes.
5881
+ */
5882
+ var FloatingSide;
5883
+ (function (FloatingSide) {
5884
+ FloatingSide["Below"] = "below";
5885
+ FloatingSide["Above"] = "above";
5886
+ })(FloatingSide || (FloatingSide = {}));
5887
+ /** Emil's popover spacing: close enough to read as the control's, clear of its focus ring. */
5888
+ const FLOATING_GAP = 8;
5889
+ /** The viewport margin — a panel never touches the screen's edge. */
5890
+ const FLOATING_MARGIN = 12;
5891
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
5892
+ /** Where `panel` goes beside `anchor` inside `viewport`. */
5893
+ function placeFloating(anchor, panel, viewport, options = {}) {
5894
+ const gap = options.gap ?? FLOATING_GAP;
5895
+ const margin = options.margin ?? FLOATING_MARGIN;
5896
+ const anchorBottom = anchor.top + anchor.height;
5897
+ const roomBelow = viewport.height - anchorBottom - gap - margin;
5898
+ const roomAbove = anchor.top - gap - margin;
5899
+ // Below is the default; above only when below is too short AND above is roomier (a field near the screen's foot).
5900
+ const side = roomBelow >= panel.height || roomBelow >= roomAbove ? FloatingSide.Below : FloatingSide.Above;
5901
+ const wantTop = side === FloatingSide.Below ? anchorBottom + gap : anchor.top - gap - panel.height;
5902
+ const top = clamp(wantTop, margin, viewport.height - margin - panel.height);
5903
+ const start = options.rtl ? anchor.left + anchor.width : anchor.left;
5904
+ const wantLeft = options.rtl ? start - panel.width : start;
5905
+ const left = clamp(wantLeft, margin, viewport.width - margin - panel.width);
5906
+ // The origin is the anchor's start corner, in the panel's own coordinates, kept on the panel's box.
5907
+ const originX = Math.round(clamp(start - left, 0, panel.width));
5908
+ const originY = side === FloatingSide.Below ? 0 : Math.round(panel.height);
5909
+ return { top: Math.round(top), left: Math.round(left), side, origin: `${originX}px ${originY}px` };
5910
+ }
5911
+
5912
+ var CalendarClass;
5913
+ (function (CalendarClass) {
5914
+ CalendarClass["Panel"] = "iox-cal";
5915
+ CalendarClass["Grab"] = "iox-cal__grab";
5916
+ CalendarClass["Head"] = "iox-cal__head";
5917
+ CalendarClass["Title"] = "iox-cal__title";
5918
+ CalendarClass["Nav"] = "iox-cal__nav";
5919
+ CalendarClass["Prev"] = "iox-cal__prev";
5920
+ CalendarClass["Next"] = "iox-cal__next";
5921
+ CalendarClass["Grid"] = "iox-cal__grid";
5922
+ CalendarClass["Weekday"] = "iox-cal__weekday";
5923
+ CalendarClass["Cell"] = "iox-cal__cell";
5924
+ CalendarClass["Day"] = "iox-cal__day";
5925
+ })(CalendarClass || (CalendarClass = {}));
5926
+ /** On the panel while it leaves (the shared CSS plays the exit on it). */
5927
+ const CALENDAR_CLOSING_ATTRIBUTE = 'data-iox-closing';
5928
+ /** On a panel opened from the KEYBOARD: it appears and goes with no motion (Emil 2 — keyboard actions never animate). */
5929
+ const CALENDAR_INSTANT_ATTRIBUTE = 'data-iox-instant';
5930
+ /** On the panel the builder canvas draws for its design-time Open state (in the flow, inert). */
5931
+ const CALENDAR_DESIGN_ATTRIBUTE = 'data-iox-cal-design';
5932
+ /** A phone gets the sheet (the drawer curve, the scrim) — the same width the shared CSS keys on. */
5933
+ const CALENDAR_SHEET_QUERY = '(max-width: 767px)';
5934
+ const RUNTIME = '__ioxDatePicker';
5935
+ const ISO_ATTRIBUTE = 'data-iox-iso';
5936
+ const CHEVRON_BACK = 'M15 18l-6-6 6-6';
5937
+ const CHEVRON_AHEAD = 'M9 18l6-6-6-6';
5938
+ /** An 1 Feb 2026 is a Sunday: day `i` of the week is `SUNDAY + i`. */
5939
+ const A_SUNDAY = new Date(2026, 1, 1);
5940
+ let calendarSeq = 0;
5941
+ const chevron = (doc, d) => {
5942
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
5943
+ svg.setAttribute('width', '18');
5944
+ svg.setAttribute('height', '18');
5945
+ svg.setAttribute('viewBox', '0 0 24 24');
5946
+ svg.setAttribute('aria-hidden', 'true');
5947
+ svg.setAttribute('focusable', 'false');
5948
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
5949
+ path.setAttribute('d', d);
5950
+ path.setAttribute('fill', 'none');
5951
+ path.setAttribute('stroke', 'currentColor');
5952
+ path.setAttribute('stroke-width', '2');
5953
+ path.setAttribute('stroke-linecap', 'round');
5954
+ path.setAttribute('stroke-linejoin', 'round');
5955
+ svg.appendChild(path);
5956
+ return svg;
5957
+ };
5958
+ const el = (doc, tag, cls, attrs = {}) => {
5959
+ const node = doc.createElement(tag);
5960
+ if (cls)
5961
+ node.className = cls;
5962
+ for (const [k, v] of Object.entries(attrs))
5963
+ node.setAttribute(k, v);
5964
+ return node;
5965
+ };
5966
+ /** The language the field speaks: the host's, the nearest `[lang]`, the document's. */
5967
+ function fieldLanguage(field, lang) {
5968
+ return lang || field.closest('[lang]')?.getAttribute('lang') || field.ownerDocument?.documentElement?.lang || 'en';
5969
+ }
5970
+ /** The ms a transition list takes, the longest entry (duration + delay) — how long an exit plays. */
5971
+ function transitionMs(node) {
5972
+ const style = node.ownerDocument?.defaultView?.getComputedStyle(node);
5973
+ if (!style)
5974
+ return 0;
5975
+ const parse = (list) => list.split(',').map(t => (t.trim().endsWith('ms') ? parseFloat(t) : parseFloat(t) * 1000) || 0);
5976
+ const durations = parse(style.transitionDuration);
5977
+ const delays = parse(style.transitionDelay);
5978
+ return Math.max(0, ...durations.map((d, i) => d + (delays[i % delays.length] ?? 0)));
5979
+ }
5980
+ /** The panel's skeleton: grab handle, the month and its arrows, the weekday row. The body is `paintMonth`'s. */
5981
+ function buildPanel(doc, tag, words, locale, weekdays, rtl) {
5982
+ const id = `iox-cal-${++calendarSeq}`;
5983
+ const panel = el(doc, tag, CalendarClass.Panel, { 'aria-label': words.choose, dir: rtl ? 'rtl' : 'ltr', 'data-lenis-prevent': '' });
5984
+ panel.appendChild(el(doc, 'span', CalendarClass.Grab, { 'aria-hidden': 'true' }));
5985
+ const head = el(doc, 'div', CalendarClass.Head);
5986
+ const title = el(doc, 'h2', CalendarClass.Title, { id: `${id}-title`, 'aria-live': 'polite' });
5987
+ const nav = el(doc, 'div', CalendarClass.Nav);
5988
+ const prev = el(doc, 'button', CalendarClass.Prev, { type: 'button', 'aria-label': words.previousMonth, title: words.previousMonth });
5989
+ const next = el(doc, 'button', CalendarClass.Next, { type: 'button', 'aria-label': words.nextMonth, title: words.nextMonth });
5990
+ prev.appendChild(chevron(doc, CHEVRON_BACK));
5991
+ next.appendChild(chevron(doc, CHEVRON_AHEAD));
5992
+ nav.append(prev, next);
5993
+ head.append(title, nav);
5994
+ const table = el(doc, 'table', CalendarClass.Grid, { role: 'grid', 'aria-labelledby': `${id}-title` });
5995
+ const row = el(doc, 'tr');
5996
+ const short = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
5997
+ const long = new Intl.DateTimeFormat(locale, { weekday: 'long' });
5998
+ for (const dow of weekdays) {
5999
+ const day = new Date(A_SUNDAY.getFullYear(), A_SUNDAY.getMonth(), A_SUNDAY.getDate() + dow);
6000
+ const th = el(doc, 'th', CalendarClass.Weekday, { scope: 'col', abbr: long.format(day) });
6001
+ th.textContent = short.format(day);
6002
+ row.appendChild(th);
6003
+ }
6004
+ const thead = el(doc, 'thead');
6005
+ thead.appendChild(row);
6006
+ table.append(thead, el(doc, 'tbody'));
6007
+ panel.append(head, table);
6008
+ return panel;
6009
+ }
6010
+ /** Draw one month into the panel: its title, its six rows, which arrows can move. */
6011
+ function paintMonth(panel, model, locale) {
6012
+ const doc = panel.ownerDocument;
6013
+ const title = panel.querySelector(`.${CalendarClass.Title}`);
6014
+ if (title)
6015
+ title.textContent = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(model.year, model.month, 1));
6016
+ const full = new Intl.DateTimeFormat(locale, { dateStyle: 'full' });
6017
+ const body = panel.querySelector('tbody');
6018
+ body.textContent = '';
6019
+ for (const week of model.weeks) {
6020
+ const tr = el(doc, 'tr');
6021
+ for (const day of week) {
6022
+ const td = el(doc, 'td', CalendarClass.Cell, { role: 'gridcell' });
6023
+ if (day) {
6024
+ const button = el(doc, 'button', CalendarClass.Day, { type: 'button', tabindex: day.focus ? '0' : '-1', [ISO_ATTRIBUTE]: day.iso });
6025
+ button.setAttribute('aria-label', full.format(new Date(model.year, model.month, day.day)));
6026
+ button.textContent = String(day.day);
6027
+ if (day.today)
6028
+ button.setAttribute('aria-current', 'date');
6029
+ if (day.disabled)
6030
+ button.setAttribute('aria-disabled', 'true');
6031
+ if (day.selected)
6032
+ td.setAttribute('aria-selected', 'true');
6033
+ td.appendChild(button);
6034
+ }
6035
+ tr.appendChild(td);
6036
+ }
6037
+ body.appendChild(tr);
6038
+ }
6039
+ panel.querySelector(`.${CalendarClass.Prev}`)?.toggleAttribute('disabled', !model.canPrev);
6040
+ panel.querySelector(`.${CalendarClass.Next}`)?.toggleAttribute('disabled', !model.canNext);
6041
+ }
6042
+ /** The date field's parts. */
6043
+ function partsOf(field) {
6044
+ return {
6045
+ button: field.querySelector(`button.${FieldClass.DateButton}`),
6046
+ input: field.querySelector(`input.${FieldClass.DateInput}`),
6047
+ words: field.querySelector(`.${FieldClass.DateValue}`),
6048
+ };
6049
+ }
6050
+ /** Write a day (or nothing) into a date field: the hidden input carries it, the button says it in words. */
6051
+ function setDateFieldValue(field, iso, options = {}) {
6052
+ const { input, words } = partsOf(field);
6053
+ const day = isIsoDate(iso) ? iso : '';
6054
+ if (input)
6055
+ input.value = day;
6056
+ if (!words)
6057
+ return;
6058
+ const lang = fieldLanguage(field, options.lang);
6059
+ if (day) {
6060
+ words.textContent = formatDateInWords(day, lang, options.today ?? todayIso());
6061
+ words.removeAttribute(DATE_EMPTY_ATTRIBUTE);
6062
+ }
6063
+ else {
6064
+ words.textContent = options.placeholder ?? words.getAttribute('data-iox-placeholder') ?? datePickerWords(lang).choose;
6065
+ words.setAttribute(DATE_EMPTY_ATTRIBUTE, '');
6066
+ }
6067
+ }
6068
+ /** The days a field accepts today, from the raw Earliest / Latest its button carries. */
6069
+ function rangeOf(button, today) {
6070
+ const bound = (attr) => resolveDateBound(parseDateBound(button.getAttribute(attr) ?? ''), today);
6071
+ return { min: bound(DATE_MIN_ATTRIBUTE), max: bound(DATE_MAX_ATTRIBUTE) };
6072
+ }
6073
+ /** One live date field. Built by `attachDatePicker`; idempotent per field. */
6074
+ class DatePickerRuntime {
6075
+ constructor(field, options = {}) {
6076
+ this.field = field;
6077
+ this.options = options;
6078
+ this.panel = null;
6079
+ this.focusIso = '';
6080
+ this.closing = false;
6081
+ this.listeners = [];
6082
+ const { button, words } = partsOf(field);
6083
+ if (words && !words.hasAttribute('data-iox-placeholder') && words.hasAttribute(DATE_EMPTY_ATTRIBUTE)) {
6084
+ words.setAttribute('data-iox-placeholder', words.textContent ?? '');
6085
+ }
6086
+ // A click the keyboard made (Enter / Space on the button) has no pointer: `detail` 0.
6087
+ if (button)
6088
+ this.listen(button, 'click', e => this.open(e.detail === 0));
6089
+ // A successful submit resets the form: a hidden input keeps its value through `form.reset()` (its value IS its
6090
+ // attribute), so the field clears itself. A host that renders the field (`onPick` — the component path) owns
6091
+ // its words and its value: the runtime never writes over what the host's view tracks.
6092
+ const form = field.closest('form');
6093
+ if (form && !options.onPick)
6094
+ this.listen(form, 'reset', () => setDateFieldValue(field, '', { lang: this.lang() }));
6095
+ field[RUNTIME] = this;
6096
+ }
6097
+ get isOpen() { return !!this.panel; }
6098
+ value() { return partsOf(this.field).input?.value ?? ''; }
6099
+ destroy() {
6100
+ this.dismiss(true);
6101
+ for (const [target, type, fn] of this.listeners)
6102
+ target.removeEventListener(type, fn);
6103
+ this.listeners.length = 0;
6104
+ delete this.field[RUNTIME];
6105
+ }
6106
+ /** Open the calendar; `instant` (opened from the keyboard) skips the entrance and, later, the exit. */
6107
+ open(instant = false) {
6108
+ const { button } = partsOf(this.field);
6109
+ if (!button || this.panel || button.disabled)
6110
+ return;
6111
+ const doc = this.field.ownerDocument;
6112
+ const lang = this.lang();
6113
+ const rtl = this.rtl();
6114
+ const today = this.today();
6115
+ const { min, max } = rangeOf(button, today);
6116
+ const selected = this.value();
6117
+ this.focusIso = clampIso(isIsoDate(selected) ? selected : today, min, max);
6118
+ const weekStart = weekStartFor(lang);
6119
+ const model = () => calendarMonth(this.focusIso, { weekStart, today, min, max, selected: this.value() || null });
6120
+ const panel = buildPanel(doc, 'dialog', this.words(lang), lang, model().weekdays, rtl);
6121
+ const sheet = !!doc.defaultView?.matchMedia?.(CALENDAR_SHEET_QUERY).matches;
6122
+ paintMonth(panel, model(), lang);
6123
+ if (instant)
6124
+ panel.setAttribute(CALENDAR_INSTANT_ATTRIBUTE, '');
6125
+ this.field.appendChild(panel);
6126
+ this.panel = panel;
6127
+ this.closing = false;
6128
+ const repaint = (focusDay) => {
6129
+ paintMonth(panel, model(), lang);
6130
+ if (focusDay)
6131
+ this.focusDay();
6132
+ };
6133
+ const pickable = (b) => !!b && b.getAttribute('aria-disabled') !== 'true';
6134
+ this.listen(panel, 'click', e => {
6135
+ const target = e.target;
6136
+ const day = target.closest?.(`.${CalendarClass.Day}`);
6137
+ // Enter / Space on a day is a click with no pointer: the calendar goes at once (Emil 2).
6138
+ if (day) {
6139
+ if (pickable(day))
6140
+ this.pick(day.getAttribute(ISO_ATTRIBUTE), e.detail === 0);
6141
+ return;
6142
+ }
6143
+ if (target.closest?.(`.${CalendarClass.Prev}`)) {
6144
+ this.focusIso = clampIso(addMonthsIso(this.focusIso, -1), min, max);
6145
+ repaint(false);
6146
+ return;
6147
+ }
6148
+ if (target.closest?.(`.${CalendarClass.Next}`)) {
6149
+ this.focusIso = clampIso(addMonthsIso(this.focusIso, 1), min, max);
6150
+ repaint(false);
6151
+ return;
6152
+ }
6153
+ // A modal dialog hears clicks on its backdrop as its own: outside the panel's box closes it.
6154
+ const r = panel.getBoundingClientRect();
6155
+ const { clientX: x, clientY: y } = e;
6156
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom)
6157
+ this.close();
6158
+ });
6159
+ this.listen(panel, 'keydown', e => {
6160
+ const key = e;
6161
+ if (!key.target?.closest?.(`.${CalendarClass.Day}`))
6162
+ return;
6163
+ const next = calendarKeyTarget(key.key, this.focusIso, { weekStart, rtl, shift: key.shiftKey, min, max });
6164
+ if (!next)
6165
+ return;
6166
+ key.preventDefault();
6167
+ this.focusIso = next;
6168
+ repaint(true);
6169
+ });
6170
+ // Esc: the dialog's own cancel — a keyboard action, so it goes at once (Emil 2), focus back on the button.
6171
+ this.listen(panel, 'cancel', e => { e.preventDefault(); this.close(true); });
6172
+ if (sheet)
6173
+ panel.setAttribute('data-iox-cal-sheet', '');
6174
+ panel.showModal();
6175
+ if (!sheet)
6176
+ this.place(rtl);
6177
+ this.focusDay();
6178
+ button.setAttribute('aria-expanded', 'true');
6179
+ this.options.lockScroll?.();
6180
+ }
6181
+ /** Close with the exit the shared CSS plays (faster than the entrance — Emil 30), then give focus back; `instant`
6182
+ * (a keyboard close — Esc, Enter on a day) or a panel opened from the keyboard goes at once (Emil 2). */
6183
+ close(instant = false) {
6184
+ if (!this.panel || this.closing)
6185
+ return;
6186
+ this.closing = true;
6187
+ const panel = this.panel;
6188
+ partsOf(this.field).button?.setAttribute('aria-expanded', 'false');
6189
+ if (instant || panel.hasAttribute(CALENDAR_INSTANT_ATTRIBUTE)) {
6190
+ this.dismiss(false);
6191
+ return;
6192
+ }
6193
+ panel.setAttribute(CALENDAR_CLOSING_ATTRIBUTE, '');
6194
+ const ms = transitionMs(panel);
6195
+ if (ms <= 0) {
6196
+ this.dismiss(false);
6197
+ return;
6198
+ }
6199
+ setTimeout(() => this.dismiss(false), ms);
6200
+ }
6201
+ pick(iso, instant = false) {
6202
+ const { button } = partsOf(this.field);
6203
+ if (this.options.onPick)
6204
+ this.options.onPick(iso);
6205
+ else
6206
+ setDateFieldValue(this.field, iso, { lang: this.lang(), today: this.today() });
6207
+ // The form's runtime hears the change on the control it knows (the button carries `iox-field__control`).
6208
+ button?.dispatchEvent(new Event('input', { bubbles: true }));
6209
+ button?.dispatchEvent(new Event('change', { bubbles: true }));
6210
+ this.close(instant);
6211
+ }
6212
+ dismiss(silent) {
6213
+ const panel = this.panel;
6214
+ if (!panel)
6215
+ return;
6216
+ this.panel = null;
6217
+ this.closing = false;
6218
+ if (panel.open)
6219
+ panel.close();
6220
+ panel.remove();
6221
+ this.options.unlockScroll?.();
6222
+ const { button } = partsOf(this.field);
6223
+ button?.setAttribute('aria-expanded', 'false');
6224
+ if (!silent)
6225
+ button?.focus({ preventScroll: true });
6226
+ }
6227
+ focusDay() {
6228
+ const day = this.panel?.querySelector(`.${CalendarClass.Day}[${ISO_ATTRIBUTE}="${this.focusIso}"]`);
6229
+ day?.focus({ preventScroll: true });
6230
+ }
6231
+ /** Under the field, aligned to its reading start; above when there is no room — the ONE `placeFloating`. */
6232
+ place(rtl) {
6233
+ const panel = this.panel;
6234
+ const { button } = partsOf(this.field);
6235
+ const view = this.field.ownerDocument.defaultView;
6236
+ if (!panel || !button || !view)
6237
+ return;
6238
+ const anchor = button.getBoundingClientRect();
6239
+ const spot = placeFloating({ top: anchor.top, left: anchor.left, width: anchor.width, height: anchor.height }, { width: panel.offsetWidth, height: panel.offsetHeight }, { width: view.innerWidth, height: view.innerHeight }, { rtl });
6240
+ panel.style.top = `${spot.top}px`;
6241
+ panel.style.left = `${spot.left}px`;
6242
+ panel.style.transformOrigin = spot.origin;
6243
+ panel.setAttribute('data-iox-side', spot.side);
6244
+ }
6245
+ lang() { return fieldLanguage(this.field, this.options.lang); }
6246
+ rtl() {
6247
+ if (typeof this.options.rtl === 'boolean')
6248
+ return this.options.rtl;
6249
+ return this.field.ownerDocument.defaultView?.getComputedStyle(this.field).direction === 'rtl';
6250
+ }
6251
+ today() { return this.options.today?.() ?? todayIso(); }
6252
+ words(lang) { return { ...datePickerWords(lang), ...(this.options.words ?? {}) }; }
6253
+ listen(target, type, fn) {
6254
+ target.addEventListener(type, fn);
6255
+ this.listeners.push([target, type, fn]);
6256
+ }
6257
+ }
6258
+ /** The runtime already running a field, if any. */
6259
+ function datePickerOf(field) {
6260
+ return field?.[RUNTIME] ?? null;
6261
+ }
6262
+ /** Run a date field — once: a field already running is returned as is. Null when `field` holds no date button. */
6263
+ function attachDatePicker(field, options = {}) {
6264
+ if (!field || !partsOf(field).button)
6265
+ return null;
6266
+ return datePickerOf(field) ?? new DatePickerRuntime(field, options);
6267
+ }
6268
+ /** Run every date field under `root` (the string path: a page's or a global's markup). */
6269
+ function attachDatePickers(root, options = {}) {
6270
+ if (!root?.querySelectorAll)
6271
+ return [];
6272
+ const out = [];
6273
+ root.querySelectorAll(`.${FieldClass.Root}`).forEach(field => {
6274
+ const runtime = attachDatePicker(field, options);
6275
+ if (runtime)
6276
+ out.push(runtime);
6277
+ });
6278
+ return out;
6279
+ }
6280
+ /**
6281
+ * The builder canvas's design-time Open state: the SAME panel, drawn in the flow under the date button (a modal in the
6282
+ * top layer would escape the canvas's zoom and cover the builder), inert — the author selects the Field and styles
6283
+ * its Calendar parts; nothing in it takes focus or a click. `false` removes it.
6284
+ */
6285
+ function showCalendarDesign(field, show, options = {}) {
6286
+ if (!field)
6287
+ return;
6288
+ const existing = field.querySelector(`:scope > [${CALENDAR_DESIGN_ATTRIBUTE}]`);
6289
+ if (!show) {
6290
+ existing?.remove();
6291
+ return;
6292
+ }
6293
+ if (existing)
6294
+ return;
6295
+ const { button } = partsOf(field);
6296
+ if (!button)
6297
+ return;
6298
+ const lang = fieldLanguage(field, options.lang);
6299
+ const today = options.today ?? todayIso();
6300
+ const { min, max } = rangeOf(button, today);
6301
+ const selected = (partsOf(field).input?.value || '') || null;
6302
+ const focus = clampIso(selected ?? today, min, max);
6303
+ const model = calendarMonth(focus, { weekStart: weekStartFor(lang), today, min, max, selected });
6304
+ const rtl = typeof options.rtl === 'boolean' ? options.rtl : field.ownerDocument.defaultView?.getComputedStyle(field).direction === 'rtl';
6305
+ const panel = buildPanel(field.ownerDocument, 'div', datePickerWords(lang), lang, model.weekdays, rtl);
6306
+ panel.setAttribute(CALENDAR_DESIGN_ATTRIBUTE, '');
6307
+ panel.setAttribute('inert', '');
6308
+ panel.setAttribute('aria-hidden', 'true');
6309
+ paintMonth(panel, model, lang);
6310
+ const after = partsOf(field).input ?? button;
6311
+ after.insertAdjacentElement('afterend', panel);
6312
+ }
6313
+
5141
6314
  let fieldSeq = 0;
5142
6315
  /**
5143
6316
  * The compound Field: label + control + help + error + required mark, as ONE node (decision D2).
@@ -5153,8 +6326,22 @@ let fieldSeq = 0;
5153
6326
  *
5154
6327
  * ⚠️ Control type is a TRAIT, not a component per type (decision D4) — one component, one config,
5155
6328
  * and choosing `email` also chooses email validation.
6329
+ *
6330
+ * The markup is forms-core `fieldHtml`'s, element for element (the engine's string path writes it): Choices is a
6331
+ * fieldset of chips or rows (premium N1), Date is a button + a hidden ISO input that opens the look's calendar (N8).
6332
+ * The submitted name is the FORM's unique name for this field (forms-core `uniqueFieldNames`, vectoriox/iox-ui#43).
5156
6333
  */
5157
6334
  class IoxFieldComponent {
6335
+ set control(value) {
6336
+ this._control = value || ControlType.Text;
6337
+ this.syncDate();
6338
+ }
6339
+ get control() { return this._control; }
6340
+ /** Choices (N1): `chips` (the default) or `list` — the look's radio / checkbox rows. */
6341
+ set appearance(value) {
6342
+ this._appearance = choiceAppearanceOf({ appearance: value });
6343
+ }
6344
+ get appearance() { return this._appearance; }
5158
6345
  /** Explicit choices (bound data, a hand-built node). Wins over `optionsText`. */
5159
6346
  set options(value) {
5160
6347
  this._options = Array.isArray(value) ? value : [];
@@ -5168,22 +6355,45 @@ class IoxFieldComponent {
5168
6355
  this.refreshChoices();
5169
6356
  }
5170
6357
  get optionsText() { return this._optionsText; }
6358
+ /**
6359
+ * Forces the error appearance without a real failure — how the builder lets an author DESIGN
6360
+ * the error state (decision D5). At runtime the state comes from the runtime service instead.
6361
+ * Turning it on shakes the field once, so the author sees the motion they are designing (N2).
6362
+ */
6363
+ set forceInvalid(value) {
6364
+ const was = this._forceInvalid;
6365
+ this._forceInvalid = !!value;
6366
+ if (this._forceInvalid && !was && this.viewReady)
6367
+ shakeElement(this.host?.nativeElement);
6368
+ }
6369
+ get forceInvalid() { return this._forceInvalid; }
6370
+ /** The builder canvas's design-time Open state for a Date (N8): the calendar drawn under the field, inert. */
6371
+ set designOpen(value) {
6372
+ this._designOpen = !!value;
6373
+ this.syncDesignCalendar();
6374
+ }
6375
+ get designOpen() { return this._designOpen; }
5171
6376
  /**
5172
6377
  * Classic constructor injection, not `inject()`: this library's specs construct components
5173
6378
  * directly (its vitest setup cannot run `TestBed.createComponent`), and a field initializer
5174
6379
  * calling `inject()` throws NG0203 outside an injection context.
5175
6380
  *
5176
6381
  * `runtime` is optional because a Field dropped OUTSIDE any Form must still render — it simply
5177
- * has nothing to submit to. That is the same ancestry rule from the other side.
6382
+ * has nothing to submit to. That is the same ancestry rule from the other side. `isPreview` says the field is
6383
+ * LIVE (the engine, the admin's Preview): only then does its date button open a calendar — on the editing canvas
6384
+ * a click selects the field, and the calendar is the design-time Open state.
5178
6385
  */
5179
- constructor(runtime, resolveMessage) {
6386
+ constructor(runtime, resolveMessage, host, isPreview, pageScroll) {
5180
6387
  this.runtime = runtime;
5181
6388
  this.resolveMessage = resolveMessage;
6389
+ this.host = host;
6390
+ this.isPreview = isPreview;
6391
+ this.pageScroll = pageScroll;
5182
6392
  this.nodeId = '';
5183
6393
  /** The submitted key. Blank means "derive it from the label" — authors never invent keys. */
5184
6394
  this.name = '';
5185
6395
  this.label = '';
5186
- this.control = ControlType.Text;
6396
+ this._control = ControlType.Text;
5187
6397
  this.placeholder = '';
5188
6398
  this.help = '';
5189
6399
  this.required = false;
@@ -5195,6 +6405,12 @@ class IoxFieldComponent {
5195
6405
  this.rangeMax = 100;
5196
6406
  this.rangeStep = 1;
5197
6407
  this.accept = '';
6408
+ /** Choices (N1): several answers (checkboxes) or one (radios). */
6409
+ this.multiple = false;
6410
+ this._appearance = ChoiceAppearance.Chips;
6411
+ /** Date (N8): the first and last day it takes — '' any · N days from today · an ISO day (forms-core `dates.ts`). */
6412
+ this.earliest = '';
6413
+ this.latest = '';
5198
6414
  /** What the radio group / dropdown renders: the shared forms-core reading of the two inputs above. */
5199
6415
  this.choices = [];
5200
6416
  this._options = [];
@@ -5202,33 +6418,42 @@ class IoxFieldComponent {
5202
6418
  this.value = '';
5203
6419
  /** Rules beyond `required` — carried on the node, interpreted by forms-core. */
5204
6420
  this.rules = [];
5205
- /**
5206
- * Forces the error appearance without a real failure — how the builder lets an author DESIGN
5207
- * the error state (decision D5). At runtime the state comes from the runtime service instead.
5208
- */
5209
- this.forceInvalid = false;
6421
+ this._forceInvalid = false;
5210
6422
  /** Placeholder shown while designing the error state, so the block has something to style. */
5211
6423
  this.designErrorText = 'Please check this field';
5212
6424
  /** Only after a successful check — never on an untouched field. */
5213
6425
  this.showValid = false;
6426
+ this._designOpen = false;
5214
6427
  this.valueChange = new EventEmitter();
5215
6428
  this.touched = new EventEmitter();
5216
6429
  /** Stable per instance so `for`/`id` and `aria-describedby` always pair up. */
5217
6430
  this.controlId = `iox-field-${++fieldSeq}`;
6431
+ this.registration = null;
6432
+ this.picker = null;
6433
+ this.viewReady = false;
5218
6434
  }
5219
6435
  ngOnInit() {
5220
- this.unregister = this.runtime?.register({
6436
+ this.registration = {
5221
6437
  // Functions, not values: a trait edited on the canvas takes effect with no re-register.
5222
- spec: () => this.toSpec(),
6438
+ baseName: () => baseFieldName({ name: this.name, label: this.label }),
6439
+ spec: (name) => this.toSpec(name),
5223
6440
  getValue: () => this.value,
5224
- });
6441
+ };
6442
+ this.unregister = this.runtime?.register(this.registration);
6443
+ }
6444
+ ngAfterViewInit() {
6445
+ this.viewReady = true;
6446
+ this.syncDate();
6447
+ this.syncDesignCalendar();
5225
6448
  }
5226
6449
  ngOnDestroy() {
5227
6450
  this.unregister?.();
6451
+ this.picker?.destroy();
6452
+ this.picker = null;
5228
6453
  }
5229
- /** The submitted key: the author's override, else derived from the label. */
6454
+ /** The submitted key: the form's unique name for this field (iox-ui#43), else the field's own base name. */
5230
6455
  get fieldName() {
5231
- return this.name || deriveFieldName(this.label);
6456
+ return (this.registration && this.runtime?.nameOf(this.registration)) || baseFieldName({ name: this.name, label: this.label });
5232
6457
  }
5233
6458
  /** Failing AND allowed to say so — or forced, while an author designs the state. */
5234
6459
  get invalid() {
@@ -5243,27 +6468,55 @@ class IoxFieldComponent {
5243
6468
  const error = this.runtime?.errorFor(this.fieldName);
5244
6469
  return error ? this.resolveMessage(error.messageKey, error.params) : '';
5245
6470
  }
5246
- toSpec() {
5247
- const spec = {
5248
- name: this.fieldName,
5249
- control: this.control ?? ControlType.Text,
5250
- label: this.label,
5251
- };
5252
- if (this.required)
5253
- spec.required = true;
5254
- if (this.rules?.length)
5255
- spec.rules = this.rules;
5256
- if (this.choices.length)
5257
- spec.options = this.choices;
5258
- return spec;
6471
+ /** The field's spec under the name its form gave it — forms-core `fieldSpecOf`, the reading the saved spec uses. */
6472
+ toSpec(name) {
6473
+ return fieldSpecOf({
6474
+ control: this.control, label: this.label, required: this.required, rules: this.rules, options: this.choices,
6475
+ multiple: this.multiple, earliest: this.earliest, latest: this.latest,
6476
+ }, name);
5259
6477
  }
5260
6478
  refreshChoices() {
5261
6479
  this.choices = fieldOptionsOf({ options: this._options, optionsText: this._optionsText });
5262
6480
  }
5263
- /** `file`, `date`, `number`… map straight through; everything else is a text input. */
6481
+ /** `file`, `number`… map straight through; everything else is a text input (forms-core `inputTypeOf`). */
5264
6482
  get inputType() {
5265
- const passthrough = ['email', 'tel', 'url', 'number', 'password', 'date', 'time', 'file', 'hidden'];
5266
- return passthrough.includes(String(this.control)) ? String(this.control) : 'text';
6483
+ return inputTypeOf(String(this.control));
6484
+ }
6485
+ /** A Date's chosen day (the hidden input's value) — '' while none is. */
6486
+ get dateIso() {
6487
+ return isIsoDate(this.value) ? String(this.value) : '';
6488
+ }
6489
+ /** A Date's chosen day in words, in the field's language — '' while none is. */
6490
+ get dateWords() {
6491
+ const iso = this.dateIso;
6492
+ if (!iso)
6493
+ return '';
6494
+ const lang = this.host?.nativeElement ? fieldLanguage(this.host.nativeElement) : 'en';
6495
+ return formatDateInWords(iso, lang, todayIso());
6496
+ }
6497
+ /** What an unpicked Date says: the author's hint, else "Choose a date" in the field's language. */
6498
+ get datePlaceholder() {
6499
+ if (this.placeholder)
6500
+ return this.placeholder;
6501
+ return datePickerWords(this.host?.nativeElement ? fieldLanguage(this.host.nativeElement) : null).choose;
6502
+ }
6503
+ /** Whether a Choices option is chosen — one answer, or one of several. */
6504
+ isChosen(optionValue) {
6505
+ if (isMultiValueField({ control: this.control, multiple: this.multiple })) {
6506
+ return Array.isArray(this.value) && this.value.includes(optionValue);
6507
+ }
6508
+ return this.value === optionValue;
6509
+ }
6510
+ /** A Choices option ticked or unticked: the answer becomes the list of ticked ones, or the one picked. */
6511
+ toggle(optionValue, checked) {
6512
+ if (!isMultiValueField({ control: this.control, multiple: this.multiple })) {
6513
+ this.emit(optionValue);
6514
+ return;
6515
+ }
6516
+ const current = Array.isArray(this.value) ? this.value : [];
6517
+ const next = checked ? [...current.filter(v => v !== optionValue), optionValue] : current.filter(v => v !== optionValue);
6518
+ // In the options' order, whatever order they were ticked in.
6519
+ this.emit(this.choices.map(c => c.value).filter(v => next.includes(v)));
5267
6520
  }
5268
6521
  /** Help and error are BOTH announced, in that order, when both are present. */
5269
6522
  get describedBy() {
@@ -5279,23 +6532,79 @@ class IoxFieldComponent {
5279
6532
  this.runtime?.onChange(this.fieldName);
5280
6533
  this.valueChange.emit(value);
5281
6534
  }
5282
- touch() {
6535
+ touch(event) {
6536
+ // Leaving one chip for the next, or the date button for its own calendar, is not leaving the FIELD.
6537
+ const next = event?.relatedTarget;
6538
+ if (next && this.host?.nativeElement?.contains?.(next))
6539
+ return;
5283
6540
  // Blur is what lets Smart timing start speaking for this field.
5284
6541
  this.runtime?.onBlur(this.fieldName);
5285
6542
  this.touched.emit();
5286
6543
  }
5287
- 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 }); }
5288
- 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", rangeMin: "rangeMin", rangeMax: "rangeMax", rangeStep: "rangeStep", accept: "accept", options: "options", optionsText: "optionsText", 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: `
5289
- <label class="iox-field__label"
5290
- [class.iox-field__label--hidden]="labelHidden"
5291
- [attr.for]="controlId">
5292
- {{ label }}
5293
- @if (required) {
5294
- <span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>
6544
+ /** A live Date runs the ONE date picker; any other control (or the canvas) runs none. */
6545
+ syncDate() {
6546
+ const element = this.host?.nativeElement;
6547
+ if (!this.viewReady || !element)
6548
+ return;
6549
+ const live = this.control === ControlType.Date && this.isPreview === true && typeof window !== 'undefined';
6550
+ if (!live) {
6551
+ this.picker?.destroy();
6552
+ this.picker = null;
6553
+ return;
6554
+ }
6555
+ // The view may not have rendered the button yet after a control change: attach on the next frame's DOM.
6556
+ queueMicrotask(() => {
6557
+ if (this.picker || this.control !== ControlType.Date)
6558
+ return;
6559
+ this.picker = attachDatePicker(element, {
6560
+ onPick: iso => this.emit(iso),
6561
+ lockScroll: () => this.pageScroll?.stop(),
6562
+ unlockScroll: () => this.pageScroll?.start(),
6563
+ });
6564
+ });
6565
+ }
6566
+ /** The canvas's Open state: the calendar drawn under the field (never on a live page). */
6567
+ syncDesignCalendar() {
6568
+ const element = this.host?.nativeElement;
6569
+ if (!this.viewReady || !element || this.isPreview === true)
6570
+ return;
6571
+ queueMicrotask(() => showCalendarDesign(element, this._designOpen && this.control === ControlType.Date));
6572
+ }
6573
+ 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 }, { token: i0.ElementRef, optional: true }, { token: IS_PREVIEW, optional: true }, { token: PageScrollProvider, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
6574
+ 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", rangeMin: "rangeMin", rangeMax: "rangeMax", rangeStep: "rangeStep", accept: "accept", multiple: "multiple", appearance: "appearance", earliest: "earliest", latest: "latest", options: "options", optionsText: "optionsText", value: "value", rules: "rules", forceInvalid: "forceInvalid", designErrorText: "designErrorText", showValid: "showValid", designOpen: "designOpen" }, 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: `
6575
+ @switch (control) {
6576
+ @case ('choices') {
6577
+ <fieldset [class]="'iox-field__choices ' + (appearance === 'list' ? 'iox-field__choices--list' : 'iox-field__choices--chips')"
6578
+ [attr.aria-describedby]="describedBy">
6579
+ <legend class="iox-field__label" [class.iox-field__label--hidden]="labelHidden" [id]="controlId + '-label'">{{ label }}@if (required) {<span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>}</legend>
6580
+ <div [class]="appearance === 'list' ? 'iox-field__group' : 'iox-field__chips'">
6581
+ @for (option of choices; track option.value) {
6582
+ <label [class]="appearance === 'list' ? 'iox-field__option' : 'iox-field__chip'">
6583
+ <input [class]="appearance === 'list' ? 'iox-field__control' : 'iox-field__control iox-field__control--chip'"
6584
+ [type]="multiple ? 'checkbox' : 'radio'"
6585
+ [name]="fieldName"
6586
+ [value]="option.value"
6587
+ [checked]="isChosen(option.value)"
6588
+ [attr.aria-invalid]="invalid ? 'true' : null"
6589
+ [attr.disabled]="disabled ? '' : null"
6590
+ (change)="toggle(option.value, $any($event.target).checked)"
6591
+ (blur)="touch($event)">
6592
+ <span>{{ option.label }}</span>
6593
+ </label>
6594
+ }
6595
+ </div>
6596
+ </fieldset>
5295
6597
  }
5296
- </label>
6598
+ @default {
6599
+ <label class="iox-field__label"
6600
+ [class.iox-field__label--hidden]="labelHidden"
6601
+ [id]="controlId + '-label'"
6602
+ [attr.for]="controlId">{{ label }}@if (required) {<span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>}</label>
6603
+ }
6604
+ }
5297
6605
 
5298
6606
  @switch (control) {
6607
+ @case ('choices') {}
5299
6608
  @case ('textarea') {
5300
6609
  <textarea class="iox-field__control"
5301
6610
  [id]="controlId"
@@ -5308,7 +6617,7 @@ class IoxFieldComponent {
5308
6617
  [attr.disabled]="disabled ? '' : null"
5309
6618
  [value]="value ?? ''"
5310
6619
  (input)="emit($any($event.target).value)"
5311
- (blur)="touch()"></textarea>
6620
+ (blur)="touch($event)"></textarea>
5312
6621
  }
5313
6622
  @case ('select') {
5314
6623
  <select class="iox-field__control"
@@ -5319,7 +6628,7 @@ class IoxFieldComponent {
5319
6628
  [attr.aria-describedby]="describedBy"
5320
6629
  [attr.disabled]="disabled ? '' : null"
5321
6630
  (change)="emit($any($event.target).value)"
5322
- (blur)="touch()">
6631
+ (blur)="touch($event)">
5323
6632
  <option value="" [selected]="!value">{{ placeholder || '' }}</option>
5324
6633
  @for (option of choices; track option.value) {
5325
6634
  <option [value]="option.value" [selected]="option.value === value">{{ option.label }}</option>
@@ -5337,7 +6646,7 @@ class IoxFieldComponent {
5337
6646
  [attr.disabled]="disabled ? '' : null"
5338
6647
  [checked]="value === true"
5339
6648
  (change)="emit($any($event.target).checked)"
5340
- (blur)="touch()">
6649
+ (blur)="touch($event)">
5341
6650
  }
5342
6651
  @case ('range') {
5343
6652
  <input class="iox-field__control iox-field__control--range"
@@ -5351,7 +6660,7 @@ class IoxFieldComponent {
5351
6660
  [attr.disabled]="disabled ? '' : null"
5352
6661
  [value]="value ?? rangeMin"
5353
6662
  (input)="emit(+$any($event.target).value)"
5354
- (blur)="touch()">
6663
+ (blur)="touch($event)">
5355
6664
  }
5356
6665
  @case ('checkbox') {
5357
6666
  <input class="iox-field__control iox-field__control--checkbox"
@@ -5364,7 +6673,7 @@ class IoxFieldComponent {
5364
6673
  [attr.disabled]="disabled ? '' : null"
5365
6674
  [checked]="value === true"
5366
6675
  (change)="emit($any($event.target).checked)"
5367
- (blur)="touch()">
6676
+ (blur)="touch($event)">
5368
6677
  }
5369
6678
  @case ('radio') {
5370
6679
  <div class="iox-field__group" role="radiogroup" [attr.aria-labelledby]="controlId + '-label'">
@@ -5377,12 +6686,27 @@ class IoxFieldComponent {
5377
6686
  [checked]="option.value === value"
5378
6687
  [attr.disabled]="disabled ? '' : null"
5379
6688
  (change)="emit(option.value)"
5380
- (blur)="touch()">
6689
+ (blur)="touch($event)">
5381
6690
  <span>{{ option.label }}</span>
5382
6691
  </label>
5383
6692
  }
5384
6693
  </div>
5385
6694
  }
6695
+ @case ('date') {
6696
+ <button type="button"
6697
+ class="iox-field__control iox-field__control--date"
6698
+ [id]="controlId"
6699
+ aria-haspopup="dialog"
6700
+ [attr.aria-expanded]="designOpen ? 'true' : 'false'"
6701
+ [attr.aria-labelledby]="controlId + '-label ' + controlId + '-value'"
6702
+ [attr.aria-invalid]="invalid ? 'true' : null"
6703
+ [attr.aria-describedby]="describedBy"
6704
+ [attr.disabled]="disabled ? '' : null"
6705
+ [attr.data-iox-date-min]="earliest || null"
6706
+ [attr.data-iox-date-max]="latest || null"
6707
+ (blur)="touch($event)"><span class="iox-field__date-value" [id]="controlId + '-value'" [attr.data-iox-empty]="dateWords ? null : ''">{{ dateWords || datePlaceholder }}</span><svg class="iox-field__date-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="5" width="17" height="15.5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 10h17M8 3v4M16 3v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
6708
+ <input type="hidden" class="iox-field__date-input" [name]="fieldName" [value]="dateIso">
6709
+ }
5386
6710
  @default {
5387
6711
  <input class="iox-field__control"
5388
6712
  [id]="controlId"
@@ -5396,7 +6720,7 @@ class IoxFieldComponent {
5396
6720
  [attr.accept]="control === 'file' ? (accept || null) : null"
5397
6721
  [value]="control === 'file' ? '' : (value ?? '')"
5398
6722
  (input)="emit($any($event.target).value)"
5399
- (blur)="touch()">
6723
+ (blur)="touch($event)">
5400
6724
  }
5401
6725
  }
5402
6726
 
@@ -5411,16 +6735,39 @@ class IoxFieldComponent {
5411
6735
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxFieldComponent, decorators: [{
5412
6736
  type: Component,
5413
6737
  args: [{ selector: 'div[iox-field]', template: `
5414
- <label class="iox-field__label"
5415
- [class.iox-field__label--hidden]="labelHidden"
5416
- [attr.for]="controlId">
5417
- {{ label }}
5418
- @if (required) {
5419
- <span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>
6738
+ @switch (control) {
6739
+ @case ('choices') {
6740
+ <fieldset [class]="'iox-field__choices ' + (appearance === 'list' ? 'iox-field__choices--list' : 'iox-field__choices--chips')"
6741
+ [attr.aria-describedby]="describedBy">
6742
+ <legend class="iox-field__label" [class.iox-field__label--hidden]="labelHidden" [id]="controlId + '-label'">{{ label }}@if (required) {<span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>}</legend>
6743
+ <div [class]="appearance === 'list' ? 'iox-field__group' : 'iox-field__chips'">
6744
+ @for (option of choices; track option.value) {
6745
+ <label [class]="appearance === 'list' ? 'iox-field__option' : 'iox-field__chip'">
6746
+ <input [class]="appearance === 'list' ? 'iox-field__control' : 'iox-field__control iox-field__control--chip'"
6747
+ [type]="multiple ? 'checkbox' : 'radio'"
6748
+ [name]="fieldName"
6749
+ [value]="option.value"
6750
+ [checked]="isChosen(option.value)"
6751
+ [attr.aria-invalid]="invalid ? 'true' : null"
6752
+ [attr.disabled]="disabled ? '' : null"
6753
+ (change)="toggle(option.value, $any($event.target).checked)"
6754
+ (blur)="touch($event)">
6755
+ <span>{{ option.label }}</span>
6756
+ </label>
6757
+ }
6758
+ </div>
6759
+ </fieldset>
5420
6760
  }
5421
- </label>
6761
+ @default {
6762
+ <label class="iox-field__label"
6763
+ [class.iox-field__label--hidden]="labelHidden"
6764
+ [id]="controlId + '-label'"
6765
+ [attr.for]="controlId">{{ label }}@if (required) {<span class="iox-field__required" aria-hidden="true">{{ requiredMark }}</span>}</label>
6766
+ }
6767
+ }
5422
6768
 
5423
6769
  @switch (control) {
6770
+ @case ('choices') {}
5424
6771
  @case ('textarea') {
5425
6772
  <textarea class="iox-field__control"
5426
6773
  [id]="controlId"
@@ -5433,7 +6780,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5433
6780
  [attr.disabled]="disabled ? '' : null"
5434
6781
  [value]="value ?? ''"
5435
6782
  (input)="emit($any($event.target).value)"
5436
- (blur)="touch()"></textarea>
6783
+ (blur)="touch($event)"></textarea>
5437
6784
  }
5438
6785
  @case ('select') {
5439
6786
  <select class="iox-field__control"
@@ -5444,7 +6791,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5444
6791
  [attr.aria-describedby]="describedBy"
5445
6792
  [attr.disabled]="disabled ? '' : null"
5446
6793
  (change)="emit($any($event.target).value)"
5447
- (blur)="touch()">
6794
+ (blur)="touch($event)">
5448
6795
  <option value="" [selected]="!value">{{ placeholder || '' }}</option>
5449
6796
  @for (option of choices; track option.value) {
5450
6797
  <option [value]="option.value" [selected]="option.value === value">{{ option.label }}</option>
@@ -5462,7 +6809,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5462
6809
  [attr.disabled]="disabled ? '' : null"
5463
6810
  [checked]="value === true"
5464
6811
  (change)="emit($any($event.target).checked)"
5465
- (blur)="touch()">
6812
+ (blur)="touch($event)">
5466
6813
  }
5467
6814
  @case ('range') {
5468
6815
  <input class="iox-field__control iox-field__control--range"
@@ -5476,7 +6823,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5476
6823
  [attr.disabled]="disabled ? '' : null"
5477
6824
  [value]="value ?? rangeMin"
5478
6825
  (input)="emit(+$any($event.target).value)"
5479
- (blur)="touch()">
6826
+ (blur)="touch($event)">
5480
6827
  }
5481
6828
  @case ('checkbox') {
5482
6829
  <input class="iox-field__control iox-field__control--checkbox"
@@ -5489,7 +6836,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5489
6836
  [attr.disabled]="disabled ? '' : null"
5490
6837
  [checked]="value === true"
5491
6838
  (change)="emit($any($event.target).checked)"
5492
- (blur)="touch()">
6839
+ (blur)="touch($event)">
5493
6840
  }
5494
6841
  @case ('radio') {
5495
6842
  <div class="iox-field__group" role="radiogroup" [attr.aria-labelledby]="controlId + '-label'">
@@ -5502,12 +6849,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5502
6849
  [checked]="option.value === value"
5503
6850
  [attr.disabled]="disabled ? '' : null"
5504
6851
  (change)="emit(option.value)"
5505
- (blur)="touch()">
6852
+ (blur)="touch($event)">
5506
6853
  <span>{{ option.label }}</span>
5507
6854
  </label>
5508
6855
  }
5509
6856
  </div>
5510
6857
  }
6858
+ @case ('date') {
6859
+ <button type="button"
6860
+ class="iox-field__control iox-field__control--date"
6861
+ [id]="controlId"
6862
+ aria-haspopup="dialog"
6863
+ [attr.aria-expanded]="designOpen ? 'true' : 'false'"
6864
+ [attr.aria-labelledby]="controlId + '-label ' + controlId + '-value'"
6865
+ [attr.aria-invalid]="invalid ? 'true' : null"
6866
+ [attr.aria-describedby]="describedBy"
6867
+ [attr.disabled]="disabled ? '' : null"
6868
+ [attr.data-iox-date-min]="earliest || null"
6869
+ [attr.data-iox-date-max]="latest || null"
6870
+ (blur)="touch($event)"><span class="iox-field__date-value" [id]="controlId + '-value'" [attr.data-iox-empty]="dateWords ? null : ''">{{ dateWords || datePlaceholder }}</span><svg class="iox-field__date-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="5" width="17" height="15.5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 10h17M8 3v4M16 3v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
6871
+ <input type="hidden" class="iox-field__date-input" [name]="fieldName" [value]="dateIso">
6872
+ }
5511
6873
  @default {
5512
6874
  <input class="iox-field__control"
5513
6875
  [id]="controlId"
@@ -5521,7 +6883,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5521
6883
  [attr.accept]="control === 'file' ? (accept || null) : null"
5522
6884
  [value]="control === 'file' ? '' : (value ?? '')"
5523
6885
  (input)="emit($any($event.target).value)"
5524
- (blur)="touch()">
6886
+ (blur)="touch($event)">
5525
6887
  }
5526
6888
  }
5527
6889
 
@@ -5543,6 +6905,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5543
6905
  }] }, { type: undefined, decorators: [{
5544
6906
  type: Inject,
5545
6907
  args: [IOX_FORM_MESSAGES]
6908
+ }] }, { type: i0.ElementRef, decorators: [{
6909
+ type: Optional
6910
+ }] }, { type: undefined, decorators: [{
6911
+ type: Optional
6912
+ }, {
6913
+ type: Inject,
6914
+ args: [IS_PREVIEW]
6915
+ }] }, { type: PageScrollProvider, decorators: [{
6916
+ type: Optional
5546
6917
  }] }], propDecorators: { nodeId: [{
5547
6918
  type: Input
5548
6919
  }], name: [{
@@ -5573,6 +6944,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5573
6944
  type: Input
5574
6945
  }], accept: [{
5575
6946
  type: Input
6947
+ }], multiple: [{
6948
+ type: Input
6949
+ }], appearance: [{
6950
+ type: Input
6951
+ }], earliest: [{
6952
+ type: Input
6953
+ }], latest: [{
6954
+ type: Input
5576
6955
  }], options: [{
5577
6956
  type: Input
5578
6957
  }], optionsText: [{
@@ -5587,6 +6966,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5587
6966
  type: Input
5588
6967
  }], showValid: [{
5589
6968
  type: Input
6969
+ }], designOpen: [{
6970
+ type: Input
5590
6971
  }], valueChange: [{
5591
6972
  type: Output
5592
6973
  }], touched: [{
@@ -5600,36 +6981,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImpo
5600
6981
  * action list on the FORM (decision D9), so there is nothing here to wire and nothing to get out of
5601
6982
  * sync. The button owns only its own appearance.
5602
6983
  *
5603
- * It follows its FORM's state (vectoriox/iox-ui#42): its two labels are both in the markup and the shared
5604
- * iox-forms.css shows "While sending" while the form's `data-iox-state` is submitting — on the canvas's Sending
5605
- * state, the component path and the string path alike; busy and disabled are written by the ONE applier
5606
- * (`syncSubmitButtons`) the form's runtimes call. Before, the button switched only on a `submitting` input
5607
- * nothing ever wrote. The string renderer writes the same markup (forms-core `submitButtonLabelsHtml`).
6984
+ * It follows its FORM's state (vectoriox/iox-ui#42, premium N2): its three labels — at rest, "While sending", "Sent"
6985
+ * with a check — are all in the markup, stacked in one cell, and the shared iox-forms.css shows the one the form's
6986
+ * `data-iox-state` names, swapped in place behind a 2px blur (Emil 87/139); the check draws once on Sent. Busy and
6987
+ * disabled are written by the ONE applier (`syncSubmitButtons`) the form's runtimes and the canvas call. The string
6988
+ * renderer writes the same markup (forms-core `submitButtonLabelsHtml`).
5608
6989
  */
5609
6990
  class IoxSubmitButtonComponent {
5610
- constructor() {
6991
+ constructor(resolveMessage = null, host = null) {
6992
+ this.resolveMessage = resolveMessage;
6993
+ this.host = host;
5611
6994
  this.nodeId = '';
5612
6995
  this.label = 'Submit';
5613
6996
  this.loadingLabel = 'Sending…';
6997
+ /** What it says once its form has sent (N2). Empty: the catalog's "Sent", in the host's language. */
6998
+ this.sentLabel = '';
5614
6999
  this.disableWhileSubmitting = true;
5615
7000
  }
5616
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxSubmitButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
5617
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.31", type: IoxSubmitButtonComponent, isStandalone: false, selector: "button[iox-submit]", inputs: { nodeId: "nodeId", label: "label", loadingLabel: "loadingLabel", disableWhileSubmitting: "disableWhileSubmitting" }, host: { properties: { "class": "'iox-submit iox-node-' + nodeId", "attr.type": "'submit'", "attr.data-iox-keep-enabled": "disableWhileSubmitting === false ? \"\" : null" } }, ngImport: i0, template: `@if (loadingLabel) {<span class="iox-submit__labels"><span class="iox-submit__idle">{{ label }}</span><span class="iox-submit__busy">{{ loadingLabel }}</span></span>} @else {<ng-container>{{ label }}</ng-container>}`, isInline: true, styles: [":host{box-sizing:border-box;cursor:pointer}\n"] }); }
7001
+ /** The author's Sent words; else the host's translation of the catalog's; else "Sent" in the page's language. */
7002
+ get sentWords() {
7003
+ if (this.sentLabel)
7004
+ return this.sentLabel;
7005
+ const key = SUBMIT_MESSAGE_KEYS.Sent;
7006
+ const translated = (this.resolveMessage ?? defaultMessageResolver)(key);
7007
+ if (translated && translated !== key && translated !== DEFAULT_EN_MESSAGES[key])
7008
+ return translated;
7009
+ const element = this.host?.nativeElement;
7010
+ return sentWord(element?.closest?.('[lang]')?.getAttribute('lang') ?? element?.ownerDocument?.documentElement?.lang);
7011
+ }
7012
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxSubmitButtonComponent, deps: [{ token: IOX_FORM_MESSAGES, optional: true }, { token: i0.ElementRef, optional: true }], target: i0.ɵɵFactoryTarget.Component }); }
7013
+ 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", sentLabel: "sentLabel", disableWhileSubmitting: "disableWhileSubmitting" }, host: { properties: { "class": "'iox-submit iox-node-' + nodeId", "attr.type": "'submit'", "attr.data-iox-keep-enabled": "disableWhileSubmitting === false ? \"\" : null" } }, ngImport: i0, template: `<span class="iox-submit__labels"><span class="iox-submit__idle">{{ label }}</span><span class="iox-submit__busy">{{ loadingLabel || label }}</span><span class="iox-submit__sent"><svg class="iox-submit__check" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12.5l4.5 4.5L19 7.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span>{{ sentWords }}</span></span></span>`, isInline: true, styles: [":host{box-sizing:border-box;cursor:pointer}\n"] }); }
5618
7014
  }
5619
7015
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.31", ngImport: i0, type: IoxSubmitButtonComponent, decorators: [{
5620
7016
  type: Component,
5621
- args: [{ selector: 'button[iox-submit]', template: `@if (loadingLabel) {<span class="iox-submit__labels"><span class="iox-submit__idle">{{ label }}</span><span class="iox-submit__busy">{{ loadingLabel }}</span></span>} @else {<ng-container>{{ label }}</ng-container>}`, host: {
7017
+ args: [{ selector: 'button[iox-submit]', template: `<span class="iox-submit__labels"><span class="iox-submit__idle">{{ label }}</span><span class="iox-submit__busy">{{ loadingLabel || label }}</span><span class="iox-submit__sent"><svg class="iox-submit__check" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12.5l4.5 4.5L19 7.5" pathLength="100" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span>{{ sentWords }}</span></span></span>`, host: {
5622
7018
  '[class]': "'iox-submit iox-node-' + nodeId",
5623
7019
  '[attr.type]': "'submit'",
5624
7020
  // forms-core SUBMIT_KEEP_ENABLED_ATTRIBUTE — read by `syncSubmitButtons`.
5625
7021
  '[attr.data-iox-keep-enabled]': 'disableWhileSubmitting === false ? "" : null',
5626
7022
  }, standalone: false, styles: [":host{box-sizing:border-box;cursor:pointer}\n"] }]
5627
- }], propDecorators: { nodeId: [{
7023
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
7024
+ type: Optional
7025
+ }, {
7026
+ type: Inject,
7027
+ args: [IOX_FORM_MESSAGES]
7028
+ }] }, { type: i0.ElementRef, decorators: [{
7029
+ type: Optional
7030
+ }] }], propDecorators: { nodeId: [{
5628
7031
  type: Input
5629
7032
  }], label: [{
5630
7033
  type: Input
5631
7034
  }], loadingLabel: [{
5632
7035
  type: Input
7036
+ }], sentLabel: [{
7037
+ type: Input
5633
7038
  }], disableWhileSubmitting: [{
5634
7039
  type: Input
5635
7040
  }] } });
@@ -6220,6 +7625,11 @@ const PART_CLASS_NAMES = {
6220
7625
  help: 'iox-field__help',
6221
7626
  error: 'iox-field__error',
6222
7627
  required: 'iox-field__required',
7628
+ // Premium M3: a Choices field's chip (N1), a Date's calendar and each of its days (N8) — the classes forms-core
7629
+ // `fieldHtml` and the date picker runtime render.
7630
+ chip: 'iox-field__chip',
7631
+ calendar: 'iox-cal',
7632
+ calendarDay: 'iox-cal__day',
6223
7633
  },
6224
7634
  };
6225
7635
  /** The class a declared part compiles against, e.g. ('Field', 'error') -> 'iox-field__error'.
@@ -8772,13 +10182,19 @@ class StringFormRuntime {
8772
10182
  const name = this.fieldNameOf(event);
8773
10183
  if (!name)
8774
10184
  return;
10185
+ // Leaving one chip for the next, or the date button for its own calendar, is not leaving the FIELD: Smart timing
10186
+ // must not say "Choose a date" while the visitor is choosing one.
10187
+ const next = event.relatedTarget;
10188
+ if (next && this.fieldElement(name)?.contains(next))
10189
+ return;
8775
10190
  this.state = onFieldBlur(this.state, name, this.values(), this.fullSpec());
8776
10191
  this.render();
8777
10192
  }
8778
10193
  onSubmit(event) {
8779
10194
  // Never the browser's own submission: it would reload the page with the answers in the address and send nothing.
8780
10195
  event.preventDefault();
8781
- if (this.inFlight)
10196
+ // Sent is final (premium N2): the button reads "Sent" and is disabled — nothing sends twice.
10197
+ if (this.inFlight || this.form.getAttribute(FORM_STATE_ATTRIBUTE) === FormRenderState.Success)
8782
10198
  return;
8783
10199
  const values = this.values();
8784
10200
  this.state = onSubmitAttempt(this.state, values, this.fullSpec());
@@ -8856,10 +10272,16 @@ class StringFormRuntime {
8856
10272
  const el = this.fieldElement(spec.name);
8857
10273
  if (!el)
8858
10274
  return undefined;
10275
+ // A Date's answer is the ISO day its hidden input carries (N8) — never the button's words.
10276
+ if (spec.control === ControlType.Date)
10277
+ return el.querySelector(`input.${FieldClass.DateInput}`)?.value ?? '';
8859
10278
  const controls = this.controlsOf(el);
8860
10279
  const first = controls[0];
8861
10280
  if (!first)
8862
10281
  return undefined;
10282
+ // Several answers (Choices, N1): the ticked options' values, in the options' order — forms-core decides.
10283
+ if (isMultiValueField(spec))
10284
+ return controls.filter(c => c.checked).map(c => c.value);
8863
10285
  if (first.type === 'radio')
8864
10286
  return controls.find(c => c.checked)?.value ?? '';
8865
10287
  if (first.type === 'checkbox')
@@ -8920,12 +10342,14 @@ class StringFormRuntime {
8920
10342
  const next = failureAnnouncement(errors);
8921
10343
  Promise.resolve().then(() => { region.textContent = next; });
8922
10344
  }
10345
+ /** Focus the first field that failed and shake it once (premium N2 — the ONE `shakeElement`). */
8923
10346
  focusFirstInvalid() {
8924
10347
  for (const field of this.spec.fields) {
8925
10348
  if (!this.state.errors[field.name])
8926
10349
  continue;
8927
10350
  const el = this.fieldElement(field.name);
8928
10351
  this.controlsOf(el ?? this.form)[0]?.focus();
10352
+ shakeElement(el);
8929
10353
  return;
8930
10354
  }
8931
10355
  }
@@ -8959,5 +10383,5 @@ function attachStringForm(form, options = {}) {
8959
10383
  * Generated bundle index. Do not edit.
8960
10384
  */
8961
10385
 
8962
- export { A11yAttribute, A11yTrait, ACCESSIBLE_NAME_TYPES, AOSService, ARIA_CURRENT_PAGE, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, COMPONENT_STATE_SELECTOR, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CURRENT_LINK_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ComponentState, ContainerComponent, ContextStore, DECORATIVE_TYPES, DEFAULT_GALLERY_SCHEDULER, DEFAULT_INTERACTION_EASING, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DIALOG_CLOSE_ABOVE_ATTRIBUTE, DIALOG_NODE_TYPE, DeclarationContext, EASING_KEYWORDS, EASING_TOKENS, ENTRANCE_ACTION_TYPES, ENTRANCE_FALLBACK_CSS, ENTRANCE_NOSCRIPT_CSS, ENTRANCE_PRESTATE_ATTRIBUTE, EXIT_ACTION_TYPES, EasingKeyword, EasingToken, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION_TRAITS, HOVER_MEDIA_QUERY, HOVER_STATES, INTERACTION_ANIMATION_ID, INTERACTION_HIDDEN_ATTRIBUTE, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, IoxVideoComponent, LAST_EMITTED_STATES, LEGACY_DIRECTION_KEY, LEGACY_HOVER_TRANSITION_TRAITS, LEGACY_SWIPER_CONTROLS, LinkedContainerComponent, MANAGED_A11Y_ATTRIBUTES, MAX_LINES_KEY, NAMED_GROUP_ROLES, NO_PAGE_TRANSITION, ON_STATE_ROLES, OUTER_STYLE_PROPS, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PART_PAGINATION_MODIFIER, PHONE_TRANSITION_QUERY, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, PointerKind, REDUCED_MOTION_QUERY, RELATIONAL_STATE_PSEUDO, RevealActionType, SCROLLED_ATTRIBUTE, SCROLLED_STATE_ROLE, SCROLLED_THRESHOLD_PX, SCROLL_EASINGS, SELF_PLAYING_TRIGGERS, SLIDER_INPUTS_ATTRIBUTE, SLIDER_INPUT_NAMES, STATE_PSEUDO_CLASS, STRUCTURAL_STATES, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_A11Y_OPTIONS, SWIPER_RESPONSIVE_TRAITS, SWIPER_TRIGGER_EVENTS, ScrollEasing, SectionComponent, SettleMode, StringFormRuntime, SwiperModuleName, TARGETED_STATE_SUFFIX, TEXT_ALIGN_KEY, TEXT_DIRECTION_KEY, TRANSITION_FEELS, TRANSITION_PROPERTY_KEY, TextAlign, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, VisibilityActionType, activeActions, animateAction, applyBindingPipes, applyFeel, attachStringForm, bootSlider, breakpointDef, breakpointForWidth, breakpointQueryFor, buildInteractionKeyframes, buildPartSelector, buildSwiperBreakpoints, buildTargetedStateSelector, buildTargetedSuffixSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, closeAboveMediaQuery, collectContextSlotKeys, compileDeclarations, compilePageStyle, componentStatesFor, composeVirtualTraits, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, declaredInputNames, decomposeBackdropFilter, defaultHoverTransitionTraits, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, dialogCloseAboveMinWidth, domHasSliderParts, effectiveActionType, effectiveTransitionId, entrancePreStateStyle, entranceStartsHidden, feelFor, finalKeyframe, framesFromLiveValue, gateHoverRule, getSeoValue, hasHoverState, hasOwnPageTransition, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isCubicBezier, isCurrentLink, isEasingToken, isExternalOrFragment, isGradientPaint, isHoverPointer, isHoverState, isInputOverriddenAt, isInteractionEasing, isLastEmittedStateKey, isOverriddenAt, isPressFeedback, isScrolled, isStructuralState, isValidSlotKey, isWholeBackdropSet, keyframePropertyName, keyframeStyleEntries, legacyEnterToTransition, mediaQueryFor, mergeSwiperBreakpoints, migrateLegacyDirection, migrateLegacyTextAlign, motionAllowed, nearestCommonAncestorId, needsAccessibleName, needsGroupName, nodeA11yAttributes, nodeCssId, nodeTraitValue, nodeUsesContext, normalizeAppPath, normalizeGalleryImage, normalizeIconClass, orderStatesForEmit, pageTransitionOf, parentBreakpoint, parseUrlList, partClassName, planPartRule, planResponsiveRules, planScroll, planStateRule, planSwiperOptions, playDualLayerTransition, playInteraction, prefersReducedMotion, renderDefaultDeclarations, resetPageStyleField, resetSeoField, resolveBoundValue, resolveContextValue, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolveInteractionEasing, resolvePageTransition, resolvePath, resolveSeo, resolveStateStyles, resolveStyleProps, resolveToggleVisibility, resolveValueChain, rewriteViewportUnits, safeSetInput, serializeA11yAttributes, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, settleInteraction, settleModeFor, sliderControlElements, sliderInputsFromProps, sliderInputsOf, splitOuterProps, splitPressFeedback, stateNamesIn, statePseudoClass, stateSelectorSuffix, stateTransitionProperties, stringFormRuntimeOf, stripSuppressedProps, swiperAutoplayAllowed, swiperLoopOptions, swiperModuleNames, syncNodeA11yAttributes, syncSubmitButtons, targetedStateSelector, transitionPropertyList, viewportTransitionContext, watchScrolled, withContextSlots, withStateTransitions, wrapInBreakpointQuery, wrapInMediaQuery };
10386
+ export { A11yAttribute, A11yTrait, ACCESSIBLE_NAME_TYPES, AOSService, ARIA_CURRENT_PAGE, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CALENDAR_CLOSING_ATTRIBUTE, CALENDAR_DESIGN_ATTRIBUTE, CALENDAR_INSTANT_ATTRIBUTE, CALENDAR_SHEET_QUERY, COMPONENT_STATE_SELECTOR, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CURRENT_LINK_TYPES, CalendarClass, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ComponentState, ContainerComponent, ContextStore, DECORATIVE_TYPES, DEFAULT_GALLERY_SCHEDULER, DEFAULT_INTERACTION_EASING, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DIALOG_CLOSE_ABOVE_ATTRIBUTE, DIALOG_NODE_TYPE, DatePickerRuntime, DeclarationContext, EASING_KEYWORDS, EASING_TOKENS, ENTRANCE_ACTION_TYPES, ENTRANCE_FALLBACK_CSS, ENTRANCE_NOSCRIPT_CSS, ENTRANCE_PRESTATE_ATTRIBUTE, EXIT_ACTION_TYPES, EasingKeyword, EasingToken, FLOATING_GAP, FLOATING_MARGIN, FloatingSide, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION_TRAITS, HOVER_MEDIA_QUERY, HOVER_STATES, INTERACTION_ANIMATION_ID, INTERACTION_HIDDEN_ATTRIBUTE, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, IoxVideoComponent, LAST_EMITTED_STATES, LEGACY_DIRECTION_KEY, LEGACY_HOVER_TRANSITION_TRAITS, LEGACY_SWIPER_CONTROLS, LinkedContainerComponent, MANAGED_A11Y_ATTRIBUTES, MAX_LINES_KEY, NAMED_GROUP_ROLES, NO_PAGE_TRANSITION, ON_STATE_ROLES, OUTER_STYLE_PROPS, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PART_PAGINATION_MODIFIER, PHONE_TRANSITION_QUERY, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, PointerKind, REDUCED_MOTION_QUERY, RELATIONAL_STATE_PSEUDO, RevealActionType, SCROLLED_ATTRIBUTE, SCROLLED_STATE_ROLE, SCROLLED_THRESHOLD_PX, SCROLL_EASINGS, SELF_PLAYING_TRIGGERS, SHAKE_DURATION_MS, SHAKE_EASING, SHAKE_KEYFRAMES, SLIDER_INPUTS_ATTRIBUTE, SLIDER_INPUT_NAMES, STATE_PSEUDO_CLASS, STRUCTURAL_STATES, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_A11Y_OPTIONS, SWIPER_RESPONSIVE_TRAITS, SWIPER_TRIGGER_EVENTS, ScrollEasing, SectionComponent, SettleMode, StringFormRuntime, SwiperModuleName, TARGETED_STATE_SUFFIX, TEXT_ALIGN_KEY, TEXT_DIRECTION_KEY, TRANSITION_FEELS, TRANSITION_PROPERTY_KEY, TextAlign, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, VisibilityActionType, activeActions, animateAction, applyBindingPipes, applyFeel, attachDatePicker, attachDatePickers, attachStringForm, bootSlider, breakpointDef, breakpointForWidth, breakpointQueryFor, buildInteractionKeyframes, buildPartSelector, buildSwiperBreakpoints, buildTargetedStateSelector, buildTargetedSuffixSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, closeAboveMediaQuery, collectContextSlotKeys, compileDeclarations, compilePageStyle, componentStatesFor, composeVirtualTraits, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, datePickerOf, declaredInputNames, decomposeBackdropFilter, defaultHoverTransitionTraits, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, dialogCloseAboveMinWidth, domHasSliderParts, effectiveActionType, effectiveTransitionId, entrancePreStateStyle, entranceStartsHidden, feelFor, fieldLanguage, finalKeyframe, framesFromLiveValue, gateHoverRule, getSeoValue, hasHoverState, hasOwnPageTransition, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isCubicBezier, isCurrentLink, isEasingToken, isExternalOrFragment, isGradientPaint, isHoverPointer, isHoverState, isInputOverriddenAt, isInteractionEasing, isLastEmittedStateKey, isOverriddenAt, isPressFeedback, isScrolled, isStructuralState, isValidSlotKey, isWholeBackdropSet, keyframePropertyName, keyframeStyleEntries, legacyEnterToTransition, mediaQueryFor, mergeSwiperBreakpoints, migrateLegacyDirection, migrateLegacyTextAlign, motionAllowed, nearestCommonAncestorId, needsAccessibleName, needsGroupName, nodeA11yAttributes, nodeCssId, nodeTraitValue, nodeUsesContext, normalizeAppPath, normalizeGalleryImage, normalizeIconClass, orderStatesForEmit, pageTransitionOf, parentBreakpoint, parseUrlList, partClassName, placeFloating, planPartRule, planResponsiveRules, planScroll, planStateRule, planSwiperOptions, playDualLayerTransition, playInteraction, prefersReducedMotion, renderDefaultDeclarations, resetPageStyleField, resetSeoField, resolveBoundValue, resolveContextValue, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolveInteractionEasing, resolvePageTransition, resolvePath, resolveSeo, resolveStateStyles, resolveStyleProps, resolveToggleVisibility, resolveValueChain, rewriteViewportUnits, safeSetInput, serializeA11yAttributes, setDateFieldValue, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, settleInteraction, settleModeFor, shakeElement, showCalendarDesign, sliderControlElements, sliderInputsFromProps, sliderInputsOf, splitOuterProps, splitPressFeedback, stateNamesIn, statePseudoClass, stateSelectorSuffix, stateTransitionProperties, stringFormRuntimeOf, stripSuppressedProps, swiperAutoplayAllowed, swiperLoopOptions, swiperModuleNames, syncNodeA11yAttributes, syncSubmitButtons, targetedStateSelector, transitionPropertyList, viewportTransitionContext, watchScrolled, withContextSlots, withStateTransitions, wrapInBreakpointQuery, wrapInMediaQuery };
8963
10387
  //# sourceMappingURL=vectoriox-iox-ui.mjs.map