@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.
- package/CHANGELOG.md +5 -4
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- 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
|
-
|
|
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;
|