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

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,497 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+
4
+ import { WithDesignerFeatures } from '../components/with-designer-features';
5
+ import SectionWrapper from '../sections/section-wrapper';
6
+ import { generateThemeCSS } from '../utils';
7
+
8
+ /**
9
+ * Every declaration a composition can put on `[data-block-id]` /
10
+ * `[data-section-id]` that establishes a clipping context for the element's
11
+ * descendants. The action toolbar is drawn *above* that box, so before the fix
12
+ * each one of these erased rename / duplicate / delete for the block or
13
+ * section it was set on, with no way back except undo.
14
+ */
15
+ const CLIPPING_DECLARATIONS: Array<[string, string]> = [
16
+ ['overflow', 'hidden'],
17
+ ['overflow', 'clip'],
18
+ ['overflow', 'auto'],
19
+ ['overflow', 'scroll'],
20
+ ['overflow-x', 'hidden'],
21
+ ['overflow-y', 'hidden'],
22
+ ['clip-path', 'inset(0)'],
23
+ ['contain', 'paint'],
24
+ ['contain', 'strict'],
25
+ ['contain', 'content'],
26
+ ['contain', 'layout paint']
27
+ ];
28
+
29
+ const ancestorsOf = (element: Element): Element[] => {
30
+ const chain: Element[] = [];
31
+ for (let node = element.parentElement; node; node = node.parentElement) {
32
+ chain.push(node);
33
+ }
34
+ return chain;
35
+ };
36
+
37
+ const stylableAncestorsOf = (element: Element): Element[] =>
38
+ ancestorsOf(element).filter(
39
+ (node) =>
40
+ node.hasAttribute('data-block-id') || node.hasAttribute('data-section-id')
41
+ );
42
+
43
+ const block = (over: Record<string, unknown> = {}) => ({
44
+ id: 'b1',
45
+ type: 'group',
46
+ label: 'Promo Card',
47
+ properties: {},
48
+ styles: {},
49
+ order: 0,
50
+ hidden: false,
51
+ ...over
52
+ });
53
+
54
+ const section = (over: Record<string, unknown> = {}) =>
55
+ ({
56
+ id: 's1',
57
+ type: 'generic-section',
58
+ name: 'promo',
59
+ label: 'Promo Section',
60
+ properties: {},
61
+ styles: {},
62
+ blocks: [],
63
+ order: 0,
64
+ hidden: false,
65
+ ...over
66
+ }) as any;
67
+
68
+ const handlers = () => ({
69
+ onMoveUp: jest.fn(),
70
+ onMoveDown: jest.fn(),
71
+ onDuplicate: jest.fn(),
72
+ onToggleVisibility: jest.fn(),
73
+ onDelete: jest.fn(),
74
+ onRename: jest.fn()
75
+ });
76
+
77
+ const renderBlock = (
78
+ clip: Array<[string, string]> = [],
79
+ actions = handlers(),
80
+ over: Record<string, unknown> = {}
81
+ ) => {
82
+ const view = render(
83
+ <WithDesignerFeatures
84
+ block={block(over)}
85
+ placeholderId="ph"
86
+ sectionId="s1"
87
+ isDesigner
88
+ isSelected
89
+ {...actions}
90
+ >
91
+ <span>block content</span>
92
+ </WithDesignerFeatures>
93
+ );
94
+
95
+ const host = view.container.querySelector(
96
+ '[data-block-id="b1"]'
97
+ ) as HTMLElement;
98
+ clip.forEach(([property, value]) => host.style.setProperty(property, value));
99
+
100
+ return { ...view, host, actions };
101
+ };
102
+
103
+ const renderSection = (
104
+ clip: Array<[string, string]> = [],
105
+ actions = handlers(),
106
+ over: Record<string, unknown> = {}
107
+ ) => {
108
+ const view = render(
109
+ <SectionWrapper
110
+ section={section(over)}
111
+ placeholderId="ph"
112
+ isDesigner
113
+ isSelected
114
+ {...actions}
115
+ >
116
+ <span>section content</span>
117
+ </SectionWrapper>
118
+ );
119
+
120
+ const host = view.container.querySelector(
121
+ '[data-section-id="s1"]'
122
+ ) as HTMLElement;
123
+ clip.forEach(([property, value]) => host.style.setProperty(property, value));
124
+
125
+ return { ...view, host, actions };
126
+ };
127
+
128
+ describe('designer chrome survives a clipping composition', () => {
129
+ describe.each(CLIPPING_DECLARATIONS)('%s: %s', (property, value) => {
130
+ it('keeps the block toolbar outside every element the composition styles', () => {
131
+ const { host } = renderBlock([[property, value]]);
132
+
133
+ const rename = screen.getByTitle('Click to rename');
134
+ const duplicate = screen.getByTitle('Duplicate');
135
+ const remove = screen.getByTitle('Delete');
136
+
137
+ [rename, duplicate, remove].forEach((control) => {
138
+ expect(host.contains(control)).toBe(false);
139
+ expect(stylableAncestorsOf(control)).toEqual([]);
140
+ });
141
+ });
142
+
143
+ it('keeps the section toolbar outside every element the composition styles', () => {
144
+ const { host } = renderSection([[property, value]]);
145
+
146
+ const rename = screen.getByTitle('Click to rename');
147
+ const duplicate = screen.getByTitle('Duplicate');
148
+ const remove = screen.getByTitle('Delete');
149
+
150
+ [rename, duplicate, remove].forEach((control) => {
151
+ expect(host.contains(control)).toBe(false);
152
+ expect(stylableAncestorsOf(control)).toEqual([]);
153
+ });
154
+ });
155
+
156
+ it('leaves rename, duplicate and delete working on the block', () => {
157
+ const { actions } = renderBlock([[property, value]]);
158
+
159
+ fireEvent.click(screen.getByTitle('Duplicate'));
160
+ expect(actions.onDuplicate).toHaveBeenCalledTimes(1);
161
+
162
+ fireEvent.click(screen.getByTitle('Delete'));
163
+ expect(actions.onDelete).toHaveBeenCalledTimes(1);
164
+
165
+ fireEvent.click(screen.getByTitle('Click to rename'));
166
+ fireEvent.change(screen.getByRole('textbox'), {
167
+ target: { value: 'Renamed Card' }
168
+ });
169
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
170
+ expect(actions.onRename).toHaveBeenCalledWith('Renamed Card');
171
+ });
172
+
173
+ it('still emits the declaration onto the storefront element', () => {
174
+ const css = generateThemeCSS([
175
+ {
176
+ id: 's1',
177
+ styles: { [property]: value },
178
+ blocks: [{ id: 'b1', styles: { [property]: value } }]
179
+ }
180
+ ] as any);
181
+
182
+ expect(css).toContain(`[data-section-id="s1"] {`);
183
+ expect(css).toContain(`[data-block-id="b1"] {`);
184
+ // Once for the section rule, once for the block rule.
185
+ expect(css.split(`${property}: ${value};`)).toHaveLength(3);
186
+ });
187
+ });
188
+ });
189
+
190
+ describe('designer chrome placement is unchanged', () => {
191
+ afterEach(() => jest.restoreAllMocks());
192
+
193
+ it('pins the toolbar to the host box it was drawn against before', () => {
194
+ const hostRect = {
195
+ top: 120,
196
+ left: 40,
197
+ right: 340,
198
+ bottom: 320,
199
+ width: 300,
200
+ height: 200,
201
+ x: 40,
202
+ y: 120
203
+ };
204
+
205
+ jest
206
+ .spyOn(Element.prototype, 'getBoundingClientRect')
207
+ .mockImplementation(function (this: Element) {
208
+ const rect = this.hasAttribute('data-block-id')
209
+ ? hostRect
210
+ : {
211
+ top: 0,
212
+ left: 0,
213
+ right: 0,
214
+ bottom: 0,
215
+ width: 0,
216
+ height: 0,
217
+ x: 0,
218
+ y: 0
219
+ };
220
+ return { ...rect, toJSON: () => rect } as DOMRect;
221
+ });
222
+
223
+ renderBlock();
224
+
225
+ const toolbar = document.querySelector(
226
+ '[data-action-toolbar]'
227
+ ) as HTMLElement;
228
+ expect(toolbar).not.toBeNull();
229
+
230
+ // Same offsets as before: above the host's top edge, flush with its left.
231
+ expect(toolbar.className).toContain('absolute');
232
+ expect(toolbar.className).toContain('-top-0.5');
233
+ expect(toolbar.className).toContain('left-0');
234
+ expect(toolbar.className).toContain('-translate-y-full');
235
+
236
+ const frame = toolbar.parentElement as HTMLElement;
237
+ expect(frame.style.position).toBe('fixed');
238
+ expect(frame.style.top).toBe('120px');
239
+ expect(frame.style.left).toBe('40px');
240
+ });
241
+
242
+ it('leaves nothing in the host that takes layout space', () => {
243
+ const { host } = renderBlock();
244
+
245
+ const anchor = host.querySelector(
246
+ '[data-action-toolbar-anchor]'
247
+ ) as HTMLElement;
248
+
249
+ // Anything with a box here is a flex item or grid cell the storefront does
250
+ // not have, i.e. a canvas/storefront parity break.
251
+ expect(anchor.style.display).toBe('none');
252
+ });
253
+
254
+ it('draws no toolbar for a hidden block, which has no box to sit above', () => {
255
+ renderBlock([], handlers(), { hidden: true });
256
+
257
+ expect(document.querySelector('[data-action-toolbar]')).toBeNull();
258
+ });
259
+
260
+ /**
261
+ * "Move Up" is a button on this toolbar. It reorders the block: the host
262
+ * moves to a different y, but it is not resized, it is not scrolled, and
263
+ * none of its own attributes change — so no scroll, resize, ResizeObserver
264
+ * or MutationObserver record is produced. Drawn in-tree the toolbar came
265
+ * along for free; measured once on mount it is left behind, floating over an
266
+ * unrelated part of the page until something else happens to fire a
267
+ * listener. The canvas re-renders for every one of these, so a re-render has
268
+ * to be a measuring point too.
269
+ */
270
+ it('follows the host when a re-render moves it with no scroll, resize or attribute change', () => {
271
+ let hostTop = 300;
272
+
273
+ jest
274
+ .spyOn(Element.prototype, 'getBoundingClientRect')
275
+ .mockImplementation(function (this: Element) {
276
+ const rect = this.hasAttribute('data-block-id')
277
+ ? {
278
+ top: hostTop,
279
+ left: 40,
280
+ right: 340,
281
+ bottom: hostTop + 200,
282
+ width: 300,
283
+ height: 200,
284
+ x: 40,
285
+ y: hostTop
286
+ }
287
+ : {
288
+ top: 0,
289
+ left: 0,
290
+ right: 0,
291
+ bottom: 0,
292
+ width: 0,
293
+ height: 0,
294
+ x: 0,
295
+ y: 0
296
+ };
297
+ return { ...rect, toJSON: () => rect } as DOMRect;
298
+ });
299
+
300
+ const actions = handlers();
301
+ const { rerender } = renderBlock([], actions);
302
+
303
+ const frameOf = () =>
304
+ (document.querySelector('[data-action-toolbar]') as HTMLElement)
305
+ .parentElement as HTMLElement;
306
+
307
+ expect(frameOf().style.top).toBe('300px');
308
+
309
+ // The reorder the toolbar's own button triggers.
310
+ hostTop = 100;
311
+ rerender(
312
+ <WithDesignerFeatures
313
+ block={block()}
314
+ placeholderId="ph"
315
+ sectionId="s1"
316
+ isDesigner
317
+ isSelected
318
+ {...actions}
319
+ >
320
+ <span>block content</span>
321
+ </WithDesignerFeatures>
322
+ );
323
+
324
+ expect(frameOf().style.top).toBe('100px');
325
+ });
326
+
327
+ /**
328
+ * The shape the canvas actually renders: a block inside the section that
329
+ * owns it, both able to draw chrome. Once the toolbar is resolved by walking
330
+ * up from an anchor instead of being handed its host, picking the *nearest*
331
+ * styled ancestor is the whole contract — a block toolbar pinned to its
332
+ * section would sit at the wrong end of a tall section.
333
+ */
334
+ it('pins a nested block toolbar to the block, not to the section around it', () => {
335
+ const sectionRect = {
336
+ top: 0,
337
+ left: 0,
338
+ right: 1440,
339
+ bottom: 900,
340
+ width: 1440,
341
+ height: 900,
342
+ x: 0,
343
+ y: 0
344
+ };
345
+ const blockRect = {
346
+ top: 640,
347
+ left: 220,
348
+ right: 520,
349
+ bottom: 840,
350
+ width: 300,
351
+ height: 200,
352
+ x: 220,
353
+ y: 640
354
+ };
355
+
356
+ jest
357
+ .spyOn(Element.prototype, 'getBoundingClientRect')
358
+ .mockImplementation(function (this: Element) {
359
+ const rect = this.hasAttribute('data-block-id')
360
+ ? blockRect
361
+ : this.hasAttribute('data-section-id')
362
+ ? sectionRect
363
+ : {
364
+ top: 0,
365
+ left: 0,
366
+ right: 0,
367
+ bottom: 0,
368
+ width: 0,
369
+ height: 0,
370
+ x: 0,
371
+ y: 0
372
+ };
373
+ return { ...rect, toJSON: () => rect } as DOMRect;
374
+ });
375
+
376
+ render(
377
+ <SectionWrapper
378
+ section={section()}
379
+ placeholderId="ph"
380
+ isDesigner
381
+ isSelected
382
+ {...handlers()}
383
+ >
384
+ <WithDesignerFeatures
385
+ block={block()}
386
+ placeholderId="ph"
387
+ sectionId="s1"
388
+ isDesigner
389
+ isSelected
390
+ {...handlers()}
391
+ >
392
+ <span>block content</span>
393
+ </WithDesignerFeatures>
394
+ </SectionWrapper>
395
+ );
396
+
397
+ const anchors = Array.from(
398
+ document.querySelectorAll('[data-action-toolbar-anchor]')
399
+ );
400
+ expect(anchors).toHaveLength(2);
401
+
402
+ const frames = Array.from(
403
+ document.querySelectorAll('[data-action-toolbar]')
404
+ ).map((toolbar) => (toolbar.parentElement as HTMLElement).style);
405
+
406
+ const tops = frames.map((style) => style.top).sort();
407
+ const lefts = frames.map((style) => style.left).sort();
408
+
409
+ expect(tops).toEqual(['0px', '640px']);
410
+ expect(lefts).toEqual(['0px', '220px']);
411
+
412
+ // The two must be pinned to different boxes: one toolbar landing on both
413
+ // corners would mean `closest` resolved to the same host twice.
414
+ expect(new Set(frames.map((style) => `${style.top}/${style.left}`)).size)
415
+ .toBe(2);
416
+ });
417
+
418
+ it('follows the host when a re-render moves a section', () => {
419
+ let hostTop = 500;
420
+
421
+ jest
422
+ .spyOn(Element.prototype, 'getBoundingClientRect')
423
+ .mockImplementation(function (this: Element) {
424
+ const rect = this.hasAttribute('data-section-id')
425
+ ? {
426
+ top: hostTop,
427
+ left: 0,
428
+ right: 1440,
429
+ bottom: hostTop + 400,
430
+ width: 1440,
431
+ height: 400,
432
+ x: 0,
433
+ y: hostTop
434
+ }
435
+ : {
436
+ top: 0,
437
+ left: 0,
438
+ right: 0,
439
+ bottom: 0,
440
+ width: 0,
441
+ height: 0,
442
+ x: 0,
443
+ y: 0
444
+ };
445
+ return { ...rect, toJSON: () => rect } as DOMRect;
446
+ });
447
+
448
+ const actions = handlers();
449
+ const { rerender } = renderSection([], actions);
450
+
451
+ const frameOf = () =>
452
+ (document.querySelector('[data-action-toolbar]') as HTMLElement)
453
+ .parentElement as HTMLElement;
454
+
455
+ expect(frameOf().style.top).toBe('500px');
456
+
457
+ hostTop = 60;
458
+ rerender(
459
+ <SectionWrapper
460
+ section={section()}
461
+ placeholderId="ph"
462
+ isDesigner
463
+ isSelected
464
+ {...actions}
465
+ >
466
+ <span>section content</span>
467
+ </SectionWrapper>
468
+ );
469
+
470
+ expect(frameOf().style.top).toBe('60px');
471
+ });
472
+ });
473
+
474
+ describe('storefront output is untouched', () => {
475
+ it('renders no designer chrome and adds nothing to the document', () => {
476
+ const { container } = render(
477
+ <WithDesignerFeatures
478
+ block={block()}
479
+ placeholderId="ph"
480
+ sectionId="s1"
481
+ isDesigner={false}
482
+ isSelected
483
+ {...handlers()}
484
+ >
485
+ <span>block content</span>
486
+ </WithDesignerFeatures>
487
+ );
488
+
489
+ const host = container.querySelector('[data-block-id="b1"]') as HTMLElement;
490
+ expect(host.children).toHaveLength(1);
491
+ expect(host.innerHTML).toBe('<span>block content</span>');
492
+ expect(document.querySelectorAll('[data-action-toolbar]')).toHaveLength(0);
493
+ expect(
494
+ document.querySelectorAll('[data-action-toolbar-anchor]')
495
+ ).toHaveLength(0);
496
+ });
497
+ });