@akinon/pz-theme 2.0.165-beta.0 → 2.0.165
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/CHANGELOG.md +7 -4
- package/package.json +4 -4
- package/src/__tests__/profile-form-composed.test.tsx +795 -0
- package/src/auth/auth-field-errors.tsx +59 -13
- package/src/auth/form-field-defaults.tsx +74 -0
- package/src/blocks/auth-consent-block.tsx +78 -19
- package/src/blocks/auth-field-block.tsx +212 -50
- package/src/blocks/auth-form-block.tsx +3 -4
- package/src/blocks/birth-date-selects.tsx +206 -0
- package/src/blocks/forgot-password-block.tsx +3 -4
- package/src/blocks/otp-form-block.tsx +3 -4
- package/src/blocks/profile-form-block.tsx +353 -10
- package/src/blocks/register-form-block.tsx +12 -6
- package/src/blocks/reset-password-block.tsx +3 -4
|
@@ -56,6 +56,51 @@ export interface AuthFieldValidation {
|
|
|
56
56
|
|
|
57
57
|
type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Every control a form checks: the named ones, plus the parts of a compound
|
|
61
|
+
* field.
|
|
62
|
+
*
|
|
63
|
+
* A compound field — the day, month and year selects of a birth date — submits
|
|
64
|
+
* ONE value under one name through a hidden input, and a hidden input is never
|
|
65
|
+
* validated. So the visible parts carry no name of their own (they would post
|
|
66
|
+
* three stray keys) and say which field they belong to through
|
|
67
|
+
* `data-field-name` instead; their `required` is then reported under that
|
|
68
|
+
* field's name, which is where the field draws its message.
|
|
69
|
+
*/
|
|
70
|
+
const VALIDATED_CONTROLS =
|
|
71
|
+
'input[name], select[name], textarea[name], [data-field-name]';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The box a control is presented in: its Auth Field or Consent Row when it has
|
|
75
|
+
* one, so "can the shopper see this question" is asked of the field rather
|
|
76
|
+
* than of the native control. A brand that draws its own tick or radio hides
|
|
77
|
+
* the native input and styles a sibling — the question is still on screen —
|
|
78
|
+
* and a compound field's hidden input is visible exactly when its parts are.
|
|
79
|
+
*/
|
|
80
|
+
export const fieldBoxOf = (control: HTMLElement): HTMLElement =>
|
|
81
|
+
control.closest<HTMLElement>('[data-auth-field], [data-auth-consent]') ??
|
|
82
|
+
(control instanceof HTMLInputElement && control.type === 'hidden'
|
|
83
|
+
? (control.parentElement ?? control)
|
|
84
|
+
: control);
|
|
85
|
+
|
|
86
|
+
/** The name a control's message is filed under. */
|
|
87
|
+
const fieldNameOf = (control: FormControl): string =>
|
|
88
|
+
control.name || control.dataset.fieldName || '';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The field an `input`/`change` event was about, for the form that clears that
|
|
92
|
+
* field's message as soon as it is edited. Same rule as the collector: a
|
|
93
|
+
* compound field's part answers with the field's name.
|
|
94
|
+
*/
|
|
95
|
+
export const eventFieldName = (target: EventTarget | null): string => {
|
|
96
|
+
const element = target as {
|
|
97
|
+
name?: string;
|
|
98
|
+
dataset?: { fieldName?: string };
|
|
99
|
+
} | null;
|
|
100
|
+
|
|
101
|
+
return element?.name || element?.dataset?.fieldName || '';
|
|
102
|
+
};
|
|
103
|
+
|
|
59
104
|
/**
|
|
60
105
|
* Whether a control is actually on screen for this breakpoint.
|
|
61
106
|
*
|
|
@@ -65,7 +110,7 @@ type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
|
65
110
|
* scrolled out of view, sized to nothing, or covered by another is still a
|
|
66
111
|
* control the shopper can be sent to.
|
|
67
112
|
*/
|
|
68
|
-
const isVisibleControl = (control:
|
|
113
|
+
export const isVisibleControl = (control: HTMLElement): boolean => {
|
|
69
114
|
if (typeof control.checkVisibility === 'function') {
|
|
70
115
|
return control.checkVisibility();
|
|
71
116
|
}
|
|
@@ -98,9 +143,7 @@ export const collectAuthFieldErrors = (
|
|
|
98
143
|
let firstInvalid: HTMLElement | null = null;
|
|
99
144
|
|
|
100
145
|
const controls = Array.from(
|
|
101
|
-
form.querySelectorAll<FormControl>(
|
|
102
|
-
'input[name], select[name], textarea[name]'
|
|
103
|
-
)
|
|
146
|
+
form.querySelectorAll<FormControl>(VALIDATED_CONTROLS)
|
|
104
147
|
);
|
|
105
148
|
|
|
106
149
|
controls.forEach((control) => {
|
|
@@ -116,12 +159,15 @@ export const collectAuthFieldErrors = (
|
|
|
116
159
|
// `noValidate` had already suppressed the browser's bubble, and the
|
|
117
160
|
// message this function returns was drawn inside the hidden wrapper. A
|
|
118
161
|
// field the shopper cannot see is not one they can be asked to fill in.
|
|
119
|
-
if (!isVisibleControl(control)) return;
|
|
162
|
+
if (!isVisibleControl(fieldBoxOf(control))) return;
|
|
163
|
+
|
|
164
|
+
// First one wins per name: a radio group, a compound field's parts, or a
|
|
165
|
+
// name a composition duplicated, is one question to the shopper rather
|
|
166
|
+
// than several.
|
|
167
|
+
const name = fieldNameOf(control);
|
|
120
168
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (!errors[control.name]) {
|
|
124
|
-
errors[control.name] = control.validity.valueMissing
|
|
169
|
+
if (!errors[name]) {
|
|
170
|
+
errors[name] = control.validity.valueMissing
|
|
125
171
|
? messages.required
|
|
126
172
|
: messages.invalid;
|
|
127
173
|
}
|
|
@@ -151,13 +197,13 @@ export const focusFirstAuthFieldError = (
|
|
|
151
197
|
if (!form || Object.keys(errors).length === 0) return;
|
|
152
198
|
|
|
153
199
|
const controls = Array.from(
|
|
154
|
-
form.querySelectorAll<FormControl>(
|
|
155
|
-
'input[name], select[name], textarea[name]'
|
|
156
|
-
)
|
|
200
|
+
form.querySelectorAll<FormControl>(VALIDATED_CONTROLS)
|
|
157
201
|
);
|
|
158
202
|
|
|
159
203
|
const first = controls.find(
|
|
160
|
-
(control) =>
|
|
204
|
+
(control) =>
|
|
205
|
+
Boolean(errors[fieldNameOf(control)]) &&
|
|
206
|
+
isVisibleControl(fieldBoxOf(control))
|
|
161
207
|
);
|
|
162
208
|
|
|
163
209
|
first?.focus();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a composed form already knows about each of its fields, keyed by the
|
|
7
|
+
* name the field submits under.
|
|
8
|
+
*
|
|
9
|
+
* A sign-up starts empty, so the field blocks were written as empty controls
|
|
10
|
+
* the form reads back through `FormData`. A form that EDITS a record — the
|
|
11
|
+
* profile form — has to start from the record instead: the shopper's name in
|
|
12
|
+
* the name box, their birth date in the date selects, their current answer in
|
|
13
|
+
* each consent tick. The fields are child blocks at any depth, so the form has
|
|
14
|
+
* no reference to hand a value to; each field reads its own entry here, the
|
|
15
|
+
* same way it reads its error message from `auth-field-errors.tsx`.
|
|
16
|
+
*
|
|
17
|
+
* `readOnly` names the fields the form shows but cannot change — the profile's
|
|
18
|
+
* e-mail, which commerce only moves through its own verified flow. Such a
|
|
19
|
+
* field is drawn disabled, so it is neither submitted nor validated.
|
|
20
|
+
*
|
|
21
|
+
* Outside such a form nothing is provided and every field starts empty, which
|
|
22
|
+
* is exactly what a register or auth form has always drawn.
|
|
23
|
+
*/
|
|
24
|
+
export interface FormFieldDefaultsValue {
|
|
25
|
+
values: Readonly<Record<string, unknown>>;
|
|
26
|
+
readOnly: ReadonlyArray<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_DEFAULTS: FormFieldDefaultsValue = { values: {}, readOnly: [] };
|
|
30
|
+
|
|
31
|
+
const FormFieldDefaultsContext =
|
|
32
|
+
React.createContext<FormFieldDefaultsValue>(NO_DEFAULTS);
|
|
33
|
+
|
|
34
|
+
export const FormFieldDefaultsProvider = FormFieldDefaultsContext.Provider;
|
|
35
|
+
|
|
36
|
+
/** The value a text-like control starts with: '' when the form has none. */
|
|
37
|
+
export const useFormFieldDefault = (name: string): string => {
|
|
38
|
+
const { values } = React.useContext(FormFieldDefaultsContext);
|
|
39
|
+
const value = name ? values[name] : undefined;
|
|
40
|
+
|
|
41
|
+
if (value === null || value === undefined) return '';
|
|
42
|
+
|
|
43
|
+
return String(value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a tick starts ticked. Only an explicit `true` counts: commerce keeps
|
|
48
|
+
* `null` for a consent the shopper was never asked about, and drawing that as
|
|
49
|
+
* ticked would put an agreement in their mouth.
|
|
50
|
+
*/
|
|
51
|
+
export const useFormFieldChecked = (
|
|
52
|
+
name: string,
|
|
53
|
+
tickedValue = 'true'
|
|
54
|
+
): boolean => {
|
|
55
|
+
const { values } = React.useContext(FormFieldDefaultsContext);
|
|
56
|
+
const stored = name ? values[name] : undefined;
|
|
57
|
+
|
|
58
|
+
if (stored === true) return true;
|
|
59
|
+
// A custom tick that sends its own value (`yes`, `1`) is ticked when that is
|
|
60
|
+
// what is stored.
|
|
61
|
+
return (
|
|
62
|
+
tickedValue !== 'true' &&
|
|
63
|
+
stored !== undefined &&
|
|
64
|
+
stored !== null &&
|
|
65
|
+
String(stored) === tickedValue
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Whether the hosting form shows this field without letting it change. */
|
|
70
|
+
export const useFormFieldReadOnly = (name: string): boolean => {
|
|
71
|
+
const { readOnly } = React.useContext(FormFieldDefaultsContext);
|
|
72
|
+
|
|
73
|
+
return Boolean(name) && readOnly.includes(name);
|
|
74
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
|
|
5
5
|
import { useAuthFieldError } from '../auth/auth-field-errors';
|
|
6
|
+
import { useFormFieldChecked } from '../auth/form-field-defaults';
|
|
6
7
|
import { getResponsiveValue, partAttrs } from '../utils';
|
|
7
8
|
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
8
9
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
@@ -27,12 +28,37 @@ import { BlockRendererProps } from './block-renderer-registry';
|
|
|
27
28
|
* reads out of `FormData` — and an unticked checkbox simply is not in there,
|
|
28
29
|
* which is exactly the "did they agree" question the form asks.
|
|
29
30
|
*
|
|
31
|
+
* A contact-channel tick (`email`, `sms`, `call`, `whatsapp`) is one channel's
|
|
32
|
+
* answer under the flag commerce stores for it (`email_allowed` …). A register
|
|
33
|
+
* form reads it in place of its blanket marketing answer for that channel; a
|
|
34
|
+
* profile form starts it from the shopper's stored answer and saves it back.
|
|
35
|
+
*
|
|
30
36
|
* Properties: consentType, text, required, and for a `custom` tick the
|
|
31
37
|
* name it submits and the value it sends when ticked.
|
|
32
38
|
* Parts: consent, checkbox, consent-text, error.
|
|
33
39
|
*/
|
|
34
40
|
|
|
35
|
-
export type
|
|
41
|
+
export type AuthContactChannel = 'email' | 'sms' | 'call' | 'whatsapp';
|
|
42
|
+
|
|
43
|
+
export type AuthConsentType =
|
|
44
|
+
| 'legal'
|
|
45
|
+
| 'marketing'
|
|
46
|
+
| 'custom'
|
|
47
|
+
| AuthContactChannel;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The contact channels and the profile flag each one is stored under. The same
|
|
51
|
+
* four flags the register form fans its marketing answer out to.
|
|
52
|
+
*/
|
|
53
|
+
export const AUTH_CONTACT_CHANNEL_FLAGS: Record<AuthContactChannel, string> = {
|
|
54
|
+
email: 'email_allowed',
|
|
55
|
+
sms: 'sms_allowed',
|
|
56
|
+
call: 'call_allowed',
|
|
57
|
+
whatsapp: 'whatsapp_allowed'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const isContactChannel = (value: string): value is AuthContactChannel =>
|
|
61
|
+
value in AUTH_CONTACT_CHANNEL_FLAGS;
|
|
36
62
|
|
|
37
63
|
/**
|
|
38
64
|
* What each tick is called on the wire.
|
|
@@ -74,7 +100,13 @@ const DEFAULT_TEXT: Record<AuthConsentType, string> = {
|
|
|
74
100
|
marketing: 'I would like to receive campaign and marketing messages.',
|
|
75
101
|
// A custom tick is whatever the brand has to ask this month; nothing here
|
|
76
102
|
// could guess its sentence.
|
|
77
|
-
custom: ''
|
|
103
|
+
custom: '',
|
|
104
|
+
// A channel row is usually one of a list under a heading, so its caption
|
|
105
|
+
// is the channel's name rather than a sentence.
|
|
106
|
+
email: 'E-mail',
|
|
107
|
+
sms: 'SMS',
|
|
108
|
+
call: 'Phone call',
|
|
109
|
+
whatsapp: 'WhatsApp'
|
|
78
110
|
};
|
|
79
111
|
|
|
80
112
|
const AuthConsentBlock = ({
|
|
@@ -90,7 +122,10 @@ const AuthConsentBlock = ({
|
|
|
90
122
|
)
|
|
91
123
|
).trim();
|
|
92
124
|
const consentType: AuthConsentType =
|
|
93
|
-
rawType === 'marketing' || rawType === 'custom'
|
|
125
|
+
rawType === 'marketing' || rawType === 'custom' || isContactChannel(rawType)
|
|
126
|
+
? rawType
|
|
127
|
+
: 'legal';
|
|
128
|
+
const isChannel = isContactChannel(consentType);
|
|
94
129
|
|
|
95
130
|
/**
|
|
96
131
|
* The two known ticks own their names because commerce owns their meaning: the
|
|
@@ -109,7 +144,9 @@ const AuthConsentBlock = ({
|
|
|
109
144
|
const name =
|
|
110
145
|
consentType === 'custom'
|
|
111
146
|
? customName
|
|
112
|
-
:
|
|
147
|
+
: isContactChannel(consentType)
|
|
148
|
+
? AUTH_CONTACT_CHANNEL_FLAGS[consentType]
|
|
149
|
+
: AUTH_CONSENT_NAMES[consentType as 'legal' | 'marketing'];
|
|
113
150
|
|
|
114
151
|
/**
|
|
115
152
|
* What a ticked custom consent sends. `true` by default because that is what
|
|
@@ -117,10 +154,11 @@ const AuthConsentBlock = ({
|
|
|
117
154
|
* by typing that instead. (The browser's own default for a valueless checkbox
|
|
118
155
|
* is `on`, which is why the value is set explicitly rather than left off.)
|
|
119
156
|
*/
|
|
120
|
-
const tickedValue =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
157
|
+
const tickedValue = isChannel
|
|
158
|
+
? 'true'
|
|
159
|
+
: String(
|
|
160
|
+
getResponsiveValue(block.properties?.tickedValue, currentBreakpoint, '')
|
|
161
|
+
).trim() || 'true';
|
|
124
162
|
|
|
125
163
|
const text = String(
|
|
126
164
|
getResponsiveValue(
|
|
@@ -135,18 +173,30 @@ const AuthConsentBlock = ({
|
|
|
135
173
|
* because that is what each one IS — but it stays a toggle: a brand whose
|
|
136
174
|
* agreement is not mandatory in its market must be able to say so without
|
|
137
175
|
* swapping the block for another.
|
|
176
|
+
*
|
|
177
|
+
* A contact permission is the shopper's choice by definition: a form that
|
|
178
|
+
* refused to save until it was ticked would be extracting consent, not
|
|
179
|
+
* asking for it. So a channel tick is never required, whatever the toggle
|
|
180
|
+
* (which the editor seeds on for every new row) says.
|
|
138
181
|
*/
|
|
139
|
-
const required =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
182
|
+
const required =
|
|
183
|
+
!isChannel &&
|
|
184
|
+
readToggle(
|
|
185
|
+
block.properties?.required,
|
|
186
|
+
currentBreakpoint,
|
|
187
|
+
consentType === 'legal'
|
|
188
|
+
);
|
|
144
189
|
|
|
145
190
|
// The form puts its own sentence here — "you have to accept the agreement"
|
|
146
191
|
// beats "this field is required" for a legal opt-in — and clears it as soon
|
|
147
192
|
// as the box is ticked.
|
|
148
193
|
const errorMessage = useAuthFieldError(name);
|
|
149
194
|
const insideConsentHost = React.useContext(AuthConsentHostContext);
|
|
195
|
+
/**
|
|
196
|
+
* The shopper's stored answer, when the hosting form edits a record (the
|
|
197
|
+
* Profile Form). Unticked everywhere else, which is what a sign-up asks.
|
|
198
|
+
*/
|
|
199
|
+
const startsTicked = useFormFieldChecked(name, tickedValue);
|
|
150
200
|
|
|
151
201
|
// Same refusal as an unnamed custom field: a tick that submits nothing is not
|
|
152
202
|
// a consent, and the canvas has to say what it is waiting for.
|
|
@@ -158,8 +208,10 @@ const AuthConsentBlock = ({
|
|
|
158
208
|
|
|
159
209
|
// A membership or marketing tick outside the form that reads it would post a
|
|
160
210
|
// string commerce ignores. A CUSTOM tick is the brand's own key, forwarded
|
|
161
|
-
// verbatim by any composed form,
|
|
162
|
-
|
|
211
|
+
// verbatim by any composed form, and a channel tick is a flag both the
|
|
212
|
+
// register and the profile form read by name, so those belong wherever the
|
|
213
|
+
// author puts them.
|
|
214
|
+
if (consentType !== 'custom' && !isChannel && !insideConsentHost) {
|
|
163
215
|
if (!isDesigner) return null;
|
|
164
216
|
|
|
165
217
|
return (
|
|
@@ -177,12 +229,19 @@ const AuthConsentBlock = ({
|
|
|
177
229
|
style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}
|
|
178
230
|
>
|
|
179
231
|
<input
|
|
232
|
+
// Keyed by name so a tick whose type changes on the canvas (e-mail
|
|
233
|
+
// → WhatsApp) starts again from ITS stored answer: `defaultChecked`
|
|
234
|
+
// is only read when the box mounts.
|
|
235
|
+
key={name}
|
|
180
236
|
type="checkbox"
|
|
181
237
|
name={name}
|
|
182
|
-
// Only the custom
|
|
183
|
-
// by presence and re-expressed as flags, so a value on them
|
|
184
|
-
// a string the form throws away.
|
|
185
|
-
value={
|
|
238
|
+
// Only the custom and channel ticks carry a value: the two known ones
|
|
239
|
+
// are read by presence and re-expressed as flags, so a value on them
|
|
240
|
+
// would be a string the form throws away.
|
|
241
|
+
value={
|
|
242
|
+
consentType === 'custom' || isChannel ? tickedValue : undefined
|
|
243
|
+
}
|
|
244
|
+
defaultChecked={startsTicked || undefined}
|
|
186
245
|
required={required || undefined}
|
|
187
246
|
aria-invalid={errorMessage ? true : undefined}
|
|
188
247
|
{...partAttrs('checkbox')}
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
5
5
|
import { useAuthFieldError } from '../auth/auth-field-errors';
|
|
6
|
+
import {
|
|
7
|
+
useFormFieldDefault,
|
|
8
|
+
useFormFieldReadOnly
|
|
9
|
+
} from '../auth/form-field-defaults';
|
|
6
10
|
import { DesignerPlaceholder, readToggle } from './product-block-utils';
|
|
7
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
12
|
+
import BirthDateSelects from './birth-date-selects';
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* One field of an auth form, as a block of its own.
|
|
@@ -20,9 +25,17 @@ import { BlockRendererProps } from './block-renderer-registry';
|
|
|
20
25
|
* form owns those. Standalone (outside any form) it still renders, but
|
|
21
26
|
* nothing collects its value, which the designer sees immediately.
|
|
22
27
|
*
|
|
23
|
-
*
|
|
24
|
-
* a
|
|
25
|
-
*
|
|
28
|
+
* Inside a form that edits a record (the Profile Form) the field starts from
|
|
29
|
+
* the record's value, and a field that form cannot change (the e-mail) is drawn
|
|
30
|
+
* disabled — see `auth/form-field-defaults.tsx`.
|
|
31
|
+
*
|
|
32
|
+
* Properties: fieldType, label, placeholder, showLabel, required, autoComplete;
|
|
33
|
+
* for a `gender` field the two option captions (femaleLabel, maleLabel) and
|
|
34
|
+
* choiceControl (select | radio); for a `date_of_birth` field dateControl
|
|
35
|
+
* (native | selects) and, for the selects, dayPlaceholder, monthPlaceholder,
|
|
36
|
+
* yearPlaceholder, monthLabels, minAge, maxAge.
|
|
37
|
+
* Parts: field, label, input, error; `choices`, `choice`, `choice-label` for
|
|
38
|
+
* gender radios; `date-parts` around the three date selects.
|
|
26
39
|
*/
|
|
27
40
|
|
|
28
41
|
type AuthFieldType =
|
|
@@ -74,6 +87,28 @@ const CUSTOM_INPUT_TYPES = [
|
|
|
74
87
|
*/
|
|
75
88
|
const GENDER_VALUES = ['female', 'male'] as const;
|
|
76
89
|
|
|
90
|
+
/** How the gender answer is asked: a select, or one radio per answer. */
|
|
91
|
+
const CHOICE_CONTROLS = ['select', 'radio'] as const;
|
|
92
|
+
type ChoiceControl = (typeof CHOICE_CONTROLS)[number];
|
|
93
|
+
|
|
94
|
+
/** How a birth date is asked: the browser's date control, or three selects. */
|
|
95
|
+
const DATE_CONTROLS = ['native', 'selects'] as const;
|
|
96
|
+
type DateControl = (typeof DATE_CONTROLS)[number];
|
|
97
|
+
|
|
98
|
+
/** Comma-separated month captions → a list; blanks dropped. */
|
|
99
|
+
const readMonthLabels = (raw: string): string[] =>
|
|
100
|
+
raw
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((caption) => caption.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
|
|
105
|
+
/** A whole number property, or its fallback when it is not one. */
|
|
106
|
+
const readCount = (raw: string, fallback: number): number => {
|
|
107
|
+
const parsed = Number.parseInt(raw, 10);
|
|
108
|
+
|
|
109
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
110
|
+
};
|
|
111
|
+
|
|
77
112
|
/** Input semantics per field: type, autocomplete, default caption. */
|
|
78
113
|
const FIELD_PRESETS: Record<
|
|
79
114
|
AuthFieldType,
|
|
@@ -170,10 +205,28 @@ const AuthFieldBlock = ({
|
|
|
170
205
|
* name; a field standing outside any form simply never has one.
|
|
171
206
|
*/
|
|
172
207
|
const errorMessage = useAuthFieldError(controlName);
|
|
208
|
+
/**
|
|
209
|
+
* Where the hosting form says this field starts, and whether it may change.
|
|
210
|
+
* Empty and editable outside a record-editing form, which is every auth form.
|
|
211
|
+
*/
|
|
212
|
+
const defaultValue = useFormFieldDefault(controlName);
|
|
213
|
+
const readOnly = useFormFieldReadOnly(controlName);
|
|
173
214
|
|
|
174
215
|
/** Authorable captions for a fixed pair of stored values. */
|
|
175
216
|
const femaleLabel = prop('femaleLabel', 'Female');
|
|
176
217
|
const maleLabel = prop('maleLabel', 'Male');
|
|
218
|
+
const choiceControl = resolveOption<ChoiceControl>(
|
|
219
|
+
block.properties?.choiceControl,
|
|
220
|
+
currentBreakpoint,
|
|
221
|
+
'select',
|
|
222
|
+
CHOICE_CONTROLS as unknown as ChoiceControl[]
|
|
223
|
+
);
|
|
224
|
+
const dateControl = resolveOption<DateControl>(
|
|
225
|
+
block.properties?.dateControl,
|
|
226
|
+
currentBreakpoint,
|
|
227
|
+
'native',
|
|
228
|
+
DATE_CONTROLS as unknown as DateControl[]
|
|
229
|
+
);
|
|
177
230
|
|
|
178
231
|
// One declaration for both controls: a select that does not look like the
|
|
179
232
|
// inputs beside it reads as a different form.
|
|
@@ -187,7 +240,140 @@ const AuthFieldBlock = ({
|
|
|
187
240
|
background: 'transparent',
|
|
188
241
|
color: 'inherit',
|
|
189
242
|
padding: '10px 12px',
|
|
190
|
-
outline: 'none'
|
|
243
|
+
outline: 'none',
|
|
244
|
+
// Shown, not editable: dimmed the way the profile form has always drawn
|
|
245
|
+
// its e-mail, so it does not read as a box the shopper forgot to fill.
|
|
246
|
+
...(readOnly ? { opacity: 0.6 } : null)
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The control itself, per field type. Every variant carries the same
|
|
251
|
+
* `data-part="input"` on what the shopper types into or picks from, so one
|
|
252
|
+
* set of input styles in the inspector dresses all of them.
|
|
253
|
+
*/
|
|
254
|
+
const renderControl = () => {
|
|
255
|
+
if (fieldType === 'gender' && choiceControl === 'radio') {
|
|
256
|
+
/**
|
|
257
|
+
* One radio per stored value, for a design that shows both answers at
|
|
258
|
+
* once. They share the field's name, so the form reads one answer and
|
|
259
|
+
* its validation reports one message for the group.
|
|
260
|
+
*/
|
|
261
|
+
return (
|
|
262
|
+
<div
|
|
263
|
+
{...partAttrs('choices')}
|
|
264
|
+
style={{ display: 'flex', flexWrap: 'wrap', gap: '24px' }}
|
|
265
|
+
>
|
|
266
|
+
{GENDER_VALUES.map((value) => (
|
|
267
|
+
<label
|
|
268
|
+
key={value}
|
|
269
|
+
data-auth-field-choice={value}
|
|
270
|
+
{...partAttrs('choice')}
|
|
271
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
|
272
|
+
>
|
|
273
|
+
<input
|
|
274
|
+
key={`${controlName}-${value}`}
|
|
275
|
+
type="radio"
|
|
276
|
+
name={controlName}
|
|
277
|
+
value={value}
|
|
278
|
+
defaultChecked={defaultValue === value}
|
|
279
|
+
required={required}
|
|
280
|
+
aria-invalid={errorMessage ? true : undefined}
|
|
281
|
+
disabled={readOnly}
|
|
282
|
+
{...partAttrs('input', { disabled: readOnly })}
|
|
283
|
+
style={{ margin: 0 }}
|
|
284
|
+
/>
|
|
285
|
+
<span {...partAttrs('choice-label')}>
|
|
286
|
+
{value === 'female' ? femaleLabel : maleLabel}
|
|
287
|
+
</span>
|
|
288
|
+
</label>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (fieldType === 'gender') {
|
|
295
|
+
/**
|
|
296
|
+
* A select, because the answer is one of two stored values rather than
|
|
297
|
+
* free text — and it carries the SAME `data-part="input"` as every other
|
|
298
|
+
* field's control, so a brand's border, height and focus ring reach it
|
|
299
|
+
* without a second set of controls in the inspector.
|
|
300
|
+
*
|
|
301
|
+
* The empty first option is what makes "not answered" expressible: with
|
|
302
|
+
* `required` off a shopper may leave it, and with `required` on the form
|
|
303
|
+
* reports it like any other empty field.
|
|
304
|
+
*/
|
|
305
|
+
return (
|
|
306
|
+
<select
|
|
307
|
+
key={`${controlName}-select`}
|
|
308
|
+
name={controlName}
|
|
309
|
+
autoComplete={autoComplete}
|
|
310
|
+
defaultValue={defaultValue}
|
|
311
|
+
aria-label={showLabel ? undefined : label}
|
|
312
|
+
aria-invalid={errorMessage ? true : undefined}
|
|
313
|
+
required={required}
|
|
314
|
+
disabled={readOnly}
|
|
315
|
+
{...partAttrs('input', { disabled: readOnly })}
|
|
316
|
+
style={controlStyle}
|
|
317
|
+
>
|
|
318
|
+
<option value="">{placeholder || label}</option>
|
|
319
|
+
{GENDER_VALUES.map((value) => (
|
|
320
|
+
<option key={value} value={value}>
|
|
321
|
+
{value === 'female' ? femaleLabel : maleLabel}
|
|
322
|
+
</option>
|
|
323
|
+
))}
|
|
324
|
+
</select>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (fieldType === 'date_of_birth' && dateControl === 'selects') {
|
|
329
|
+
return (
|
|
330
|
+
<BirthDateSelects
|
|
331
|
+
key={`${controlName}-selects`}
|
|
332
|
+
name={controlName}
|
|
333
|
+
defaultValue={defaultValue}
|
|
334
|
+
required={required}
|
|
335
|
+
label={label}
|
|
336
|
+
dayPlaceholder={prop('dayPlaceholder', 'Day')}
|
|
337
|
+
monthPlaceholder={prop('monthPlaceholder', 'Month')}
|
|
338
|
+
yearPlaceholder={prop('yearPlaceholder', 'Year')}
|
|
339
|
+
monthLabels={readMonthLabels(prop('monthLabels', ''))}
|
|
340
|
+
minAge={readCount(prop('minAge', ''), 0)}
|
|
341
|
+
maxAge={readCount(prop('maxAge', ''), 100)}
|
|
342
|
+
invalid={Boolean(errorMessage)}
|
|
343
|
+
controlStyle={controlStyle}
|
|
344
|
+
disabled={readOnly}
|
|
345
|
+
/>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<input
|
|
351
|
+
// Uncontrolled controls read their starting value once, on mount; the
|
|
352
|
+
// keys make a field whose type or name changes on the canvas start
|
|
353
|
+
// again from the value stored for what it is now.
|
|
354
|
+
key={`${controlName}-${controlType}`}
|
|
355
|
+
type={controlType}
|
|
356
|
+
name={controlName}
|
|
357
|
+
autoComplete={autoComplete}
|
|
358
|
+
placeholder={placeholder || undefined}
|
|
359
|
+
// A date control only takes `YYYY-MM-DD`; commerce may send a time
|
|
360
|
+
// after it, which would leave the control blank.
|
|
361
|
+
defaultValue={
|
|
362
|
+
(controlType === 'date' ? defaultValue.slice(0, 10) : defaultValue) ||
|
|
363
|
+
undefined
|
|
364
|
+
}
|
|
365
|
+
aria-label={showLabel ? undefined : label}
|
|
366
|
+
aria-invalid={errorMessage ? true : undefined}
|
|
367
|
+
// `required` stays on the control even though the form runs the check
|
|
368
|
+
// itself: it IS the check. `noValidate` up on the form suppresses the
|
|
369
|
+
// browser's bubble, not its `validity` reporting.
|
|
370
|
+
required={required}
|
|
371
|
+
readOnly={readOnly}
|
|
372
|
+
disabled={readOnly}
|
|
373
|
+
{...partAttrs('input', { disabled: readOnly })}
|
|
374
|
+
style={controlStyle}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
191
377
|
};
|
|
192
378
|
|
|
193
379
|
// A custom field with no name collects nothing, so a shopper must not be
|
|
@@ -201,62 +387,38 @@ const AuthFieldBlock = ({
|
|
|
201
387
|
);
|
|
202
388
|
}
|
|
203
389
|
|
|
390
|
+
/**
|
|
391
|
+
* A field with several controls — two gender radios, three date selects — is
|
|
392
|
+
* a group, not a label: a `<label>` names exactly one control, and wrapped
|
|
393
|
+
* around several it would hand every click on its caption to the first.
|
|
394
|
+
*/
|
|
395
|
+
const isGroup =
|
|
396
|
+
(fieldType === 'gender' && choiceControl === 'radio') ||
|
|
397
|
+
(fieldType === 'date_of_birth' && dateControl === 'selects');
|
|
398
|
+
const Wrapper = isGroup ? 'div' : 'label';
|
|
399
|
+
|
|
204
400
|
return (
|
|
205
|
-
<
|
|
401
|
+
<Wrapper
|
|
206
402
|
data-auth-field={controlName}
|
|
207
403
|
// Fills its block wrapper; see the note in auth-step-block.tsx. In a row
|
|
208
404
|
// Group two fields still share the row — a flex item shrinks from 100%.
|
|
209
405
|
className="w-full"
|
|
406
|
+
{...(isGroup
|
|
407
|
+
? {
|
|
408
|
+
role: fieldType === 'gender' ? 'radiogroup' : 'group',
|
|
409
|
+
'aria-label': label,
|
|
410
|
+
...(fieldType === 'gender' && required
|
|
411
|
+
? { 'aria-required': true }
|
|
412
|
+
: null)
|
|
413
|
+
}
|
|
414
|
+
: null)}
|
|
210
415
|
{...partAttrs('field')}
|
|
211
416
|
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
|
|
212
417
|
>
|
|
213
418
|
{/* Hiding the caption must not cost the input its accessible name, so
|
|
214
419
|
the label text moves onto aria-label instead of disappearing. */}
|
|
215
420
|
{showLabel && <span {...partAttrs('label')}>{label}</span>}
|
|
216
|
-
{
|
|
217
|
-
/**
|
|
218
|
-
* A select, because the answer is one of two stored values rather than
|
|
219
|
-
* free text — and it carries the SAME `data-part="input"` as every other
|
|
220
|
-
* field's control, so a brand's border, height and focus ring reach it
|
|
221
|
-
* without a second set of controls in the inspector.
|
|
222
|
-
*
|
|
223
|
-
* The empty first option is what makes "not answered" expressible: with
|
|
224
|
-
* `required` off a shopper may leave it, and with `required` on the form
|
|
225
|
-
* reports it like any other empty field.
|
|
226
|
-
*/
|
|
227
|
-
<select
|
|
228
|
-
name={controlName}
|
|
229
|
-
autoComplete={autoComplete}
|
|
230
|
-
defaultValue=""
|
|
231
|
-
aria-label={showLabel ? undefined : label}
|
|
232
|
-
aria-invalid={errorMessage ? true : undefined}
|
|
233
|
-
required={required}
|
|
234
|
-
{...partAttrs('input')}
|
|
235
|
-
style={controlStyle}
|
|
236
|
-
>
|
|
237
|
-
<option value="">{placeholder || label}</option>
|
|
238
|
-
{GENDER_VALUES.map((value) => (
|
|
239
|
-
<option key={value} value={value}>
|
|
240
|
-
{value === 'female' ? femaleLabel : maleLabel}
|
|
241
|
-
</option>
|
|
242
|
-
))}
|
|
243
|
-
</select>
|
|
244
|
-
) : (
|
|
245
|
-
<input
|
|
246
|
-
type={controlType}
|
|
247
|
-
name={controlName}
|
|
248
|
-
autoComplete={autoComplete}
|
|
249
|
-
placeholder={placeholder || undefined}
|
|
250
|
-
aria-label={showLabel ? undefined : label}
|
|
251
|
-
aria-invalid={errorMessage ? true : undefined}
|
|
252
|
-
// `required` stays on the control even though the form runs the check
|
|
253
|
-
// itself: it IS the check. `noValidate` up on the form suppresses the
|
|
254
|
-
// browser's bubble, not its `validity` reporting.
|
|
255
|
-
required={required}
|
|
256
|
-
{...partAttrs('input')}
|
|
257
|
-
style={controlStyle}
|
|
258
|
-
/>
|
|
259
|
-
)}
|
|
421
|
+
{renderControl()}
|
|
260
422
|
{/* Under the field and in the flow, so the layout makes room for it
|
|
261
423
|
instead of the message floating over the next control — which is the
|
|
262
424
|
half of the native bubble no brand ever wanted. */}
|
|
@@ -273,7 +435,7 @@ const AuthFieldBlock = ({
|
|
|
273
435
|
{errorMessage}
|
|
274
436
|
</span>
|
|
275
437
|
)}
|
|
276
|
-
</
|
|
438
|
+
</Wrapper>
|
|
277
439
|
);
|
|
278
440
|
};
|
|
279
441
|
|