@akinon/pz-theme 2.0.93-rc.0 → 2.0.93

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.
@@ -0,0 +1,275 @@
1
+ import React from 'react';
2
+
3
+ import { devWarn } from './dev-warn';
4
+
5
+ /**
6
+ * Making `react-multi-carousel` emit its slides on the server.
7
+ *
8
+ * ## The bug this exists to end
9
+ *
10
+ * The library only renders slides at all when it believes it can size them.
11
+ * `getInitialState` computes
12
+ *
13
+ * shouldRenderOnSSR = ssr && deviceType && !domFullyLoaded && flexBisis
14
+ * shouldRenderAtAll = shouldRenderOnSSR || domFullyLoaded
15
+ *
16
+ * and `CarouselItems` returns `null` unless `shouldRenderAtAll`. On the server
17
+ * `domFullyLoaded` is false — there is no container to measure — so without
18
+ * `ssr` and `deviceType` every slider shipped an empty
19
+ * `<ul class="react-multi-carousel-track">`. Measured on a live storefront at
20
+ * 1440x900: the hero band was 0px at `domcontentloaded` and 850px once
21
+ * hydrated, so the page opened on its SECOND section and five home-page
22
+ * carousels were blank at first paint. CLS 0.70 desktop, 1.03 on Fast 3G.
23
+ *
24
+ * ## Why the stylesheet below is the fix, not a detail
25
+ *
26
+ * Handing the library `ssr`/`deviceType` alone is not enough. It sizes SSR
27
+ * slides with `flex: 1 0 <100/items>%` — a PERCENTAGE of the track. But
28
+ * `.react-multi-carousel-list` is `display:flex` and the track is a flex item
29
+ * with the default `flex: 0 1 auto`, so the track's base size is its content,
30
+ * and its content is asking for a percentage of the track. Circular: the
31
+ * browser resolves the percentage against an indefinite basis and the whole
32
+ * thing collapses to the widest slide's intrinsic width. Measured with the
33
+ * `width:100%` rule below: track 1440, hero 1440. Without it: track 300,
34
+ * hero 300 — the original bug plus a broken hero.
35
+ *
36
+ * The library cannot fix this itself because it has no stylesheet of its own
37
+ * to gate; it reverts to measured pixel widths at mount, at which point
38
+ * `width:100%` is wrong (the track is wider than the viewport by design, it is
39
+ * what `translate3d` slides). So the rule has to exist for exactly the window
40
+ * between first paint and mount, and then stop existing. That is the gate
41
+ * attribute: it is written into the server markup, survives the client's first
42
+ * (hydrating) render unchanged, and is dropped in an effect once the library's
43
+ * own `componentDidMount` has measured.
44
+ *
45
+ * ## Why `deviceType` is a constant and never `currentBreakpoint`
46
+ *
47
+ * `deviceType` names a key of the responsive map, and the library reads
48
+ * `responsive[deviceType]` on paths that do not null-check it. It must be the
49
+ * same value on the server and in the client's first render, and there is
50
+ * exactly one such value: a literal.
51
+ *
52
+ * The `currentBreakpoint` prop is NOT that value. It moves on mount —
53
+ * `theme-placeholder-wrapper` measures `window.innerWidth` outside an iframe
54
+ * and calls `setCurrentBreakpoint`, and the editor drives it directly. Passing
55
+ * it would hand the library a `deviceType` that changes underneath it, and on
56
+ * a map that has no `tablet` band (`slider-block` has only `desktop` and
57
+ * `mobile`) `getPartialVisibilityGutter` then reads
58
+ * `responsive['tablet'].paritialVisibilityGutter` and throws — a blank section
59
+ * for any slider that authored a slide peek, on a tablet, in the editor.
60
+ * `carousel-ssr.test.tsx` fails if anyone substitutes the prop.
61
+ *
62
+ * ## Side effects that are real and deliberately not hidden
63
+ *
64
+ * - The library sets `overflow:hidden` on the track only while
65
+ * `shouldRenderOnSSR`, reverting to `unset` at mount. In that window,
66
+ * deliberately overflowing slide content — a peeking card, an absolutely
67
+ * positioned badge — is clipped.
68
+ * - `shouldShowArrows` is gated on `shouldRenderAtAll`, so arrows now appear in
69
+ * the SSR markup. Only the right one: `isInLeftEnd` is true at slide 0, so
70
+ * the left arrow is suppressed exactly as it is after mount.
71
+ * - `Dots` computes `ceil((slides - slidesToShow) / slidesToSlide) + 1`, and
72
+ * `slidesToShow` is 0 until mount, so the server emits one dot per slide and
73
+ * the mounted carousel emits fewer. The dot list is `position:absolute`, so
74
+ * this costs no layout — it is a count that settles, not a shift.
75
+ * - Every `<li>` carries `aria-hidden="true"` in the SSR markup, because
76
+ * `getIfSlideIsVisbile` compares against a `slidesToShow` of 0. It settles at
77
+ * mount, and it is the same markup the library ships for every SSR consumer.
78
+ */
79
+
80
+ /** Where the carousel's `containerClass` scope is written. */
81
+ export const CAROUSEL_SSR_SCOPE_PREFIX = 'pz-carousel-ssr-';
82
+
83
+ /**
84
+ * The gate. Present in the server markup and in the client's first render,
85
+ * gone once mounted — which is what makes the stylesheet below stop applying
86
+ * without anything having to rewrite it.
87
+ */
88
+ export const CAROUSEL_SSR_GATE_ATTRIBUTE = 'data-carousel-ssr';
89
+
90
+ /**
91
+ * The `deviceType` every call site hands the library, and the responsive-map
92
+ * key it therefore has to name. A literal on purpose — see the header.
93
+ */
94
+ export const CAROUSEL_SSR_DEVICE_TYPE = 'desktop';
95
+
96
+ /** One band of a `react-multi-carousel` responsive map. */
97
+ export interface CarouselResponsiveBand {
98
+ breakpoint: { min: number; max: number };
99
+ items: number;
100
+ slidesToSlide?: number;
101
+ partialVisibilityGutter?: number;
102
+ }
103
+
104
+ export type CarouselResponsiveMap = Record<string, CarouselResponsiveBand>;
105
+
106
+ /**
107
+ * An id safe to sit inside an attribute selector and a class name, or null
108
+ * when there is nothing usable to scope with. Block ids are authored data, so
109
+ * they are sanitised rather than trusted — the same treatment
110
+ * `announcement-bar-block` gives the id it scopes its own inline `<style>`
111
+ * with.
112
+ *
113
+ * NULL RATHER THAN A SHARED CONSTANT. A literal fallback would hand every
114
+ * id-less carousel on a page the same gate value AND the same scope class, so
115
+ * each one's stylesheet would restyle the other's slides with the wrong item
116
+ * count — a wrong layout, which is worse than the empty track this module
117
+ * exists to remove. Refusing to scope is the safe failure: the carousel then
118
+ * behaves exactly as it did before this module existed.
119
+ */
120
+ export const carouselSsrScopeId = (raw: unknown): string | null => {
121
+ const cleaned = String(raw ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
122
+ return cleaned || null;
123
+ };
124
+
125
+ /**
126
+ * The library's own SSR width, formatted the way the library formats it, so
127
+ * the rule this module emits and the inline style the library writes cannot
128
+ * express the same number differently.
129
+ */
130
+ const flexBasisForItems = (items: number): string => (100 / items).toFixed(1);
131
+
132
+ /**
133
+ * A band's media query, built from the band itself.
134
+ *
135
+ * This is the whole reason the stylesheet is generated from the responsive map
136
+ * rather than from the breakpoint constants directly: a query written at one
137
+ * width while the library switches at another would apply the mobile item
138
+ * count across a range where the mounted carousel applies the desktop one, and
139
+ * the slides would visibly resize at hydration. Reading both from one object
140
+ * makes that disagreement unrepresentable.
141
+ */
142
+ const bandMediaQuery = (band: CarouselResponsiveBand): string | null => {
143
+ const { min, max } = band.breakpoint || ({} as { min: number; max: number });
144
+ const parts: string[] = [];
145
+ if (Number.isFinite(min) && min > 0) parts.push(`(min-width:${min}px)`);
146
+ if (Number.isFinite(max)) parts.push(`(max-width:${max}px)`);
147
+ return parts.length > 0 ? `@media ${parts.join(' and ')}` : null;
148
+ };
149
+
150
+ /**
151
+ * The gated stylesheet for one carousel.
152
+ *
153
+ * Two selectors, both anchored on the gate attribute so the whole sheet dies
154
+ * at mount, and both narrowed by this carousel's own scope class so a slider
155
+ * nested inside another slider's slide is not restyled by its parent's item
156
+ * count. The `!important` on the flex override is not decoration: the value it
157
+ * overrides is an inline style the library writes on every `<li>`, and nothing
158
+ * short of `!important` outranks that.
159
+ */
160
+ export const buildCarouselSsrCss = (
161
+ scopeId: string,
162
+ responsive: CarouselResponsiveMap,
163
+ deviceType: string = CAROUSEL_SSR_DEVICE_TYPE
164
+ ): string => {
165
+ const track = `[${CAROUSEL_SSR_GATE_ATTRIBUTE}="${scopeId}"] .${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}>.react-multi-carousel-track`;
166
+
167
+ let css = `${track}{width:100%;}`;
168
+
169
+ Object.keys(responsive || {}).forEach((name) => {
170
+ // The SSR band needs no rule: its width is the one the library already
171
+ // wrote inline on each slide.
172
+ if (name === deviceType) return;
173
+
174
+ const band = responsive[name];
175
+ const items = Number(band?.items);
176
+ if (!Number.isFinite(items) || items <= 0) return;
177
+
178
+ const query = bandMediaQuery(band);
179
+ if (!query) return;
180
+
181
+ css += `${query}{${track}>li{flex:1 0 ${flexBasisForItems(
182
+ items
183
+ )}% !important;}}`;
184
+ });
185
+
186
+ return css;
187
+ };
188
+
189
+ export interface CarouselSsr {
190
+ /**
191
+ * Spread onto `<Carousel>`. Together these are what make the library emit
192
+ * slides instead of `null` — and EMPTY when there is no id to scope the
193
+ * width rule with, because opting in without that rule collapses the strip.
194
+ */
195
+ carouselProps: { ssr: true; deviceType: string } | Record<string, never>;
196
+ /** Spread onto an ancestor of the carousel. Empty once mounted. */
197
+ gateProps: Record<string, string>;
198
+ /** Appended to the carousel's `containerClass`. Empty when unscoped. */
199
+ scopeClass: string;
200
+ /** The gated stylesheet, or `null` once mounted. */
201
+ gateStyle: React.ReactElement | null;
202
+ }
203
+
204
+ /**
205
+ * Everything one carousel call site needs, so the two of them cannot drift.
206
+ *
207
+ * `remountKey` must be the SAME string the call site hands `<Carousel key>`.
208
+ * That is not a convenience: `posts-slider-section` puts `currentBreakpoint`
209
+ * in its key, so switching device in the editor REMOUNTS the carousel, and a
210
+ * fresh mount is a fresh `domLoaded: false` — the library is back in its SSR
211
+ * state for one render. A gate that only tracked "has this component mounted
212
+ * once" would already be false by then, and the remounted carousel would paint
213
+ * desktop-basis slides for a frame at every breakpoint switch. Keying the gate
214
+ * to the same string means the gate comes back exactly when the SSR state
215
+ * does.
216
+ */
217
+ export const useCarouselSsr = (options: {
218
+ id: unknown;
219
+ remountKey: string;
220
+ responsive: CarouselResponsiveMap;
221
+ }): CarouselSsr => {
222
+ const { id, remountKey, responsive } = options;
223
+
224
+ const scopeId = carouselSsrScopeId(id);
225
+
226
+ // `null` rather than `false`, so the very first client render is compared
227
+ // against a key it cannot equal. That render has to match the server's
228
+ // byte for byte or React discards the markup it just hydrated — which would
229
+ // reintroduce the blank first paint this module exists to remove.
230
+ const [mountedKey, setMountedKey] = React.useState<string | null>(null);
231
+ React.useEffect(() => {
232
+ // A passive effect, deliberately: the library's `componentDidMount` runs
233
+ // during commit, before this, so by the time the gate is dropped the
234
+ // carousel has already measured its container and written real pixel
235
+ // widths. Dropping it in a layout effect would race that measurement.
236
+ setMountedKey(remountKey);
237
+ }, [remountKey]);
238
+
239
+ const gated = mountedKey !== remountKey;
240
+
241
+ if (gated && !responsive?.[CAROUSEL_SSR_DEVICE_TYPE]) {
242
+ // Not fatal — the library simply falls back to rendering nothing until
243
+ // mount, which is the behaviour that predates this module. Worth saying
244
+ // out loud, because the failure is silent and looks like the old bug.
245
+ devWarn(
246
+ 'carousel-ssr',
247
+ `Responsive map has no "${CAROUSEL_SSR_DEVICE_TYPE}" band, so slides cannot be server-rendered`
248
+ );
249
+ }
250
+
251
+ if (scopeId === null) {
252
+ // No id to scope with, so the width rule cannot be written. Opting into
253
+ // the library's SSR path WITHOUT that rule is the worst of the three
254
+ // outcomes: the track is a flex item with an indefinite width, so the
255
+ // percentage basis the library writes on each slide collapses the whole
256
+ // strip to a few hundred pixels. Staying off the SSR path leaves the empty
257
+ // track this module exists to remove, which at least looks like the bug it
258
+ // is rather than a broken layout.
259
+ return {
260
+ carouselProps: {},
261
+ gateProps: {},
262
+ scopeClass: '',
263
+ gateStyle: null
264
+ };
265
+ }
266
+
267
+ return {
268
+ carouselProps: { ssr: true, deviceType: CAROUSEL_SSR_DEVICE_TYPE },
269
+ gateProps: gated ? { [CAROUSEL_SSR_GATE_ATTRIBUTE]: scopeId } : {},
270
+ scopeClass: `${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}`,
271
+ gateStyle: gated ? (
272
+ <style>{buildCarouselSsrCss(scopeId, responsive)}</style>
273
+ ) : null
274
+ };
275
+ };