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

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 +39 -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,202 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import AuthFlowBlock from '../blocks/auth-flow-block';
5
+ import AuthStepTitleBlock from '../blocks/auth-step-title-block';
6
+ import { __resetAuthFlowBusForTests, setAuthStep } from '../auth/auth-flow-bus';
7
+ import { Block } from '../theme-block';
8
+
9
+ /**
10
+ * One title row for a flow of several steps.
11
+ *
12
+ * A drawer that walks a shopper through steps has ONE close button and ONE
13
+ * title row: the row is a SIBLING of the steps, so it renders once and cannot
14
+ * see which step is up. Authoring the title inside each step gives no such row,
15
+ * and repeating the close button per step is not a shared button. So the step
16
+ * carries its title, the flow — the thing that decided — publishes it, and this
17
+ * block renders it wherever the design puts the row.
18
+ */
19
+
20
+ jest.mock('next/navigation', () => ({
21
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() })
22
+ }));
23
+
24
+ const block = (overrides: Partial<Block>): Block =>
25
+ ({
26
+ id: 'blk',
27
+ type: 'text',
28
+ label: 'Block',
29
+ value: '',
30
+ order: 0,
31
+ hidden: false,
32
+ properties: {},
33
+ styles: {},
34
+ blocks: [],
35
+ ...overrides
36
+ }) as Block;
37
+
38
+ const step = (key: string, title: string, children: Block[] = []): Block =>
39
+ block({
40
+ id: `step-${key}`,
41
+ type: 'auth-step',
42
+ label: key,
43
+ properties: { stepKey: key, title },
44
+ blocks: children.length
45
+ ? children
46
+ : [block({ id: `${key}-body`, type: 'text', value: `${key} body` })]
47
+ });
48
+
49
+ const titleRow = block({
50
+ id: 'title-row',
51
+ type: 'auth-step-title',
52
+ properties: { tag: 'h2' }
53
+ });
54
+
55
+ const renderFlow = (
56
+ children: Block[],
57
+ properties: Record<string, unknown> = {}
58
+ ) =>
59
+ render(
60
+ <AuthFlowBlock
61
+ block={block({
62
+ id: 'flow',
63
+ type: 'auth-flow',
64
+ properties: { flowId: 'auth', initialStep: 'phone', ...properties },
65
+ blocks: children
66
+ })}
67
+ placeholderId="placeholder-1"
68
+ sectionId="section-1"
69
+ currentBreakpoint="desktop"
70
+ />
71
+ );
72
+
73
+ beforeEach(() => {
74
+ __resetAuthFlowBusForTests();
75
+ });
76
+
77
+ describe('the shared title row', () => {
78
+ it('names the step the flow is showing', () => {
79
+ renderFlow([
80
+ titleRow,
81
+ step('phone', 'ÜYE OLUN VEYA GİRİŞ YAPIN'),
82
+ step('join', 'MYGANT')
83
+ ]);
84
+
85
+ expect(screen.getByText('ÜYE OLUN VEYA GİRİŞ YAPIN')).toBeInTheDocument();
86
+ // Only the active step's title: the row is one element, not one per step.
87
+ expect(screen.queryByText('MYGANT')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('follows the flow to the next step', () => {
91
+ renderFlow([
92
+ titleRow,
93
+ step('phone', 'ÜYE OLUN VEYA GİRİŞ YAPIN'),
94
+ step('join', 'MYGANT')
95
+ ]);
96
+
97
+ // Module state, so the re-render it triggers has to be inside act().
98
+ act(() => {
99
+ setAuthStep('auth', 'join');
100
+ });
101
+
102
+ expect(screen.getByText('MYGANT')).toBeInTheDocument();
103
+ expect(
104
+ screen.queryByText('ÜYE OLUN VEYA GİRİŞ YAPIN')
105
+ ).not.toBeInTheDocument();
106
+ });
107
+
108
+ it('renders once however many steps the flow has', () => {
109
+ renderFlow([
110
+ titleRow,
111
+ step('phone', 'Phone'),
112
+ step('join', 'Join'),
113
+ step('code', 'Code')
114
+ ]);
115
+
116
+ expect(document.querySelectorAll('[data-auth-step-title]')).toHaveLength(1);
117
+ });
118
+
119
+ it('is a heading by default, for a shopper navigating by them', () => {
120
+ renderFlow([titleRow, step('phone', 'Phone step')]);
121
+
122
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
123
+ 'Phone step'
124
+ );
125
+ });
126
+
127
+ it('honours the tag a design asks for', () => {
128
+ renderFlow([
129
+ block({
130
+ id: 'title-row',
131
+ type: 'auth-step-title',
132
+ properties: { tag: 'p' }
133
+ }),
134
+ step('phone', 'Phone step')
135
+ ]);
136
+
137
+ expect(document.querySelector('p[data-auth-step-title]')).not.toBeNull();
138
+ expect(screen.queryByRole('heading')).toBeNull();
139
+ });
140
+
141
+ it('refuses a tag that is not one, rather than emitting it', () => {
142
+ renderFlow([
143
+ block({
144
+ id: 'title-row',
145
+ type: 'auth-step-title',
146
+ properties: { tag: 'script' }
147
+ }),
148
+ step('phone', 'Phone step')
149
+ ]);
150
+
151
+ expect(document.querySelector('script')).toBeNull();
152
+ expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
153
+ });
154
+
155
+ it('falls back to its own copy where a step carries no title', () => {
156
+ renderFlow([
157
+ block({
158
+ id: 'title-row',
159
+ type: 'auth-step-title',
160
+ properties: { fallbackTitle: 'Log in' }
161
+ }),
162
+ step('phone', '')
163
+ ]);
164
+
165
+ expect(screen.getByText('Log in')).toBeInTheDocument();
166
+ });
167
+
168
+ it('draws nothing at all for a shopper when there is no title', () => {
169
+ renderFlow([titleRow, step('phone', '')]);
170
+
171
+ // An empty heading is worse than no heading: a screen reader announces it.
172
+ expect(document.querySelector('[data-auth-step-title]')).toBeNull();
173
+ });
174
+
175
+ it('draws nothing outside a flow either', () => {
176
+ render(
177
+ <AuthStepTitleBlock
178
+ block={titleRow}
179
+ placeholderId="placeholder-1"
180
+ sectionId="section-1"
181
+ currentBreakpoint="desktop"
182
+ />
183
+ );
184
+
185
+ expect(document.querySelector('[data-auth-step-title]')).toBeNull();
186
+ });
187
+
188
+ it('says what it is waiting for on the canvas', () => {
189
+ const { container } = render(
190
+ <AuthStepTitleBlock
191
+ block={titleRow}
192
+ placeholderId="placeholder-1"
193
+ sectionId="section-1"
194
+ currentBreakpoint="desktop"
195
+ isDesigner
196
+ />
197
+ );
198
+
199
+ // Dropping a block that renders nothing looks like nothing happened.
200
+ expect(container.textContent).toContain('Auth Flow');
201
+ });
202
+ });
@@ -0,0 +1,216 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import ButtonBlock from '../blocks/button-block';
5
+ import ForgotPasswordBlock from '../blocks/forgot-password-block';
6
+ import {
7
+ __resetFormSubmitBusForTests,
8
+ isFormSubmitting,
9
+ publishFormSubmitState
10
+ } from '../auth/form-submit-bus';
11
+ import { Block } from '../theme-block';
12
+
13
+ /**
14
+ * The bottom-bar CTA: a Button that submits a form living somewhere else on the
15
+ * page, through the native `form` attribute.
16
+ *
17
+ * Its own submit button knows when to grey itself out — the state is right
18
+ * there. A detached CTA is outside that form, often outside its React root, so
19
+ * it stayed bright and clickable for the whole request. Pressing it again did
20
+ * nothing (every submit handler guards), and "nothing" is what reads as broken.
21
+ */
22
+
23
+ let mutationState = { isLoading: false, isSuccess: false };
24
+ const forgotPassword = jest.fn(() => ({ unwrap: () => Promise.resolve({}) }));
25
+
26
+ /**
27
+ * Every `@akinon/next/data/client/*` path resolves to ONE mock module, so
28
+ * replacing the user path replaces it for the button block's own hook too.
29
+ * The factory therefore keeps the shared proxy's behaviour and only pins the
30
+ * one hook these tests drive.
31
+ */
32
+ jest.mock('@akinon/next/data/client/user', () => {
33
+ const queryResult = () => ({
34
+ data: undefined,
35
+ isLoading: false,
36
+ isSuccess: true,
37
+ refetch: jest.fn()
38
+ });
39
+ const mutationTuple = () => [
40
+ jest.fn(() => ({ unwrap: () => Promise.resolve({}) })),
41
+ { isLoading: false, isSuccess: false, reset: jest.fn() }
42
+ ];
43
+
44
+ return new Proxy(
45
+ {},
46
+ {
47
+ get(_target, prop) {
48
+ if (prop === '__esModule') return true;
49
+ if (typeof prop !== 'string') return undefined;
50
+ if (prop === 'useForgotPasswordMutation') {
51
+ return () => [forgotPassword, mutationState];
52
+ }
53
+ if (prop.endsWith('Mutation')) return () => mutationTuple();
54
+ if (prop.endsWith('Query')) return () => queryResult();
55
+ return () => undefined;
56
+ }
57
+ }
58
+ );
59
+ });
60
+
61
+ jest.mock('next/navigation', () => ({
62
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() })
63
+ }));
64
+
65
+ const blockOf = (type: string, properties: Record<string, unknown>): Block => ({
66
+ id: `${type}-1`,
67
+ type,
68
+ label: type,
69
+ properties,
70
+ styles: {},
71
+ order: 0,
72
+ hidden: false
73
+ });
74
+
75
+ const renderCta = (submitFormId: string) =>
76
+ render(
77
+ <ButtonBlock
78
+ block={blockOf('button', { text: 'Continue', submitFormId })}
79
+ placeholderId="placeholder-1"
80
+ sectionId="section-1"
81
+ currentBreakpoint="desktop"
82
+ />
83
+ );
84
+
85
+ const cta = () => document.querySelector('button') as HTMLButtonElement;
86
+
87
+ beforeEach(() => {
88
+ __resetFormSubmitBusForTests();
89
+ mutationState = { isLoading: false, isSuccess: false };
90
+ forgotPassword.mockClear();
91
+ });
92
+
93
+ describe('a detached submit CTA', () => {
94
+ it('submits the named form natively', () => {
95
+ renderCta('reset-form');
96
+
97
+ // The native `form` attribute is what lets it live outside the form.
98
+ expect(cta().getAttribute('form')).toBe('reset-form');
99
+ expect(cta().getAttribute('type')).toBe('submit');
100
+ expect(cta().disabled).toBe(false);
101
+ });
102
+
103
+ it('greys out while that form is working', () => {
104
+ renderCta('reset-form');
105
+
106
+ act(() => {
107
+ publishFormSubmitState('reset-form', true);
108
+ });
109
+
110
+ expect(cta().disabled).toBe(true);
111
+ expect(cta().getAttribute('aria-busy')).toBe('true');
112
+
113
+ act(() => {
114
+ publishFormSubmitState('reset-form', false);
115
+ });
116
+
117
+ expect(cta().disabled).toBe(false);
118
+ });
119
+
120
+ it('ignores a different form going busy', () => {
121
+ renderCta('reset-form');
122
+
123
+ act(() => {
124
+ publishFormSubmitState('login-form', true);
125
+ });
126
+
127
+ expect(cta().disabled).toBe(false);
128
+ });
129
+
130
+ it('is published by the form that owns the id', () => {
131
+ mutationState = { isLoading: true, isSuccess: false };
132
+
133
+ render(
134
+ <ForgotPasswordBlock
135
+ block={blockOf('forgot-password', { formHtmlId: 'reset-form' })}
136
+ placeholderId="placeholder-1"
137
+ sectionId="section-1"
138
+ currentBreakpoint="desktop"
139
+ />
140
+ );
141
+
142
+ expect(isFormSubmitting('reset-form')).toBe(true);
143
+ });
144
+
145
+ it('stops claiming to be busy once the form leaves the page', () => {
146
+ mutationState = { isLoading: true, isSuccess: false };
147
+
148
+ const view = render(
149
+ <ForgotPasswordBlock
150
+ block={blockOf('forgot-password', { formHtmlId: 'reset-form' })}
151
+ placeholderId="placeholder-1"
152
+ sectionId="section-1"
153
+ currentBreakpoint="desktop"
154
+ />
155
+ );
156
+
157
+ expect(isFormSubmitting('reset-form')).toBe(true);
158
+
159
+ view.unmount();
160
+
161
+ // A closed drawer must not leave every CTA on the page disabled.
162
+ expect(isFormSubmitting('reset-form')).toBe(false);
163
+ });
164
+
165
+ it('leaves an ordinary button alone', () => {
166
+ render(
167
+ <ButtonBlock
168
+ block={blockOf('button', { text: 'Shop', tag: 'button' })}
169
+ placeholderId="placeholder-1"
170
+ sectionId="section-1"
171
+ currentBreakpoint="desktop"
172
+ />
173
+ );
174
+
175
+ act(() => {
176
+ publishFormSubmitState('reset-form', true);
177
+ });
178
+
179
+ expect(cta().disabled).toBe(false);
180
+ });
181
+
182
+ it('does not fire the form twice when the CTA is pressed again', () => {
183
+ mutationState = { isLoading: true, isSuccess: false };
184
+
185
+ render(
186
+ <>
187
+ <ForgotPasswordBlock
188
+ block={blockOf('forgot-password', {
189
+ formHtmlId: 'reset-form',
190
+ showSubmit: false
191
+ })}
192
+ placeholderId="placeholder-1"
193
+ sectionId="section-1"
194
+ currentBreakpoint="desktop"
195
+ />
196
+ <ButtonBlock
197
+ block={blockOf('button', {
198
+ text: 'Continue',
199
+ submitFormId: 'reset-form'
200
+ })}
201
+ placeholderId="placeholder-1"
202
+ sectionId="section-1"
203
+ currentBreakpoint="desktop"
204
+ />
205
+ </>
206
+ );
207
+
208
+ const button = screen.getByText('Continue').closest('button');
209
+
210
+ expect(button?.disabled).toBe(true);
211
+
212
+ fireEvent.click(button as HTMLButtonElement);
213
+
214
+ expect(forgotPassword).not.toHaveBeenCalled();
215
+ });
216
+ });