@vectoriox/iox-ui 5.82.2 → 5.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1547 -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 +303 -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 +349 -10
@@ -39,8 +39,9 @@ const TEXTUAL: readonly ControlType[] = [
39
39
  ControlType.Password, ControlType.Textarea,
40
40
  ];
41
41
 
42
- /** Controls with a comparable magnitude. */
43
- const NUMERIC: readonly ControlType[] = [ControlType.Number, ControlType.Date, ControlType.Time, ControlType.Range];
42
+ /** Controls with a comparable magnitude. A Date is not one of them: its bounds are DAYS (an ISO day or "N days from
43
+ * today", `dates.ts`), set by the Field's own Earliest / Latest — a number box here read `2026-11-06` as 2026. */
44
+ const NUMERIC: readonly ControlType[] = [ControlType.Number, ControlType.Time, ControlType.Range];
44
45
 
45
46
  export const RULE_DESCRIPTORS: readonly RuleDescriptor[] = Object.freeze([
46
47
  {
@@ -79,18 +79,22 @@ export function parseClientFormSpec(raw: string | null | undefined): ClientFormS
79
79
  }
80
80
  }
81
81
 
82
- /** How a submit button wears its form's state (vectoriox/iox-ui#42). */
82
+ /** How a submit button wears its form's state (vectoriox/iox-ui#42, premium N2). */
83
83
  export interface SubmitButtonView {
84
84
  busy: boolean;
85
85
  disabled: boolean;
86
86
  }
87
87
 
88
- /** While its form sends, a submit button is busy, and disabled unless its author turned that off — ONE rule for the
89
- * canvas's Sending state, the component path and the string path. Its "Sending…" label is the shared CSS's, keyed on
90
- * the same `data-iox-state`. */
88
+ /**
89
+ * While its form sends, a submit button is busy, and disabled unless its author turned that off; once the form has
90
+ * SENT it stays disabled, reading "Sent" with its check — nothing sends a lead twice (premium N2, the success in place).
91
+ * ONE rule for the canvas's Sending / Success states, the component path and the string path. Its labels are the shared
92
+ * CSS's, keyed on the same `data-iox-state`.
93
+ */
91
94
  export function submitButtonView(state: FormRenderState | string | null | undefined, disableWhileSubmitting = true): SubmitButtonView {
92
95
  const busy = state === FormRenderState.Submitting;
93
- return { busy, disabled: busy && disableWhileSubmitting !== false };
96
+ const sent = state === FormRenderState.Success;
97
+ return { busy, disabled: sent || (busy && disableWhileSubmitting !== false) };
94
98
  }
95
99
 
96
100
  /** On a submit button whose author turned "Disable while sending" off. */
@@ -100,17 +104,43 @@ export enum SubmitLabelClass {
100
104
  Labels = 'iox-submit__labels',
101
105
  Idle = 'iox-submit__idle',
102
106
  Busy = 'iox-submit__busy',
107
+ Sent = 'iox-submit__sent',
108
+ Check = 'iox-submit__check',
109
+ }
110
+
111
+ /** The check the Sent label draws once (premium N2 — the rare-tier beat, Emil 135/136): one stroke, `pathLength` 100 so
112
+ * the shared CSS draws it by `stroke-dashoffset` whatever its real length. */
113
+ export const SUBMIT_CHECK_SVG = `<svg class="${SubmitLabelClass.Check}" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false">` +
114
+ '<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>';
115
+
116
+ /** "Sent" in the site's language, for a button whose author wrote no Sent words (a string-rendered site has no
117
+ * translation service — the document's `lang` picks, like the date picker's words). */
118
+ const SENT_WORDS: Readonly<Record<string, string>> = Object.freeze({ en: 'Sent', he: 'נשלח' });
119
+
120
+ export function sentWord(lang: string | null | undefined): string {
121
+ const language = String(lang ?? '').toLowerCase().split(/[-_]/)[0];
122
+ return SENT_WORDS[language === 'iw' ? 'he' : language] ?? SENT_WORDS['en'];
123
+ }
124
+
125
+ /** A submit button's three labels: what it says at rest, while its form sends, and once it has sent. */
126
+ export interface SubmitLabels {
127
+ idle: string;
128
+ /** "While sending" — none: the button keeps its idle words while sending. */
129
+ busy?: string | null;
130
+ /** "Sent" — none: the catalog's word, translated by the host (`SUBMIT_MESSAGE_KEYS.Sent`). */
131
+ sent: string;
103
132
  }
104
133
 
105
134
  /**
106
- * A submit button's words, as markup: its label, or — when it has a "While sending" label — both, stacked in one
107
- * cell (the button keeps the longer one's width and never jumps). The string renderer's copy of
108
- * `IoxSubmitButtonComponent`'s template; `escape` is the renderer's.
135
+ * A submit button's words, as markup: its three labels stacked in one grid cell (the button keeps the longest one's
136
+ * width and never jumps), each shown by the form's state through the shared CSS — swapped in place behind a 2px blur
137
+ * (Emil 87/139). The string renderer's copy of `IoxSubmitButtonComponent`'s template; `escape` is the renderer's.
109
138
  */
110
- export function submitButtonLabelsHtml(label: string, loadingLabel: string | null | undefined, escape: (v: string) => string): string {
111
- if (!loadingLabel) return escape(label);
112
- return `<span class="${SubmitLabelClass.Labels}"><span class="${SubmitLabelClass.Idle}">${escape(label)}</span>` +
113
- `<span class="${SubmitLabelClass.Busy}">${escape(loadingLabel)}</span></span>`;
139
+ export function submitButtonLabelsHtml(labels: SubmitLabels, escape: (v: string) => string): string {
140
+ const busy = labels.busy || labels.idle;
141
+ return `<span class="${SubmitLabelClass.Labels}"><span class="${SubmitLabelClass.Idle}">${escape(labels.idle)}</span>` +
142
+ `<span class="${SubmitLabelClass.Busy}">${escape(busy)}</span>` +
143
+ `<span class="${SubmitLabelClass.Sent}">${SUBMIT_CHECK_SVG}<span>${escape(labels.sent)}</span></span></span>`;
114
144
  }
115
145
 
116
146
  export const FORM_HONEYPOT_CLASS = 'iox-form__hp';
@@ -9,12 +9,15 @@ import {
9
9
  FieldSpec,
10
10
  FormSpec,
11
11
  IMPLIED_PATTERN,
12
+ isMultiValueField,
13
+ OPTION_CONTROLS,
12
14
  PATTERN_PRESETS,
13
15
  PatternPreset,
14
16
  RuleName,
15
17
  ValidationRule,
16
18
  } from './form.model';
17
- import { PATTERN_MESSAGE_KEYS, RULE_MESSAGE_KEYS } from './messages';
19
+ import { addDaysIso, isDateBoundParams, isIsoDate, resolveDateBound, todayIso } from './dates';
20
+ import { CHOICE_MESSAGE_KEYS, DATE_MESSAGE_KEYS, PATTERN_MESSAGE_KEYS, RULE_MESSAGE_KEYS } from './messages';
18
21
 
19
22
  export interface FieldError {
20
23
  /** i18n key, or the author's own override string when they set one. */
@@ -33,6 +36,16 @@ export interface FormResult {
33
36
  errors: Record<string, FieldError>;
34
37
  }
35
38
 
39
+ /**
40
+ * When "today" is, for a Date's relative bounds (premium N8). A browser leaves it out: its own local day is the
41
+ * visitor's. The SERVER passes `dateSlackDays: 1` — it cannot know the visitor's time zone, so a day either side of
42
+ * its own today is still accepted.
43
+ */
44
+ export interface ValidateOptions {
45
+ today?: string;
46
+ dateSlackDays?: number;
47
+ }
48
+
36
49
  /** A file, as either runtime hands it over. Kept structural so this file imports no DOM types. */
37
50
  interface FileLike {
38
51
  name?: string;
@@ -55,6 +68,7 @@ export function validateField(
55
68
  value: unknown,
56
69
  spec: FieldSpec,
57
70
  form: Record<string, unknown> = {},
71
+ options: ValidateOptions = {},
58
72
  ): FieldResult {
59
73
  const rules = effectiveRules(spec);
60
74
  const empty = isEmpty(value, spec.control);
@@ -63,7 +77,7 @@ export function validateField(
63
77
  const appliesToEmpty = rule.name === RuleName.Required || rule.name === RuleName.Accepted;
64
78
  if (empty && !appliesToEmpty) continue;
65
79
 
66
- const failure = check(rule, value, spec, form);
80
+ const failure = check(rule, value, spec, form, options);
67
81
  if (failure) {
68
82
  return {
69
83
  valid: false,
@@ -82,10 +96,11 @@ export function validateField(
82
96
  export function validateForm(
83
97
  values: Record<string, unknown>,
84
98
  spec: FormSpec,
99
+ options: ValidateOptions = {},
85
100
  ): FormResult {
86
101
  const errors: Record<string, FieldError> = {};
87
102
  for (const field of spec.fields ?? []) {
88
- const result = validateField(values?.[field.name], field, values ?? {});
103
+ const result = validateField(values?.[field.name], field, values ?? {}, options);
89
104
  if (!result.valid) {
90
105
  errors[field.name] = { messageKey: result.messageKey!, params: result.params };
91
106
  }
@@ -93,6 +108,22 @@ export function validateForm(
93
108
  return { valid: Object.keys(errors).length === 0, errors };
94
109
  }
95
110
 
111
+ /**
112
+ * The answers in the shape each field takes (premium N1): a field that takes several answers gets a LIST (one answer
113
+ * posted on its own is a list of one; nothing is an empty list). ONE reading for a host's transport and the server,
114
+ * before validation — `isMultiValueField` decides. A single-answer field is left as posted: two answers to it is the
115
+ * validator's to refuse, never quietly cut to one.
116
+ */
117
+ export function normaliseAnswers(values: Record<string, unknown> | null | undefined, spec: Pick<FormSpec, 'fields'>): Record<string, unknown> {
118
+ const out: Record<string, unknown> = { ...(values ?? {}) };
119
+ for (const field of spec.fields ?? []) {
120
+ if (!isMultiValueField(field) || field.control === ControlType.File) continue;
121
+ if (!(field.name in out)) continue;
122
+ out[field.name] = chosen(out[field.name]);
123
+ }
124
+ return out;
125
+ }
126
+
96
127
  /** The name of the first field with an error, in spec order — what the browser should focus. */
97
128
  export function firstInvalidField(spec: FormSpec, errors: Record<string, FieldError>): string | null {
98
129
  for (const field of spec.fields ?? []) if (errors[field.name]) return field.name;
@@ -124,9 +155,51 @@ function effectiveRules(spec: FieldSpec): ValidationRule[] {
124
155
  implied.push({ name: RuleName.Pattern, params: { preset } });
125
156
  }
126
157
 
158
+ // An answer to a field of CHOICES must be one the form offered (premium N1) — only when the options are the
159
+ // field's own: options from a data source are not known here, so they are not judged.
160
+ if (OPTION_CONTROLS.has(spec.control) && !spec.optionsSource && (spec.options ?? []).length) {
161
+ implied.push({ name: RuleName.OneOf });
162
+ }
163
+ // A Date's answer is a real day before any bound is asked about it (premium N8).
164
+ if (spec.control === ControlType.Date) implied.push({ name: RuleName.ValidDate });
165
+
127
166
  return [...implied, ...declared];
128
167
  }
129
168
 
169
+ /** "Required" in the words of the control: choose, not fill in. */
170
+ function requiredMessageKey(spec: FieldSpec): string {
171
+ if (spec.control === ControlType.Date) return CHOICE_MESSAGE_KEYS.Date;
172
+ if (isMultiValueField(spec) && spec.control === ControlType.Choices) return CHOICE_MESSAGE_KEYS.Some;
173
+ if (OPTION_CONTROLS.has(spec.control)) return CHOICE_MESSAGE_KEYS.One;
174
+ return RULE_MESSAGE_KEYS[RuleName.Required];
175
+ }
176
+
177
+ /** The answers a field of choices gave, as a list — a single answer is a list of one, nothing is an empty list. */
178
+ function chosen(value: unknown): unknown[] {
179
+ if (Array.isArray(value)) return value;
180
+ return value === null || value === undefined || value === '' ? [] : [value];
181
+ }
182
+
183
+ /** The `oneOf` verdict: every answer is one of the options, and a single-answer field got at most one. */
184
+ function isOneOf(value: unknown, spec: FieldSpec): boolean {
185
+ const offered = new Set((spec.options ?? []).map(o => String(o.value)));
186
+ const answers = chosen(value);
187
+ if (!isMultiValueField(spec) && answers.length > 1) return false;
188
+ return answers.every(a => (typeof a === 'string' || typeof a === 'number') && offered.has(String(a)));
189
+ }
190
+
191
+ /** A Date bound, as a failure or null — the bound resolved against today, widened by the server's slack. */
192
+ function dateBoundFailure(rule: ValidationRule, value: unknown, options: ValidateOptions): FieldError | null {
193
+ const today = options.today ?? todayIso();
194
+ const slack = Math.max(0, Math.trunc(options.dateSlackDays ?? 0));
195
+ const bound = resolveDateBound(rule.params, today);
196
+ if (!bound || !isIsoDate(value)) return null;
197
+ const day = String(value);
198
+ if (rule.name === RuleName.Min && day < addDaysIso(bound, -slack)) return { messageKey: DATE_MESSAGE_KEYS.Min };
199
+ if (rule.name === RuleName.Max && day > addDaysIso(bound, slack)) return { messageKey: DATE_MESSAGE_KEYS.Max };
200
+ return null;
201
+ }
202
+
130
203
  /** Empty means "the visitor gave us nothing" — per control type, not a blanket falsy check. */
131
204
  function isEmpty(value: unknown, control: ControlType): boolean {
132
205
  if (value === null || value === undefined) return true;
@@ -141,18 +214,31 @@ function check(
141
214
  value: unknown,
142
215
  spec: FieldSpec,
143
216
  form: Record<string, unknown>,
217
+ options: ValidateOptions,
144
218
  ): FieldError | null {
145
219
  const fail = (messageKey: string, params?: Record<string, any>): FieldError => ({ messageKey, params });
146
220
  const defaultKey = RULE_MESSAGE_KEYS[rule.name];
147
221
  const n = Number(rule.params?.['n']);
148
222
 
223
+ // A Date's min / max are DAYS (an ISO day or an offset from today), never a number to parseFloat — `2026-11-06`
224
+ // read as 2026 is what the numeric reading did.
225
+ if ((rule.name === RuleName.Min || rule.name === RuleName.Max) && spec.control === ControlType.Date && isDateBoundParams(rule.params)) {
226
+ return dateBoundFailure(rule, value, options);
227
+ }
228
+
149
229
  switch (rule.name) {
150
230
  case RuleName.Required:
151
- return isEmpty(value, spec.control) ? fail(defaultKey) : null;
231
+ return isEmpty(value, spec.control) ? fail(requiredMessageKey(spec)) : null;
152
232
 
153
233
  case RuleName.Accepted:
154
234
  return value === true ? null : fail(defaultKey);
155
235
 
236
+ case RuleName.OneOf:
237
+ return isOneOf(value, spec) ? null : fail(defaultKey);
238
+
239
+ case RuleName.ValidDate:
240
+ return isIsoDate(value) ? null : fail(defaultKey);
241
+
156
242
  case RuleName.MinLength:
157
243
  return text(value).length < n ? fail(defaultKey, { n }) : null;
158
244
 
@@ -1,6 +1,6 @@
1
1
  import { ELEMENT_ACTION_DESCRIPTORS, resolveActionAvailability } from '@vectoriox/iox-ui/actions-core';
2
2
  import { auditDesignContrast, DesignTokenLike } from '@vectoriox/iox-ui/design-core';
3
- import { collectFormSpec, ControlType, FIELD_NODE_TYPE, FORM_NODE_TYPE } from '@vectoriox/iox-ui/forms-core';
3
+ import { collectFormSpec, ControlType, FIELD_NODE_TYPE, FORM_CHANNEL_ROLE, FORM_NODE_TYPE, isChannelFilled } from '@vectoriox/iox-ui/forms-core';
4
4
 
5
5
  import { blocksPublish, ComposedPlaceholder, PlaceholderKind } from './compose-site';
6
6
  import { navShapeOf } from './nav-shape';
@@ -40,6 +40,9 @@ export enum SiteFindingCode {
40
40
  NoHeader = 'no-header',
41
41
  NoPhoneMenu = 'no-phone-menu',
42
42
  FormWithoutId = 'form-without-id',
43
+ /** Premium N2: a form's second way to reach you (WhatsApp, a phone, an email) has no address yet — the studio's own
44
+ * fact, never written for it. */
45
+ EmptyChannel = 'empty-channel',
43
46
  EmailNeedsMailer = 'email-needs-mailer',
44
47
  TrackerWithoutBanner = 'tracker-without-banner',
45
48
  NoTitle = 'no-title',
@@ -64,6 +67,7 @@ export const FINDING_SEVERITY: Readonly<Record<SiteFindingCode, CheckSeverity>>
64
67
  [SiteFindingCode.NoHeader]: CheckSeverity.Warn,
65
68
  [SiteFindingCode.NoPhoneMenu]: CheckSeverity.Warn,
66
69
  [SiteFindingCode.FormWithoutId]: CheckSeverity.Block,
70
+ [SiteFindingCode.EmptyChannel]: CheckSeverity.Block,
67
71
  [SiteFindingCode.EmailNeedsMailer]: CheckSeverity.Warn,
68
72
  [SiteFindingCode.TrackerWithoutBanner]: CheckSeverity.Warn,
69
73
  [SiteFindingCode.NoTitle]: CheckSeverity.Warn,
@@ -87,6 +91,7 @@ const CHECK_OF: Readonly<Record<SiteFindingCode, SiteCheckId>> = {
87
91
  [SiteFindingCode.NoHeader]: SiteCheckId.Navigation,
88
92
  [SiteFindingCode.NoPhoneMenu]: SiteCheckId.Navigation,
89
93
  [SiteFindingCode.FormWithoutId]: SiteCheckId.Forms,
94
+ [SiteFindingCode.EmptyChannel]: SiteCheckId.Forms,
90
95
  [SiteFindingCode.EmailNeedsMailer]: SiteCheckId.Forms,
91
96
  [SiteFindingCode.TrackerWithoutBanner]: SiteCheckId.Consent,
92
97
  [SiteFindingCode.NoTitle]: SiteCheckId.Seo,
@@ -243,6 +248,9 @@ function checkForms(ctx: Ctx, snapshot: SiteAuditSnapshot, pages: ReadonlyArray<
243
248
  }
244
249
  walkSerialized(n.children ?? [], f => {
245
250
  if (f.type === FIELD_NODE_TYPE && PERSONAL_CONTROLS.has(String(f.props['control'] ?? ControlType.Text))) personal = true;
251
+ if (f.role === FORM_CHANNEL_ROLE && !isChannelFilled(f.props['href'])) {
252
+ add(ctx, SiteFindingCode.EmptyChannel, 'A form\'s second way to reach you has no number or address yet — only you can fill it in.', { ...where, nodeId: f.id });
253
+ }
246
254
  });
247
255
  });
248
256
  return personal;
@@ -1,6 +1,6 @@
1
1
  import { AnimateId, COLOR_SCHEMES, DesignChoices, LinkType, resolveDesignChoices, setColorScheme } from '@vectoriox/iox-ui/design-core';
2
2
  import { SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, SeoRawDoc } from '@vectoriox/iox-ui/seo-core';
3
- import { FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
3
+ import { channelOpensNewTab, FORM_ACTIONS_ROLE, FORM_CHANNEL_ROLE, FormPresetChannel, FormPresetDef, FormPresetField, formPresetById } from '@vectoriox/iox-ui/forms-core';
4
4
  import {
5
5
  ACCORDION_BIND_PROP, ACCORDION_CONTENT_LIMIT, ACCORDION_WORDS, accordionAnswerOf, accordionItems, accordionQuestionOf, AccordionRole,
6
6
  buildAccordionRecipe, buildCardRecipe, buildCarouselRecipe, buildLightboxRecipe, CardLayout, CardPart, CardRole, CARD_LAYOUTS, CARD_PARTS, CompositeNode,
@@ -215,7 +215,10 @@ const FORM_SUCCESS = 'FormSuccess';
215
215
  const FORM_ERROR = 'FormError';
216
216
  const FORM_STATE_BLOCKS = [FORM_SUCCESS, FORM_ERROR];
217
217
  /** The Field traits a preset field fills, 1:1 (`FormPresetField`). */
218
- const PRESET_FIELD_PROPS: ReadonlyArray<keyof FormPresetField> = ['label', 'control', 'required', 'help', 'placeholder', 'optionsText', 'rows'];
218
+ const PRESET_FIELD_PROPS: ReadonlyArray<keyof FormPresetField> = ['label', 'control', 'required', 'help', 'placeholder', 'optionsText', 'rows', 'multiple', 'appearance', 'earliest', 'latest'];
219
+ const FIELD_GROUP = 'FieldGroup';
220
+ /** The second channel beside Send (premium N2): the quieter button. */
221
+ const CHANNEL_STYLE = 'button-secondary';
219
222
  const LABEL_TAG = 'span';
220
223
  const TEXT = 'Text';
221
224
  /** A button's label reads the button's own type and ink (contract element style, W1.1 Q6). */
@@ -494,7 +497,23 @@ function presetChildren(preset: FormPresetDef, id: string): SerializedNode[] {
494
497
  block.children = [create(`${id}-${n + 1 + i}-0`, TEXT, { content: words })];
495
498
  return block;
496
499
  });
497
- return [...fields, submit, ...blocks];
500
+ return [...fields, preset.channel ? actionsRow(submit, preset.channel, `${id}-${n}`) : submit, ...blocks];
501
+ }
502
+
503
+ /**
504
+ * Send and the second channel in ONE row (premium N2), the way the builder's tray builds it (iox-builder
505
+ * `buildFormActionsRow`): the channel's address is the studio's own fact and starts EMPTY — the Publishable gate
506
+ * blocks publishing until it is filled (`auditSite`, empty-channel). Never a number written for it.
507
+ */
508
+ function actionsRow(submit: SerializedNode, channel: FormPresetChannel, id: string): SerializedNode {
509
+ const row = create(`${id}-row`, FIELD_GROUP);
510
+ row.role = FORM_ACTIONS_ROLE;
511
+ Object.assign(row.styleProps, { flexWrap: 'wrap', alignItems: 'center', gap: '12px' });
512
+ const button = create(`${id}-channel`, BUTTON_BLOCK, { tag: 'a', linkType: LinkType.External, href: '', target: channelOpensNewTab(channel.kind) ? '_blank' : '_self' }, CHANNEL_STYLE);
513
+ button.role = FORM_CHANNEL_ROLE;
514
+ button.children = [create(`${id}-channel-0`, TEXT, { content: channel.label }, BUTTON_LABEL_STYLE)];
515
+ row.children = [submit, button];
516
+ return row;
498
517
  }
499
518
 
500
519
  /** A Form: its stable id, its preset's name and children (or the pattern's own children), and both state
@@ -3835,6 +3835,10 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3835
3835
  "label": "Label",
3836
3836
  "control": "text",
3837
3837
  "placeholder": "",
3838
+ "multiple": false,
3839
+ "appearance": "chips",
3840
+ "earliest": "",
3841
+ "latest": "",
3838
3842
  "help": "",
3839
3843
  "rows": 4,
3840
3844
  "rangeMin": 0,
@@ -3938,6 +3942,10 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
3938
3942
  "label",
3939
3943
  "control",
3940
3944
  "placeholder",
3945
+ "multiple",
3946
+ "appearance",
3947
+ "earliest",
3948
+ "latest",
3941
3949
  "help",
3942
3950
  "rows",
3943
3951
  "rangeMin",
@@ -4241,6 +4249,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
4241
4249
  "props": {
4242
4250
  "label": "Send",
4243
4251
  "loadingLabel": "Sending…",
4252
+ "sentLabel": "",
4244
4253
  "disableWhileSubmitting": true
4245
4254
  },
4246
4255
  "styleProps": {
@@ -4332,6 +4341,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
4332
4341
  "traits": [
4333
4342
  "label",
4334
4343
  "loadingLabel",
4344
+ "sentLabel",
4335
4345
  "disableWhileSubmitting"
4336
4346
  ],
4337
4347
  "styleTraits": [
@@ -1,5 +1,5 @@
1
1
  import { ANIMATE_IDS, DesignChoices } from '@vectoriox/iox-ui/design-core';
2
- import { FORM_PRESETS } from '@vectoriox/iox-ui/forms-core';
2
+ import { planFormPresetIds } from '@vectoriox/iox-ui/forms-core';
3
3
 
4
4
  import { describeShapeKey, jsonAnyOf, jsonBoolean, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText } from './json-schema';
5
5
  import { patternLinkSlots, patternOptionalParts, patternSlots, PatternGlobalPosition, SITE_PATTERNS } from './patterns';
@@ -320,7 +320,7 @@ export function wireSectionJsonSchema(): JsonSchema {
320
320
  links: jsonListOf(shaped(PlanWireEntry.SlotLink, { slot: closedOrText(unique(patterns.flatMap(patternLinkSlots))), route: jsonText() })),
321
321
  binding: jsonText(),
322
322
  without: jsonListOf(closedOrText(unique(patterns.flatMap(patternOptionalParts)))),
323
- form: orNone(FORM_PRESETS.map(p => p.id)),
323
+ form: orNone(planFormPresetIds()),
324
324
  enter: orNone(ANIMATE_IDS),
325
325
  });
326
326
  }
@@ -464,3 +464,13 @@ span.iox-video__play { pointer-events: none; }
464
464
  .iox-role-slider-pagination .swiper-pagination-bullet-active { width: 34px; }
465
465
  .iox-role-slider-pagination .swiper-pagination-bullet-active::after { width: 26px; background: currentColor; }
466
466
  .iox-role-slider-pagination .swiper-pagination-bullet:focus-visible { outline: none; box-shadow: var(--iox-focus-ring); }
467
+
468
+ /* ── The date picker's sheet (premium components N8) ────────────────────────────────────────────────
469
+ On a phone the calendar is a bottom sheet over the page, dimmed by the look's own scrim (a dialog's — Emil 108:
470
+ a modal task dims to focus). Its colours are the contract's `calendar` / `calendar-day` (iox-forms.css, under the
471
+ field marker); its placement and motion are iox-forms.css's too — this is the one token the sheet reads. */
472
+ @media (max-width: 767px) {
473
+ .iox-es-field dialog.iox-cal::backdrop {
474
+ background: var(--iox-scrim);
475
+ }
476
+ }