@akinon/pz-theme 2.0.83-rc.0 → 2.0.83

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +31 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -0,0 +1,326 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFormBlock from '../blocks/auth-form-block';
5
+ import OtpFormBlock from '../blocks/otp-form-block';
6
+ import { containsAuthErrorBlock } from '../auth/form-error-context';
7
+ import { Block } from '../theme-block';
8
+
9
+ /**
10
+ * Where a form-level message lands.
11
+ *
12
+ * The form used to append it after everything it drew, and in a composition
13
+ * that is wrong twice: the CTA is one of the children, so a failed login wrote
14
+ * "e-posta veya şifre hatalı" BELOW the button that had just been pressed, and
15
+ * the padding the design puts on its own wrapper never reaches a node appended
16
+ * outside that wrapper — so the line also sat flush against the drawer's edge
17
+ * while every other line was inset by 24px.
18
+ *
19
+ * The form cannot fix either from where it stands: it does not know which child
20
+ * is the CTA, nor which wrapper carries the padding. So it publishes the message
21
+ * and a Form Error block draws it where the design says. These cases hold both
22
+ * halves of that contract — the block draws it, and the form stands down — plus
23
+ * the fallback that keeps a composition which has not placed one from going
24
+ * silent.
25
+ */
26
+
27
+ const signIn = jest.fn(() =>
28
+ Promise.resolve({ error: 'CredentialsSignin', url: null })
29
+ );
30
+
31
+ jest.mock('next-auth/react', () => ({
32
+ signIn: (...args: unknown[]) => signIn(...args),
33
+ useSession: () => ({ data: null, status: 'unauthenticated' })
34
+ }));
35
+
36
+ jest.mock('next/navigation', () => ({
37
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
38
+ }));
39
+
40
+ const block = (overrides: Partial<Block>): Block =>
41
+ ({
42
+ id: 'blk',
43
+ type: 'text',
44
+ label: 'Block',
45
+ value: '',
46
+ order: 0,
47
+ hidden: false,
48
+ properties: {},
49
+ styles: {},
50
+ ...overrides
51
+ }) as Block;
52
+
53
+ const emailField = block({
54
+ id: 'field-email',
55
+ type: 'auth-field',
56
+ properties: { fieldType: 'email', label: 'E-mail' },
57
+ order: 0
58
+ });
59
+
60
+ const passwordField = block({
61
+ id: 'field-password',
62
+ type: 'auth-field',
63
+ properties: { fieldType: 'password', label: 'Password' },
64
+ order: 1
65
+ });
66
+
67
+ const errorBlock = (properties: Record<string, unknown> = {}) =>
68
+ block({
69
+ id: 'error-row',
70
+ type: 'auth-error',
71
+ label: 'Form Error',
72
+ properties,
73
+ order: 2
74
+ });
75
+
76
+ /** The composed CTA, which is what the message used to end up underneath. */
77
+ const ctaBlock = block({
78
+ id: 'cta',
79
+ type: 'button',
80
+ label: 'CTA',
81
+ properties: { text: 'Devam Et', tag: 'button', buttonType: 'submit' },
82
+ order: 3
83
+ });
84
+
85
+ const renderLoginForm = (children: Block[], isDesigner = false) =>
86
+ render(
87
+ <AuthFormBlock
88
+ block={
89
+ block({
90
+ id: 'auth-form-1',
91
+ type: 'auth-form',
92
+ label: 'Auth Form',
93
+ properties: { showSubmit: false },
94
+ blocks: children
95
+ }) as Block
96
+ }
97
+ placeholderId="placeholder-1"
98
+ sectionId="section-1"
99
+ currentBreakpoint="desktop"
100
+ isDesigner={isDesigner}
101
+ />
102
+ );
103
+
104
+ /** Fails one submit, which is the only way to a real form-level message. */
105
+ const failASubmit = async (container: HTMLElement) => {
106
+ fireEvent.change(container.querySelector('input[name="email"]')!, {
107
+ target: { value: 'someone@example.com' }
108
+ });
109
+ fireEvent.change(container.querySelector('input[name="password"]')!, {
110
+ target: { value: 'hunter2' }
111
+ });
112
+ fireEvent.submit(container.querySelector('form')!);
113
+
114
+ await waitFor(() => expect(signIn).toHaveBeenCalled());
115
+ };
116
+
117
+ beforeEach(() => {
118
+ signIn.mockClear();
119
+ });
120
+
121
+ describe('the Form Error block', () => {
122
+ it('draws the message where the design put it, and the form stops appending its own', async () => {
123
+ const { container } = renderLoginForm([
124
+ emailField,
125
+ passwordField,
126
+ errorBlock(),
127
+ ctaBlock
128
+ ]);
129
+
130
+ await failASubmit(container);
131
+
132
+ const authored = await waitFor(() =>
133
+ container.querySelector('[data-auth-error]')
134
+ );
135
+
136
+ expect(authored).not.toBeNull();
137
+ expect(authored).toHaveAttribute('role', 'alert');
138
+ expect(authored).toHaveAttribute('data-auth-error-state', 'error');
139
+ // The form's own node is what used to land under the button; with the block
140
+ // placed there must be exactly one message on the page, not two.
141
+ expect(container.querySelector('[data-auth-form-error]')).toBeNull();
142
+
143
+ // And it is where it was authored: BEFORE the CTA, not after it.
144
+ const cta = screen.getByRole('button', { name: 'Devam Et' });
145
+ expect(
146
+ authored!.compareDocumentPosition(cta) & Node.DOCUMENT_POSITION_FOLLOWING
147
+ ).toBeTruthy();
148
+ });
149
+
150
+ it("falls back to the form's own node for a composition that has not placed one", async () => {
151
+ const { container } = renderLoginForm([
152
+ emailField,
153
+ passwordField,
154
+ ctaBlock
155
+ ]);
156
+
157
+ await failASubmit(container);
158
+
159
+ const fallback = await waitFor(() =>
160
+ container.querySelector('[data-auth-form-error]')
161
+ );
162
+
163
+ expect(fallback).not.toBeNull();
164
+ expect(container.querySelector('[data-auth-error]')).toBeNull();
165
+ // Documented rather than desired: appended after every child, which is
166
+ // below the CTA. That is the placement the block exists to replace.
167
+ const cta = screen.getByRole('button', { name: 'Devam Et' });
168
+ expect(
169
+ fallback!.compareDocumentPosition(cta) & Node.DOCUMENT_POSITION_PRECEDING
170
+ ).toBeTruthy();
171
+ });
172
+
173
+ it('shows nothing at all until the form has something to say', () => {
174
+ const { container } = renderLoginForm([
175
+ emailField,
176
+ passwordField,
177
+ errorBlock(),
178
+ ctaBlock
179
+ ]);
180
+
181
+ expect(container.querySelector('[data-auth-error]')).toBeNull();
182
+ expect(container.textContent).not.toContain('went wrong');
183
+ });
184
+
185
+ it('draws a preview in the canvas so the state can be dressed without failing a login', () => {
186
+ const { container } = renderLoginForm(
187
+ [
188
+ emailField,
189
+ passwordField,
190
+ errorBlock({ previewMessage: 'Hatalı giriş.' }),
191
+ ctaBlock
192
+ ],
193
+ true
194
+ );
195
+
196
+ const preview = container.querySelector('[data-auth-error]');
197
+
198
+ expect(preview).not.toBeNull();
199
+ expect(preview).toHaveTextContent('Hatalı giriş.');
200
+ expect(preview).toHaveAttribute('data-auth-error-state', 'preview');
201
+ // A designer's screen reader must not announce a failure that never
202
+ // happened.
203
+ expect(preview).not.toHaveAttribute('role');
204
+ });
205
+ });
206
+
207
+ describe('an OTP form whose phases are composed separately', () => {
208
+ const phase = (key: string, children: Block[]) =>
209
+ block({
210
+ id: `phase-${key}`,
211
+ type: 'otp-phase',
212
+ label: key,
213
+ properties: { phaseKey: key },
214
+ blocks: children,
215
+ order: key === 'phone' ? 0 : 1
216
+ });
217
+
218
+ const renderOtp = (children: Block[]) =>
219
+ render(
220
+ <OtpFormBlock
221
+ block={
222
+ block({
223
+ id: 'otp-form-1',
224
+ type: 'otp-form',
225
+ label: 'OTP Form',
226
+ properties: { collectPhone: true },
227
+ blocks: children
228
+ }) as Block
229
+ }
230
+ placeholderId="placeholder-1"
231
+ sectionId="section-1"
232
+ currentBreakpoint="desktop"
233
+ isDesigner
234
+ />
235
+ );
236
+
237
+ it('does not draw an error block that belongs to the phase it is not showing', () => {
238
+ const { container } = renderOtp([
239
+ phase('phone', [
240
+ block({
241
+ id: 'phone-field',
242
+ type: 'auth-field',
243
+ properties: { fieldType: 'phone' }
244
+ })
245
+ ]),
246
+ phase('code', [errorBlock({ previewMessage: 'Kod hatalı.' })])
247
+ ]);
248
+
249
+ expect(container.querySelector('[data-otp-form]')).not.toBeNull();
250
+ // The phone phase is up, so the code phase's block is not on screen — and a
251
+ // block that is not rendered cannot carry the message, which is why the
252
+ // form asks about the RENDERED children before standing down. The rule
253
+ // itself is pinned on `containsAuthErrorBlock` below.
254
+ expect(container.querySelector('[data-auth-error]')).toBeNull();
255
+ });
256
+
257
+ it('stands down for an error block among the chrome both phases draw', () => {
258
+ const { container } = renderOtp([
259
+ errorBlock({ previewMessage: 'Kod hatalı.' }),
260
+ phase('phone', [
261
+ block({
262
+ id: 'phone-field',
263
+ type: 'auth-field',
264
+ properties: { fieldType: 'phone' }
265
+ })
266
+ ])
267
+ ]);
268
+
269
+ // Shared chrome renders in both halves, so the block is on screen and the
270
+ // canvas preview proves it is the one that will carry the message.
271
+ expect(container.querySelector('[data-auth-error]')).toHaveTextContent(
272
+ 'Kod hatalı.'
273
+ );
274
+ });
275
+ });
276
+
277
+ describe('the rule the form uses to decide whether to stand down', () => {
278
+ const errorAt = (children: Block[]) => [
279
+ block({ id: 'wrapper', type: 'group', blocks: children })
280
+ ];
281
+
282
+ it('finds an error block however deep the composition buried it', () => {
283
+ expect(containsAuthErrorBlock(errorAt(errorAt([errorBlock()])))).toBe(true);
284
+ });
285
+
286
+ it('answers no for an error block the composition has hidden', () => {
287
+ // The forms filter their OWN children before asking, but the recursion did
288
+ // not: a Form Error tucked into a Group and then hidden counted as
289
+ // authored, silenced the form's built-in message, and drew nothing itself.
290
+ // The shopper was told nothing at all.
291
+ const hidden = { ...errorBlock(), hidden: true } as Block;
292
+
293
+ expect(containsAuthErrorBlock(errorAt([hidden]))).toBe(false);
294
+ expect(
295
+ containsAuthErrorBlock([
296
+ block({
297
+ id: 'wrapper',
298
+ type: 'group',
299
+ hidden: true,
300
+ blocks: [errorBlock()]
301
+ })
302
+ ])
303
+ ).toBe(false);
304
+ });
305
+
306
+ it('answers no for a composition that has none', () => {
307
+ expect(containsAuthErrorBlock(errorAt([emailField, ctaBlock]))).toBe(false);
308
+ expect(containsAuthErrorBlock(undefined)).toBe(false);
309
+ expect(containsAuthErrorBlock([])).toBe(false);
310
+ });
311
+
312
+ it('is asked of the rendered subset, so a hidden phase cannot silence the form', () => {
313
+ // What the OTP form passes while the phone phase is up: the shared chrome
314
+ // plus that phase. The code phase — and the error block inside it — is not
315
+ // in the list, so the answer is no and the form keeps its own message.
316
+ const sharedChrome = block({ id: 'logo', type: 'image' });
317
+ const phonePhase = block({
318
+ id: 'phase-phone',
319
+ type: 'otp-phase',
320
+ properties: { phaseKey: 'phone' },
321
+ blocks: [emailField]
322
+ });
323
+
324
+ expect(containsAuthErrorBlock([sharedChrome, phonePhase])).toBe(false);
325
+ });
326
+ });
@@ -0,0 +1,204 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFormBlock from '../blocks/auth-form-block';
5
+ import { Block } from '../theme-block';
6
+
7
+ /**
8
+ * A composed auth form validates its own fields.
9
+ *
10
+ * It used to leave the job to the browser: `required` on the inputs and no
11
+ * `noValidate`, so an empty submit raised the native bubble — the browser's
12
+ * copy, over the field, gone on the next click, and nothing a brand can style.
13
+ * The storefront's own auth pages draw the message under the field instead, so
14
+ * these hold that shape: our message, in the DOM, per field, cleared on edit.
15
+ */
16
+
17
+ const signIn = jest.fn(() => Promise.resolve({ error: null, url: '/' }));
18
+
19
+ jest.mock('next-auth/react', () => ({
20
+ signIn: (...args: unknown[]) => signIn(...args),
21
+ useSession: () => ({ data: null, status: 'unauthenticated' })
22
+ }));
23
+
24
+ jest.mock('next/navigation', () => ({
25
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
26
+ }));
27
+
28
+ const authField = (
29
+ id: string,
30
+ fieldType: string,
31
+ order: number,
32
+ properties: Record<string, unknown> = {}
33
+ ): Block => ({
34
+ id,
35
+ type: 'auth-field',
36
+ label: id,
37
+ properties: { fieldType, ...properties },
38
+ styles: {},
39
+ order,
40
+ hidden: false
41
+ });
42
+
43
+ const formBlock = (
44
+ children: Block[],
45
+ properties: Record<string, unknown> = {}
46
+ ): Block => ({
47
+ id: 'auth-form-1',
48
+ type: 'auth-form',
49
+ label: 'Auth Form',
50
+ properties,
51
+ styles: {},
52
+ order: 0,
53
+ hidden: false,
54
+ blocks: children
55
+ });
56
+
57
+ const renderForm = (
58
+ children: Block[],
59
+ properties: Record<string, unknown> = {}
60
+ ) =>
61
+ render(
62
+ <AuthFormBlock
63
+ block={formBlock(children, properties)}
64
+ placeholderId="placeholder-1"
65
+ sectionId="section-1"
66
+ currentBreakpoint="desktop"
67
+ />
68
+ );
69
+
70
+ const composedFields = [
71
+ authField('field-email', 'email', 0),
72
+ authField('field-password', 'password', 1)
73
+ ];
74
+
75
+ const submitForm = () => {
76
+ fireEvent.submit(document.querySelector('form') as HTMLFormElement);
77
+ };
78
+
79
+ describe('composed auth form validation', () => {
80
+ beforeEach(() => {
81
+ signIn.mockClear();
82
+ });
83
+
84
+ it('turns the native bubble off', () => {
85
+ renderForm(composedFields);
86
+
87
+ expect((document.querySelector('form') as HTMLFormElement).noValidate).toBe(
88
+ true
89
+ );
90
+ });
91
+
92
+ it('fills the block wrapper it sits in', () => {
93
+ renderForm(composedFields);
94
+
95
+ // The wrapper is a flex row and this form is its single item, so without
96
+ // the class the whole form shrink-wraps to its longest line of text.
97
+ expect(
98
+ (document.querySelector('form') as HTMLFormElement).classList
99
+ ).toContain('w-full');
100
+ });
101
+
102
+ it('draws its own message under every empty required field', () => {
103
+ renderForm(composedFields);
104
+
105
+ submitForm();
106
+
107
+ expect(screen.getAllByText('This field is required.')).toHaveLength(2);
108
+ expect(signIn).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('marks the offending input and focuses the first of them', () => {
112
+ renderForm(composedFields);
113
+
114
+ submitForm();
115
+
116
+ const email = document.querySelector(
117
+ 'input[name="email"]'
118
+ ) as HTMLInputElement;
119
+
120
+ expect(email.getAttribute('aria-invalid')).toBe('true');
121
+ expect(document.activeElement).toBe(email);
122
+ });
123
+
124
+ it('takes the brand copy from the block', () => {
125
+ renderForm(composedFields, { requiredMessage: 'Bu alan gereklidir.' });
126
+
127
+ submitForm();
128
+
129
+ expect(screen.getAllByText('Bu alan gereklidir.')).toHaveLength(2);
130
+ });
131
+
132
+ it('separates a wrong value from a missing one', () => {
133
+ renderForm(composedFields, {
134
+ requiredMessage: 'Required.',
135
+ invalidMessage: 'Enter a valid e-mail.'
136
+ });
137
+
138
+ fireEvent.change(document.querySelector('input[name="email"]')!, {
139
+ target: { value: 'not-an-email' }
140
+ });
141
+ submitForm();
142
+
143
+ expect(screen.getByText('Enter a valid e-mail.')).toBeTruthy();
144
+ // The password is still empty, and still says so.
145
+ expect(screen.getByText('Required.')).toBeTruthy();
146
+ });
147
+
148
+ it('drops a field message as soon as that field is edited', () => {
149
+ renderForm(composedFields);
150
+
151
+ submitForm();
152
+ expect(screen.getAllByText('This field is required.')).toHaveLength(2);
153
+
154
+ // `input`, not `change`: this is what a keystroke raises.
155
+ fireEvent.input(document.querySelector('input[name="email"]')!, {
156
+ target: { value: 'shopper@example.com' }
157
+ });
158
+
159
+ // The password's message stays: it is still empty.
160
+ expect(screen.getAllByText('This field is required.')).toHaveLength(1);
161
+ });
162
+
163
+ it('leaves a field the designer made optional alone', () => {
164
+ renderForm([
165
+ authField('field-email', 'email', 0),
166
+ authField('field-phone', 'phone', 1, { required: false })
167
+ ]);
168
+
169
+ submitForm();
170
+
171
+ expect(screen.getAllByText('This field is required.')).toHaveLength(1);
172
+ });
173
+
174
+ it('submits once every field is filled in', () => {
175
+ renderForm(composedFields);
176
+
177
+ fireEvent.change(document.querySelector('input[name="email"]')!, {
178
+ target: { value: 'shopper@example.com' }
179
+ });
180
+ fireEvent.change(document.querySelector('input[name="password"]')!, {
181
+ target: { value: 'hunter2' }
182
+ });
183
+ submitForm();
184
+
185
+ expect(signIn).toHaveBeenCalledTimes(1);
186
+ expect(screen.queryByText('This field is required.')).toBeNull();
187
+ });
188
+ });
189
+
190
+ describe('fixed-layout auth form validation', () => {
191
+ beforeEach(() => {
192
+ signIn.mockClear();
193
+ });
194
+
195
+ it('draws the message under its own fields too', () => {
196
+ renderForm([], { requiredMessage: 'Bu alan gereklidir.' });
197
+
198
+ submitForm();
199
+
200
+ // The uncomposed form draws e-mail and password itself.
201
+ expect(screen.getAllByText('Bu alan gereklidir.')).toHaveLength(2);
202
+ expect(signIn).not.toHaveBeenCalled();
203
+ });
204
+ });