@akinon/pz-theme 2.0.165-rc.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.
@@ -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
 
@@ -11,6 +11,7 @@ import {
11
11
  AuthFieldErrors,
12
12
  AuthFieldErrorsProvider,
13
13
  clearAuthFieldError,
14
+ eventFieldName,
14
15
  collectAuthFieldErrors,
15
16
  focusFirstAuthFieldError
16
17
  } from '../auth/auth-field-errors';
@@ -413,11 +414,9 @@ const AuthFormBlock = ({
413
414
  );
414
415
 
415
416
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
416
- const target = event.target as { name?: string } | null;
417
+ const name = eventFieldName(event.target);
417
418
 
418
- setFieldErrors((current) =>
419
- clearAuthFieldError(current, target?.name || '')
420
- );
419
+ setFieldErrors((current) => clearAuthFieldError(current, name));
421
420
  };
422
421
 
423
422
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { partAttrs } from '../utils';
5
+
6
+ /**
7
+ * A birth date asked as three selects — day, month, year — instead of the
8
+ * browser's date control.
9
+ *
10
+ * The native control is the right default (its value is already the
11
+ * `YYYY-MM-DD` commerce stores, and it follows the shopper's locale), but it
12
+ * cannot be dressed: its popup, its separators and its empty "gg.aa.yyyy" mask
13
+ * belong to the browser. A design that draws "Gün / Ay / Yıl" as three boxes
14
+ * of the brand's own select styling had no way to get there.
15
+ *
16
+ * The three selects carry no `name` — they would post three stray keys — and
17
+ * one hidden input submits the composed date under the field's name, so the
18
+ * hosting form reads exactly what it read from the native control. Each select
19
+ * says which field it belongs to through `data-field-name`, which is how the
20
+ * form's validation files a missing part under that field.
21
+ */
22
+
23
+ export interface BirthDateSelectsProps {
24
+ /** The name the composed `YYYY-MM-DD` value submits under. */
25
+ name: string;
26
+ /** A stored date to start from; anything that is not `YYYY-MM-DD…` is none. */
27
+ defaultValue: string;
28
+ required: boolean;
29
+ /** The field's caption, prefixed to each select's accessible name. */
30
+ label: string;
31
+ dayPlaceholder: string;
32
+ monthPlaceholder: string;
33
+ yearPlaceholder: string;
34
+ /**
35
+ * Twelve captions for the month select, January first. Anything shorter is
36
+ * ignored and the months are numbered instead (01–12), which reads right in
37
+ * every language.
38
+ */
39
+ monthLabels: string[];
40
+ /** The youngest age offered: the latest year is this many years ago. */
41
+ minAge: number;
42
+ /** The oldest age offered: the earliest year is this many years ago. */
43
+ maxAge: number;
44
+ invalid: boolean;
45
+ controlStyle: React.CSSProperties;
46
+ disabled?: boolean;
47
+ }
48
+
49
+ const pad = (value: string) => value.padStart(2, '0');
50
+
51
+ /** Days in a month (1–12); 31 until the month is known, 29 for February until the year is. */
52
+ export const daysInMonth = (year: string, month: string): number => {
53
+ if (!month) return 31;
54
+
55
+ // Day 0 of the next month is the last day of this one; a leap year stands in
56
+ // until a year is picked, so 29 February stays choosable.
57
+ return new Date(Number(year) || 2000, Number(month), 0).getDate();
58
+ };
59
+
60
+ /** `YYYY-MM-DD` → its parts as the selects hold them (no zero padding). */
61
+ export const splitIsoDate = (
62
+ value: string
63
+ ): { day: string; month: string; year: string } => {
64
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value || '');
65
+
66
+ if (!match) return { day: '', month: '', year: '' };
67
+
68
+ return {
69
+ year: match[1],
70
+ month: String(Number(match[2])),
71
+ day: String(Number(match[3]))
72
+ };
73
+ };
74
+
75
+ /** The parts → `YYYY-MM-DD`, or '' while any part is missing. */
76
+ export const joinIsoDate = (day: string, month: string, year: string) =>
77
+ day && month && year ? `${year}-${pad(month)}-${pad(day)}` : '';
78
+
79
+ const BirthDateSelects = ({
80
+ name,
81
+ defaultValue,
82
+ required,
83
+ label,
84
+ dayPlaceholder,
85
+ monthPlaceholder,
86
+ yearPlaceholder,
87
+ monthLabels,
88
+ minAge,
89
+ maxAge,
90
+ invalid,
91
+ controlStyle,
92
+ disabled = false
93
+ }: BirthDateSelectsProps) => {
94
+ const [parts, setParts] = useState(() => splitIsoDate(defaultValue));
95
+
96
+ const latestYear = new Date().getFullYear() - Math.max(0, minAge);
97
+ const earliestYear =
98
+ new Date().getFullYear() - Math.max(Math.max(0, minAge), maxAge);
99
+ const years: string[] = [];
100
+
101
+ for (let year = latestYear; year >= earliestYear; year -= 1) {
102
+ years.push(String(year));
103
+ }
104
+
105
+ // A stored year outside the offered range is still the shopper's answer; it
106
+ // stays selectable rather than being silently dropped on the next save.
107
+ if (parts.year && !years.includes(parts.year)) {
108
+ years.push(parts.year);
109
+ years.sort((a, b) => Number(b) - Number(a));
110
+ }
111
+
112
+ const dayCount = daysInMonth(parts.year, parts.month);
113
+ const months =
114
+ monthLabels.length >= 12
115
+ ? monthLabels.slice(0, 12)
116
+ : Array.from({ length: 12 }, (_, index) => pad(String(index + 1)));
117
+
118
+ const update =
119
+ (key: 'day' | 'month' | 'year') =>
120
+ (event: React.ChangeEvent<HTMLSelectElement>) => {
121
+ const nextValue = event.target.value;
122
+
123
+ setParts((current) => {
124
+ const next = { ...current, [key]: nextValue };
125
+
126
+ // 31 April does not exist; a day the new month cannot hold is cleared
127
+ // rather than rolled into the next month.
128
+ if (Number(next.day) > daysInMonth(next.year, next.month)) {
129
+ next.day = '';
130
+ }
131
+
132
+ return next;
133
+ });
134
+ };
135
+
136
+ // A half-answered date is not a date: once any part is picked, the others
137
+ // are asked for too, even on an optional field.
138
+ const partRequired =
139
+ required || Boolean(parts.day || parts.month || parts.year);
140
+
141
+ const select = (
142
+ key: 'day' | 'month' | 'year',
143
+ placeholder: string,
144
+ options: { value: string; caption: string }[]
145
+ ) => (
146
+ <select
147
+ data-field-name={name}
148
+ data-date-part={key}
149
+ value={parts[key]}
150
+ onChange={update(key)}
151
+ required={partRequired}
152
+ disabled={disabled}
153
+ aria-label={`${label} ${placeholder}`.trim()}
154
+ aria-invalid={invalid ? true : undefined}
155
+ autoComplete={`bday-${key}`}
156
+ {...partAttrs('input', { disabled })}
157
+ style={{ ...controlStyle, flex: '1 1 0', minWidth: 0 }}
158
+ >
159
+ <option value="">{placeholder}</option>
160
+ {options.map((option) => (
161
+ <option key={option.value} value={option.value}>
162
+ {option.caption}
163
+ </option>
164
+ ))}
165
+ </select>
166
+ );
167
+
168
+ return (
169
+ <div
170
+ data-birth-date-selects=""
171
+ {...partAttrs('date-parts')}
172
+ style={{ display: 'flex', gap: '8px', width: '100%' }}
173
+ >
174
+ {select(
175
+ 'day',
176
+ dayPlaceholder,
177
+ Array.from({ length: dayCount }, (_, index) => ({
178
+ value: String(index + 1),
179
+ caption: pad(String(index + 1))
180
+ }))
181
+ )}
182
+ {select(
183
+ 'month',
184
+ monthPlaceholder,
185
+ months.map((caption, index) => ({
186
+ value: String(index + 1),
187
+ caption
188
+ }))
189
+ )}
190
+ {select(
191
+ 'year',
192
+ yearPlaceholder,
193
+ years.map((year) => ({ value: year, caption: year }))
194
+ )}
195
+ <input
196
+ type="hidden"
197
+ name={name}
198
+ value={joinIsoDate(parts.day, parts.month, parts.year)}
199
+ disabled={disabled}
200
+ readOnly
201
+ />
202
+ </div>
203
+ );
204
+ };
205
+
206
+ export default BirthDateSelects;
@@ -8,6 +8,7 @@ import {
8
8
  AuthFieldErrors,
9
9
  AuthFieldErrorsProvider,
10
10
  clearAuthFieldError,
11
+ eventFieldName,
11
12
  collectAuthFieldErrors,
12
13
  focusFirstAuthFieldError
13
14
  } from '../auth/auth-field-errors';
@@ -224,11 +225,9 @@ const ForgotPasswordBlock = ({
224
225
  }, [formHtmlId, isLoading]);
225
226
 
226
227
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
227
- const target = event.target as { name?: string } | null;
228
+ const name = eventFieldName(event.target);
228
229
 
229
- setFieldErrors((current) =>
230
- clearAuthFieldError(current, target?.name || '')
231
- );
230
+ setFieldErrors((current) => clearAuthFieldError(current, name));
232
231
  };
233
232
 
234
233
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {