@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
|
@@ -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>) => {
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
AuthFieldErrors,
|
|
25
25
|
AuthFieldErrorsProvider,
|
|
26
26
|
clearAuthFieldError,
|
|
27
|
+
eventFieldName,
|
|
27
28
|
collectAuthFieldErrors
|
|
28
29
|
} from '../auth/auth-field-errors';
|
|
29
30
|
import {
|
|
@@ -424,11 +425,9 @@ const OtpFormBlock = ({
|
|
|
424
425
|
* `change`.
|
|
425
426
|
*/
|
|
426
427
|
const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
|
|
427
|
-
const
|
|
428
|
+
const name = eventFieldName(event.target);
|
|
428
429
|
|
|
429
|
-
setFieldErrors((current) =>
|
|
430
|
-
clearAuthFieldError(current, target?.name || '')
|
|
431
|
-
);
|
|
430
|
+
setFieldErrors((current) => clearAuthFieldError(current, name));
|
|
432
431
|
};
|
|
433
432
|
|
|
434
433
|
const submitPhone = async (event: React.FormEvent<HTMLFormElement>) => {
|