@akinon/pz-theme 2.0.164-rc.1 → 2.0.165-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 +16 -58
- package/package.json +4 -4
- package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
- package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
- package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
- package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
- package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
- package/src/blocks/__tests__/text-block.test.tsx +6 -34
- package/src/blocks/accordion-block.tsx +10 -0
- package/src/blocks/hotspot-block.tsx +6 -1
- package/src/blocks/listing-breadcrumb-block.tsx +10 -1
- package/src/blocks/order-detail-block.tsx +95 -113
- package/src/blocks/product-breadcrumb-block.tsx +9 -1
- package/src/blocks/social-glyphs.tsx +31 -0
- package/src/blocks/social-icons-list-block.tsx +72 -8
- package/src/blocks/text-block.tsx +15 -6
- package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
- package/src/chrome/theme-page-up.tsx +303 -0
- package/src/chrome/use-editor-theme-settings.ts +104 -0
- package/src/components/with-designer-features.tsx +27 -0
- package/src/custom/README.md +219 -100
- package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
- package/src/custom/collect.ts +9 -4
- package/src/custom/property-types.ts +15 -15
- package/src/custom/use-theme-setting.ts +5 -21
- package/src/editor-locale-bridge.tsx +11 -39
- package/src/index.ts +15 -0
- package/src/overlays/theme-modals.tsx +10 -0
- package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
- package/src/sections/coupon-banner-section.tsx +15 -1
- package/src/sections/section-wrapper.tsx +7 -0
- package/src/sections/tabs-section.tsx +13 -0
- package/src/theme-placeholder-wrapper.tsx +50 -3
- package/src/theme-section.tsx +15 -0
- package/src/utils/__tests__/svg-image-source.test.ts +123 -0
- package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
- package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
- package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
- package/src/utils/index.ts +36 -1
- package/src/utils/svg-image-source.ts +84 -0
- package/src/utils/theme-settings-css.ts +717 -0
- package/src/utils/theme-settings-readers.ts +205 -0
- package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
- package/src/blocks/order-line-actions.ts +0 -45
- package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
- package/src/utils/sanitize-rich-text.ts +0 -215
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
|
|
5
|
+
let mockLocale = 'en-us';
|
|
6
|
+
jest.mock('@akinon/next/hooks', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
useLocalization: () => ({ locale: mockLocale })
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import ThemePageUp, { measureBottomOverlay } from '../theme-page-up';
|
|
12
|
+
import { __resetEditorThemeSettingsForTests } from '../use-editor-theme-settings';
|
|
13
|
+
import { __resetEditorBridgeForTests } from '../../editor-bridge';
|
|
14
|
+
import { PAGE_UP_OFFSET_VARIABLE } from '../../utils/theme-settings-readers';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The settings page's "Page up oku" back-to-top button.
|
|
18
|
+
*
|
|
19
|
+
* What matters beyond "it scrolls": it is invisible AND unreachable until the
|
|
20
|
+
* page has scrolled a viewport, it costs no scroll handler, a theme-supplied
|
|
21
|
+
* icon can never inject markup, the designer can flip it live, and a brand
|
|
22
|
+
* with a fixed bottom bar can lift it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type ObserverRecord = {
|
|
26
|
+
callback: IntersectionObserverCallback;
|
|
27
|
+
observed: Element[];
|
|
28
|
+
disconnected: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const observers: ObserverRecord[] = [];
|
|
32
|
+
|
|
33
|
+
class RecordingIntersectionObserver {
|
|
34
|
+
private readonly record: ObserverRecord;
|
|
35
|
+
|
|
36
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
37
|
+
this.record = { callback, observed: [], disconnected: false };
|
|
38
|
+
observers.push(this.record);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
observe(target: Element) {
|
|
42
|
+
this.record.observed.push(target);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
unobserve() {}
|
|
46
|
+
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.record.disconnected = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
takeRecords() {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Drives the newest observer the way the browser would. */
|
|
57
|
+
const sentinelAt = (isIntersecting: boolean, top: number) =>
|
|
58
|
+
act(() => {
|
|
59
|
+
const record = observers[observers.length - 1];
|
|
60
|
+
record.callback(
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
isIntersecting,
|
|
64
|
+
boundingClientRect: { top } as DOMRectReadOnly
|
|
65
|
+
} as IntersectionObserverEntry
|
|
66
|
+
],
|
|
67
|
+
null as unknown as IntersectionObserver
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const scrolledPast = () => sentinelAt(false, -120);
|
|
72
|
+
|
|
73
|
+
const EDITOR_ORIGIN = 'http://localhost:5174';
|
|
74
|
+
|
|
75
|
+
const pushEditorSettings = (
|
|
76
|
+
type: 'LOAD_THEME' | 'UPDATE_THEME',
|
|
77
|
+
settings: Record<string, unknown> | undefined
|
|
78
|
+
) =>
|
|
79
|
+
act(() => {
|
|
80
|
+
fireEvent(
|
|
81
|
+
window,
|
|
82
|
+
new MessageEvent('message', {
|
|
83
|
+
origin: EDITOR_ORIGIN,
|
|
84
|
+
data: { type, data: { theme: settings ? { settings } : {} } }
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const button = () =>
|
|
90
|
+
document.querySelector<HTMLButtonElement>('button[data-theme-page-up]');
|
|
91
|
+
|
|
92
|
+
const originalObserver = window.IntersectionObserver;
|
|
93
|
+
const originalMatchMedia = window.matchMedia;
|
|
94
|
+
const originalElementsFromPoint = document.elementsFromPoint;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
observers.length = 0;
|
|
98
|
+
window.IntersectionObserver =
|
|
99
|
+
RecordingIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
100
|
+
__resetEditorThemeSettingsForTests();
|
|
101
|
+
__resetEditorBridgeForTests();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
window.IntersectionObserver = originalObserver;
|
|
106
|
+
window.matchMedia = originalMatchMedia;
|
|
107
|
+
document.elementsFromPoint = originalElementsFromPoint;
|
|
108
|
+
jest.restoreAllMocks();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('ThemePageUp — enabling', () => {
|
|
112
|
+
it('renders nothing unless pageUpEnabled is true', () => {
|
|
113
|
+
for (const settings of [
|
|
114
|
+
null,
|
|
115
|
+
{},
|
|
116
|
+
{ pageUpEnabled: false },
|
|
117
|
+
{ pageUpEnabled: 'true' }
|
|
118
|
+
]) {
|
|
119
|
+
const { container, unmount } = render(
|
|
120
|
+
<ThemePageUp settings={settings} />
|
|
121
|
+
);
|
|
122
|
+
expect(container.innerHTML).toBe('');
|
|
123
|
+
unmount();
|
|
124
|
+
}
|
|
125
|
+
expect(observers).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is server-rendered hidden, so the first client render matches', () => {
|
|
129
|
+
const html = renderToStaticMarkup(
|
|
130
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(html).toContain('data-theme-page-up');
|
|
134
|
+
expect(html).toContain('visibility:hidden');
|
|
135
|
+
expect(html).toContain('aria-hidden="true"');
|
|
136
|
+
expect(html).toContain('tabindex="-1"');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('ThemePageUp — visibility', () => {
|
|
141
|
+
it('stays hidden and unreachable until the sentinel is scrolled past', () => {
|
|
142
|
+
render(<ThemePageUp settings={{ pageUpEnabled: true }} />);
|
|
143
|
+
|
|
144
|
+
const sentinel = document.querySelector<HTMLElement>(
|
|
145
|
+
'[data-page-up-sentinel]'
|
|
146
|
+
)!;
|
|
147
|
+
expect(sentinel).not.toBeNull();
|
|
148
|
+
// One viewport down, and zero-sized so it never adds a scrollbar.
|
|
149
|
+
expect(sentinel.style.top).toBe('100vh');
|
|
150
|
+
expect(sentinel.style.height).toBe('0px');
|
|
151
|
+
expect(observers[0].observed).toEqual([sentinel]);
|
|
152
|
+
|
|
153
|
+
expect(button()!.getAttribute('aria-hidden')).toBe('true');
|
|
154
|
+
expect(button()!.tabIndex).toBe(-1);
|
|
155
|
+
expect(button()!.style.visibility).toBe('hidden');
|
|
156
|
+
expect(button()!.style.pointerEvents).toBe('none');
|
|
157
|
+
|
|
158
|
+
// Below the fold on a fresh page is not "scrolled past".
|
|
159
|
+
sentinelAt(false, 900);
|
|
160
|
+
expect(button()!.style.visibility).toBe('hidden');
|
|
161
|
+
|
|
162
|
+
scrolledPast();
|
|
163
|
+
expect(button()!.getAttribute('aria-hidden')).toBeNull();
|
|
164
|
+
expect(button()!.hasAttribute('tabindex')).toBe(false);
|
|
165
|
+
expect(button()!.style.visibility).toBe('visible');
|
|
166
|
+
expect(button()!.style.opacity).toBe('1');
|
|
167
|
+
|
|
168
|
+
sentinelAt(true, 300);
|
|
169
|
+
expect(button()!.style.visibility).toBe('hidden');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('takes the sentinel and the observer down on unmount', () => {
|
|
173
|
+
const { unmount } = render(
|
|
174
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
175
|
+
);
|
|
176
|
+
unmount();
|
|
177
|
+
|
|
178
|
+
expect(document.querySelector('[data-page-up-sentinel]')).toBeNull();
|
|
179
|
+
expect(observers[0].disconnected).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('is a labelled button, in the shop language', () => {
|
|
183
|
+
const { unmount } = render(
|
|
184
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
185
|
+
);
|
|
186
|
+
scrolledPast();
|
|
187
|
+
|
|
188
|
+
const control = screen.getByRole('button', { name: 'Back to top' });
|
|
189
|
+
expect(control.getAttribute('type')).toBe('button');
|
|
190
|
+
unmount();
|
|
191
|
+
|
|
192
|
+
mockLocale = 'tr-tr';
|
|
193
|
+
try {
|
|
194
|
+
render(<ThemePageUp settings={{ pageUpEnabled: true }} />);
|
|
195
|
+
scrolledPast();
|
|
196
|
+
expect(
|
|
197
|
+
screen.getByRole('button', { name: 'Sayfanın başına dön' })
|
|
198
|
+
).toBeInTheDocument();
|
|
199
|
+
} finally {
|
|
200
|
+
mockLocale = 'en-us';
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('ThemePageUp — scrolling', () => {
|
|
206
|
+
const reduceMotion = (reduce: boolean) => {
|
|
207
|
+
window.matchMedia = ((query: string) => ({
|
|
208
|
+
matches: reduce && query.includes('prefers-reduced-motion'),
|
|
209
|
+
media: query,
|
|
210
|
+
onchange: null,
|
|
211
|
+
addListener: () => {},
|
|
212
|
+
removeListener: () => {},
|
|
213
|
+
addEventListener: () => {},
|
|
214
|
+
removeEventListener: () => {},
|
|
215
|
+
dispatchEvent: () => false
|
|
216
|
+
})) as unknown as typeof window.matchMedia;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it('scrolls smoothly to the top and moves focus to the content', () => {
|
|
220
|
+
reduceMotion(false);
|
|
221
|
+
const scrollTo = jest
|
|
222
|
+
.spyOn(window, 'scrollTo')
|
|
223
|
+
.mockImplementation(() => {});
|
|
224
|
+
render(
|
|
225
|
+
<>
|
|
226
|
+
<main>content</main>
|
|
227
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
228
|
+
</>
|
|
229
|
+
);
|
|
230
|
+
scrolledPast();
|
|
231
|
+
|
|
232
|
+
fireEvent.click(button()!);
|
|
233
|
+
|
|
234
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
|
235
|
+
const main = document.querySelector('main')!;
|
|
236
|
+
expect(document.activeElement).toBe(main);
|
|
237
|
+
expect(main.getAttribute('tabindex')).toBe('-1');
|
|
238
|
+
|
|
239
|
+
// The temporary tab stop goes away with the focus.
|
|
240
|
+
act(() => {
|
|
241
|
+
main.blur();
|
|
242
|
+
});
|
|
243
|
+
expect(main.hasAttribute('tabindex')).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('jumps without animation under reduced motion', () => {
|
|
247
|
+
reduceMotion(true);
|
|
248
|
+
const scrollTo = jest
|
|
249
|
+
.spyOn(window, 'scrollTo')
|
|
250
|
+
.mockImplementation(() => {});
|
|
251
|
+
render(<ThemePageUp settings={{ pageUpEnabled: true }} />);
|
|
252
|
+
scrolledPast();
|
|
253
|
+
|
|
254
|
+
fireEvent.click(button()!);
|
|
255
|
+
|
|
256
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('ThemePageUp — icon', () => {
|
|
261
|
+
const iconOf = (settings: Record<string, unknown>) => {
|
|
262
|
+
const { container, unmount } = render(
|
|
263
|
+
<ThemePageUp settings={{ pageUpEnabled: true, ...settings }} />
|
|
264
|
+
);
|
|
265
|
+
const icon = container.querySelector('[data-page-up-icon]')!;
|
|
266
|
+
const result = {
|
|
267
|
+
kind: icon.getAttribute('data-page-up-icon'),
|
|
268
|
+
src: icon.getAttribute('src'),
|
|
269
|
+
path: icon.querySelector('path')?.getAttribute('d') ?? null,
|
|
270
|
+
html: container.innerHTML
|
|
271
|
+
};
|
|
272
|
+
unmount();
|
|
273
|
+
return result;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
it('draws the catalogue up-arrow when nothing is uploaded', () => {
|
|
277
|
+
expect(iconOf({})).toMatchObject({
|
|
278
|
+
kind: 'glyph',
|
|
279
|
+
path: 'M4.5 15l7.5-7.5 7.5 7.5'
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("uses the theme's uploaded up-arrow icon before the catalogue", () => {
|
|
284
|
+
const markup =
|
|
285
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1 1"/></svg>';
|
|
286
|
+
|
|
287
|
+
expect(iconOf({ icons: { 'up-arrow': markup } })).toMatchObject({
|
|
288
|
+
kind: 'image',
|
|
289
|
+
src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('prefers pageUpIcon over everything else', () => {
|
|
294
|
+
const own = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>';
|
|
295
|
+
|
|
296
|
+
expect(
|
|
297
|
+
iconOf({ pageUpIcon: own, icons: { 'up-arrow': '<svg><path/></svg>' } })
|
|
298
|
+
).toMatchObject({
|
|
299
|
+
kind: 'image',
|
|
300
|
+
src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(own)}`
|
|
301
|
+
});
|
|
302
|
+
expect(
|
|
303
|
+
iconOf({ pageUpIcon: 'https://cdn.example.com/up.png' })
|
|
304
|
+
).toMatchObject({ kind: 'image', src: 'https://cdn.example.com/up.png' });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('never puts icon markup into the DOM', () => {
|
|
308
|
+
const hostile =
|
|
309
|
+
'<svg onload="alert(1)"><script>alert(2)</script><path d="M0 0"/></svg>';
|
|
310
|
+
const { html, kind } = iconOf({ pageUpIcon: hostile });
|
|
311
|
+
|
|
312
|
+
expect(kind).toBe('image');
|
|
313
|
+
expect(document.querySelector('script')).toBeNull();
|
|
314
|
+
expect(html).not.toContain('<script');
|
|
315
|
+
expect(html).not.toContain('onload=');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('falls back to the catalogue arrow for an unusable icon value', () => {
|
|
319
|
+
for (const pageUpIcon of ['javascript:alert(1)', '//evil.example/up.svg']) {
|
|
320
|
+
expect(iconOf({ pageUpIcon })).toMatchObject({
|
|
321
|
+
kind: 'glyph',
|
|
322
|
+
path: 'M4.5 15l7.5-7.5 7.5 7.5'
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('ThemePageUp — live in the designer', () => {
|
|
329
|
+
it('follows the editor once it has spoken, ignoring the saved settings', () => {
|
|
330
|
+
render(<ThemePageUp settings={{}} />);
|
|
331
|
+
expect(button()).toBeNull();
|
|
332
|
+
|
|
333
|
+
pushEditorSettings('LOAD_THEME', { pageUpEnabled: true });
|
|
334
|
+
expect(button()).not.toBeNull();
|
|
335
|
+
|
|
336
|
+
// A frame with no settings says nothing about them.
|
|
337
|
+
pushEditorSettings('UPDATE_THEME', undefined);
|
|
338
|
+
expect(button()).not.toBeNull();
|
|
339
|
+
|
|
340
|
+
pushEditorSettings('UPDATE_THEME', { primaryColor: '#111111' });
|
|
341
|
+
expect(button()).toBeNull();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('switches the icon live', () => {
|
|
345
|
+
render(<ThemePageUp settings={{ pageUpEnabled: true }} />);
|
|
346
|
+
expect(
|
|
347
|
+
document
|
|
348
|
+
.querySelector('[data-page-up-icon]')!
|
|
349
|
+
.getAttribute('data-page-up-icon')
|
|
350
|
+
).toBe('glyph');
|
|
351
|
+
|
|
352
|
+
pushEditorSettings('UPDATE_THEME', {
|
|
353
|
+
pageUpEnabled: true,
|
|
354
|
+
pageUpIcon: '<svg xmlns="http://www.w3.org/2000/svg"/>'
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(
|
|
358
|
+
document
|
|
359
|
+
.querySelector('[data-page-up-icon]')!
|
|
360
|
+
.getAttribute('data-page-up-icon')
|
|
361
|
+
).toBe('image');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('draws the uploaded icon of a theme saved with the button off once the designer turns it on', () => {
|
|
365
|
+
const markup =
|
|
366
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1 1"/></svg>';
|
|
367
|
+
render(<ThemePageUp settings={{ icons: { 'up-arrow': markup } }} />);
|
|
368
|
+
expect(button()).toBeNull();
|
|
369
|
+
|
|
370
|
+
pushEditorSettings('UPDATE_THEME', {
|
|
371
|
+
pageUpEnabled: true,
|
|
372
|
+
icons: { 'up-arrow': markup }
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(
|
|
376
|
+
document.querySelector('[data-page-up-icon]')!.getAttribute('src')
|
|
377
|
+
).toBe(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('ignores frames from an untrusted origin', () => {
|
|
381
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
382
|
+
render(<ThemePageUp settings={{}} />);
|
|
383
|
+
|
|
384
|
+
act(() => {
|
|
385
|
+
fireEvent(
|
|
386
|
+
window,
|
|
387
|
+
new MessageEvent('message', {
|
|
388
|
+
origin: 'https://evil.example',
|
|
389
|
+
data: {
|
|
390
|
+
type: 'LOAD_THEME',
|
|
391
|
+
data: { theme: { settings: { pageUpEnabled: true } } }
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(button()).toBeNull();
|
|
398
|
+
expect(warn).toHaveBeenCalledWith(
|
|
399
|
+
expect.stringContaining('untrusted origin')
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('ThemePageUp — bottom overlays', () => {
|
|
405
|
+
it('reads the brand offset variable, with the measured lift as its fallback', () => {
|
|
406
|
+
const html = renderToStaticMarkup(
|
|
407
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(html).toContain('--pz-page-up-lift:0px');
|
|
411
|
+
expect(html).toContain(
|
|
412
|
+
`bottom:calc(16px + var(${PAGE_UP_OFFSET_VARIABLE}, var(--pz-page-up-lift, 0px)) + env(safe-area-inset-bottom, 0px))`
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('is painted from the theme colour roles, with fallbacks', () => {
|
|
417
|
+
const html = renderToStaticMarkup(
|
|
418
|
+
<ThemePageUp settings={{ pageUpEnabled: true }} />
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(html).toContain('background:var(--theme-background, #fff)');
|
|
422
|
+
expect(html).toContain('color:var(--theme-heading, #111)');
|
|
423
|
+
expect(html).toContain('border:1px solid var(--theme-border, #e2e2e2)');
|
|
424
|
+
expect(html).toContain('z-index:30');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const fixedBar = (top: number, bottom: number, position = 'fixed') => {
|
|
428
|
+
const bar = document.createElement('div');
|
|
429
|
+
bar.style.position = position;
|
|
430
|
+
document.body.appendChild(bar);
|
|
431
|
+
jest.spyOn(bar, 'getBoundingClientRect').mockReturnValue({
|
|
432
|
+
top,
|
|
433
|
+
bottom,
|
|
434
|
+
left: 0,
|
|
435
|
+
right: window.innerWidth,
|
|
436
|
+
width: window.innerWidth,
|
|
437
|
+
height: bottom - top,
|
|
438
|
+
x: 0,
|
|
439
|
+
y: top,
|
|
440
|
+
toJSON: () => ({})
|
|
441
|
+
} as DOMRect);
|
|
442
|
+
return bar;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Answers like a browser: only the elements whose box contains the point.
|
|
446
|
+
const hitTest = (elements: HTMLElement[]) =>
|
|
447
|
+
jest.fn((_x: number, y: number) =>
|
|
448
|
+
elements.filter((element) => {
|
|
449
|
+
const box = element.getBoundingClientRect();
|
|
450
|
+
return y >= box.top && y <= box.bottom;
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
it('lifts itself above a fixed bar covering the bottom edge', async () => {
|
|
455
|
+
const { innerHeight } = window;
|
|
456
|
+
const bar = fixedBar(innerHeight - 56, innerHeight);
|
|
457
|
+
document.elementsFromPoint = jest.fn(() => [bar, document.body]);
|
|
458
|
+
|
|
459
|
+
expect(measureBottomOverlay(null)).toBe(56);
|
|
460
|
+
|
|
461
|
+
render(<ThemePageUp settings={{ pageUpEnabled: true }} />);
|
|
462
|
+
expect(button()!.style.getPropertyValue('--pz-page-up-lift')).toBe('0px');
|
|
463
|
+
|
|
464
|
+
scrolledPast();
|
|
465
|
+
// The measurement runs on the next frame.
|
|
466
|
+
await act(async () => {
|
|
467
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(button()!.style.getPropertyValue('--pz-page-up-lift')).toBe('56px');
|
|
471
|
+
bar.remove();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('does not climb an overlay or a bar that leaves the bottom edge', () => {
|
|
475
|
+
const { innerHeight } = window;
|
|
476
|
+
const drawer = fixedBar(0, innerHeight);
|
|
477
|
+
const floating = fixedBar(innerHeight - 120, innerHeight - 40);
|
|
478
|
+
const sticky = fixedBar(innerHeight - 30, innerHeight, 'sticky');
|
|
479
|
+
const staticRow = fixedBar(innerHeight - 200, innerHeight, 'static');
|
|
480
|
+
document.elementsFromPoint = hitTest([drawer, floating, staticRow, sticky]);
|
|
481
|
+
|
|
482
|
+
// Only the sticky strip counts: the floating panel stops above both the
|
|
483
|
+
// bottom edge and the button's resting spot.
|
|
484
|
+
expect(measureBottomOverlay(null)).toBe(30);
|
|
485
|
+
|
|
486
|
+
[drawer, floating, sticky, staticRow].forEach((node) => node.remove());
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// A chat or WhatsApp launcher parked in the same corner.
|
|
490
|
+
it('lifts itself above a floating button in its resting spot', () => {
|
|
491
|
+
const { innerHeight } = window;
|
|
492
|
+
const launcher = fixedBar(innerHeight - 72, innerHeight - 16);
|
|
493
|
+
document.elementsFromPoint = hitTest([launcher]);
|
|
494
|
+
|
|
495
|
+
expect(measureBottomOverlay(null)).toBe(72);
|
|
496
|
+
|
|
497
|
+
launcher.remove();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('leaves the lift to the brand when the variable is set', () => {
|
|
501
|
+
const { innerHeight } = window;
|
|
502
|
+
const bar = fixedBar(innerHeight - 56, innerHeight);
|
|
503
|
+
document.elementsFromPoint = jest.fn(() => [bar]);
|
|
504
|
+
|
|
505
|
+
const host = document.createElement('button');
|
|
506
|
+
host.style.setProperty(PAGE_UP_OFFSET_VARIABLE, '80px');
|
|
507
|
+
document.body.appendChild(host);
|
|
508
|
+
|
|
509
|
+
expect(measureBottomOverlay(host)).toBe(0);
|
|
510
|
+
|
|
511
|
+
host.remove();
|
|
512
|
+
bar.remove();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('measures nothing where the browser cannot hit-test', () => {
|
|
516
|
+
// jsdom has no elementsFromPoint, like an old browser.
|
|
517
|
+
(document as unknown as { elementsFromPoint?: unknown }).elementsFromPoint =
|
|
518
|
+
undefined;
|
|
519
|
+
|
|
520
|
+
expect(measureBottomOverlay(null)).toBe(0);
|
|
521
|
+
});
|
|
522
|
+
});
|