@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.
- package/CHANGELOG.md +10 -51
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- 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/__tests__/text-block.test.tsx +6 -34
- 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/order-detail-block.tsx +95 -113
- 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
- package/src/blocks/text-block.tsx +2 -5
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/theme-placeholder-wrapper.tsx +0 -3
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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>) => {
|