@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.
@@ -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: FormControl): boolean => {
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
- // First one wins per name: a radio group, or a name a composition
122
- // duplicated, is one question to the shopper rather than several.
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) => Boolean(errors[control.name]) && isVisibleControl(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 AuthConsentType = 'legal' | 'marketing' | 'custom';
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' ? rawType : 'legal';
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
- : AUTH_CONSENT_NAMES[consentType as 'legal' | 'marketing'];
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
- String(
122
- getResponsiveValue(block.properties?.tickedValue, currentBreakpoint, '')
123
- ).trim() || 'true';
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 = readToggle(
140
- block.properties?.required,
141
- currentBreakpoint,
142
- consentType === 'legal'
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, so it belongs wherever the author puts it.
162
- if (consentType !== 'custom' && !insideConsentHost) {
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 tick carries a value: the two known ones are read
183
- // by presence and re-expressed as flags, so a value on them would be
184
- // a string the form throws away.
185
- value={consentType === 'custom' ? tickedValue : undefined}
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
- * Properties: fieldType, label, placeholder, showLabel, required, autoComplete, and for
24
- * a `gender` field the two option captions (femaleLabel, maleLabel).
25
- * Parts: field, label, input, error.
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
- <label
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
- {fieldType === 'gender' ? (
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
- </label>
438
+ </Wrapper>
277
439
  );
278
440
  };
279
441