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

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.
@@ -0,0 +1,272 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import {
5
+ __resetOverlayBusForTests,
6
+ getOpenModalIds,
7
+ isModalOpen,
8
+ openModal,
9
+ registerModalHost
10
+ } from '../overlay-bus';
11
+ import {
12
+ __resetModalDomBridgeForTests,
13
+ installModalDomBridge
14
+ } from '../modal-dom-bridge';
15
+ import {
16
+ canAutoOpen,
17
+ clearAutoOpenRecord,
18
+ markAutoOpened
19
+ } from '../modal-frequency';
20
+ import { resolveBlockAction, useBlockAction } from '../../hooks/use-block-action';
21
+
22
+ const mkBlock = (properties: Record<string, unknown>) => ({ properties });
23
+
24
+ describe('block actions', () => {
25
+ beforeEach(() => {
26
+ __resetOverlayBusForTests();
27
+ registerModalHost();
28
+ });
29
+
30
+ it('reads nothing from a block published before actions existed', () => {
31
+ // The whole backward-compatibility promise in one assertion.
32
+ expect(resolveBlockAction(mkBlock({ url: '/x' }))).toEqual({
33
+ type: 'none',
34
+ target: ''
35
+ });
36
+ });
37
+
38
+ it('refuses an action type it does not know', () => {
39
+ expect(
40
+ resolveBlockAction(mkBlock({ actionType: 'launch-missiles' })).type
41
+ ).toBe('none');
42
+ });
43
+
44
+ it('resolves per breakpoint like every other authored value', () => {
45
+ const block = mkBlock({
46
+ actionType: { desktop: 'open-modal', mobile: 'none' },
47
+ actionTarget: 'newsletter'
48
+ });
49
+
50
+ expect(resolveBlockAction(block, 'desktop').type).toBe('open-modal');
51
+ expect(resolveBlockAction(block, 'mobile').type).toBe('none');
52
+ });
53
+
54
+ const Trigger = ({
55
+ properties,
56
+ isDesigner = false
57
+ }: {
58
+ properties: Record<string, unknown>;
59
+ isDesigner?: boolean;
60
+ }) => {
61
+ const action = useBlockAction(mkBlock(properties), { isDesigner });
62
+
63
+ return (
64
+ <button type="button" onClick={action.onClick} {...action.ariaProps}>
65
+ trigger
66
+ </button>
67
+ );
68
+ };
69
+
70
+ it('opens the named modal and announces itself as a dialog trigger', () => {
71
+ render(
72
+ <Trigger
73
+ properties={{ actionType: 'open-modal', actionTarget: 'newsletter' }}
74
+ />
75
+ );
76
+
77
+ const button = screen.getByRole('button', { name: 'trigger' });
78
+ expect(button).toHaveAttribute('aria-haspopup', 'dialog');
79
+ expect(button).toHaveAttribute('aria-expanded', 'false');
80
+
81
+ act(() => {
82
+ button.click();
83
+ });
84
+
85
+ expect(isModalOpen('newsletter')).toBe(true);
86
+ expect(button).toHaveAttribute('aria-expanded', 'true');
87
+ expect(button).toHaveAttribute('aria-controls', 'theme-modal-newsletter');
88
+ });
89
+
90
+ it('claims aria-controls only while the modal is actually mounted', () => {
91
+ render(
92
+ <Trigger
93
+ properties={{ actionType: 'open-modal', actionTarget: 'newsletter' }}
94
+ />
95
+ );
96
+
97
+ // Pointing at an element that is not in the document is a broken
98
+ // reference, not a helpful one.
99
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-controls');
100
+ });
101
+
102
+ it('does nothing in the designer, where a click selects the block', () => {
103
+ render(
104
+ <Trigger
105
+ properties={{ actionType: 'open-modal', actionTarget: 'newsletter' }}
106
+ isDesigner
107
+ />
108
+ );
109
+
110
+ act(() => {
111
+ screen.getByRole('button').click();
112
+ });
113
+
114
+ expect(isModalOpen('newsletter')).toBe(false);
115
+ });
116
+
117
+ it('is inert when the action names no target', () => {
118
+ render(<Trigger properties={{ actionType: 'open-modal' }} />);
119
+
120
+ expect(screen.getByRole('button')).not.toHaveAttribute('aria-haspopup');
121
+ });
122
+
123
+ it('closes and toggles', () => {
124
+ openModal('a');
125
+
126
+ const { rerender } = render(
127
+ <Trigger properties={{ actionType: 'close-modal', actionTarget: 'a' }} />
128
+ );
129
+ act(() => {
130
+ screen.getByRole('button').click();
131
+ });
132
+ expect(isModalOpen('a')).toBe(false);
133
+
134
+ rerender(
135
+ <Trigger properties={{ actionType: 'toggle-modal', actionTarget: 'a' }} />
136
+ );
137
+ act(() => {
138
+ screen.getByRole('button').click();
139
+ });
140
+ expect(isModalOpen('a')).toBe(true);
141
+ });
142
+
143
+ it('closes the top modal without naming one', () => {
144
+ openModal('a');
145
+ openModal('b');
146
+
147
+ render(<Trigger properties={{ actionType: 'close-top-modal' }} />);
148
+ act(() => {
149
+ screen.getByRole('button').click();
150
+ });
151
+
152
+ expect(getOpenModalIds()).toEqual(['a']);
153
+ });
154
+ });
155
+
156
+ describe('DOM bridge', () => {
157
+ beforeEach(() => {
158
+ __resetOverlayBusForTests();
159
+ __resetModalDomBridgeForTests();
160
+ registerModalHost();
161
+ installModalDomBridge();
162
+ });
163
+
164
+ afterEach(() => __resetModalDomBridgeForTests());
165
+
166
+ it('opens from authored HTML that React does not own', () => {
167
+ // The embed block, a brand's own chrome, an agency snippet — none of them
168
+ // can call a hook.
169
+ render(
170
+ <div
171
+ dangerouslySetInnerHTML={{
172
+ __html: '<a href="/somewhere" data-pz-modal-open="newsletter">Sign up</a>'
173
+ }}
174
+ />
175
+ );
176
+
177
+ fireEvent.click(screen.getByText('Sign up'));
178
+
179
+ expect(isModalOpen('newsletter')).toBe(true);
180
+ });
181
+
182
+ it('finds the attribute on an ancestor of what was actually clicked', () => {
183
+ render(
184
+ <div
185
+ dangerouslySetInnerHTML={{
186
+ __html:
187
+ '<button data-pz-modal-open="a"><span>inner</span></button>'
188
+ }}
189
+ />
190
+ );
191
+
192
+ fireEvent.click(screen.getByText('inner'));
193
+
194
+ expect(isModalOpen('a')).toBe(true);
195
+ });
196
+
197
+ it('exposes the same actions on window for brand code', () => {
198
+ act(() => {
199
+ window.pzTheme?.openModal('from-window');
200
+ });
201
+
202
+ expect(isModalOpen('from-window')).toBe(true);
203
+ });
204
+
205
+ it('ignores an ordinary click', () => {
206
+ render(<button type="button">plain</button>);
207
+
208
+ fireEvent.click(screen.getByText('plain'));
209
+
210
+ expect(getOpenModalIds()).toEqual([]);
211
+ });
212
+ });
213
+
214
+ describe('auto-open frequency', () => {
215
+ beforeEach(() => {
216
+ clearAutoOpenRecord('m');
217
+ });
218
+
219
+ it('always means always', () => {
220
+ markAutoOpened('m', 'always');
221
+ expect(canAutoOpen('m', 'always')).toBe(true);
222
+ });
223
+
224
+ it('once-per-session holds for the tab', () => {
225
+ expect(canAutoOpen('m', 'once-per-session')).toBe(true);
226
+ markAutoOpened('m', 'once-per-session');
227
+ expect(canAutoOpen('m', 'once-per-session')).toBe(false);
228
+ });
229
+
230
+ it('once-ever holds across sessions', () => {
231
+ markAutoOpened('m', 'once-ever');
232
+ expect(canAutoOpen('m', 'once-ever')).toBe(false);
233
+ });
234
+
235
+ it('once-per-day reopens after the day has passed', () => {
236
+ const now = Date.now();
237
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
238
+
239
+ markAutoOpened('m', 'once-per-day');
240
+ expect(canAutoOpen('m', 'once-per-day')).toBe(false);
241
+
242
+ nowSpy.mockReturnValue(now + 25 * 60 * 60 * 1000);
243
+ expect(canAutoOpen('m', 'once-per-day')).toBe(true);
244
+
245
+ nowSpy.mockRestore();
246
+ });
247
+
248
+ it('fails open on a corrupted stamp rather than suppressing forever', () => {
249
+ window.localStorage.setItem('pz-theme:modal-seen:m', 'not-a-number');
250
+
251
+ expect(canAutoOpen('m', 'once-per-day')).toBe(true);
252
+ });
253
+
254
+ it('survives storage being unavailable, and still shows the modal', () => {
255
+ const getItem = jest
256
+ .spyOn(Storage.prototype, 'getItem')
257
+ .mockImplementation(() => {
258
+ throw new Error('denied');
259
+ });
260
+ const setItem = jest
261
+ .spyOn(Storage.prototype, 'setItem')
262
+ .mockImplementation(() => {
263
+ throw new Error('denied');
264
+ });
265
+
266
+ expect(canAutoOpen('m', 'once-ever')).toBe(true);
267
+ expect(() => markAutoOpened('m', 'once-ever')).not.toThrow();
268
+
269
+ getItem.mockRestore();
270
+ setItem.mockRestore();
271
+ });
272
+ });
@@ -0,0 +1,197 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ import {
5
+ __resetOverlayBusForTests,
6
+ closeAllModals,
7
+ closeModal,
8
+ closeTopModal,
9
+ getOpenModalIds,
10
+ isModalHostMounted,
11
+ isModalOpen,
12
+ openModal,
13
+ registerModalHost,
14
+ subscribeToModals,
15
+ toggleModal,
16
+ useModalOpen,
17
+ useOpenModalIds
18
+ } from '../overlay-bus';
19
+ import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
20
+
21
+ describe('overlay bus', () => {
22
+ beforeEach(() => {
23
+ __resetOverlayBusForTests();
24
+ resetDevWarnCacheForTests();
25
+ // Every case but the host ones assumes the brand mounted <ThemeModals />.
26
+ registerModalHost();
27
+ });
28
+
29
+ it('opens, closes and toggles by id', () => {
30
+ openModal('newsletter');
31
+ expect(isModalOpen('newsletter')).toBe(true);
32
+
33
+ closeModal('newsletter');
34
+ expect(isModalOpen('newsletter')).toBe(false);
35
+
36
+ toggleModal('newsletter');
37
+ expect(isModalOpen('newsletter')).toBe(true);
38
+
39
+ toggleModal('newsletter');
40
+ expect(isModalOpen('newsletter')).toBe(false);
41
+ });
42
+
43
+ it('keeps an id at most once, which is what stops a trigger looping on itself', () => {
44
+ openModal('newsletter');
45
+ openModal('newsletter');
46
+ openModal('newsletter');
47
+
48
+ expect(getOpenModalIds()).toEqual(['newsletter']);
49
+ });
50
+
51
+ it('refuses to stack more modals than a page could mean', () => {
52
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
53
+
54
+ for (let index = 0; index < 12; index++) openModal(`modal-${index}`);
55
+
56
+ expect(getOpenModalIds()).toHaveLength(8);
57
+ expect(warn).toHaveBeenCalledWith(
58
+ expect.stringContaining('opening them in a loop')
59
+ );
60
+
61
+ warn.mockRestore();
62
+ });
63
+
64
+ it('closes the most recently opened modal', () => {
65
+ openModal('a');
66
+ openModal('b');
67
+
68
+ closeTopModal();
69
+
70
+ expect(getOpenModalIds()).toEqual(['a']);
71
+ });
72
+
73
+ it('notifies subscribers only when the set actually changes', () => {
74
+ const listener = jest.fn();
75
+ const unsubscribe = subscribeToModals(listener);
76
+
77
+ openModal('a');
78
+ expect(listener).toHaveBeenCalledTimes(1);
79
+
80
+ // Already open, already closed: neither is a change.
81
+ openModal('a');
82
+ closeModal('b');
83
+ expect(listener).toHaveBeenCalledTimes(1);
84
+
85
+ closeModal('a');
86
+ expect(listener).toHaveBeenCalledTimes(2);
87
+
88
+ unsubscribe();
89
+ openModal('a');
90
+ expect(listener).toHaveBeenCalledTimes(2);
91
+ });
92
+
93
+ it('keeps a stable snapshot reference while nothing changes', () => {
94
+ openModal('a');
95
+ expect(getOpenModalIds()).toBe(getOpenModalIds());
96
+ });
97
+
98
+ describe('host beacon', () => {
99
+ it('names the missing host rather than failing silently', () => {
100
+ // A trigger the designer configured correctly, that does nothing when
101
+ // clicked, with no error anywhere, is the worst symptom this feature
102
+ // can produce. It is also the likeliest, since the host is one line
103
+ // each brand has to remember.
104
+ __resetOverlayBusForTests();
105
+ const warn = jest
106
+ .spyOn(console, 'warn')
107
+ .mockImplementation(() => undefined);
108
+
109
+ expect(isModalHostMounted()).toBe(false);
110
+ openModal('newsletter');
111
+
112
+ expect(warn).toHaveBeenCalledWith(
113
+ expect.stringContaining('<ThemeModals /> is not mounted')
114
+ );
115
+ // Queued anyway: the host may simply not have mounted yet this tick.
116
+ expect(isModalOpen('newsletter')).toBe(true);
117
+
118
+ warn.mockRestore();
119
+ });
120
+
121
+ it('stops warning once a host registers, and resumes when it leaves', () => {
122
+ const warn = jest
123
+ .spyOn(console, 'warn')
124
+ .mockImplementation(() => undefined);
125
+
126
+ const unregister = registerModalHost();
127
+ openModal('a');
128
+ expect(warn).not.toHaveBeenCalled();
129
+
130
+ unregister();
131
+ unregister();
132
+ // The beforeEach registration is still counted, so one release of a
133
+ // second host must not take the page's only host away.
134
+ expect(isModalHostMounted()).toBe(true);
135
+
136
+ warn.mockRestore();
137
+ });
138
+ });
139
+
140
+ it('clears everything at once', () => {
141
+ openModal('a');
142
+ openModal('b');
143
+
144
+ closeAllModals();
145
+
146
+ expect(getOpenModalIds()).toEqual([]);
147
+ });
148
+
149
+ describe('across React roots', () => {
150
+ /**
151
+ * The reason this module exists. Header and body render as separate
152
+ * `ThemePlaceholderWrapper` trees, so a trigger in one has no provider,
153
+ * no context and no callback reaching a surface in the other.
154
+ */
155
+ const Trigger = () => (
156
+ <button type="button" onClick={() => openModal('newsletter')}>
157
+ open
158
+ </button>
159
+ );
160
+
161
+ const Surface = () => {
162
+ const open = useModalOpen('newsletter');
163
+
164
+ return <div>{open ? 'surface open' : 'surface closed'}</div>;
165
+ };
166
+
167
+ it('lets a trigger in one tree open a surface in another', () => {
168
+ render(<Trigger />);
169
+ render(<Surface />);
170
+
171
+ expect(screen.getByText('surface closed')).toBeInTheDocument();
172
+
173
+ act(() => {
174
+ screen.getByRole('button', { name: 'open' }).click();
175
+ });
176
+
177
+ expect(screen.getByText('surface open')).toBeInTheDocument();
178
+ });
179
+
180
+ it('re-renders a list subscriber without looping on a fresh array', () => {
181
+ const Ids = () => {
182
+ const ids = useOpenModalIds();
183
+
184
+ return <div>{ids.join(',') || 'none'}</div>;
185
+ };
186
+
187
+ render(<Ids />);
188
+ expect(screen.getByText('none')).toBeInTheDocument();
189
+
190
+ act(() => {
191
+ openModal('a');
192
+ });
193
+
194
+ expect(screen.getByText('a')).toBeInTheDocument();
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,234 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ jest.mock('next/navigation', () => ({
5
+ usePathname: () => '/product/1',
6
+ useSearchParams: () => new URLSearchParams()
7
+ }));
8
+
9
+ jest.mock('next-auth/react', () => ({
10
+ useSession: () => ({ status: 'unauthenticated' })
11
+ }));
12
+
13
+ jest.mock('@akinon/next/hooks/use-localization', () => ({
14
+ useLocalization: () => ({ locale: 'en', currency: 'USD' })
15
+ }));
16
+
17
+ import ThemeModalMount from '../theme-modal-mount';
18
+ import {
19
+ __resetOverlayBusForTests,
20
+ closeModal,
21
+ isModalOpen,
22
+ openModal,
23
+ registerModalHost
24
+ } from '../overlay-bus';
25
+ import { __resetModalPreviewForTests } from '../use-modal-preview';
26
+ import { clearAutoOpenRecord } from '../modal-frequency';
27
+ import { readThemeModals, ThemeModal } from '../modal-registry';
28
+
29
+ const mkModal = (overrides: Record<string, unknown> = {}): ThemeModal =>
30
+ readThemeModals([{ id: 'newsletter', name: 'Newsletter', ...overrides }])[0];
31
+
32
+ const renderMount = (modal: ThemeModal) =>
33
+ render(
34
+ <ThemeModalMount modal={modal}>
35
+ <p>composed content</p>
36
+ </ThemeModalMount>
37
+ );
38
+
39
+ describe('ThemeModalMount', () => {
40
+ beforeEach(() => {
41
+ __resetOverlayBusForTests();
42
+ __resetModalPreviewForTests();
43
+ registerModalHost();
44
+ clearAutoOpenRecord('newsletter');
45
+ document.body.style.overflow = '';
46
+ });
47
+
48
+ it('renders nothing at all while closed', () => {
49
+ renderMount(mkModal());
50
+
51
+ // Not merely hidden: a closed modal must not be in the accessibility tree,
52
+ // the tab order, or the page as duplicate content.
53
+ expect(screen.queryByText('composed content')).not.toBeInTheDocument();
54
+ expect(document.querySelector('[data-theme-modal]')).toBeNull();
55
+ });
56
+
57
+ it('mounts the composition when the bus opens it', () => {
58
+ renderMount(mkModal());
59
+
60
+ act(() => {
61
+ openModal('newsletter');
62
+ });
63
+
64
+ expect(screen.getByText('composed content')).toBeInTheDocument();
65
+ expect(screen.getByRole('dialog')).toHaveAttribute(
66
+ 'aria-label',
67
+ 'Newsletter'
68
+ );
69
+ });
70
+
71
+ it('claims the page and freezes it behind a centred modal', () => {
72
+ renderMount(mkModal());
73
+
74
+ act(() => {
75
+ openModal('newsletter');
76
+ });
77
+
78
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
79
+ expect(document.body.style.overflow).toBe('hidden');
80
+
81
+ act(() => {
82
+ closeModal('newsletter');
83
+ });
84
+
85
+ expect(document.body.style.overflow).toBe('');
86
+ });
87
+
88
+ it('closes from the close button', () => {
89
+ renderMount(mkModal());
90
+
91
+ act(() => {
92
+ openModal('newsletter');
93
+ });
94
+
95
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
96
+
97
+ expect(isModalOpen('newsletter')).toBe(false);
98
+ });
99
+
100
+ it('closes from the backdrop, and does not when that is turned off', () => {
101
+ const { unmount } = renderMount(mkModal());
102
+
103
+ act(() => {
104
+ openModal('newsletter');
105
+ });
106
+ // The centred modal's root IS its backdrop — one node, as published.
107
+ fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
108
+ expect(isModalOpen('newsletter')).toBe(false);
109
+
110
+ unmount();
111
+ __resetOverlayBusForTests();
112
+ registerModalHost();
113
+
114
+ renderMount(mkModal({ chrome: { closeOnBackdrop: false } }));
115
+ act(() => {
116
+ openModal('newsletter');
117
+ });
118
+ fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
119
+ expect(isModalOpen('newsletter')).toBe(true);
120
+ });
121
+
122
+ it('draws a drawer without claiming the page when it has no backdrop', () => {
123
+ renderMount(
124
+ mkModal({
125
+ presentation: 'drawer',
126
+ chrome: { showBackdrop: false }
127
+ })
128
+ );
129
+
130
+ act(() => {
131
+ openModal('newsletter');
132
+ });
133
+
134
+ // With the page behind still usable, `aria-modal` would be a lie and the
135
+ // scroll lock would be a bug.
136
+ expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal');
137
+ expect(document.body.style.overflow).toBe('');
138
+ });
139
+
140
+ describe('visibility rules', () => {
141
+ it('is absent on a page the rules exclude', () => {
142
+ renderMount(
143
+ mkModal({
144
+ visibilityRules: {
145
+ enabled: true,
146
+ pathnameMatchType: 'startsWith',
147
+ pathnameValue: '/basket'
148
+ }
149
+ })
150
+ );
151
+
152
+ act(() => {
153
+ openModal('newsletter');
154
+ });
155
+
156
+ expect(screen.queryByText('composed content')).not.toBeInTheDocument();
157
+ });
158
+
159
+ it('closes itself when it is opened somewhere it does not belong', () => {
160
+ // The bus is module state and survives client-side navigation, so
161
+ // nothing else would close a modal carried off its own page.
162
+ renderMount(
163
+ mkModal({
164
+ visibilityRules: {
165
+ enabled: true,
166
+ pathnameMatchType: 'equals',
167
+ pathnameValue: '/nowhere'
168
+ }
169
+ })
170
+ );
171
+
172
+ act(() => {
173
+ openModal('newsletter');
174
+ });
175
+
176
+ expect(isModalOpen('newsletter')).toBe(false);
177
+ });
178
+
179
+ it('renders where the rules allow it', () => {
180
+ renderMount(
181
+ mkModal({
182
+ visibilityRules: {
183
+ enabled: true,
184
+ pathnameMatchType: 'startsWith',
185
+ pathnameValue: '/product'
186
+ }
187
+ })
188
+ );
189
+
190
+ act(() => {
191
+ openModal('newsletter');
192
+ });
193
+
194
+ expect(screen.getByText('composed content')).toBeInTheDocument();
195
+ });
196
+ });
197
+
198
+ describe('accessible name', () => {
199
+ it('falls back to the modal name when none was authored', () => {
200
+ renderMount(mkModal({ name: 'Size guide' }));
201
+
202
+ act(() => {
203
+ openModal('newsletter');
204
+ });
205
+
206
+ expect(screen.getByRole('dialog')).toHaveAttribute(
207
+ 'aria-label',
208
+ 'Size guide'
209
+ );
210
+ });
211
+ });
212
+
213
+ describe('sealed-in guard', () => {
214
+ it('keeps a close button when nothing else could dismiss the modal', () => {
215
+ renderMount(
216
+ mkModal({
217
+ chrome: {
218
+ showClose: false,
219
+ closeOnBackdrop: false,
220
+ closeOnEscape: false
221
+ }
222
+ })
223
+ );
224
+
225
+ act(() => {
226
+ openModal('newsletter');
227
+ });
228
+
229
+ expect(
230
+ screen.getByRole('button', { name: 'Close' })
231
+ ).toBeInTheDocument();
232
+ });
233
+ });
234
+ });