@akinon/pz-theme 2.0.58-rc.0 → 2.0.59-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.
- package/CHANGELOG.md +19 -31
- package/package.json +4 -4
- package/src/blocks/button-block.tsx +23 -1
- package/src/blocks/link-block.tsx +21 -10
- package/src/blocks/panel-block.tsx +67 -237
- package/src/chrome/__tests__/theme-config-source.test.ts +53 -7
- package/src/chrome/placeholder-slugs.ts +41 -0
- package/src/chrome/theme-config-source.ts +17 -2
- package/src/components/with-designer-features.tsx +49 -2
- package/src/hooks/use-block-action.ts +209 -0
- package/src/index.ts +51 -1
- package/src/overlays/__tests__/modal-preview-host.test.tsx +316 -0
- package/src/overlays/__tests__/modal-registry.test.ts +205 -0
- package/src/overlays/__tests__/modal-triggers.test.tsx +272 -0
- package/src/overlays/__tests__/overlay-bus.test.tsx +197 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +234 -0
- package/src/overlays/modal-dom-bridge.ts +104 -0
- package/src/overlays/modal-frequency.ts +104 -0
- package/src/overlays/modal-host-beacon.tsx +35 -0
- package/src/overlays/modal-preview-host.tsx +159 -0
- package/src/overlays/modal-registry.ts +289 -0
- package/src/overlays/overlay-bus.ts +214 -0
- package/src/overlays/overlay-surface.tsx +386 -0
- package/src/overlays/theme-modal-mount.tsx +179 -0
- package/src/overlays/theme-modals.tsx +82 -0
- package/src/overlays/use-editor-modals.ts +109 -0
- package/src/overlays/use-modal-auto-trigger.ts +187 -0
- package/src/overlays/use-modal-preview.ts +127 -0
- package/src/overlays/use-overlay-breakpoint.ts +68 -0
|
@@ -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
|
+
});
|