@akinon/pz-theme 2.0.82-beta.0 → 2.0.82

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,470 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ // Stub the recursive ThemeBlock so a slide is one countable marker.
5
+ jest.mock('../../theme-block', () => ({
6
+ __esModule: true,
7
+ default: ({ block }: { block: { id: string } }) => (
8
+ <div data-testid="slide" data-id={block.id} />
9
+ )
10
+ }));
11
+
12
+ /**
13
+ * Stand in for react-multi-carousel, which measures its container and renders
14
+ * an empty track at a jsdom width of zero — so neither a slide nor an arrow
15
+ * reaches the DOM through the real one. The stub renders exactly what the
16
+ * section hands it, which is the thing under test: which blocks became slides,
17
+ * and which arrows were passed in.
18
+ */
19
+ jest.mock('react-multi-carousel', () => ({
20
+ __esModule: true,
21
+ default: ({
22
+ children,
23
+ customLeftArrow,
24
+ customRightArrow,
25
+ arrows
26
+ }: {
27
+ children?: React.ReactNode;
28
+ customLeftArrow?: React.ReactNode;
29
+ customRightArrow?: React.ReactNode;
30
+ arrows?: boolean;
31
+ }) => (
32
+ <div data-testid="carousel" data-arrows={String(arrows)}>
33
+ {customLeftArrow}
34
+ {customRightArrow}
35
+ {children}
36
+ </div>
37
+ )
38
+ }));
39
+
40
+ import PostsSliderSection from '../posts-slider-section';
41
+ import {
42
+ isSliderArrowIconBlock,
43
+ LEFT_ARROW_ICON_LABEL,
44
+ RIGHT_ARROW_ICON_LABEL
45
+ } from '../../utils/slider-arrows';
46
+
47
+ /**
48
+ * The slider sections used to hand `arrows` straight to react-multi-carousel and
49
+ * let the library draw its own buttons, so an arrow was a thing a brand could
50
+ * switch off and nothing else. It is now drawn from two icon blocks the section
51
+ * carries — the same layers the `slider` BLOCK has always had, which is what
52
+ * makes a Featured Brand Collection's arrows selectable.
53
+ *
54
+ * What these protect, in order: that the arrow blocks never appear as slides,
55
+ * that the whole authored style map lands (the block's version reads a
56
+ * hand-picked ten keys and drops the rest), and that a section with no arrow
57
+ * blocks still behaves exactly as it did.
58
+ */
59
+
60
+ const slide = (id: string) => ({
61
+ id,
62
+ type: 'group',
63
+ label: id,
64
+ styles: {},
65
+ order: 0,
66
+ blocks: []
67
+ });
68
+
69
+ const arrow = (label: string, styles: Record<string, unknown> = {}) => ({
70
+ id: `arrow-${label === LEFT_ARROW_ICON_LABEL ? 'left' : 'right'}`,
71
+ type: 'icon',
72
+ label,
73
+ value: { url: '', alt: label },
74
+ properties: {},
75
+ styles,
76
+ order: 9
77
+ });
78
+
79
+ const renderSection = (blocks: any[], over: Record<string, unknown> = {}) =>
80
+ render(
81
+ <PostsSliderSection
82
+ {...({
83
+ section: {
84
+ id: 'sec',
85
+ type: 'flash-deals-slider',
86
+ label: 'sec',
87
+ // Two visible slides against one item per view, so the arrows are
88
+ // shown at all: `showArrows` also requires something to scroll to.
89
+ properties: { 'items-per-slide': 1, 'show-arrows': true },
90
+ styles: {},
91
+ blocks
92
+ },
93
+ currentBreakpoint: 'desktop',
94
+ placeholderId: 'ph',
95
+ isDesigner: false,
96
+ selectedBlockId: null,
97
+ ...over
98
+ } as any)}
99
+ />
100
+ );
101
+
102
+ const renderSectionWithStyles = (styles: Record<string, unknown>) =>
103
+ render(
104
+ <PostsSliderSection
105
+ {...({
106
+ section: {
107
+ id: 'sec',
108
+ type: 'flash-deals-slider',
109
+ label: 'sec',
110
+ properties: { 'items-per-slide': 1, 'show-arrows': true },
111
+ styles,
112
+ blocks: [slide('a'), slide('b')]
113
+ },
114
+ currentBreakpoint: 'desktop',
115
+ placeholderId: 'ph',
116
+ isDesigner: false,
117
+ selectedBlockId: null
118
+ } as any)}
119
+ />
120
+ );
121
+
122
+ const arrowButtons = (container: HTMLElement) =>
123
+ Array.from(
124
+ container.querySelectorAll('button[data-block-id^="arrow-"]')
125
+ ) as HTMLButtonElement[];
126
+
127
+ describe('identifying an arrow block', () => {
128
+ it('matches an icon block carrying either arrow label', () => {
129
+ expect(isSliderArrowIconBlock(arrow(LEFT_ARROW_ICON_LABEL))).toBe(true);
130
+ expect(isSliderArrowIconBlock(arrow(RIGHT_ARROW_ICON_LABEL))).toBe(true);
131
+ });
132
+
133
+ it('leaves an icon block a designer added as a real slide alone', () => {
134
+ // The section now allows `icon` blocks, so type alone cannot mean "arrow".
135
+ expect(
136
+ isSliderArrowIconBlock({ type: 'icon', label: 'Payment Badge' })
137
+ ).toBe(false);
138
+ });
139
+
140
+ it('does not match a non-icon block that happens to share the label', () => {
141
+ expect(
142
+ isSliderArrowIconBlock({ type: 'text', label: LEFT_ARROW_ICON_LABEL })
143
+ ).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe('the arrow blocks are chrome, not slides', () => {
148
+ it('renders only the real slides', () => {
149
+ const { container } = renderSection([
150
+ slide('a'),
151
+ slide('b'),
152
+ arrow(LEFT_ARROW_ICON_LABEL),
153
+ arrow(RIGHT_ARROW_ICON_LABEL)
154
+ ]);
155
+
156
+ const ids = Array.from(
157
+ container.querySelectorAll('[data-testid="slide"]')
158
+ ).map((node) => node.getAttribute('data-id'));
159
+
160
+ expect(ids).not.toContain('arrow-left');
161
+ expect(ids).not.toContain('arrow-right');
162
+ expect(ids).toContain('a');
163
+ });
164
+
165
+ it('still renders an icon block that is not an arrow as a slide', () => {
166
+ const { container } = renderSection([
167
+ slide('a'),
168
+ slide('b'),
169
+ { ...slide('badge'), type: 'icon', label: 'Payment Badge' }
170
+ ]);
171
+
172
+ const ids = Array.from(
173
+ container.querySelectorAll('[data-testid="slide"]')
174
+ ).map((node) => node.getAttribute('data-id'));
175
+
176
+ expect(ids).toContain('badge');
177
+ });
178
+ });
179
+
180
+ describe('the arrow wears everything the designer authored', () => {
181
+ it('applies style keys the block renderer used to drop', () => {
182
+ const { container } = renderSection([
183
+ slide('a'),
184
+ slide('b'),
185
+ arrow(LEFT_ARROW_ICON_LABEL, {
186
+ // None of these three are in the `slider` block's allowlist, which is
187
+ // the gap this closes.
188
+ 'box-shadow': { desktop: '0 2px 8px rgba(0,0,0,0.4)' },
189
+ border: { desktop: '2px solid #ff0000' },
190
+ transition: { desktop: 'all 0.3s ease' }
191
+ })
192
+ ]);
193
+
194
+ const [button] = arrowButtons(container);
195
+
196
+ expect(button).toBeTruthy();
197
+ expect(button.style.boxShadow).toBe('0 2px 8px rgba(0,0,0,0.4)');
198
+ expect(button.style.border).toBe('2px solid #ff0000');
199
+ expect(button.style.transition).toBe('all 0.3s ease');
200
+ });
201
+
202
+ it('lets an authored value replace the plate default rather than lose to it', () => {
203
+ const { container } = renderSection([
204
+ slide('a'),
205
+ slide('b'),
206
+ arrow(LEFT_ARROW_ICON_LABEL, {
207
+ 'border-radius': { desktop: '4px' },
208
+ 'background-color': { desktop: '#ffffff' },
209
+ width: { desktop: '64px' }
210
+ })
211
+ ]);
212
+
213
+ const [button] = arrowButtons(container);
214
+
215
+ // The circle was the one thing the library button never let go of.
216
+ expect(button.style.borderRadius).toBe('4px');
217
+ // jsdom normalises the authored hex; what matters is that it replaced the
218
+ // default translucent black rather than losing to it.
219
+ expect(button.style.backgroundColor).toBe('rgb(255, 255, 255)');
220
+ expect(button.style.width).toBe('64px');
221
+ });
222
+
223
+ it('keeps the defaults for everything left unauthored', () => {
224
+ const { container } = renderSection([
225
+ slide('a'),
226
+ slide('b'),
227
+ arrow(LEFT_ARROW_ICON_LABEL)
228
+ ]);
229
+
230
+ const [button] = arrowButtons(container);
231
+
232
+ expect(button.style.borderRadius).toBe('50%');
233
+ expect(button.style.width).toBe('40px');
234
+ });
235
+
236
+ it('carries data-block-id, which is how the generated stylesheet reaches it', () => {
237
+ // States, part keys and the custom-CSS hatch are all emitted against
238
+ // `[data-block-id="…"]`, so without the attribute half the editor's
239
+ // controls would write CSS that matched nothing.
240
+ const { container } = renderSection([
241
+ slide('a'),
242
+ slide('b'),
243
+ arrow(RIGHT_ARROW_ICON_LABEL)
244
+ ]);
245
+
246
+ expect(
247
+ container.querySelector('button[data-block-id="arrow-right"]')
248
+ ).toBeTruthy();
249
+ });
250
+
251
+ it('reads the size control onto the glyph, not the plate', () => {
252
+ const { container } = renderSection([
253
+ slide('a'),
254
+ slide('b'),
255
+ arrow(LEFT_ARROW_ICON_LABEL, { size: { desktop: 32 } })
256
+ ]);
257
+
258
+ const [button] = arrowButtons(container);
259
+ const glyph = button.querySelector('img') as HTMLImageElement;
260
+
261
+ expect(glyph.style.width).toBe('32px');
262
+ // `size` is not a CSS property; it must not reach the button.
263
+ expect(button.getAttribute('style')).not.toContain('size:');
264
+ });
265
+ });
266
+
267
+ /**
268
+ * The section's own declarations belong to the `<section>` element.
269
+ *
270
+ * `generateThemeCSS` emits them against `[data-section-id="…"]`, and this
271
+ * component used to apply the very same map inline to the box it renders inside
272
+ * that section — so each of them landed on two nested elements. The compounding
273
+ * ones are what a designer actually saw: 68px of side padding came out as 136,
274
+ * and the band's background was painted full-width and then again inside itself.
275
+ */
276
+ describe('the section is dressed once, by the section element', () => {
277
+ it('puts no section declaration on the box inside it', () => {
278
+ const { container } = renderSectionWithStyles({
279
+ 'padding-left': { desktop: '68px' },
280
+ 'background-color': { desktop: '#f8f9fb' },
281
+ border: { desktop: '2px solid #000000' }
282
+ });
283
+
284
+ const box = container.firstElementChild as HTMLElement;
285
+
286
+ // Not "the right subset" — none of it. The section element carries all of
287
+ // it, and anything here would be a second copy.
288
+ expect(box.getAttribute('style')).toBeNull();
289
+ });
290
+
291
+ it('leaves padding to the stylesheet rather than adding a second copy', () => {
292
+ const { container } = renderSectionWithStyles({
293
+ 'padding-left': { desktop: '68px' },
294
+ 'padding-right': { desktop: '68px' }
295
+ });
296
+
297
+ const box = container.firstElementChild as HTMLElement;
298
+
299
+ expect(box.style.paddingLeft).toBe('');
300
+ expect(box.style.paddingRight).toBe('');
301
+ });
302
+
303
+ it('does not repaint the band background inside the band', () => {
304
+ const { container } = renderSectionWithStyles({
305
+ 'background-color': { desktop: '#f8f9fb' }
306
+ });
307
+
308
+ const box = container.firstElementChild as HTMLElement;
309
+
310
+ expect(box.style.backgroundColor).toBe('');
311
+ });
312
+
313
+ it('keeps its own centring and content width, which are the box\'s to own', () => {
314
+ const { container } = renderSectionWithStyles({});
315
+
316
+ const box = container.firstElementChild as HTMLElement;
317
+
318
+ expect(box.className).toContain('mx-auto');
319
+ expect(box.className).toContain('max-w-7xl');
320
+ });
321
+ });
322
+
323
+ /**
324
+ * Where the arrow is in the DOM decides what a designer can ask of it.
325
+ *
326
+ * react-multi-carousel renders a `customArrow` inside
327
+ * `.react-multi-carousel-list`, which carries `overflow: hidden` — that is what
328
+ * hides the off-screen slides, so it cannot be opened. An arrow in there is
329
+ * clipped the moment it is offset past the container, which is exactly the
330
+ * design that asks for the arrow flush to the page edge while the cards stay
331
+ * inset. So the arrows are siblings of the carousel instead, in the box that
332
+ * positions it and clips nothing.
333
+ */
334
+ describe('the arrow sits outside the carousel, not inside its clipped list', () => {
335
+ it('is not a descendant of the carousel', () => {
336
+ const { container } = renderSection([
337
+ slide('a'),
338
+ slide('b'),
339
+ arrow(LEFT_ARROW_ICON_LABEL)
340
+ ]);
341
+
342
+ const carousel = container.querySelector('[data-testid="carousel"]')!;
343
+ const [button] = arrowButtons(container);
344
+
345
+ expect(carousel.contains(button)).toBe(false);
346
+ });
347
+
348
+ it('turns the library arrows off, so there is only one pair', () => {
349
+ const { container } = renderSection([
350
+ slide('a'),
351
+ slide('b'),
352
+ arrow(LEFT_ARROW_ICON_LABEL)
353
+ ]);
354
+
355
+ expect(
356
+ container
357
+ .querySelector('[data-testid="carousel"]')
358
+ ?.getAttribute('data-arrows')
359
+ ).toBe('false');
360
+ });
361
+
362
+ it('keeps the library arrows when the section brought none of its own', () => {
363
+ const { container } = renderSection([slide('a'), slide('b')]);
364
+
365
+ expect(
366
+ container
367
+ .querySelector('[data-testid="carousel"]')
368
+ ?.getAttribute('data-arrows')
369
+ ).toBe('true');
370
+ });
371
+
372
+ it('carries a negative offset through instead of falling back to the default', () => {
373
+ // The whole point: -124px is how the arrow reaches past the band's padding.
374
+ const { container } = renderSection([
375
+ slide('a'),
376
+ slide('b'),
377
+ arrow(LEFT_ARROW_ICON_LABEL, { left: { desktop: '-124px' } })
378
+ ]);
379
+
380
+ const [button] = arrowButtons(container);
381
+
382
+ expect(button.style.left).toBe('-124px');
383
+ // The `left-4` class default must step aside, or the two would fight.
384
+ expect(button.className).not.toContain('left-4');
385
+ });
386
+ });
387
+
388
+ /**
389
+ * The band and the slides are two boxes now.
390
+ *
391
+ * They used to be one, so there was nowhere to say "inset the cards from the
392
+ * left, leave the arrows at the edge". Padding the section moved the arrows with
393
+ * the cards — they are positioned inside it — and the only way out was a
394
+ * negative offset on each arrow kept equal to that padding by hand, at every
395
+ * breakpoint.
396
+ */
397
+ describe('the slides have their own box, which the arrows are not in', () => {
398
+ it('wraps the carousel in the slides part', () => {
399
+ const { container } = renderSection([slide('a'), slide('b')]);
400
+
401
+ const slides = container.querySelector('[data-part="slides"]');
402
+
403
+ expect(slides).toBeTruthy();
404
+ expect(slides!.querySelector('[data-testid="carousel"]')).toBeTruthy();
405
+ });
406
+
407
+ it('leaves the arrows outside it, so insetting the cards cannot move them', () => {
408
+ const { container } = renderSection([
409
+ slide('a'),
410
+ slide('b'),
411
+ arrow(LEFT_ARROW_ICON_LABEL),
412
+ arrow(RIGHT_ARROW_ICON_LABEL)
413
+ ]);
414
+
415
+ const slides = container.querySelector('[data-part="slides"]')!;
416
+
417
+ arrowButtons(container).forEach((button) => {
418
+ expect(slides.contains(button)).toBe(false);
419
+ });
420
+ expect(arrowButtons(container)).toHaveLength(2);
421
+ });
422
+
423
+ it('keeps the arrows in the box that positions them', () => {
424
+ // Siblings of the slides box, children of the box that is `relative` — that
425
+ // is what makes an authored offset resolve against the band.
426
+ const { container } = renderSection([
427
+ slide('a'),
428
+ slide('b'),
429
+ arrow(LEFT_ARROW_ICON_LABEL)
430
+ ]);
431
+
432
+ const box = container.firstElementChild as HTMLElement;
433
+ const [button] = arrowButtons(container);
434
+
435
+ expect(button.parentElement).toBe(box);
436
+ expect(box.className).toContain('relative');
437
+ });
438
+ });
439
+
440
+ describe('a section without arrow blocks', () => {
441
+ it('draws no custom arrow at all', () => {
442
+ const { container } = renderSection([slide('a'), slide('b')]);
443
+
444
+ expect(arrowButtons(container)).toHaveLength(0);
445
+ });
446
+ });
447
+
448
+ describe('the arrow in the designer', () => {
449
+ it('is marked when its own layer is the selected one', () => {
450
+ const { container } = renderSection(
451
+ [slide('a'), slide('b'), arrow(LEFT_ARROW_ICON_LABEL)],
452
+ { isDesigner: true, selectedBlockId: 'arrow-left' }
453
+ );
454
+
455
+ const [button] = arrowButtons(container);
456
+
457
+ expect(button.className).toContain('ring-2');
458
+ });
459
+
460
+ it('is not marked when something else is selected', () => {
461
+ const { container } = renderSection(
462
+ [slide('a'), slide('b'), arrow(LEFT_ARROW_ICON_LABEL)],
463
+ { isDesigner: true, selectedBlockId: 'a' }
464
+ );
465
+
466
+ const [button] = arrowButtons(container);
467
+
468
+ expect(button.className).not.toContain('ring-2');
469
+ });
470
+ });