@akinon/pz-theme 2.0.36-rc.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 +8 -31
  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
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useState, useRef, useEffect } from 'react';
3
+ import { useState, useRef, useEffect, useLayoutEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
 
5
6
  interface ActionToolbarProps {
6
7
  label: string;
@@ -14,6 +15,41 @@ interface ActionToolbarProps {
14
15
  onRename?: (newLabel: string) => void;
15
16
  }
16
17
 
18
+ /**
19
+ * The element a toolbar belongs to: the one the composition's own styles are
20
+ * emitted onto (`collectBlockCSS` / `generateThemeCSS`), and therefore the one
21
+ * that can be given a clipping context.
22
+ */
23
+ const HOST_SELECTOR = '[data-block-id],[data-section-id]';
24
+
25
+ /**
26
+ * At <body> the toolbar no longer inherits its host's stacking context, so it
27
+ * has to out-rank whatever the storefront paints — a sticky header or an open
28
+ * drawer would otherwise cover the controls it just became reachable from.
29
+ * `zIndex` still orders block chrome above section chrome.
30
+ */
31
+ const CHROME_Z_BASE = 2147483000;
32
+
33
+ // `useLayoutEffect` would warn during SSR; the toolbar only ever mounts in the
34
+ // designer, but the module is part of a server-rendered package.
35
+ const useIsomorphicLayoutEffect =
36
+ typeof window === 'undefined' ? useEffect : useLayoutEffect;
37
+
38
+ /**
39
+ * A host with no box (hidden block/section) reports a zero rect, which would
40
+ * park the portaled toolbar at the viewport origin instead of hiding it with
41
+ * the thing it manages — the containment the old in-tree render gave for free.
42
+ */
43
+ const isHostRendered = (host: HTMLElement): boolean => {
44
+ if (!host.isConnected) return false;
45
+
46
+ for (let node: Element | null = host; node; node = node.parentElement) {
47
+ if (window.getComputedStyle(node).display === 'none') return false;
48
+ }
49
+
50
+ return true;
51
+ };
52
+
17
53
  const LockIcon = () => (
18
54
  <svg
19
55
  width="14"
@@ -43,6 +79,17 @@ const LockIcon = () => (
43
79
  </svg>
44
80
  );
45
81
 
82
+ /**
83
+ * The toolbar draws *above* its host's top edge — outside the host's box.
84
+ * Rendered as a child of the host, any clipping declaration the composition
85
+ * puts on it (`overflow`, `overflow-x/y`, `clip-path`, `contain`, …) cuts the
86
+ * toolbar away, and rename / duplicate / delete exist nowhere else: the sidebar
87
+ * offers only select, drag, toggle-visibility and add-child. The clip is real
88
+ * CSS the storefront must keep, so the editor's own chrome moves out of its
89
+ * reach instead of the property being banned — it is portaled to <body>, where
90
+ * no composition-owned element is an ancestor, and pinned back onto the host's
91
+ * viewport box so it lands exactly where it always did.
92
+ */
46
93
  export default function ActionToolbar({
47
94
  label,
48
95
  isLocked = false,
@@ -57,6 +104,100 @@ export default function ActionToolbar({
57
104
  const [isRenaming, setIsRenaming] = useState(false);
58
105
  const [newLabel, setNewLabel] = useState(label);
59
106
  const inputRef = useRef<HTMLInputElement>(null);
107
+ const anchorRef = useRef<HTMLSpanElement>(null);
108
+ const measureRef = useRef<(() => void) | null>(null);
109
+ const [origin, setOrigin] = useState<{ top: number; left: number } | null>(
110
+ null
111
+ );
112
+
113
+ useIsomorphicLayoutEffect(() => {
114
+ const anchor = anchorRef.current;
115
+ if (!anchor) return;
116
+
117
+ const host =
118
+ anchor.closest<HTMLElement>(HOST_SELECTOR) ?? anchor.parentElement;
119
+ if (!host) return;
120
+
121
+ let frame = 0;
122
+
123
+ const measure = () => {
124
+ frame = 0;
125
+
126
+ const rect = host.getBoundingClientRect();
127
+
128
+ // Only a host with no box at all can be the hidden one, which keeps the
129
+ // ancestor walk off the scroll path.
130
+ if (rect.width === 0 && rect.height === 0 && !isHostRendered(host)) {
131
+ setOrigin(null);
132
+ return;
133
+ }
134
+
135
+ // `getBoundingClientRect` is the border box, but an absolutely positioned
136
+ // child used to resolve against the host's padding box — subtracting the
137
+ // border back out keeps a bordered block's toolbar where it was.
138
+ const next = {
139
+ top: rect.top + host.clientTop,
140
+ left: rect.left + host.clientLeft
141
+ };
142
+
143
+ setOrigin((previous) =>
144
+ previous && previous.top === next.top && previous.left === next.left
145
+ ? previous
146
+ : next
147
+ );
148
+ };
149
+
150
+ const schedule = () => {
151
+ if (frame) return;
152
+ frame = requestAnimationFrame(measure);
153
+ };
154
+
155
+ measureRef.current = measure;
156
+ measure();
157
+
158
+ // `true` so scrolling inside any nested scroller repositions too — scroll
159
+ // does not bubble.
160
+ window.addEventListener('scroll', schedule, true);
161
+ window.addEventListener('resize', schedule);
162
+
163
+ const resizeObserver =
164
+ typeof ResizeObserver === 'undefined'
165
+ ? null
166
+ : new ResizeObserver(schedule);
167
+ resizeObserver?.observe(host);
168
+ resizeObserver?.observe(document.documentElement);
169
+
170
+ // Hiding a block or section only rewrites the host's inline style, and
171
+ // reflowing content above it moves the host without resizing it — neither
172
+ // reaches a scroll or resize listener.
173
+ const mutationObserver =
174
+ typeof MutationObserver === 'undefined'
175
+ ? null
176
+ : new MutationObserver(schedule);
177
+ mutationObserver?.observe(host, {
178
+ attributes: true,
179
+ attributeFilter: ['style', 'class', 'hidden']
180
+ });
181
+
182
+ return () => {
183
+ if (frame) cancelAnimationFrame(frame);
184
+ measureRef.current = null;
185
+ window.removeEventListener('scroll', schedule, true);
186
+ window.removeEventListener('resize', schedule);
187
+ resizeObserver?.disconnect();
188
+ mutationObserver?.disconnect();
189
+ };
190
+ }, []);
191
+
192
+ // Reordering a block — what this toolbar's own Move Up / Move Down buttons
193
+ // do — moves the host without resizing it, without scrolling and without
194
+ // touching its own attributes, so none of the listeners above see it; the
195
+ // same is true of an insertion or a text edit anywhere above it. The canvas
196
+ // re-renders for all of them, so a render is a measuring point too. Bailing
197
+ // out on an unchanged origin keeps this from looping.
198
+ useIsomorphicLayoutEffect(() => {
199
+ measureRef.current?.();
200
+ });
60
201
 
61
202
  useEffect(() => {
62
203
  if (isRenaming && inputRef.current) {
@@ -91,10 +232,10 @@ export default function ActionToolbar({
91
232
  handleCancelRename();
92
233
  }
93
234
  };
94
- return (
235
+ const toolbar = (
95
236
  <div
237
+ data-action-toolbar=""
96
238
  className="absolute -top-0.5 left-0 -translate-y-full flex items-center pointer-events-auto gap-1 min-w-max"
97
- style={{ zIndex }}
98
239
  >
99
240
  <div className="flex items-center gap-1">
100
241
  {isRenaming ? (
@@ -302,4 +443,42 @@ export default function ActionToolbar({
302
443
  </div>
303
444
  </div>
304
445
  );
446
+
447
+ return (
448
+ <>
449
+ {/*
450
+ The handle on the host, left where the toolbar used to render so the
451
+ toolbar still belongs to whatever mounted it — no prop plumbing, and a
452
+ block toolbar still resolves to its own wrapper rather than an
453
+ ancestor's. `display: none` is inline rather than the `hidden`
454
+ attribute so no brand reset can promote the handle into a flex item or
455
+ a grid cell and open a gap the storefront does not have.
456
+ */}
457
+ <span
458
+ ref={anchorRef}
459
+ data-action-toolbar-anchor=""
460
+ style={{ display: 'none' }}
461
+ />
462
+ {origin
463
+ ? createPortal(
464
+ // A zero-size frame pinned to the host's top-left corner. The
465
+ // toolbar inside it keeps its original offsets, so the rendered
466
+ // position is the one it had as a child of the host.
467
+ <div
468
+ style={{
469
+ position: 'fixed',
470
+ top: origin.top,
471
+ left: origin.left,
472
+ width: 0,
473
+ height: 0,
474
+ zIndex: CHROME_Z_BASE + zIndex
475
+ }}
476
+ >
477
+ {toolbar}
478
+ </div>,
479
+ document.body
480
+ )
481
+ : null}
482
+ </>
483
+ );
305
484
  }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ /**
6
+ * Pins a part's state open while the designer styles it.
7
+ *
8
+ * Hover and focus cannot be held: the moment the cursor leaves the canvas for
9
+ * the colour picker, the state is gone and the designer is typing blind. The
10
+ * editor therefore says "show me this part in this state" and the state is
11
+ * written onto the matching elements as `data-state`, which the generated
12
+ * rules match alongside the real `:hover` / `:focus-visible`.
13
+ *
14
+ * Nothing here runs on the storefront — it only mounts in the designer.
15
+ */
16
+ const PREVIEW_ATTRIBUTE = 'data-part-state-preview';
17
+
18
+ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
19
+ useEffect(() => {
20
+ if (!isDesigner) return undefined;
21
+
22
+ const clear = () => {
23
+ document
24
+ .querySelectorAll<HTMLElement>(`[${PREVIEW_ATTRIBUTE}]`)
25
+ .forEach((element) => {
26
+ const pinned = element.getAttribute(PREVIEW_ATTRIBUTE) || '';
27
+ const current = element.getAttribute('data-state') || '';
28
+
29
+ // The renderer may have changed the element's real state while the
30
+ // preview was pinned — a swatch became selected, a button became
31
+ // disabled. Restoring the remembered value would write that away, so
32
+ // only the pinned token is removed.
33
+ const next = current
34
+ .split(/\s+/)
35
+ .filter((token) => token && token !== pinned)
36
+ .join(' ');
37
+
38
+ if (next) element.setAttribute('data-state', next);
39
+ else element.removeAttribute('data-state');
40
+ element.removeAttribute(PREVIEW_ATTRIBUTE);
41
+ });
42
+ };
43
+
44
+ const handleMessage = (event: MessageEvent) => {
45
+ if (event.data?.type !== 'PREVIEW_PART_STATE') return;
46
+
47
+ const { blockId, partName, state } = event.data.data || {};
48
+ clear();
49
+
50
+ if (!blockId || !partName || !state) return;
51
+
52
+ const block = document.querySelector<HTMLElement>(
53
+ `[data-block-id="${CSS.escape(String(blockId))}"]`
54
+ );
55
+ if (!block) return;
56
+
57
+ Array.from(
58
+ block.querySelectorAll<HTMLElement>(
59
+ `[data-part="${CSS.escape(String(partName))}"]`
60
+ )
61
+ )
62
+ // Part names are shared between blocks on purpose, so a descendant
63
+ // search also finds the identically-named part of a block nested
64
+ // inside this one. A part belongs to its closest `[data-block-id]`
65
+ // ancestor — the same rule a click already uses to decide which part
66
+ // it selected (hooks/use-designer-features). Stamping a nested block's
67
+ // part would fire that block's own state rules and show the author a
68
+ // canvas that disagrees with the storefront.
69
+ .filter((element) => element.closest('[data-block-id]') === block)
70
+ .forEach((element) => {
71
+ const existing = element.getAttribute('data-state') || '';
72
+ // The pinned token is remembered, not the whole value: the renderer
73
+ // is free to change the element's real state while it is pinned.
74
+ element.setAttribute(PREVIEW_ATTRIBUTE, String(state));
75
+ element.setAttribute(
76
+ 'data-state',
77
+ existing ? `${existing} ${state}` : String(state)
78
+ );
79
+ });
80
+ };
81
+
82
+ window.addEventListener('message', handleMessage);
83
+ return () => {
84
+ window.removeEventListener('message', handleMessage);
85
+ clear();
86
+ };
87
+ }, [isDesigner]);
88
+
89
+ return null;
90
+ };
91
+
92
+ export default PartStatePreview;