@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,351 @@
1
+ /**
2
+ * The third breakpoint.
3
+ *
4
+ * Two things have to be true at once and they pull against each other: a
5
+ * composition that never heard of a tablet must emit the bytes it emitted
6
+ * before, and a composition that authors one must actually get it — at the
7
+ * device, at the canvas and in the stylesheet, all saying the same thing.
8
+ *
9
+ * The tests below are ordered by what they protect. First that nothing moved.
10
+ * Then that the cascade resolves the same way whichever of the three readers
11
+ * you ask. Then the media-query order, which is the only reason a tablet rule
12
+ * beats the mobile rule it sits inside — equal specificity, so source position
13
+ * decides, and asserting it is cheaper than rediscovering it in production.
14
+ */
15
+ import {
16
+ generateThemeCSS,
17
+ getResponsiveValue,
18
+ buildCustomCss,
19
+ DESKTOP_MIN_WIDTH,
20
+ TABLET_MIN_WIDTH,
21
+ MOBILE_MEDIA_QUERY,
22
+ TABLET_MEDIA_QUERY,
23
+ breakpointForWidth,
24
+ isNarrowBreakpoint
25
+ } from '../index';
26
+ import { isBreakpointAllowed } from '../visibility-rules';
27
+
28
+ const section = (styles: Record<string, unknown>, id = 's1') => [
29
+ { id, styles } as never
30
+ ];
31
+
32
+ /**
33
+ * The declarations that actually apply at `width`, in cascade order, for a
34
+ * stylesheet this generator produced. Every rule it emits carries the same
35
+ * selector and therefore the same specificity, so the last matching
36
+ * declaration for a property wins — which makes "later in the string" the
37
+ * whole tie-break, and lets this read the CSS the way a browser would.
38
+ */
39
+ const computedAt = (css: string, width: number): Record<string, string> => {
40
+ const applied: Record<string, string> = {};
41
+
42
+ const queryMatches = (prelude: string): boolean => {
43
+ const max = prelude.match(/max-width:\s*(\d+)px/);
44
+ const min = prelude.match(/min-width:\s*(\d+)px/);
45
+ if (max && width > Number(max[1])) return false;
46
+ if (min && width < Number(min[1])) return false;
47
+ return true;
48
+ };
49
+
50
+ const readDeclarations = (body: string) => {
51
+ body.split(';').forEach((declaration) => {
52
+ const [property, ...rest] = declaration.split(':');
53
+ if (rest.length === 0) return;
54
+ const name = property.trim();
55
+ if (!name || name.startsWith('@')) return;
56
+ applied[name] = rest.join(':').trim();
57
+ });
58
+ };
59
+
60
+ // Walk top level: either `@media (...) { <rules> }` or `selector { ... }`.
61
+ let index = 0;
62
+ while (index < css.length) {
63
+ const open = css.indexOf('{', index);
64
+ if (open === -1) break;
65
+
66
+ const prelude = css.slice(index, open).trim();
67
+ let depth = 0;
68
+ let close = open;
69
+ for (let i = open; i < css.length; i++) {
70
+ if (css[i] === '{') depth += 1;
71
+ else if (css[i] === '}') {
72
+ depth -= 1;
73
+ if (depth === 0) {
74
+ close = i;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+
80
+ const body = css.slice(open + 1, close);
81
+
82
+ if (prelude.startsWith('@media')) {
83
+ if (queryMatches(prelude)) {
84
+ // One nested rule per band in this generator's output.
85
+ const innerOpen = body.indexOf('{');
86
+ if (innerOpen !== -1) {
87
+ readDeclarations(body.slice(innerOpen + 1, body.lastIndexOf('}')));
88
+ }
89
+ }
90
+ } else if (!prelude.includes(':hover')) {
91
+ readDeclarations(body);
92
+ }
93
+
94
+ index = close + 1;
95
+ }
96
+
97
+ return applied;
98
+ };
99
+
100
+ describe('the two boundaries', () => {
101
+ it('keeps the existing line at 1023/1024 and puts the new one at 767/768', () => {
102
+ expect(DESKTOP_MIN_WIDTH).toBe(1024);
103
+ expect(TABLET_MIN_WIDTH).toBe(768);
104
+ expect(MOBILE_MEDIA_QUERY).toBe('@media (max-width: 1023px)');
105
+ expect(TABLET_MEDIA_QUERY).toBe(
106
+ '@media (min-width: 768px) and (max-width: 1023px)'
107
+ );
108
+ });
109
+
110
+ it('classifies a width on each side of both lines, and exactly on them', () => {
111
+ expect(breakpointForWidth(1440)).toBe('desktop');
112
+ expect(breakpointForWidth(1024)).toBe('desktop');
113
+ expect(breakpointForWidth(1023)).toBe('tablet');
114
+ expect(breakpointForWidth(768)).toBe('tablet');
115
+ expect(breakpointForWidth(767)).toBe('mobile');
116
+ expect(breakpointForWidth(390)).toBe('mobile');
117
+ });
118
+
119
+ it('counts a tablet as narrower than desktop, which is what the old code asked', () => {
120
+ expect(isNarrowBreakpoint('tablet')).toBe(true);
121
+ expect(isNarrowBreakpoint('mobile')).toBe(true);
122
+ expect(isNarrowBreakpoint('desktop')).toBe(false);
123
+ expect(isNarrowBreakpoint(undefined)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('nothing authored at the new breakpoint changes anything', () => {
128
+ it('emits the exact stylesheet it emitted before, with no tablet rule', () => {
129
+ const css = generateThemeCSS(
130
+ section({
131
+ 'font-size': { desktop: '20px', mobile: '14px' },
132
+ color: { desktop: '#111', mobile: '#111' }
133
+ })
134
+ );
135
+
136
+ expect(css).toBe(
137
+ '[data-section-id="s1"] {\n' +
138
+ ' font-size: 20px;\n' +
139
+ ' color: #111;\n' +
140
+ '}\n' +
141
+ '@media (max-width: 1023px) {\n' +
142
+ ' [data-section-id="s1"] {\n' +
143
+ ' font-size: 14px;\n' +
144
+ ' }\n' +
145
+ '}\n'
146
+ );
147
+ expect(css).not.toContain('min-width');
148
+ });
149
+
150
+ it('leaves a tablet reading the mobile value, exactly as the old query did', () => {
151
+ const css = generateThemeCSS(
152
+ section({ 'font-size': { desktop: '20px', mobile: '14px' } })
153
+ );
154
+
155
+ expect(computedAt(css, 1440)['font-size']).toBe('20px');
156
+ expect(computedAt(css, 900)['font-size']).toBe('14px');
157
+ expect(computedAt(css, 500)['font-size']).toBe('14px');
158
+ });
159
+
160
+ it('does not write a rule for a tablet value equal to what it would inherit', () => {
161
+ const redundantAgainstMobile = generateThemeCSS(
162
+ section({ gap: { desktop: '24px', tablet: '12px', mobile: '12px' } })
163
+ );
164
+ expect(redundantAgainstMobile).not.toContain('min-width');
165
+
166
+ const redundantAgainstDesktop = generateThemeCSS(
167
+ section({ gap: { desktop: '24px', tablet: '24px' } })
168
+ );
169
+ expect(redundantAgainstDesktop).not.toContain('min-width');
170
+ });
171
+ });
172
+
173
+ describe('a value authored only at tablet', () => {
174
+ const css = generateThemeCSS(
175
+ section({
176
+ 'grid-template-columns': {
177
+ desktop: 'repeat(4, 1fr)',
178
+ tablet: 'repeat(3, 1fr)',
179
+ mobile: 'repeat(2, 1fr)'
180
+ }
181
+ })
182
+ );
183
+
184
+ it('applies at each of the three widths', () => {
185
+ expect(computedAt(css, 1440)['grid-template-columns']).toBe(
186
+ 'repeat(4, 1fr)'
187
+ );
188
+ expect(computedAt(css, 900)['grid-template-columns']).toBe('repeat(3, 1fr)');
189
+ expect(computedAt(css, 500)['grid-template-columns']).toBe('repeat(2, 1fr)');
190
+ });
191
+
192
+ it('applies exactly on each boundary, on both sides', () => {
193
+ expect(computedAt(css, 1024)['grid-template-columns']).toBe(
194
+ 'repeat(4, 1fr)'
195
+ );
196
+ expect(computedAt(css, 1023)['grid-template-columns']).toBe(
197
+ 'repeat(3, 1fr)'
198
+ );
199
+ expect(computedAt(css, 768)['grid-template-columns']).toBe('repeat(3, 1fr)');
200
+ expect(computedAt(css, 767)['grid-template-columns']).toBe('repeat(2, 1fr)');
201
+ });
202
+
203
+ it('reaches the tablet band even when no mobile value was authored', () => {
204
+ const noMobile = generateThemeCSS(
205
+ section({ padding: { desktop: '40px', tablet: '20px' } })
206
+ );
207
+
208
+ expect(computedAt(noMobile, 1440).padding).toBe('40px');
209
+ expect(computedAt(noMobile, 900).padding).toBe('20px');
210
+ // Nothing narrower was authored, so a phone keeps the desktop value —
211
+ // the same answer the reader gives, and the same one it gave before.
212
+ expect(computedAt(noMobile, 500).padding).toBe('40px');
213
+ });
214
+ });
215
+
216
+ describe('media query order is the mechanism, so it is asserted not assumed', () => {
217
+ it('writes the tablet query after the mobile one', () => {
218
+ const css = generateThemeCSS(
219
+ section({ gap: { desktop: '24px', tablet: '16px', mobile: '8px' } })
220
+ );
221
+
222
+ const mobileAt = css.indexOf(MOBILE_MEDIA_QUERY);
223
+ const tabletAt = css.indexOf(TABLET_MEDIA_QUERY);
224
+
225
+ expect(mobileAt).toBeGreaterThan(-1);
226
+ expect(tabletAt).toBeGreaterThan(-1);
227
+ expect(tabletAt).toBeGreaterThan(mobileAt);
228
+ });
229
+
230
+ it('keeps that order for every block in a composition, not just the first', () => {
231
+ const css = generateThemeCSS([
232
+ {
233
+ id: 'sec',
234
+ styles: { gap: { desktop: '4px', tablet: '3px', mobile: '2px' } },
235
+ blocks: [
236
+ {
237
+ id: 'b1',
238
+ styles: { gap: { desktop: '8px', tablet: '6px', mobile: '4px' } }
239
+ },
240
+ {
241
+ id: 'b2',
242
+ styles: { gap: { desktop: '9px', tablet: '7px', mobile: '5px' } }
243
+ }
244
+ ]
245
+ } as never
246
+ ]);
247
+
248
+ // Each selector's own mobile rule must precede its own tablet rule.
249
+ ['sec', 'b1', 'b2'].forEach((id) => {
250
+ const attribute = id === 'sec' ? 'data-section-id' : 'data-block-id';
251
+ const selector = `[${attribute}="${id}"]`;
252
+ const mobileAt = css.indexOf(`${MOBILE_MEDIA_QUERY} {\n ${selector}`);
253
+ const tabletAt = css.indexOf(`${TABLET_MEDIA_QUERY} {\n ${selector}`);
254
+
255
+ expect(mobileAt).toBeGreaterThan(-1);
256
+ expect(tabletAt).toBeGreaterThan(mobileAt);
257
+ });
258
+ });
259
+
260
+ it('orders the scoped custom-CSS hatch the same way', () => {
261
+ const css = buildCustomCss(
262
+ {
263
+ customCss: {
264
+ desktop: 'color: red;',
265
+ tablet: 'color: green;',
266
+ mobile: 'color: blue;'
267
+ }
268
+ },
269
+ '[data-block-id="b"]'
270
+ );
271
+
272
+ expect(css.indexOf(TABLET_MEDIA_QUERY)).toBeGreaterThan(
273
+ css.indexOf(MOBILE_MEDIA_QUERY)
274
+ );
275
+ expect(computedAt(css, 900).color).toBe('green');
276
+ expect(computedAt(css, 500).color).toBe('blue');
277
+ expect(computedAt(css, 1440).color).toBe('red');
278
+ });
279
+ });
280
+
281
+ describe('the readers agree with the stylesheet', () => {
282
+ const authored = { desktop: '20px', tablet: '16px', mobile: '12px' };
283
+ const noTablet = { desktop: '20px', mobile: '12px' };
284
+
285
+ it('resolves the same value the cascade produces, at every breakpoint', () => {
286
+ const css = generateThemeCSS(section({ 'font-size': authored }));
287
+
288
+ ([
289
+ ['desktop', 1440],
290
+ ['tablet', 900],
291
+ ['mobile', 500]
292
+ ] as const).forEach(([breakpoint, width]) => {
293
+ expect(getResponsiveValue(authored, breakpoint)).toBe(
294
+ computedAt(css, width)['font-size']
295
+ );
296
+ });
297
+ });
298
+
299
+ it('falls through tablet -> mobile -> desktop, matching the cascade', () => {
300
+ const css = generateThemeCSS(section({ 'font-size': noTablet }));
301
+
302
+ expect(getResponsiveValue(noTablet, 'tablet')).toBe('12px');
303
+ expect(getResponsiveValue(noTablet, 'tablet')).toBe(
304
+ computedAt(css, 900)['font-size']
305
+ );
306
+
307
+ expect(getResponsiveValue({ desktop: '20px' }, 'tablet')).toBe('20px');
308
+ });
309
+
310
+ it('leaves desktop and mobile resolving exactly as they always did', () => {
311
+ expect(getResponsiveValue(noTablet, 'desktop')).toBe('20px');
312
+ expect(getResponsiveValue(noTablet, 'mobile')).toBe('12px');
313
+ // A tablet value must not leak into the mobile answer.
314
+ expect(getResponsiveValue({ desktop: '20px', tablet: '16px' }, 'mobile')).toBe(
315
+ '20px'
316
+ );
317
+ expect(getResponsiveValue(undefined, 'tablet', 'fallback')).toBe('fallback');
318
+ });
319
+
320
+ it('shows the canvas the value the device will render', () => {
321
+ // The designer path asks for one breakpoint and gets a single flat rule;
322
+ // it has to be the same declaration the storefront cascade lands on.
323
+ const storefront = generateThemeCSS(section({ 'font-size': noTablet }));
324
+ const canvas = generateThemeCSS(section({ 'font-size': noTablet }), 'tablet');
325
+
326
+ expect(canvas).toContain('font-size: 12px');
327
+ expect(computedAt(canvas, 900)['font-size']).toBe(
328
+ computedAt(storefront, 900)['font-size']
329
+ );
330
+ });
331
+ });
332
+
333
+ describe('visibility rules survive the third breakpoint', () => {
334
+ it('keeps a published mobile-only rule showing on a tablet', () => {
335
+ // This is the regression that a literal read would cause on compositions
336
+ // nobody touched: `mobile` meant everything below 1024 when it was saved.
337
+ expect(isBreakpointAllowed(['mobile'], 'tablet')).toBe(true);
338
+ expect(isBreakpointAllowed(['mobile'], 'mobile')).toBe(true);
339
+ expect(isBreakpointAllowed(['mobile'], 'desktop')).toBe(false);
340
+ });
341
+
342
+ it('keeps a desktop-only rule hidden on a tablet', () => {
343
+ expect(isBreakpointAllowed(['desktop'], 'tablet')).toBe(false);
344
+ expect(isBreakpointAllowed(['desktop'], 'desktop')).toBe(true);
345
+ });
346
+
347
+ it('reads a rule that names the tablet literally', () => {
348
+ expect(isBreakpointAllowed(['desktop', 'tablet'], 'tablet')).toBe(true);
349
+ expect(isBreakpointAllowed(['desktop', 'tablet'], 'mobile')).toBe(false);
350
+ });
351
+ });
@@ -0,0 +1,83 @@
1
+ import { generateThemeCSS } from '../index';
2
+
3
+ /**
4
+ * The Group descriptor in pz-dynamic-widget gained the box chrome a container
5
+ * needs — background colour, border, size, overflow, shadow. That was a
6
+ * descriptor-only change, and this is why: the group renderer draws
7
+ * `className="contents"` and styles nothing itself, while `collectBlockCSS`
8
+ * emits every root style key of a block onto its `[data-block-id]` wrapper.
9
+ *
10
+ * These lock that route. If someone later teaches the group renderer to consume
11
+ * styles itself, or narrows the CSS generator to a known-key allowlist, the
12
+ * controls the editor now offers would go quiet — and this fails first.
13
+ */
14
+ const groupSection = (styles: Record<string, unknown>) => [
15
+ {
16
+ id: 'sec-1',
17
+ blocks: [{ id: 'grp-1', type: 'group', styles } as never]
18
+ }
19
+ ];
20
+
21
+ describe('group box chrome reaches the stylesheet', () => {
22
+ it('emits every chrome key onto the block wrapper', () => {
23
+ const css = generateThemeCSS(
24
+ groupSection({
25
+ 'background-color': '#ffffff',
26
+ 'border-style': 'solid',
27
+ 'border-width': '1px',
28
+ 'border-color': '#e5e7eb',
29
+ 'border-radius': '12px',
30
+ width: '320px',
31
+ height: '200px',
32
+ 'min-height': '120px',
33
+ 'max-width': '1200px',
34
+ overflow: 'hidden',
35
+ 'box-shadow': '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
36
+ }) as never
37
+ );
38
+
39
+ expect(css).toContain('[data-block-id="grp-1"]');
40
+ // Hex is normalised to rgba by the existing background-opacity maths.
41
+ expect(css).toContain('background-color: rgba(255, 255, 255, 1)');
42
+ expect(css).toContain('border-style: solid');
43
+ expect(css).toContain('border-width: 1px');
44
+ expect(css).toContain('border-color: #e5e7eb');
45
+ expect(css).toContain('border-radius: 12px');
46
+ expect(css).toContain('width: 320px');
47
+ expect(css).toContain('height: 200px');
48
+ expect(css).toContain('min-height: 120px');
49
+ expect(css).toContain('max-width: 1200px');
50
+ expect(css).toContain('overflow: hidden');
51
+ expect(css).toContain('box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1)');
52
+ });
53
+
54
+ it('honours a per-breakpoint chrome value', () => {
55
+ const css = generateThemeCSS(
56
+ groupSection({
57
+ 'border-radius': { desktop: '12px', mobile: '0px' }
58
+ }) as never
59
+ );
60
+
61
+ expect(css).toContain('border-radius: 12px');
62
+ expect(css).toContain('@media (max-width: 1023px)');
63
+ expect(css).toContain('border-radius: 0px');
64
+ });
65
+
66
+ it('writes nothing for a group that was never given any chrome', () => {
67
+ // The controls all ship without a default, so this is the shape every
68
+ // existing composition still has: no chrome keys, no chrome CSS.
69
+ const css = generateThemeCSS(
70
+ groupSection({
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ gap: '0px'
74
+ }) as never
75
+ );
76
+
77
+ expect(css).not.toContain('background-color');
78
+ expect(css).not.toContain('border');
79
+ expect(css).not.toContain('overflow');
80
+ expect(css).not.toContain('box-shadow');
81
+ expect(css).not.toContain('max-width');
82
+ });
83
+ });