@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.
- package/design-core/index.d.ts +200 -0
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +215 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs +733 -56
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +40 -4
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +1547 -135
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/forms-core/index.d.ts +366 -18
- package/generation-core/index.d.ts +3 -0
- package/index.d.ts +303 -25
- package/package.json +1 -1
- package/src/design-core/contrast-audit.ts +4 -0
- package/src/design-core/flavor-spec.generated.ts +209 -1
- package/src/forms-core/channel.ts +85 -0
- package/src/forms-core/collect.ts +63 -30
- package/src/forms-core/dates.ts +299 -0
- package/src/forms-core/field-markup.ts +159 -0
- package/src/forms-core/form-presets.ts +51 -2
- package/src/forms-core/form.model.ts +34 -2
- package/src/forms-core/messages.ts +31 -0
- package/src/forms-core/public-api.ts +3 -0
- package/src/forms-core/rule-catalog.ts +3 -2
- package/src/forms-core/submit-lifecycle.ts +42 -12
- package/src/forms-core/validate.ts +90 -4
- package/src/generation-core/audit-site.ts +9 -1
- package/src/generation-core/compose-site.ts +22 -3
- package/src/generation-core/node-baselines.generated.ts +10 -0
- package/src/generation-core/plan-wire.ts +2 -2
- package/styles/iox-design.css +10 -0
- 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
|
-
|
|
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
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
|
107
|
-
*
|
|
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(
|
|
111
|
-
|
|
112
|
-
return `<span class="${SubmitLabelClass.Labels}"><span class="${SubmitLabelClass.Idle}">${escape(
|
|
113
|
-
`<span class="${SubmitLabelClass.Busy}">${escape(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
323
|
+
form: orNone(planFormPresetIds()),
|
|
324
324
|
enter: orNone(ANIMATE_IDS),
|
|
325
325
|
});
|
|
326
326
|
}
|
package/styles/iox-design.css
CHANGED
|
@@ -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
|
+
}
|