@akinon/pz-theme 2.0.100-rc.0 → 2.0.101-beta.0

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.
@@ -8,6 +8,11 @@ import {
8
8
  getResponsiveValue,
9
9
  resolveOption
10
10
  } from '../utils';
11
+ import {
12
+ PHONE_AND_TABLET_MEDIA_CONDITION,
13
+ PHONE_ONLY_MEDIA_CONDITION,
14
+ TABLET_ONLY_MEDIA_CONDITION
15
+ } from '../utils/breakpoints';
11
16
  import { BlockRendererProps } from './block-renderer-registry';
12
17
 
13
18
  const pickImageSource = (value: unknown, currentBreakpoint: string): string => {
@@ -63,8 +68,73 @@ const isValidImageSource = (src: string): boolean => {
63
68
  }
64
69
  };
65
70
 
71
+ /**
72
+ * The narrow-band overrides, resolved to absolute URLs.
73
+ *
74
+ * Authored as two ordinary `image` properties rather than one map, so the
75
+ * merchant gets the SAME upload control they already know, and so a block that
76
+ * was published before this existed simply has neither key — which is what
77
+ * makes the "no override" path below byte-identical to the old renderer.
78
+ *
79
+ * The desktop source deliberately stays on `block.value`. Moving it to a
80
+ * property would blank every published image (the rule this repo already wrote
81
+ * down for the twin case in the editor's video-block-sources test), and the
82
+ * save path flattens an `image` block's `value` to a single string anyway, so a
83
+ * per-breakpoint map could never survive a round trip there.
84
+ */
85
+ const resolveOverride = (value: unknown, currentBreakpoint: string): string => {
86
+ const raw = pickImageSource(value, currentBreakpoint);
87
+ const url = normalizeImageSource(raw);
88
+ return isValidImageSource(url) ? url : '';
89
+ };
90
+
91
+ export interface PictureSource {
92
+ media: string;
93
+ srcSet: string;
94
+ }
95
+
96
+ /**
97
+ * The `<source>` list, in FIRST-MATCH order.
98
+ *
99
+ * Mirrors how the generated stylesheet cascades, which is the behaviour the
100
+ * merchant has already learned everywhere else in the editor: a value authored
101
+ * at "mobile" applies up to 1023px *unless* a tablet value narrows it. CSS says
102
+ * that with an overlap plus source order; `<picture>` has no cascade, so the
103
+ * overlap has to be removed here instead — hence the phone band widening to
104
+ * 1023 exactly when no tablet image was authored.
105
+ *
106
+ * A band equal to the desktop source is dropped rather than emitted: an
107
+ * identical candidate would make the browser re-decide for nothing, and it is
108
+ * what keeps an untouched block on the plain-`<img>` path.
109
+ */
110
+ export const buildPictureSources = (
111
+ desktop: string,
112
+ tablet: string,
113
+ mobile: string
114
+ ): PictureSource[] => {
115
+ const sources: PictureSource[] = [];
116
+ const phoneSrc = mobile || '';
117
+ const tabletSrc = tablet || '';
118
+
119
+ if (phoneSrc && phoneSrc !== desktop) {
120
+ sources.push({
121
+ media: tabletSrc
122
+ ? PHONE_ONLY_MEDIA_CONDITION
123
+ : PHONE_AND_TABLET_MEDIA_CONDITION,
124
+ srcSet: phoneSrc
125
+ });
126
+ }
127
+
128
+ if (tabletSrc && tabletSrc !== desktop) {
129
+ sources.push({ media: TABLET_ONLY_MEDIA_CONDITION, srcSet: tabletSrc });
130
+ }
131
+
132
+ return sources;
133
+ };
134
+
66
135
  const ImageBlock = ({
67
136
  block,
137
+ isDesigner = false,
68
138
  currentBreakpoint = 'desktop'
69
139
  }: BlockRendererProps) => {
70
140
  const imageValue = useMemo(() => {
@@ -78,7 +148,49 @@ const ImageBlock = ({
78
148
  };
79
149
  }, [block.value, block.properties?.alt, currentBreakpoint]);
80
150
 
81
- const { url: src, alt } = imageValue;
151
+ const { url: desktopSrc, alt } = imageValue;
152
+
153
+ const imageTablet = resolveOverride(
154
+ block.properties?.imageTablet,
155
+ currentBreakpoint
156
+ );
157
+ const imageMobile = resolveOverride(
158
+ block.properties?.imageMobile,
159
+ currentBreakpoint
160
+ );
161
+
162
+ const pictureSources = useMemo(
163
+ () => buildPictureSources(desktopSrc, imageTablet, imageMobile),
164
+ [desktopSrc, imageTablet, imageMobile]
165
+ );
166
+
167
+ /*
168
+ * THE CANVAS CANNOT PREVIEW A `<picture>`.
169
+ *
170
+ * `<source media>` is answered by the browser against the REAL viewport, and
171
+ * there is no CSS that can override it. The editor draws a phone preview
172
+ * inside a desktop-wide iframe, so a real `<picture>` would show the desktop
173
+ * image in every preview device — the author would have no way to see the
174
+ * phone crop they just uploaded.
175
+ *
176
+ * So the canvas keeps resolving in JS at the breakpoint the editor selected,
177
+ * and only the storefront ships the real element. The cost is named rather
178
+ * than hidden: the canvas no longer renders byte-identical markup, so a
179
+ * malformed `media` or a dropped `srcset` is invisible there and has to be
180
+ * caught by the tests below it.
181
+ *
182
+ * The fallback chain matches the emitted source list: at tablet, an
183
+ * unauthored tablet image falls through to the phone image the same way the
184
+ * widened phone band does on the storefront.
185
+ */
186
+ const designerSrc =
187
+ currentBreakpoint === 'mobile'
188
+ ? imageMobile || desktopSrc
189
+ : currentBreakpoint === 'tablet'
190
+ ? imageTablet || imageMobile || desktopSrc
191
+ : desktopSrc;
192
+
193
+ const src = isDesigner ? designerSrc : desktopSrc;
82
194
  const loadingStrategyValue =
83
195
  block.properties?.loadingStrategy ?? block.styles?.loadingStrategy;
84
196
  const priorityValue = block.properties?.priority ?? block.styles?.priority;
@@ -152,9 +264,9 @@ const ImageBlock = ({
152
264
  */
153
265
  const boxIsSized =
154
266
  (typeof height === 'string' && height !== 'auto') || Boolean(aspectRatio);
155
- const boxWidth = isRowBound('width') ? width ?? '100%' : '100%';
267
+ const boxWidth = isRowBound('width') ? (width ?? '100%') : '100%';
156
268
  const boxHeight = isRowBound('height')
157
- ? height ?? 'auto'
269
+ ? (height ?? 'auto')
158
270
  : boxIsSized
159
271
  ? '100%'
160
272
  : 'auto';
@@ -206,7 +318,41 @@ const ImageBlock = ({
206
318
  />
207
319
  );
208
320
 
209
- return content;
321
+ /*
322
+ * No override, or the canvas: emit exactly what this block emitted before
323
+ * this feature existed. Every block published to date takes this path, so its
324
+ * HTML is unchanged byte for byte — a `<picture>` wrapping a lone `<img>`
325
+ * would be harmless to the browser but would still be a diff in every
326
+ * snapshot and every brand's CSS that targets `[data-block-id] > img`.
327
+ */
328
+ if (!src || isDesigner || pictureSources.length === 0) {
329
+ return content;
330
+ }
331
+
332
+ /*
333
+ * `display: contents` is load-bearing, not tidiness.
334
+ *
335
+ * `image` is not in INNER_ROOT_BLOCK_TYPES, so the generated stylesheet puts
336
+ * this block's width/height/aspect-ratio on the `[data-block-id]` WRAPPER and
337
+ * the <img> above is sized against it with percentages. A `<picture>` between
338
+ * the two would become the flex item the parent lays out and the percentage
339
+ * parent the <img> resolves against — so `height: 100%` would resolve against
340
+ * an auto-height box and collapse. `display: contents` removes the box while
341
+ * keeping the element, which is exactly what is wanted: the <source> list is
342
+ * markup, not layout.
343
+ */
344
+ return (
345
+ <picture style={{ display: 'contents' }}>
346
+ {pictureSources.map((source) => (
347
+ <source
348
+ key={source.media}
349
+ media={source.media}
350
+ srcSet={source.srcSet}
351
+ />
352
+ ))}
353
+ {content}
354
+ </picture>
355
+ );
210
356
  };
211
357
 
212
358
  export default ImageBlock;
@@ -114,3 +114,33 @@ export const breakpointForWidth = (width: number): BreakpointName => {
114
114
  */
115
115
  export const isNarrowBreakpoint = (breakpoint?: string): boolean =>
116
116
  breakpoint === 'mobile' || breakpoint === 'tablet';
117
+
118
+ /**
119
+ * The same two bands as media QUERIES, but as bare media CONDITIONS for a
120
+ * `<picture><source media>` attribute.
121
+ *
122
+ * Two differences from the `@media` constants above, both load-bearing:
123
+ *
124
+ * - No `@media` prefix. `<source media>` takes a media *condition*, and a
125
+ * string starting with `@media` never matches — silently, because a source
126
+ * whose condition fails is simply skipped and the browser falls through to
127
+ * the `<img>`. The bug would look like "the phone image is ignored".
128
+ * - The phone band is EXCLUSIVE of the tablet band (`max-width: 767px`), while
129
+ * `MOBILE_MEDIA_QUERY` deliberately overlaps it (`max-width: 1023px`) and
130
+ * lets source order decide. CSS resolves an overlap by cascade; `<picture>`
131
+ * resolves it by FIRST MATCH, so an overlapping phone condition placed first
132
+ * would swallow every tablet. Where no tablet image is authored the caller
133
+ * widens the phone band back to 1023 on purpose — that is what reproduces
134
+ * the stylesheet's "mobile covers tablet unless tablet overrides" rule.
135
+ */
136
+ export const PHONE_ONLY_MEDIA_CONDITION = `(max-width: ${
137
+ TABLET_MIN_WIDTH - 1
138
+ }px)`;
139
+
140
+ export const PHONE_AND_TABLET_MEDIA_CONDITION = `(max-width: ${
141
+ DESKTOP_MIN_WIDTH - 1
142
+ }px)`;
143
+
144
+ export const TABLET_ONLY_MEDIA_CONDITION = `(min-width: ${TABLET_MIN_WIDTH}px) and (max-width: ${
145
+ DESKTOP_MIN_WIDTH - 1
146
+ }px)`;