@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
@@ -0,0 +1,299 @@
1
+ /**
2
+ * DATES — the date picker's pure half (premium components N8) and the date rules' one reading.
3
+ *
4
+ * A Date field's value is an ISO day, `YYYY-MM-DD`, compared as a string (the format sorts as the calendar does).
5
+ * Its `min` / `max` rules take an ISO day OR an offset in days from today (`{ offsetDays: 0 }` = today), so "from
6
+ * today on" stays true tomorrow without a re-save. ONE reading for the validator (client AND iox-cms), the calendar
7
+ * (which days it refuses) and the builder's Earliest / Latest controls.
8
+ *
9
+ * The calendar model (six rows of seven, roving focus, the APG keys) lives here too, so the DOM runtime only draws
10
+ * what this decides — the canvas, the engine's component path and its string path draw the SAME month.
11
+ *
12
+ * ⚠️ Framework-free, like the rest of forms-core. Local-time `Date` objects are used only to walk the calendar; a
13
+ * day leaves this file as an ISO string.
14
+ */
15
+
16
+ import { ControlType, FieldSpec, RuleName, ValidationRule } from './form.model';
17
+
18
+ const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
19
+ const OFFSET = /^[+-]?\d+$/;
20
+ const DAYS_IN_WEEK = 7;
21
+ /** Always six rows, so the panel never changes size between months (APG; Emil 74 — one object, one size). */
22
+ export const CALENDAR_ROWS = 6;
23
+
24
+ const pad = (n: number, width = 2) => String(n).padStart(width, '0');
25
+
26
+ /** The ISO day of a local `Date`. */
27
+ export function isoOfDate(d: Date): string {
28
+ return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
29
+ }
30
+
31
+ /** A local `Date` at midnight for an ISO day, or null when it is not one (2026-02-30 is not). */
32
+ export function dateOfIso(iso: unknown): Date | null {
33
+ const m = typeof iso === 'string' ? ISO_DAY.exec(iso.trim()) : null;
34
+ if (!m) return null;
35
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
36
+ const date = new Date(y, mo - 1, d);
37
+ date.setFullYear(y);
38
+ return date.getFullYear() === y && date.getMonth() === mo - 1 && date.getDate() === d ? date : null;
39
+ }
40
+
41
+ /** True for a real calendar day written `YYYY-MM-DD`. */
42
+ export function isIsoDate(value: unknown): boolean {
43
+ return dateOfIso(value) !== null;
44
+ }
45
+
46
+ /** Today, as the device that runs this sees it (the visitor's local day in a browser; the server's on iox-cms). */
47
+ export function todayIso(now: Date = new Date()): string {
48
+ return isoOfDate(now);
49
+ }
50
+
51
+ /** `iso` moved by `days` (negative goes back). */
52
+ export function addDaysIso(iso: string, days: number): string {
53
+ const d = dateOfIso(iso);
54
+ if (!d) return iso;
55
+ return isoOfDate(new Date(d.getFullYear(), d.getMonth(), d.getDate() + days));
56
+ }
57
+
58
+ /** `iso` moved by `months`, its day clamped to the target month's last (31 Jan + 1 month = 28/29 Feb). */
59
+ export function addMonthsIso(iso: string, months: number): string {
60
+ const d = dateOfIso(iso);
61
+ if (!d) return iso;
62
+ const first = new Date(d.getFullYear(), d.getMonth() + months, 1);
63
+ const last = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
64
+ return isoOfDate(new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last)));
65
+ }
66
+
67
+ /** `iso` held inside [min, max] (either may be absent). */
68
+ export function clampIso(iso: string, min?: string | null, max?: string | null): string {
69
+ if (min && iso < min) return min;
70
+ if (max && iso > max) return max;
71
+ return iso;
72
+ }
73
+
74
+ // ─── bounds: Earliest / Latest ───────────────────────────────────────────────
75
+
76
+ /** A date rule's parameter: an ISO day, or a number of days from today. */
77
+ export interface DateBoundParams {
78
+ date?: string;
79
+ offsetDays?: number;
80
+ }
81
+
82
+ /**
83
+ * A Field's `earliest` / `latest` trait → the rule parameter. The trait is one string so the panel and a saved page
84
+ * stay simple: `''` = any day · an integer = that many days from today (`'0'` = today, `'30'` = a month ahead) · an
85
+ * ISO day. Anything else reads as no bound.
86
+ */
87
+ export function parseDateBound(raw: unknown): DateBoundParams | null {
88
+ if (typeof raw === 'number' && Number.isInteger(raw)) return { offsetDays: raw };
89
+ if (typeof raw !== 'string') return null;
90
+ const text = raw.trim();
91
+ if (!text) return null;
92
+ if (OFFSET.test(text)) return { offsetDays: Number(text) };
93
+ return isIsoDate(text) ? { date: text } : null;
94
+ }
95
+
96
+ /** The `min` / `max` rules a Date field's Earliest / Latest traits give it (none for "any"). */
97
+ export function dateBoundRules(earliest: unknown, latest: unknown): ValidationRule[] {
98
+ const rules: ValidationRule[] = [];
99
+ const min = parseDateBound(earliest);
100
+ const max = parseDateBound(latest);
101
+ if (min) rules.push({ name: RuleName.Min, params: { ...min } });
102
+ if (max) rules.push({ name: RuleName.Max, params: { ...max } });
103
+ return rules;
104
+ }
105
+
106
+ /** True when a rule's params are a DATE bound (an ISO day or an offset), never a number bound. */
107
+ export function isDateBoundParams(params: Record<string, any> | null | undefined): boolean {
108
+ return !!params && (typeof params['date'] === 'string' || Number.isInteger(params['offsetDays']));
109
+ }
110
+
111
+ /** The ISO day a bound names today, or null when it names none. */
112
+ export function resolveDateBound(params: Record<string, any> | null | undefined, today: string): string | null {
113
+ if (!params) return null;
114
+ if (typeof params['date'] === 'string' && isIsoDate(params['date'])) return params['date'];
115
+ if (Number.isInteger(params['offsetDays'])) return addDaysIso(today, params['offsetDays']);
116
+ return null;
117
+ }
118
+
119
+ /** The first and last day a Date field accepts today — the calendar's refused days and the validator's range. */
120
+ export function fieldDateRange(field: Pick<FieldSpec, 'rules'> | null | undefined, today: string): { min: string | null; max: string | null } {
121
+ let min: string | null = null;
122
+ let max: string | null = null;
123
+ for (const rule of field?.rules ?? []) {
124
+ if (rule.name === RuleName.Min) min = resolveDateBound(rule.params, today) ?? min;
125
+ if (rule.name === RuleName.Max) max = resolveDateBound(rule.params, today) ?? max;
126
+ }
127
+ return { min, max };
128
+ }
129
+
130
+ /** True when the field is a date picker (the control that reads these rules as days). */
131
+ export function isDateField(field: Pick<FieldSpec, 'control'> | null | undefined): boolean {
132
+ return field?.control === ControlType.Date;
133
+ }
134
+
135
+ // ─── the week, the month, the keys ───────────────────────────────────────────
136
+
137
+ /** Regions whose week starts on Sunday when the runtime cannot say (Intl.Locale#getWeekInfo is not everywhere). */
138
+ const SUNDAY_REGIONS: ReadonlySet<string> = new Set(['US', 'CA', 'IL', 'JP', 'BR', 'MX', 'PH', 'IN', 'KR', 'TW', 'ZA', 'AU']);
139
+ const SUNDAY_LANGUAGES: ReadonlySet<string> = new Set(['he', 'iw', 'ja', 'ko']);
140
+
141
+ /**
142
+ * The first day of the week for a locale, 0 = Sunday … 6 = Saturday. `Intl.Locale(…).getWeekInfo()` (or the older
143
+ * `weekInfo` getter) when the runtime has it, else a small region table; a bare `en` reads as the US.
144
+ */
145
+ export function weekStartFor(locale: string | null | undefined): number {
146
+ const tag = locale || 'en';
147
+ try {
148
+ const Ctor = (Intl as any).Locale;
149
+ if (Ctor) {
150
+ const loc = new Ctor(tag);
151
+ const info = typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : loc.weekInfo;
152
+ const first = Number(info?.firstDay);
153
+ if (first >= 1 && first <= 7) return first % DAYS_IN_WEEK;
154
+ }
155
+ } catch { /* an unknown tag falls through to the table */ }
156
+ const [language, region] = tag.split(/[-_]/);
157
+ if (region && SUNDAY_REGIONS.has(region.toUpperCase())) return 0;
158
+ if (!region && (language === 'en' || SUNDAY_LANGUAGES.has(language))) return 0;
159
+ return 1;
160
+ }
161
+
162
+ /** One cell of the month grid. */
163
+ export interface CalendarDay {
164
+ iso: string;
165
+ day: number;
166
+ today: boolean;
167
+ selected: boolean;
168
+ /** Outside Earliest / Latest: shown, announced as unavailable, never picked. */
169
+ disabled: boolean;
170
+ /** The roving tab stop — exactly one day of the month has it. */
171
+ focus: boolean;
172
+ }
173
+
174
+ export interface CalendarMonth {
175
+ year: number;
176
+ /** 0-based, as `Date` counts. */
177
+ month: number;
178
+ /** `CALENDAR_ROWS` × 7; null where the month has no day. */
179
+ weeks: Array<Array<CalendarDay | null>>;
180
+ /** The weekday of each column, 0 = Sunday — the header row reads these. */
181
+ weekdays: number[];
182
+ canPrev: boolean;
183
+ canNext: boolean;
184
+ }
185
+
186
+ export interface CalendarOptions {
187
+ weekStart: number;
188
+ today: string;
189
+ min?: string | null;
190
+ max?: string | null;
191
+ selected?: string | null;
192
+ }
193
+
194
+ /** The month `focus` sits in, as six rows of seven — the ONE grid every renderer draws. */
195
+ export function calendarMonth(focus: string, options: CalendarOptions): CalendarMonth {
196
+ const at = dateOfIso(focus) ?? dateOfIso(options.today) ?? new Date();
197
+ const year = at.getFullYear();
198
+ const month = at.getMonth();
199
+ const lead = (new Date(year, month, 1).getDay() - options.weekStart + DAYS_IN_WEEK) % DAYS_IN_WEEK;
200
+ const days = new Date(year, month + 1, 0).getDate();
201
+ const weeks: Array<Array<CalendarDay | null>> = [];
202
+ for (let r = 0; r < CALENDAR_ROWS; r++) {
203
+ const row: Array<CalendarDay | null> = [];
204
+ for (let c = 0; c < DAYS_IN_WEEK; c++) {
205
+ const n = r * DAYS_IN_WEEK + c - lead + 1;
206
+ if (n < 1 || n > days) { row.push(null); continue; }
207
+ const iso = isoOfDate(new Date(year, month, n));
208
+ row.push({
209
+ iso, day: n,
210
+ today: iso === options.today,
211
+ selected: iso === options.selected,
212
+ disabled: (!!options.min && iso < options.min) || (!!options.max && iso > options.max),
213
+ focus: iso === focus,
214
+ });
215
+ }
216
+ weeks.push(row);
217
+ }
218
+ const monthKey = (iso: string | null | undefined) => (iso ? iso.slice(0, 7) : null);
219
+ const here = monthKey(isoOfDate(new Date(year, month, 1)))!;
220
+ const minMonth = monthKey(options.min);
221
+ const maxMonth = monthKey(options.max);
222
+ return {
223
+ year, month, weeks,
224
+ weekdays: Array.from({ length: DAYS_IN_WEEK }, (_, i) => (options.weekStart + i) % DAYS_IN_WEEK),
225
+ canPrev: !minMonth || here > minMonth,
226
+ canNext: !maxMonth || here < maxMonth,
227
+ };
228
+ }
229
+
230
+ /** The keys the grid answers (APG date picker dialog). */
231
+ export enum CalendarKey {
232
+ Left = 'ArrowLeft',
233
+ Right = 'ArrowRight',
234
+ Up = 'ArrowUp',
235
+ Down = 'ArrowDown',
236
+ Home = 'Home',
237
+ End = 'End',
238
+ PageUp = 'PageUp',
239
+ PageDown = 'PageDown',
240
+ }
241
+
242
+ const KEY_MOVES: Readonly<Record<CalendarKey, (iso: string, ctx: { ahead: number; dow: number; shift: boolean }) => string>> = {
243
+ // The arrows follow the READING direction: in RTL "→" is a day back (APG; mirrored).
244
+ [CalendarKey.Right]: (iso, c) => addDaysIso(iso, c.ahead),
245
+ [CalendarKey.Left]: (iso, c) => addDaysIso(iso, -c.ahead),
246
+ [CalendarKey.Down]: iso => addDaysIso(iso, DAYS_IN_WEEK),
247
+ [CalendarKey.Up]: iso => addDaysIso(iso, -DAYS_IN_WEEK),
248
+ [CalendarKey.Home]: (iso, c) => addDaysIso(iso, -c.dow),
249
+ [CalendarKey.End]: (iso, c) => addDaysIso(iso, DAYS_IN_WEEK - 1 - c.dow),
250
+ // A month; with Shift, a year.
251
+ [CalendarKey.PageUp]: (iso, c) => addMonthsIso(iso, c.shift ? -12 : -1),
252
+ [CalendarKey.PageDown]: (iso, c) => addMonthsIso(iso, c.shift ? 12 : 1),
253
+ };
254
+
255
+ /**
256
+ * Where a key moves the focused day, held inside [min, max] — or null for a key the grid does not answer (Enter,
257
+ * Space and Escape are the runtime's: they pick or close, they never move).
258
+ */
259
+ export function calendarKeyTarget(key: string, focus: string, options: { weekStart: number; rtl?: boolean; shift?: boolean; min?: string | null; max?: string | null }): string | null {
260
+ const move = KEY_MOVES[key as CalendarKey];
261
+ const at = dateOfIso(focus);
262
+ if (!move || !at) return null;
263
+ const dow = (at.getDay() - options.weekStart + DAYS_IN_WEEK) % DAYS_IN_WEEK;
264
+ return clampIso(move(focus, { ahead: options.rtl ? -1 : 1, dow, shift: !!options.shift }), options.min, options.max);
265
+ }
266
+
267
+ // ─── words ───────────────────────────────────────────────────────────────────
268
+
269
+ /** The date in words, in the site's language: "Thu, 20 November" — the year only when it is not this year's. */
270
+ export function formatDateInWords(iso: string, locale: string | null | undefined, today: string): string {
271
+ const d = dateOfIso(iso);
272
+ if (!d) return '';
273
+ const sameYear = iso.slice(0, 4) === today.slice(0, 4);
274
+ try {
275
+ return new Intl.DateTimeFormat(locale || undefined, { weekday: 'short', day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(d);
276
+ } catch {
277
+ return iso;
278
+ }
279
+ }
280
+
281
+ /** The calendar's own words, by the site's language — the media-core `videoWords` precedent (a string-rendered
282
+ * site has no translation service; the document's `lang` picks). */
283
+ export interface DatePickerWords {
284
+ choose: string;
285
+ previousMonth: string;
286
+ nextMonth: string;
287
+ close: string;
288
+ }
289
+
290
+ export const DATE_PICKER_WORDS: Readonly<Record<'en' | 'he', DatePickerWords>> = Object.freeze({
291
+ en: { choose: 'Choose a date', previousMonth: 'Previous month', nextMonth: 'Next month', close: 'Close' },
292
+ he: { choose: 'בחרו תאריך', previousMonth: 'החודש הקודם', nextMonth: 'החודש הבא', close: 'סגירה' },
293
+ });
294
+
295
+ /** The words for a language tag (`he-IL` → Hebrew); English for anything else. */
296
+ export function datePickerWords(lang: string | null | undefined): DatePickerWords {
297
+ const language = String(lang ?? '').toLowerCase().split(/[-_]/)[0];
298
+ return language === 'he' || language === 'iw' ? DATE_PICKER_WORDS.he : DATE_PICKER_WORDS.en;
299
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A Field as MARKUP — the string renderer's copy of `IoxFieldComponent`'s template, in ONE place (it lived in the
3
+ * engine's `layout-renderer.service.ts`, a second copy beside the component). The engine's string path writes it; the
4
+ * component mirrors it element for element, so hydration replaces like with like and the shared CSS (iox-forms.css,
5
+ * the contract's field baseline) reaches both.
6
+ *
7
+ * Choices (premium N1): a `<fieldset>` whose `<legend>` is the label, its options native radios (one answer — the
8
+ * arrow keys move and pick, with no script) or checkboxes (several), each inside a `<label>` that IS the chip; the
9
+ * input is visually hidden, never removed. Date (N8): a `<button>` that says the date in words and opens the look's
10
+ * calendar, with a hidden input carrying the ISO day — the raw SSR is the final markup (nothing shifts on hydrate).
11
+ *
12
+ * Framework-free, like the rest of forms-core: `escape` is the caller's.
13
+ */
14
+
15
+ import { ChoiceAppearance, ControlType } from './form.model';
16
+ import { datePickerWords } from './dates';
17
+ import { fieldOptionsOf } from './options';
18
+
19
+ /** The classes the markup carries — the runtimes and the shared CSS key on these, never on a literal. */
20
+ export enum FieldClass {
21
+ Root = 'iox-field',
22
+ Label = 'iox-field__label',
23
+ LabelHidden = 'iox-field__label--hidden',
24
+ Required = 'iox-field__required',
25
+ Control = 'iox-field__control',
26
+ Help = 'iox-field__help',
27
+ Error = 'iox-field__error',
28
+ Group = 'iox-field__group',
29
+ Option = 'iox-field__option',
30
+ Choices = 'iox-field__choices',
31
+ ChoicesChips = 'iox-field__choices--chips',
32
+ ChoicesList = 'iox-field__choices--list',
33
+ Chips = 'iox-field__chips',
34
+ Chip = 'iox-field__chip',
35
+ ChipInput = 'iox-field__control--chip',
36
+ DateButton = 'iox-field__control--date',
37
+ DateValue = 'iox-field__date-value',
38
+ DateIcon = 'iox-field__date-icon',
39
+ DateInput = 'iox-field__date-input',
40
+ }
41
+
42
+ /** On the date button: the raw Earliest / Latest (`'0'`, an ISO day), resolved by the visitor's own today. */
43
+ export const DATE_MIN_ATTRIBUTE = 'data-iox-date-min';
44
+ export const DATE_MAX_ATTRIBUTE = 'data-iox-date-max';
45
+ /** On the date's words while no day is chosen (the placeholder's colour keys on it). */
46
+ export const DATE_EMPTY_ATTRIBUTE = 'data-iox-empty';
47
+
48
+ /** The calendar glyph inside the date button: one path set, drawn in currentColor, never announced. */
49
+ export const DATE_ICON_SVG = '<svg class="iox-field__date-icon" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
50
+ '<rect x="3.5" y="5" width="17" height="15.5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.6"/>' +
51
+ '<path d="M3.5 10h17M8 3v4M16 3v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
52
+
53
+ /** The controls that are a plain `<input type=…>`; anything else unknown is a text input. */
54
+ const INPUT_TYPES: ReadonlySet<string> = new Set(['email', 'tel', 'url', 'number', 'password', 'time', 'file', 'hidden']);
55
+
56
+ /** The `<input type>` of a control that renders as a plain input. */
57
+ export function inputTypeOf(control: string): string {
58
+ return INPUT_TYPES.has(control) ? control : 'text';
59
+ }
60
+
61
+ /** A Choices field's appearance; anything unknown reads as chips (the default). */
62
+ export function choiceAppearanceOf(props: Record<string, any> | null | undefined): ChoiceAppearance {
63
+ return props?.['appearance'] === ChoiceAppearance.List ? ChoiceAppearance.List : ChoiceAppearance.Chips;
64
+ }
65
+
66
+ export interface FieldMarkupOptions {
67
+ /** The name the field submits under — its FORM's unique name for it (`formFieldNames`, vectoriox/iox-ui#43). */
68
+ name: string;
69
+ /** The control's id; the label, help and error ids derive from it. */
70
+ id: string;
71
+ /** The node's classes (`iox-node-…`, the element-style marker…). */
72
+ cls: string;
73
+ /** Anything else the host element carries, written verbatim (an entrance's style). */
74
+ attrs?: string;
75
+ /** The site's language (the date's words). */
76
+ lang?: string | null;
77
+ escape: (value: string) => string;
78
+ }
79
+
80
+ /** A Field's whole element, as the string renderer writes it. */
81
+ export function fieldHtml(props: Record<string, any> | null | undefined, options: FieldMarkupOptions): string {
82
+ const p = props ?? {};
83
+ const { name, id, escape: esc } = options;
84
+ const control = String(p['control'] ?? ControlType.Text);
85
+ const required = !!p['required'];
86
+ const help = p['help'] ? `<p class="${FieldClass.Help}" id="${id}-help">${esc(String(p['help']))}</p>` : '';
87
+ const describedBy = p['help'] ? ` aria-describedby="${id}-help"` : '';
88
+ const labelClass = p['labelHidden'] ? `${FieldClass.Label} ${FieldClass.LabelHidden}` : FieldClass.Label;
89
+ const mark = required ? `<span class="${FieldClass.Required}" aria-hidden="true">*</span>` : '';
90
+ const text = esc(String(p['label'] ?? ''));
91
+ const label = `<label class="${labelClass}" id="${id}-label" for="${id}">${text}${mark}</label>`;
92
+ const hidden = control === ControlType.Hidden ? ' hidden' : '';
93
+ const open = `<div class="${FieldClass.Root} ${options.cls}" data-iox-field="${esc(name)}"${hidden}${options.attrs ?? ''}>`;
94
+
95
+ if (control === ControlType.Choices) {
96
+ return `${open}${choicesHtml(p, options, `<legend class="${labelClass}" id="${id}-label">${text}${mark}</legend>`, describedBy)}${help}</div>`;
97
+ }
98
+ return `${open}${label}${controlHtml(p, control, options, describedBy)}${help}</div>`;
99
+ }
100
+
101
+ function controlHtml(p: Record<string, any>, control: string, options: FieldMarkupOptions, describedBy: string): string {
102
+ const { id, escape: esc } = options;
103
+ const name = esc(options.name);
104
+ const required = p['required'] ? ' required' : '';
105
+ const placeholder = p['placeholder'] ? ` placeholder="${esc(String(p['placeholder']))}"` : '';
106
+ const choices = fieldOptionsOf(p);
107
+ const option = (o: { value: string; label: string }) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`;
108
+ const num = (v: unknown, d: number) => (Number.isFinite(Number(v)) && v !== '' && v !== null ? Number(v) : d);
109
+
110
+ switch (control) {
111
+ case ControlType.Textarea:
112
+ return `<textarea class="${FieldClass.Control}" id="${id}" name="${name}" rows="${num(p['rows'], 4)}"${placeholder}${required}${describedBy}></textarea>`;
113
+ case ControlType.Select:
114
+ return `<select class="${FieldClass.Control}" id="${id}" name="${name}"${required}${describedBy}>` +
115
+ `<option value="">${esc(String(p['placeholder'] ?? ''))}</option>${choices.map(option).join('')}</select>`;
116
+ case ControlType.Switch:
117
+ return `<input class="${FieldClass.Control} iox-field__control--switch" type="checkbox" role="switch" id="${id}" name="${name}"${describedBy}>`;
118
+ case ControlType.Range:
119
+ return `<input class="${FieldClass.Control} iox-field__control--range" type="range" id="${id}" name="${name}" ` +
120
+ `min="${num(p['rangeMin'], 0)}" max="${num(p['rangeMax'], 100)}" step="${num(p['rangeStep'], 1)}"${describedBy}>`;
121
+ case ControlType.Radio:
122
+ return `<div class="${FieldClass.Group}" role="radiogroup" aria-labelledby="${id}-label">` +
123
+ choices.map(o => `<label class="${FieldClass.Option}"><input class="${FieldClass.Control} iox-field__control--radio" type="radio" name="${name}" ` +
124
+ `value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`).join('') + `</div>`;
125
+ case ControlType.Checkbox:
126
+ return `<input class="${FieldClass.Control} iox-field__control--checkbox" type="checkbox" id="${id}" name="${name}"${required}${describedBy}>`;
127
+ case ControlType.Date:
128
+ return dateHtml(p, options, describedBy);
129
+ default:
130
+ return `<input class="${FieldClass.Control}" id="${id}" name="${name}" type="${inputTypeOf(control)}"${placeholder}${required}${describedBy}>`;
131
+ }
132
+ }
133
+
134
+ /** A Choices field's fieldset: the legend, then the options as chips or as a list. */
135
+ function choicesHtml(p: Record<string, any>, options: FieldMarkupOptions, legend: string, describedBy: string): string {
136
+ const { escape: esc } = options;
137
+ const name = esc(options.name);
138
+ const multiple = p['multiple'] === true;
139
+ const type = multiple ? 'checkbox' : 'radio';
140
+ const chips = choiceAppearanceOf(p) === ChoiceAppearance.Chips;
141
+ const items = fieldOptionsOf(p).map(o => chips
142
+ ? `<label class="${FieldClass.Chip}"><input class="${FieldClass.Control} ${FieldClass.ChipInput}" type="${type}" name="${name}" value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`
143
+ : `<label class="${FieldClass.Option}"><input class="${FieldClass.Control}" type="${type}" name="${name}" value="${esc(o.value)}"><span>${esc(o.label)}</span></label>`).join('');
144
+ const look = chips ? FieldClass.ChoicesChips : FieldClass.ChoicesList;
145
+ const row = chips ? FieldClass.Chips : FieldClass.Group;
146
+ return `<fieldset class="${FieldClass.Choices} ${look}"${describedBy}>${legend}<div class="${row}">${items}</div></fieldset>`;
147
+ }
148
+
149
+ /** The date picker's button + hidden input. The calendar itself is the runtime's, opened on press. */
150
+ function dateHtml(p: Record<string, any>, options: FieldMarkupOptions, describedBy: string): string {
151
+ const { id, escape: esc } = options;
152
+ const words = datePickerWords(options.lang);
153
+ const empty = esc(String(p['placeholder'] || words.choose));
154
+ const bound = (attr: string, raw: unknown) => (typeof raw === 'string' && raw.trim() !== '') || typeof raw === 'number' ? ` ${attr}="${esc(String(raw).trim())}"` : '';
155
+ return `<button type="button" class="${FieldClass.Control} ${FieldClass.DateButton}" id="${id}" aria-haspopup="dialog" aria-expanded="false" ` +
156
+ `aria-labelledby="${id}-label ${id}-value"${describedBy}${bound(DATE_MIN_ATTRIBUTE, p['earliest'])}${bound(DATE_MAX_ATTRIBUTE, p['latest'])}>` +
157
+ `<span class="${FieldClass.DateValue}" id="${id}-value" ${DATE_EMPTY_ATTRIBUTE}>${empty}</span>${DATE_ICON_SVG}</button>` +
158
+ `<input type="hidden" class="${FieldClass.DateInput}" name="${esc(options.name)}" value="">`;
159
+ }
@@ -6,11 +6,16 @@
6
6
  * Data only: framework-free, like the rest of forms-core.
7
7
  */
8
8
 
9
+ import type { ChannelKind } from './channel';
10
+
9
11
  export enum FormPresetId {
10
12
  Contact = 'contact',
11
13
  Newsletter = 'newsletter',
12
14
  Application = 'application',
13
15
  Booking = 'booking',
16
+ /** Premium N2: modelled on Doron's iox.studio brief — name, a phone that is also WhatsApp, the business, what they
17
+ * need and a budget as chips, Send with WhatsApp beside it. */
18
+ Lead = 'lead',
14
19
  }
15
20
 
16
21
  export interface FormPresetField {
@@ -19,9 +24,22 @@ export interface FormPresetField {
19
24
  required?: boolean;
20
25
  help?: string;
21
26
  placeholder?: string;
22
- /** Choices for select/radio, `value|Label` per line — the Field's `optionsText` trait. */
27
+ /** Choices for select/radio/choices, `value|Label` per line — the Field's `optionsText` trait. */
23
28
  optionsText?: string;
24
29
  rows?: number;
30
+ /** A Choices field that takes several answers (N1). */
31
+ multiple?: boolean;
32
+ /** A Choices field's appearance — `chips` (the default) or `list` (N1). */
33
+ appearance?: string;
34
+ /** A Date's first and last day — '' any · an integer: days from today · an ISO day (N8, `dates.ts`). */
35
+ earliest?: string;
36
+ latest?: string;
37
+ }
38
+
39
+ /** The second way to reach the studio, beside Send (N2). Its address is the studio's own and starts EMPTY. */
40
+ export interface FormPresetChannel {
41
+ kind: `${ChannelKind}`;
42
+ label: string;
25
43
  }
26
44
 
27
45
  export interface FormPresetDef {
@@ -36,6 +54,15 @@ export interface FormPresetDef {
36
54
  submitLabel: string;
37
55
  successText: string;
38
56
  errorText: string;
57
+ channel?: FormPresetChannel;
58
+ /** `false`: the AI plan wire does not offer it yet (the lead preset ships in the builder in M3; generation takes it
59
+ * up in premium M8 — the wire's enum is model-facing and does not change before then). */
60
+ inPlan?: false;
61
+ }
62
+
63
+ /** The presets a generated plan may name (the plan wire's `form` enum) — every preset not held back by `inPlan`. */
64
+ export function planFormPresetIds(): string[] {
65
+ return FORM_PRESETS.filter(p => p.inPlan !== false).map(p => p.id);
39
66
  }
40
67
 
41
68
  // forms-core is imported by both renderers (the field runtime), and a renderer that never reads the presets
@@ -93,7 +120,8 @@ export const FORM_PRESETS: readonly FormPresetDef[] = /* @__PURE__ */ Object.fre
93
120
  fields: [
94
121
  { label: 'Name', control: 'text', required: true },
95
122
  { label: 'Email', control: 'email', required: true },
96
- { label: 'Date', control: 'date', required: true },
123
+ // The first day a booking may ask for is today (N8): the calendar refuses the past.
124
+ { label: 'Date', control: 'date', required: true, earliest: '0' },
97
125
  { label: 'People', control: 'number', required: true },
98
126
  { label: 'Notes', control: 'textarea', rows: 3, help: 'Allergies, access needs, anything useful' },
99
127
  ],
@@ -101,6 +129,27 @@ export const FORM_PRESETS: readonly FormPresetDef[] = /* @__PURE__ */ Object.fre
101
129
  successText: 'Request received — we’ll confirm by email.',
102
130
  errorText: 'We couldn’t send your request. Please try again.',
103
131
  },
132
+ {
133
+ id: 'lead', name: 'Lead', icon: 'ph-thin ph-handshake',
134
+ description: 'Name, phone, the business and what they need, as chips. Stored, with WhatsApp beside Send.',
135
+ formName: 'Lead form',
136
+ fields: [
137
+ { label: 'Name', control: 'text', required: true },
138
+ { label: 'Phone / WhatsApp', control: 'tel', required: true },
139
+ { label: 'What is your business?', control: 'text' },
140
+ // The words ARE the values: what arrives in the inbox and the email reads as the visitor chose it.
141
+ { label: 'What do you need?', control: 'choices', multiple: true,
142
+ optionsText: 'Website\nBranding\nPhotos & video\nNot sure yet' },
143
+ // Neutral sizes the author renames — never a price or a currency a studio could be held to.
144
+ { label: 'Estimated budget', control: 'choices',
145
+ optionsText: 'Small project\nMedium\nLarge\nNot sure yet' },
146
+ ],
147
+ submitLabel: 'Send',
148
+ successText: 'Thanks — we’ll get back to you soon.',
149
+ errorText: 'We couldn’t send your details. Please try again — your answers are still here.',
150
+ channel: { kind: 'whatsapp', label: 'Message us on WhatsApp' },
151
+ inPlan: false,
152
+ },
104
153
  ]);
105
154
 
106
155
  /** The preset with this id, or undefined. */
@@ -21,6 +21,11 @@ export enum ControlType {
21
21
  /** A slider over a numeric range — min/max/step ride the field. */
22
22
  Range = 'range',
23
23
  Radio = 'radio',
24
+ /** One or several of a few visible choices — chips (the default) or a list of radios / checkboxes (premium N1).
25
+ * Its `multiple` trait picks the value's shape: a string, or a list of strings. */
26
+ Choices = 'choices',
27
+ /** A date the visitor picks from the look's calendar (premium N8) — a button that reads the date in words and a
28
+ * hidden input carrying the ISO `YYYY-MM-DD` value. Never the browser's own date box. */
24
29
  Date = 'date',
25
30
  Time = 'time',
26
31
  File = 'file',
@@ -42,6 +47,19 @@ export enum RuleName {
42
47
  Accepted = 'accepted',
43
48
  FileType = 'fileType',
44
49
  FileSize = 'fileSize',
50
+ /** IMPLIED, never authored: an answer of a Choices / Radio / Dropdown must be one of the field's own options
51
+ * (premium N1) — a hand-crafted POST cannot file a value the form never offered. */
52
+ OneOf = 'oneOf',
53
+ /** IMPLIED, never authored: a Date's answer is a real `YYYY-MM-DD` day (premium N8). */
54
+ ValidDate = 'validDate',
55
+ }
56
+
57
+ /** How a Choices field draws its options (premium N1). */
58
+ export enum ChoiceAppearance {
59
+ /** Each option a chip — the label is the chip; the native input inside it is visually hidden. */
60
+ Chips = 'chips',
61
+ /** The options as rows of the look's own radio / checkbox (DS9.1). */
62
+ List = 'list',
45
63
  }
46
64
 
47
65
  /** Named regexes so an author picks "Email" rather than writing one. */
@@ -100,6 +118,8 @@ export interface FieldSpec {
100
118
  options?: FieldOption[];
101
119
  optionsSource?: FieldOptionSource;
102
120
  defaultValue?: any;
121
+ /** A Choices field that takes several answers: its value is a list (premium N1). */
122
+ multiple?: boolean;
103
123
  }
104
124
 
105
125
  /** When a field is validated, once the author has stopped deciding it per field. */
@@ -139,5 +159,17 @@ export const IMPLIED_PATTERN: Readonly<Partial<Record<ControlType, PatternPreset
139
159
  [ControlType.Url]: PatternPreset.Url,
140
160
  });
141
161
 
142
- /** Controls whose value is a list rather than a scalar. */
143
- export const MULTI_VALUE_CONTROLS: ReadonlySet<ControlType> = new Set([ControlType.File]);
162
+ /**
163
+ * Whether this field's answer is a LIST rather than one value — a file list, or a Choices field that takes several
164
+ * (premium N1). Per FIELD, not per control: the same Choices control is single or multiple by its `multiple` trait.
165
+ * ONE reading for collect, validate, the runtimes' transport and the server (replaced the control-only
166
+ * `MULTI_VALUE_CONTROLS`, which nothing read).
167
+ */
168
+ export function isMultiValueField(field: Pick<FieldSpec, 'control' | 'multiple'> | null | undefined): boolean {
169
+ if (!field) return false;
170
+ if (field.control === ControlType.File) return true;
171
+ return field.control === ControlType.Choices && field.multiple === true;
172
+ }
173
+
174
+ /** The controls whose answers must be one of the field's own options (the implied `oneOf` rule). */
175
+ export const OPTION_CONTROLS: ReadonlySet<ControlType> = new Set([ControlType.Choices, ControlType.Radio, ControlType.Select]);
@@ -31,6 +31,29 @@ export const RULE_MESSAGE_KEYS: Readonly<Record<RuleName, string>> = Object.free
31
31
  [RuleName.Accepted]: `${MESSAGE_NAMESPACE}.accepted`,
32
32
  [RuleName.FileType]: `${MESSAGE_NAMESPACE}.fileType`,
33
33
  [RuleName.FileSize]: `${MESSAGE_NAMESPACE}.fileSize`,
34
+ [RuleName.OneOf]: `${MESSAGE_NAMESPACE}.oneOf`,
35
+ [RuleName.ValidDate]: `${MESSAGE_NAMESPACE}.validDate`,
36
+ });
37
+
38
+ /**
39
+ * What "required" says on a field you CHOOSE rather than type into (premium N1 / N8): "Choose an option", "Choose at
40
+ * least one", "Choose a date" — never "This field is required" beside a row of chips.
41
+ */
42
+ export const CHOICE_MESSAGE_KEYS = Object.freeze({
43
+ One: `${MESSAGE_NAMESPACE}.choose`,
44
+ Some: `${MESSAGE_NAMESPACE}.chooseSome`,
45
+ Date: `${MESSAGE_NAMESPACE}.chooseDate`,
46
+ });
47
+
48
+ /** A Date outside its Earliest / Latest. The calendar refuses those days, so this is a stale page or a hand POST. */
49
+ export const DATE_MESSAGE_KEYS = Object.freeze({
50
+ Min: `${MESSAGE_NAMESPACE}.dateMin`,
51
+ Max: `${MESSAGE_NAMESPACE}.dateMax`,
52
+ });
53
+
54
+ /** The submit button's own words (premium N2): what it says once its form has sent, when the author wrote none. */
55
+ export const SUBMIT_MESSAGE_KEYS = Object.freeze({
56
+ Sent: 'forms.submit.sent',
34
57
  });
35
58
 
36
59
  /**
@@ -79,4 +102,12 @@ export const DEFAULT_EN_MESSAGES: Readonly<Record<string, string>> = Object.free
79
102
  [FORM_ERROR_KEYS.Spam]: 'We couldn’t accept this submission',
80
103
  [FORM_ERROR_KEYS.RateLimited]: 'Too many attempts — please wait a moment and try again',
81
104
  [FORM_ERROR_KEYS.Server]: 'Something went wrong on our side — your answers are still here',
105
+ [RULE_MESSAGE_KEYS[RuleName.OneOf]]: 'Choose one of the options',
106
+ [RULE_MESSAGE_KEYS[RuleName.ValidDate]]: 'Choose a date from the calendar',
107
+ [CHOICE_MESSAGE_KEYS.One]: 'Choose an option',
108
+ [CHOICE_MESSAGE_KEYS.Some]: 'Choose at least one',
109
+ [CHOICE_MESSAGE_KEYS.Date]: 'Choose a date',
110
+ [DATE_MESSAGE_KEYS.Min]: 'Choose a later date',
111
+ [DATE_MESSAGE_KEYS.Max]: 'Choose an earlier date',
112
+ [SUBMIT_MESSAGE_KEYS.Sent]: 'Sent',
82
113
  });
@@ -21,3 +21,6 @@ export * from './transport';
21
21
  export * from './submit-lifecycle';
22
22
  export * from './form-presets';
23
23
  export * from './nesting';
24
+ export * from './dates';
25
+ export * from './channel';
26
+ export * from './field-markup';