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

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 (50) hide show
  1. package/CHANGELOG.md +5 -4
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -0,0 +1,503 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { fireEvent, render } from '@testing-library/react';
3
+ // The Node build, not the browser one: it is the path a storefront actually
4
+ // server-renders through, and the browser build wants a MessageChannel jsdom
5
+ // does not have.
6
+ import { renderToStaticMarkup } from 'react-dom/server.node';
7
+
8
+ import { useOverlay } from '../use-overlay';
9
+ import type { OverlayPointerBook } from '../use-overlay';
10
+
11
+ /**
12
+ * The defect this primitive exists for, in the shape the blocks copied it in:
13
+ * `store-locator-block.tsx:679`, `variant-selector-block.tsx:508` and
14
+ * `facet-filters-block.tsx:757` each register a DOCUMENT-level `focusin`
15
+ * listener that pulls focus back into its own panel. Two of them on one page
16
+ * is an ordinary composition — those files' own comments say so — and each one
17
+ * reads the other's focus as "outside".
18
+ *
19
+ * Reproduced below without React so the loop is the subject and nothing else:
20
+ * the pull counter aborts at 50 instead of waiting for the real
21
+ * `RangeError: Maximum call stack size exceeded`.
22
+ */
23
+ interface PullBudget {
24
+ pulls: number;
25
+ limit: number;
26
+ }
27
+
28
+ const installLegacyTrap = (panel: HTMLElement, budget: PullBudget) => {
29
+ const focusFirst = () => {
30
+ // Stands in for the call stack running out, so the test reports a number
31
+ // instead of hanging on the way to a RangeError.
32
+ if (budget.pulls >= budget.limit) return;
33
+ budget.pulls += 1;
34
+
35
+ const [first] = Array.from(panel.querySelectorAll<HTMLElement>('button'));
36
+ (first || panel).focus();
37
+ };
38
+
39
+ const handleFocusIn = (event: FocusEvent) => {
40
+ if (panel.contains(event.target as Node)) return;
41
+ focusFirst();
42
+ };
43
+
44
+ document.addEventListener('focusin', handleFocusIn);
45
+
46
+ return () => document.removeEventListener('focusin', handleFocusIn);
47
+ };
48
+
49
+ const buildLegacyPanel = (name: string) => {
50
+ const panel = document.createElement('div');
51
+ panel.tabIndex = -1;
52
+ const button = document.createElement('button');
53
+ button.textContent = name;
54
+ panel.appendChild(button);
55
+ document.body.appendChild(panel);
56
+
57
+ return panel;
58
+ };
59
+
60
+ interface ProbeProps {
61
+ name: string;
62
+ open: boolean;
63
+ modal?: boolean;
64
+ lockScroll?: boolean;
65
+ closeOnEscape?: boolean;
66
+ manageFocus?: boolean;
67
+ isDesigner?: boolean;
68
+ withTrigger?: boolean;
69
+ onClose?: () => void;
70
+ onDeactivate?: () => void;
71
+ bookRef?: { current: OverlayPointerBook | null };
72
+ }
73
+
74
+ /**
75
+ * A block-shaped consumer: a trigger, a root that survives the trigger, and a
76
+ * panel that only exists while open — the same composition every block that
77
+ * opens a picker over the page already has.
78
+ */
79
+ function OverlayProbe(props: ProbeProps) {
80
+ const {
81
+ name,
82
+ open,
83
+ withTrigger = true,
84
+ bookRef,
85
+ modal,
86
+ lockScroll,
87
+ closeOnEscape,
88
+ manageFocus,
89
+ isDesigner,
90
+ onClose,
91
+ onDeactivate
92
+ } = props;
93
+
94
+ const triggerRef = useRef<HTMLButtonElement>(null);
95
+ const rootRef = useRef<HTMLDivElement>(null);
96
+
97
+ const overlay = useOverlay<HTMLDivElement>({
98
+ open,
99
+ modal,
100
+ lockScroll,
101
+ closeOnEscape,
102
+ manageFocus,
103
+ isDesigner,
104
+ onClose,
105
+ onDeactivate,
106
+ returnFocusRef: triggerRef,
107
+ fallbackFocusRef: rootRef
108
+ });
109
+
110
+ useEffect(() => {
111
+ if (bookRef) bookRef.current = overlay.pointers;
112
+ }, [bookRef, overlay.pointers]);
113
+
114
+ return (
115
+ <div ref={rootRef} tabIndex={-1} data-testid={`${name}-root`}>
116
+ {withTrigger ? (
117
+ <button type="button" ref={triggerRef} data-testid={`${name}-trigger`}>
118
+ open {name}
119
+ </button>
120
+ ) : null}
121
+ {open ? (
122
+ <div
123
+ {...overlay.overlayProps}
124
+ role="dialog"
125
+ tabIndex={-1}
126
+ data-testid={`${name}-panel`}
127
+ >
128
+ <button type="button" data-testid={`${name}-first`}>
129
+ first
130
+ </button>
131
+ <button type="button" data-testid={`${name}-last`}>
132
+ last
133
+ </button>
134
+ </div>
135
+ ) : null}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ interface PageProps {
141
+ a?: Partial<ProbeProps>;
142
+ b?: Partial<ProbeProps> | null;
143
+ }
144
+
145
+ function Page({ a, b }: PageProps) {
146
+ return (
147
+ <div>
148
+ <button type="button" data-testid="outside">
149
+ outside
150
+ </button>
151
+ <OverlayProbe name="a" open={false} {...a} />
152
+ {b === null ? null : <OverlayProbe name="b" open={false} {...b} />}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ describe('useOverlay', () => {
158
+ afterEach(() => {
159
+ document.body.style.overflow = '';
160
+ });
161
+
162
+ it('reproduces the loop the per-block traps have: two document-level traps pull focus at each other forever', () => {
163
+ const panelA = buildLegacyPanel('a');
164
+ const panelB = buildLegacyPanel('b');
165
+ const outside = document.createElement('button');
166
+ document.body.appendChild(outside);
167
+
168
+ const budget: PullBudget = { pulls: 0, limit: 50 };
169
+ const removeA = installLegacyTrap(panelA, budget);
170
+ const removeB = installLegacyTrap(panelB, budget);
171
+
172
+ try {
173
+ outside.focus();
174
+ } finally {
175
+ removeA();
176
+ removeB();
177
+ panelA.remove();
178
+ panelB.remove();
179
+ outside.remove();
180
+ }
181
+
182
+ // Neither trap ever wins: one move of focus spends the whole budget on the
183
+ // two of them handing it back and forth, which in a browser is
184
+ // `RangeError: Maximum call stack size exceeded` and a frozen tab.
185
+ expect(budget.pulls).toBe(budget.limit);
186
+ });
187
+
188
+ it('lets only the topmost overlay pull focus back when two are open and focus moves', () => {
189
+ const { getByTestId } = render(
190
+ <Page a={{ open: true }} b={{ open: true }} />
191
+ );
192
+
193
+ let focusMoves = 0;
194
+ const countMove = () => {
195
+ focusMoves += 1;
196
+ };
197
+ document.addEventListener('focusin', countMove);
198
+
199
+ // The scenario the per-block tests never reached: both panels open, and
200
+ // THEN focus moves.
201
+ try {
202
+ getByTestId('outside').focus();
203
+ } finally {
204
+ document.removeEventListener('focusin', countMove);
205
+ }
206
+
207
+ // Focus landing outside, and one pull back in. That is the whole exchange.
208
+ expect(focusMoves).toBe(2);
209
+ expect(getByTestId('b-panel').contains(document.activeElement)).toBe(true);
210
+ // The one beneath is inert, not gone.
211
+ expect(getByTestId('a-panel')).toBeInTheDocument();
212
+ });
213
+
214
+ it('pulls focus out of the overlay beneath exactly once, without the two trading it', () => {
215
+ const { getByTestId } = render(
216
+ <Page a={{ open: true }} b={{ open: true }} />
217
+ );
218
+
219
+ getByTestId('a-first').focus();
220
+
221
+ expect(document.activeElement).toBe(getByTestId('b-first'));
222
+ });
223
+
224
+ it('claims aria-modal on the topmost only, and hands the claim back when it closes', () => {
225
+ const { getByTestId, rerender } = render(
226
+ <Page a={{ open: true }} b={{ open: false }} />
227
+ );
228
+
229
+ expect(getByTestId('a-panel')).toHaveAttribute('aria-modal', 'true');
230
+
231
+ rerender(<Page a={{ open: true }} b={{ open: true }} />);
232
+
233
+ // Two live dialogs both claiming it makes neither claim true.
234
+ expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
235
+ expect(getByTestId('b-panel')).toHaveAttribute('aria-modal', 'true');
236
+
237
+ rerender(<Page a={{ open: true }} b={{ open: false }} />);
238
+
239
+ expect(getByTestId('a-panel')).toHaveAttribute('aria-modal', 'true');
240
+ });
241
+
242
+ /**
243
+ * `innerHTML` serializes attributes in the order the DOM took them, so an
244
+ * attribute that first appears on a LATER commit is appended after
245
+ * everything the block wrote. Registration happens in an effect, so a claim
246
+ * that waits for it lands one commit late and quietly moves the markup of
247
+ * every panel that carries it — under goldens that are not about modality
248
+ * at all.
249
+ */
250
+ it('claims aria-modal on the commit that draws the panel, not the one after', () => {
251
+ function ClaimProbe({ open }: { open: boolean }) {
252
+ const overlay = useOverlay<HTMLDivElement>({ open });
253
+
254
+ return open ? (
255
+ <div
256
+ {...overlay.overlayProps}
257
+ role="dialog"
258
+ tabIndex={-1}
259
+ data-testid="claim"
260
+ />
261
+ ) : null;
262
+ }
263
+
264
+ const { getByTestId, rerender } = render(<ClaimProbe open={false} />);
265
+ rerender(<ClaimProbe open />);
266
+
267
+ // The order the props were written in, not `aria-modal` tacked on the end.
268
+ expect(getByTestId('claim').getAttributeNames()).toEqual([
269
+ 'aria-modal',
270
+ 'role',
271
+ 'tabindex',
272
+ 'data-testid'
273
+ ]);
274
+ });
275
+
276
+ it('never claims aria-modal for an overlay that is not modal', () => {
277
+ const { getByTestId } = render(<Page a={{ open: true, modal: false }} />);
278
+
279
+ // With no backdrop the page underneath is deliberately usable, so the
280
+ // claim would be a lie.
281
+ expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
282
+ });
283
+
284
+ it('wraps Tab both ways inside the topmost overlay', () => {
285
+ const { getByTestId } = render(<Page a={{ open: true }} />);
286
+
287
+ const panel = getByTestId('a-panel');
288
+ const first = getByTestId('a-first');
289
+ const last = getByTestId('a-last');
290
+
291
+ last.focus();
292
+ fireEvent.keyDown(last, { key: 'Tab' });
293
+ expect(document.activeElement).toBe(first);
294
+
295
+ fireEvent.keyDown(first, { key: 'Tab', shiftKey: true });
296
+ expect(document.activeElement).toBe(last);
297
+
298
+ expect(panel).toBeInTheDocument();
299
+ });
300
+
301
+ it('leaves focus with the overlay on top when the one beneath closes out of order', () => {
302
+ const { getByTestId, rerender } = render(
303
+ <Page a={{ open: true }} b={{ open: true }} />
304
+ );
305
+
306
+ const inTop = getByTestId('b-last');
307
+ inTop.focus();
308
+
309
+ rerender(<Page a={{ open: false }} b={{ open: true }} />);
310
+
311
+ // A closing overlay hands focus back to its trigger only when it is the
312
+ // one that owns focus; the modal still on top keeps it.
313
+ expect(document.activeElement).toBe(inTop);
314
+ expect(getByTestId('b-panel')).toHaveAttribute('aria-modal', 'true');
315
+
316
+ // And the top one still traps after the out-of-order close.
317
+ getByTestId('outside').focus();
318
+ expect(getByTestId('b-panel').contains(document.activeElement)).toBe(true);
319
+ });
320
+
321
+ it('returns focus to the trigger on close', () => {
322
+ const { getByTestId, rerender } = render(<Page a={{ open: false }} />);
323
+
324
+ getByTestId('a-trigger').focus();
325
+ rerender(<Page a={{ open: true }} />);
326
+ expect(getByTestId('a-panel').contains(document.activeElement)).toBe(true);
327
+
328
+ rerender(<Page a={{ open: false }} />);
329
+ expect(document.activeElement).toBe(getByTestId('a-trigger'));
330
+ });
331
+
332
+ it('lands focus somewhere real when the trigger is gone by the time it closes', () => {
333
+ const { getByTestId, rerender } = render(<Page a={{ open: false }} />);
334
+
335
+ getByTestId('a-trigger').focus();
336
+ rerender(<Page a={{ open: true }} />);
337
+
338
+ // The row that carried the trigger re-renders away while the panel is up.
339
+ rerender(<Page a={{ open: true, withTrigger: false }} />);
340
+ rerender(<Page a={{ open: false, withTrigger: false }} />);
341
+
342
+ expect(document.activeElement).toBe(getByTestId('a-root'));
343
+ });
344
+
345
+ it('does not leave focus inside a panel that is hidden rather than unmounted', () => {
346
+ function StickyOverlay({ open }: { open: boolean }) {
347
+ const overlay = useOverlay<HTMLDivElement>({ open });
348
+
349
+ // Some panels animate out: they stay mounted and merely stop being
350
+ // reachable, and focus left inside one is focus a shopper cannot see.
351
+ return (
352
+ <div {...overlay.overlayProps} tabIndex={-1} hidden={!open}>
353
+ <button type="button" data-testid="sticky-first">
354
+ first
355
+ </button>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ const { getByTestId, rerender } = render(<StickyOverlay open />);
361
+ expect(document.activeElement).toBe(getByTestId('sticky-first'));
362
+
363
+ rerender(<StickyOverlay open={false} />);
364
+
365
+ expect(document.activeElement).not.toBe(getByTestId('sticky-first'));
366
+ });
367
+
368
+ it('releases everything it took when it unmounts while still open', () => {
369
+ document.body.style.overflow = 'auto';
370
+ const onDeactivate = jest.fn();
371
+ const bookRef: { current: OverlayPointerBook | null } = { current: null };
372
+
373
+ const { getByTestId, unmount } = render(
374
+ <Page a={{ open: true, onDeactivate, bookRef }} />
375
+ );
376
+
377
+ getByTestId('a-first').focus();
378
+ bookRef.current?.track({ pointerId: 7, clientX: 10, clientY: 10 });
379
+ expect(bookRef.current?.size()).toBe(1);
380
+ expect(document.body.style.overflow).toBe('hidden');
381
+
382
+ unmount();
383
+
384
+ expect(document.body.style.overflow).toBe('auto');
385
+ expect(onDeactivate).toHaveBeenCalledTimes(1);
386
+ expect(bookRef.current?.size()).toBe(0);
387
+ });
388
+
389
+ it('counts every scroll-lock holder and restores the page’s own overflow only for the last', () => {
390
+ document.body.style.overflow = 'auto';
391
+
392
+ const { rerender } = render(<Page a={{ open: true }} b={{ open: true }} />);
393
+ expect(document.body.style.overflow).toBe('hidden');
394
+
395
+ rerender(<Page a={{ open: false }} b={{ open: true }} />);
396
+ // One holder left: the page must stay frozen behind the panel still up.
397
+ expect(document.body.style.overflow).toBe('hidden');
398
+
399
+ rerender(<Page a={{ open: false }} b={{ open: false }} />);
400
+ expect(document.body.style.overflow).toBe('auto');
401
+ });
402
+
403
+ it('leaves the page alone for an overlay that does not ask for the lock', () => {
404
+ document.body.style.overflow = 'auto';
405
+
406
+ render(<Page a={{ open: true, lockScroll: false }} />);
407
+
408
+ expect(document.body.style.overflow).toBe('auto');
409
+ });
410
+
411
+ it('closes the topmost overlay on Escape and nothing beneath it', () => {
412
+ const closeA = jest.fn();
413
+ const closeB = jest.fn();
414
+
415
+ render(
416
+ <Page
417
+ a={{ open: true, onClose: closeA }}
418
+ b={{ open: true, onClose: closeB }}
419
+ />
420
+ );
421
+
422
+ fireEvent.keyDown(document, { key: 'Escape' });
423
+
424
+ expect(closeB).toHaveBeenCalledTimes(1);
425
+ expect(closeA).not.toHaveBeenCalled();
426
+ });
427
+
428
+ it('honours closeOnEscape: false', () => {
429
+ const closeA = jest.fn();
430
+
431
+ render(<Page a={{ open: true, closeOnEscape: false, onClose: closeA }} />);
432
+ fireEvent.keyDown(document, { key: 'Escape' });
433
+
434
+ expect(closeA).not.toHaveBeenCalled();
435
+ });
436
+
437
+ it('hands back every pointer the overlay booked when it closes', () => {
438
+ const bookRef: { current: OverlayPointerBook | null } = { current: null };
439
+ const released: number[] = [];
440
+ const stage = {
441
+ setPointerCapture: jest.fn(),
442
+ releasePointerCapture: (id: number) => released.push(id)
443
+ } as unknown as Element;
444
+
445
+ const { rerender } = render(<Page a={{ open: true, bookRef }} />);
446
+
447
+ const book = bookRef.current as OverlayPointerBook;
448
+ book.track({ pointerId: 1, clientX: 0, clientY: 0, currentTarget: stage });
449
+ book.track({ pointerId: 2, clientX: 8, clientY: 4, currentTarget: stage });
450
+ book.track({ pointerId: 2, clientX: 9, clientY: 4, currentTarget: stage });
451
+
452
+ expect(book.size()).toBe(2);
453
+ expect(book.has(1)).toBe(true);
454
+ expect(book.points()).toEqual([
455
+ { x: 0, y: 0 },
456
+ { x: 9, y: 4 }
457
+ ]);
458
+ // Capture is taken once per pointer, not again on every move.
459
+ expect(stage.setPointerCapture).toHaveBeenCalledTimes(2);
460
+
461
+ rerender(<Page a={{ open: false, bookRef }} />);
462
+
463
+ // A finger still down when the panel goes away must not stay booked: the
464
+ // next swipe would be read as the second finger of a pinch that never
465
+ // started, for the rest of the session.
466
+ expect(book.size()).toBe(0);
467
+ expect(released.sort()).toEqual([1, 2]);
468
+ });
469
+
470
+ it('stays inert in the designer', () => {
471
+ const closeA = jest.fn();
472
+ document.body.style.overflow = 'auto';
473
+
474
+ const { getByTestId } = render(
475
+ <Page a={{ open: true, isDesigner: true, onClose: closeA }} />
476
+ );
477
+
478
+ const outside = getByTestId('outside');
479
+ outside.focus();
480
+
481
+ // The canvas is the author's workspace: nothing may reach into it.
482
+ expect(document.activeElement).toBe(outside);
483
+ expect(document.body.style.overflow).toBe('auto');
484
+ expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
485
+
486
+ fireEvent.keyDown(document, { key: 'Escape' });
487
+ expect(closeA).not.toHaveBeenCalled();
488
+ });
489
+
490
+ it('does not touch the document while rendering on the server', () => {
491
+ const addEventListener = jest.spyOn(document, 'addEventListener');
492
+ document.body.style.overflow = 'auto';
493
+
494
+ const markup = renderToStaticMarkup(<Page a={{ open: true }} />);
495
+
496
+ expect(markup).toContain('data-testid="a-panel"');
497
+ expect(markup).not.toContain('aria-modal');
498
+ expect(document.body.style.overflow).toBe('auto');
499
+ expect(addEventListener).not.toHaveBeenCalled();
500
+
501
+ addEventListener.mockRestore();
502
+ });
503
+ });
@@ -31,6 +31,20 @@ export function useDesignerFeatures({
31
31
  e.preventDefault();
32
32
  e.stopPropagation();
33
33
 
34
+ // Clicking straight on the dropdown, a swatch or a menu item should
35
+ // select *that*, not just the block it lives in — otherwise a
36
+ // designer has to guess which entry in the style list is the element
37
+ // under the cursor.
38
+ const partElement =
39
+ e.target instanceof Element
40
+ ? e.target.closest<HTMLElement>('[data-part]')
41
+ : null;
42
+ const partOwner = partElement?.closest<HTMLElement>('[data-block-id]');
43
+ const partName =
44
+ partElement && partOwner?.getAttribute('data-block-id') === blockId
45
+ ? partElement.getAttribute('data-part')
46
+ : null;
47
+
34
48
  if (window.parent) {
35
49
  postToEditor({
36
50
  type: 'SELECT_BLOCK',
@@ -39,7 +53,8 @@ export function useDesignerFeatures({
39
53
  sectionId,
40
54
  blockId,
41
55
  blockType: blockInfo?.type,
42
- blockLabel: blockInfo?.label
56
+ blockLabel: blockInfo?.label,
57
+ partName: partName || undefined
43
58
  }
44
59
  });
45
60
  }