@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.
- package/CHANGELOG.md +11 -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,316 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
usePathname: () => '/',
|
|
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
|
+
/**
|
|
18
|
+
* Stood in for so the assertions can be about WHICH region the canvas opens
|
|
19
|
+
* for the modal. The real wrapper fetches, registers and listens; none of that
|
|
20
|
+
* is what this file is pinning.
|
|
21
|
+
*/
|
|
22
|
+
jest.mock('../../theme-placeholder-wrapper', () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
default: ({ slug }: { slug: string }) => (
|
|
25
|
+
<div data-testid="composition-region" data-slug={slug} />
|
|
26
|
+
)
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import ModalPreviewHost from '../modal-preview-host';
|
|
30
|
+
import ThemeModalMount from '../theme-modal-mount';
|
|
31
|
+
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
32
|
+
import { __resetEditorModalsForTests } from '../use-editor-modals';
|
|
33
|
+
import {
|
|
34
|
+
__resetOverlayBusForTests,
|
|
35
|
+
openModal,
|
|
36
|
+
registerModalHost
|
|
37
|
+
} from '../overlay-bus';
|
|
38
|
+
import { __resetEditorBridgeForTests } from '../../editor-bridge';
|
|
39
|
+
import { readThemeModals, ThemeModal } from '../modal-registry';
|
|
40
|
+
|
|
41
|
+
const EDITOR_ORIGIN = 'http://localhost:5174';
|
|
42
|
+
|
|
43
|
+
const mkModal = (overrides: Record<string, unknown> = {}): ThemeModal =>
|
|
44
|
+
readThemeModals([{ id: 'newsletter', name: 'Newsletter', ...overrides }])[0];
|
|
45
|
+
|
|
46
|
+
const hold = (modal: ThemeModal) =>
|
|
47
|
+
act(() => {
|
|
48
|
+
fireEvent(
|
|
49
|
+
window,
|
|
50
|
+
new MessageEvent('message', {
|
|
51
|
+
origin: EDITOR_ORIGIN,
|
|
52
|
+
data: {
|
|
53
|
+
type: 'PREVIEW_OPEN_MODAL',
|
|
54
|
+
data: { modalId: modal.id, modal }
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const release = () =>
|
|
61
|
+
act(() => {
|
|
62
|
+
fireEvent(
|
|
63
|
+
window,
|
|
64
|
+
new MessageEvent('message', {
|
|
65
|
+
origin: EDITOR_ORIGIN,
|
|
66
|
+
data: { type: 'PREVIEW_CLOSE_MODAL' }
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
__resetModalPreviewForTests();
|
|
73
|
+
__resetEditorModalsForTests();
|
|
74
|
+
__resetOverlayBusForTests();
|
|
75
|
+
__resetEditorBridgeForTests();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A designer creates a modal and has to be able to SEE it. Nothing on the page
|
|
80
|
+
* opens it — its trigger may be on another route, or be a timer — and it
|
|
81
|
+
* usually does not exist on the server yet, because it was made seconds ago
|
|
82
|
+
* and nothing has been saved.
|
|
83
|
+
*
|
|
84
|
+
* So the canvas draws it from what the editor pushes. These pin that the
|
|
85
|
+
* pushing works, that it keeps working while the modal's chrome is edited, and
|
|
86
|
+
* that the canvas surface never behaves like a real one.
|
|
87
|
+
*/
|
|
88
|
+
describe('the modal a designer is composing', () => {
|
|
89
|
+
it('shows nothing until the editor asks for one', () => {
|
|
90
|
+
render(<ModalPreviewHost />);
|
|
91
|
+
|
|
92
|
+
expect(document.querySelector('[data-theme-modal]')).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('draws a modal the server has never heard of', () => {
|
|
96
|
+
// The whole first-modal case: no theme-config entry exists anywhere.
|
|
97
|
+
render(<ModalPreviewHost />);
|
|
98
|
+
|
|
99
|
+
hold(mkModal());
|
|
100
|
+
|
|
101
|
+
expect(screen.getByRole('dialog')).toHaveAttribute(
|
|
102
|
+
'aria-label',
|
|
103
|
+
'Newsletter'
|
|
104
|
+
);
|
|
105
|
+
// Its interior is an ordinary editable region, addressed by the modal's
|
|
106
|
+
// own slug — that is what lets sections be dropped into it.
|
|
107
|
+
expect(screen.getByTestId('composition-region')).toHaveAttribute(
|
|
108
|
+
'data-slug',
|
|
109
|
+
'modal-newsletter'
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('follows a width edit without a save or a reload', () => {
|
|
114
|
+
render(<ModalPreviewHost />);
|
|
115
|
+
|
|
116
|
+
hold(mkModal());
|
|
117
|
+
expect(screen.getByRole('dialog').style.maxWidth).toBe('640px');
|
|
118
|
+
|
|
119
|
+
// The designer drags the width field.
|
|
120
|
+
hold(mkModal({ chrome: { width: 420 } }));
|
|
121
|
+
|
|
122
|
+
expect(screen.getByRole('dialog').style.maxWidth).toBe('420px');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('follows a presentation edit without a save or a reload', () => {
|
|
126
|
+
render(<ModalPreviewHost />);
|
|
127
|
+
|
|
128
|
+
hold(mkModal());
|
|
129
|
+
// Centred: sized by max-width, pinned to no side.
|
|
130
|
+
expect(screen.getByRole('dialog').style.right).toBe('');
|
|
131
|
+
|
|
132
|
+
// The designer switches "Arrives As" to a drawer.
|
|
133
|
+
hold(mkModal({ presentation: 'drawer' }));
|
|
134
|
+
|
|
135
|
+
expect(screen.getByRole('dialog').style.right).toBe('0px');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('lets go when the editor goes back to a page', () => {
|
|
139
|
+
render(<ModalPreviewHost />);
|
|
140
|
+
|
|
141
|
+
hold(mkModal());
|
|
142
|
+
release();
|
|
143
|
+
|
|
144
|
+
expect(document.querySelector('[data-theme-modal]')).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('never takes the canvas over', () => {
|
|
148
|
+
render(<ModalPreviewHost />);
|
|
149
|
+
|
|
150
|
+
hold(mkModal());
|
|
151
|
+
|
|
152
|
+
const panel = screen.getByRole('dialog');
|
|
153
|
+
// The canvas is a workspace: claiming aria-modal would hide the page the
|
|
154
|
+
// designer is working on from the accessibility tree.
|
|
155
|
+
expect(panel).not.toHaveAttribute('aria-modal');
|
|
156
|
+
expect(document.body.style.overflow).not.toBe('hidden');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('survives the backdrop and close button it is meant to be styling', () => {
|
|
160
|
+
render(<ModalPreviewHost />);
|
|
161
|
+
|
|
162
|
+
hold(mkModal());
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByLabelText('Close'));
|
|
165
|
+
fireEvent.click(
|
|
166
|
+
document.querySelector('[data-part="overlay"]') as Element
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Still there: a designer colouring the backdrop must not lose the surface
|
|
170
|
+
// by clicking it.
|
|
171
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Both renderers can draw the same modal, so exactly one of them must.
|
|
177
|
+
*/
|
|
178
|
+
describe('the saved mount and the canvas host together', () => {
|
|
179
|
+
const renderBoth = (modal: ThemeModal) =>
|
|
180
|
+
render(
|
|
181
|
+
<>
|
|
182
|
+
<ThemeModalMount modal={modal}>
|
|
183
|
+
<div data-testid="composition-region" data-slug={modal.slug} />
|
|
184
|
+
</ThemeModalMount>
|
|
185
|
+
<ModalPreviewHost />
|
|
186
|
+
</>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
it('opens the modal exactly once while the editor holds it', () => {
|
|
190
|
+
registerModalHost();
|
|
191
|
+
const modal = mkModal();
|
|
192
|
+
renderBoth(modal);
|
|
193
|
+
|
|
194
|
+
hold(modal);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Two surfaces would mean two `ThemePlaceholderWrapper`s claiming one
|
|
198
|
+
* slug, so the region registers twice and the editor shows the modal's
|
|
199
|
+
* contents doubled. The saved mount stands down; the host draws.
|
|
200
|
+
*/
|
|
201
|
+
expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
|
|
202
|
+
expect(
|
|
203
|
+
document.querySelectorAll('[data-theme-modal="newsletter"]')
|
|
204
|
+
).toHaveLength(1);
|
|
205
|
+
expect(
|
|
206
|
+
document.querySelector('[data-theme-modal-state="preview"]')
|
|
207
|
+
).not.toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('hands the modal back to the saved mount when the editor lets go', () => {
|
|
211
|
+
registerModalHost();
|
|
212
|
+
const modal = mkModal();
|
|
213
|
+
renderBoth(modal);
|
|
214
|
+
|
|
215
|
+
hold(modal);
|
|
216
|
+
release();
|
|
217
|
+
|
|
218
|
+
// Closed for a shopper, and no longer held: nothing on screen at all.
|
|
219
|
+
expect(document.querySelector('[data-theme-modal]')).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The loop that actually matters: create a modal, point a trigger at it, press
|
|
225
|
+
* the trigger. The saved mounts can only offer modals the backend has stored,
|
|
226
|
+
* so before the editor's registry was read this did nothing at all for a modal
|
|
227
|
+
* made minutes ago — and nothing told the difference between a modal that does
|
|
228
|
+
* not exist yet and a trigger that is broken.
|
|
229
|
+
*/
|
|
230
|
+
describe('triggering a modal that was never saved', () => {
|
|
231
|
+
const pushEditorTheme = (modals: unknown[]) =>
|
|
232
|
+
act(() => {
|
|
233
|
+
fireEvent(
|
|
234
|
+
window,
|
|
235
|
+
new MessageEvent('message', {
|
|
236
|
+
origin: EDITOR_ORIGIN,
|
|
237
|
+
data: { type: 'UPDATE_THEME', data: { theme: { modals } } }
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('opens from the bus once the editor has pushed it', () => {
|
|
243
|
+
registerModalHost();
|
|
244
|
+
render(<ModalPreviewHost />);
|
|
245
|
+
|
|
246
|
+
// Nothing saved: the server rendered no mount for this id.
|
|
247
|
+
pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
|
|
248
|
+
|
|
249
|
+
act(() => {
|
|
250
|
+
openModal('newsletter');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(screen.getByRole('dialog')).toHaveAttribute(
|
|
254
|
+
'aria-label',
|
|
255
|
+
'Newsletter'
|
|
256
|
+
);
|
|
257
|
+
expect(screen.getByTestId('composition-region')).toHaveAttribute(
|
|
258
|
+
'data-slug',
|
|
259
|
+
'modal-newsletter'
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('behaves like a real modal, not the inert editing surface', () => {
|
|
264
|
+
registerModalHost();
|
|
265
|
+
render(<ModalPreviewHost />);
|
|
266
|
+
pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
|
|
267
|
+
|
|
268
|
+
act(() => {
|
|
269
|
+
openModal('newsletter');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// A shopper pressing close must actually close it — the inertness is for
|
|
273
|
+
// the surface being composed, not for one a trigger opened.
|
|
274
|
+
fireEvent.click(screen.getByLabelText('Close'));
|
|
275
|
+
|
|
276
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('stays shut until something opens it', () => {
|
|
280
|
+
registerModalHost();
|
|
281
|
+
render(<ModalPreviewHost />);
|
|
282
|
+
pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
|
|
283
|
+
|
|
284
|
+
expect(document.querySelector('[data-theme-modal]')).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('hands the modal back to the saved mount when the editor detaches', () => {
|
|
288
|
+
registerModalHost();
|
|
289
|
+
const modal = mkModal();
|
|
290
|
+
render(
|
|
291
|
+
<>
|
|
292
|
+
<ThemeModalMount modal={modal}>
|
|
293
|
+
<div data-testid="composition-region" data-slug={modal.slug} />
|
|
294
|
+
</ThemeModalMount>
|
|
295
|
+
<ModalPreviewHost />
|
|
296
|
+
</>
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
pushEditorTheme([{ id: 'newsletter', name: 'Newsletter' }]);
|
|
300
|
+
act(() => {
|
|
301
|
+
openModal('newsletter');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// One surface, not two: the saved mount stands down for a modal the editor
|
|
305
|
+
// knows, or the region would register twice.
|
|
306
|
+
expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
|
|
307
|
+
|
|
308
|
+
// Editor drops the modal from its registry; the saved copy takes over.
|
|
309
|
+
pushEditorTheme([]);
|
|
310
|
+
|
|
311
|
+
expect(screen.getAllByTestId('composition-region')).toHaveLength(1);
|
|
312
|
+
expect(
|
|
313
|
+
document.querySelectorAll('[data-theme-modal="newsletter"]')
|
|
314
|
+
).toHaveLength(1);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { readThemeModals } from '../modal-registry';
|
|
2
|
+
import {
|
|
3
|
+
isModalSlug,
|
|
4
|
+
modalIdFromSlug,
|
|
5
|
+
modalSlug
|
|
6
|
+
} from '../../chrome/placeholder-slugs';
|
|
7
|
+
import { resetDevWarnCacheForTests } from '../../utils/dev-warn';
|
|
8
|
+
|
|
9
|
+
describe('modal slug family', () => {
|
|
10
|
+
it('derives a slug from an addressable id', () => {
|
|
11
|
+
expect(modalSlug('newsletter')).toBe('modal-newsletter');
|
|
12
|
+
expect(modalSlug('size-guide-2')).toBe('modal-size-guide-2');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('refuses an id it cannot address rather than cleaning it', () => {
|
|
16
|
+
// Cleaning would map several ids onto one slug, and for a published
|
|
17
|
+
// content key that is a silent overwrite, not a tidy-up.
|
|
18
|
+
expect(modalSlug('News Letter')).toBeNull();
|
|
19
|
+
expect(modalSlug('news_letter')).toBeNull();
|
|
20
|
+
expect(modalSlug('-leading-hyphen')).toBeNull();
|
|
21
|
+
expect(modalSlug('')).toBeNull();
|
|
22
|
+
expect(modalSlug('ÜstBilgi')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('round-trips a slug back to its id', () => {
|
|
26
|
+
expect(modalIdFromSlug('modal-newsletter')).toBe('newsletter');
|
|
27
|
+
expect(modalIdFromSlug('page-about')).toBeNull();
|
|
28
|
+
expect(modalIdFromSlug('modal-')).toBeNull();
|
|
29
|
+
expect(isModalSlug('modal-newsletter')).toBe(true);
|
|
30
|
+
expect(isModalSlug('header')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('readThemeModals', () => {
|
|
35
|
+
beforeEach(() => resetDevWarnCacheForTests());
|
|
36
|
+
|
|
37
|
+
it('reads the JSON string the editor writes', () => {
|
|
38
|
+
expect(
|
|
39
|
+
readThemeModals(
|
|
40
|
+
JSON.stringify([{ id: 'newsletter', name: 'Newsletter popup' }])
|
|
41
|
+
)
|
|
42
|
+
).toEqual([
|
|
43
|
+
{
|
|
44
|
+
id: 'newsletter',
|
|
45
|
+
name: 'Newsletter popup',
|
|
46
|
+
slug: 'modal-newsletter',
|
|
47
|
+
presentation: 'modal',
|
|
48
|
+
chrome: {
|
|
49
|
+
ariaLabel: 'Newsletter popup',
|
|
50
|
+
showClose: true,
|
|
51
|
+
closeLabel: 'Close',
|
|
52
|
+
showBackdrop: true,
|
|
53
|
+
closeOnBackdrop: true,
|
|
54
|
+
closeOnEscape: true,
|
|
55
|
+
width: 640,
|
|
56
|
+
panelSide: 'right',
|
|
57
|
+
sheetHeight: 60,
|
|
58
|
+
lockScroll: true
|
|
59
|
+
},
|
|
60
|
+
autoTrigger: undefined,
|
|
61
|
+
visibilityRules: undefined
|
|
62
|
+
}
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('chrome', () => {
|
|
67
|
+
const chromeOf = (chrome: unknown) =>
|
|
68
|
+
readThemeModals([{ id: 'a', name: 'A', chrome }])[0].chrome;
|
|
69
|
+
|
|
70
|
+
it('names the dialog after the modal when no label was given', () => {
|
|
71
|
+
// A dialog with no accessible name is announced as just "dialog", which
|
|
72
|
+
// tells a screen reader user nothing about what took over their page.
|
|
73
|
+
expect(chromeOf(undefined).ariaLabel).toBe('A');
|
|
74
|
+
expect(chromeOf({ ariaLabel: 'Sign up' }).ariaLabel).toBe('Sign up');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forces the close button back on when nothing else could dismiss', () => {
|
|
78
|
+
expect(
|
|
79
|
+
chromeOf({
|
|
80
|
+
showClose: false,
|
|
81
|
+
closeOnBackdrop: false,
|
|
82
|
+
closeOnEscape: false
|
|
83
|
+
}).showClose
|
|
84
|
+
).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('honours the close button being off while another way out remains', () => {
|
|
88
|
+
expect(
|
|
89
|
+
chromeOf({ showClose: false, closeOnBackdrop: true }).showClose
|
|
90
|
+
).toBe(false);
|
|
91
|
+
expect(
|
|
92
|
+
chromeOf({
|
|
93
|
+
showClose: false,
|
|
94
|
+
closeOnBackdrop: false,
|
|
95
|
+
closeOnEscape: true
|
|
96
|
+
}).showClose
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('clamps geometry into a usable range', () => {
|
|
101
|
+
expect(chromeOf({ width: 10 }).width).toBe(240);
|
|
102
|
+
expect(chromeOf({ width: 99999 }).width).toBe(1600);
|
|
103
|
+
expect(chromeOf({ width: 'wide' }).width).toBe(640);
|
|
104
|
+
expect(chromeOf({ sheetHeight: 5 }).sheetHeight).toBe(20);
|
|
105
|
+
expect(chromeOf({ sheetHeight: 300 }).sheetHeight).toBe(100);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses an unknown panel side', () => {
|
|
109
|
+
expect(chromeOf({ panelSide: 'top' }).panelSide).toBe('right');
|
|
110
|
+
expect(chromeOf({ panelSide: 'left' }).panelSide).toBe('left');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reads an already-parsed array', () => {
|
|
115
|
+
expect(readThemeModals([{ id: 'a', name: 'A' }])).toHaveLength(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('answers empty for anything unusable', () => {
|
|
119
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
120
|
+
|
|
121
|
+
expect(readThemeModals(undefined)).toEqual([]);
|
|
122
|
+
expect(readThemeModals('')).toEqual([]);
|
|
123
|
+
expect(readThemeModals('{not json')).toEqual([]);
|
|
124
|
+
expect(readThemeModals({ id: 'a' })).toEqual([]);
|
|
125
|
+
|
|
126
|
+
warn.mockRestore();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('drops only the malformed entries, keeping the rest', () => {
|
|
130
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
131
|
+
|
|
132
|
+
const modals = readThemeModals([
|
|
133
|
+
{ id: 'good', name: 'Good' },
|
|
134
|
+
{ id: 'Not Addressable', name: 'Bad' },
|
|
135
|
+
null,
|
|
136
|
+
'nonsense',
|
|
137
|
+
{ id: 'also-good', name: 'Also good' }
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(modals.map((modal) => modal.id)).toEqual(['good', 'also-good']);
|
|
141
|
+
|
|
142
|
+
warn.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('collapses duplicate ids to the first, since both claim one slug', () => {
|
|
146
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
147
|
+
|
|
148
|
+
const modals = readThemeModals([
|
|
149
|
+
{ id: 'dupe', name: 'First' },
|
|
150
|
+
{ id: 'dupe', name: 'Second' }
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
expect(modals).toHaveLength(1);
|
|
154
|
+
expect(modals[0].name).toBe('First');
|
|
155
|
+
expect(warn).toHaveBeenCalledWith(
|
|
156
|
+
expect.stringContaining('Duplicate modal id')
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
warn.mockRestore();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('falls back to the id when a modal was never named', () => {
|
|
163
|
+
expect(readThemeModals([{ id: 'unnamed' }])[0].name).toBe('unnamed');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('refuses an unknown presentation instead of passing it through', () => {
|
|
167
|
+
expect(
|
|
168
|
+
readThemeModals([{ id: 'a', presentation: 'lightbox' }])[0].presentation
|
|
169
|
+
).toBe('modal');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('auto-trigger', () => {
|
|
173
|
+
it('is absent when the event is none or missing', () => {
|
|
174
|
+
expect(readThemeModals([{ id: 'a' }])[0].autoTrigger).toBeUndefined();
|
|
175
|
+
expect(
|
|
176
|
+
readThemeModals([{ id: 'a', autoTrigger: { event: 'none' } }])[0]
|
|
177
|
+
.autoTrigger
|
|
178
|
+
).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('defaults an unspecified frequency to once per session', () => {
|
|
182
|
+
// Never "always": a welcome popup on every navigation is how this
|
|
183
|
+
// feature gets switched off a week after launch.
|
|
184
|
+
expect(
|
|
185
|
+
readThemeModals([
|
|
186
|
+
{ id: 'a', autoTrigger: { event: 'delay', delayMs: 3000 } }
|
|
187
|
+
])[0].autoTrigger
|
|
188
|
+
).toEqual({
|
|
189
|
+
event: 'delay',
|
|
190
|
+
delayMs: 3000,
|
|
191
|
+
scrollPercent: undefined,
|
|
192
|
+
anchorId: undefined,
|
|
193
|
+
frequency: 'once-per-session'
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('drops a negative or non-numeric delay', () => {
|
|
198
|
+
const trigger = readThemeModals([
|
|
199
|
+
{ id: 'a', autoTrigger: { event: 'delay', delayMs: -1 } }
|
|
200
|
+
])[0].autoTrigger;
|
|
201
|
+
|
|
202
|
+
expect(trigger?.delayMs).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|