@akinon/pz-theme 2.0.93-rc.0 → 2.0.94-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.
- package/CHANGELOG.md +22 -33
- package/jest/setup.ts +50 -0
- package/package.json +5 -4
- package/src/blocks/__tests__/carousel-ssr-render.test.tsx +387 -0
- package/src/blocks/__tests__/video-block.test.tsx +89 -0
- package/src/blocks/slider-block.tsx +95 -8
- package/src/blocks/video-block.tsx +43 -4
- package/src/sections/posts-slider-section.tsx +53 -3
- package/src/utils/__tests__/breakpoint-cascade.test.ts +42 -0
- package/src/utils/__tests__/carousel-ssr.test.ts +180 -0
- package/src/utils/carousel-ssr.tsx +275 -0
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
getResponsiveValue,
|
|
13
13
|
resolveThemeCssVariables
|
|
14
14
|
} from '../utils';
|
|
15
|
-
import { isNarrowBreakpoint } from '../utils/breakpoints';
|
|
15
|
+
import { DESKTOP_MIN_WIDTH, isNarrowBreakpoint } from '../utils/breakpoints';
|
|
16
|
+
import { useCarouselSsr } from '../utils/carousel-ssr';
|
|
16
17
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
17
18
|
|
|
18
19
|
import { postToEditor } from '../editor-bridge';
|
|
@@ -115,14 +116,35 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
115
116
|
const mobilePeek = parseNumber(getResponsiveValue(slidePeek, 'mobile', 0), 0);
|
|
116
117
|
const partialVisible = desktopPeek > 0 || mobilePeek > 0;
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Where this slider's two bands meet, and why the number moved.
|
|
121
|
+
*
|
|
122
|
+
* It was hard-coded here as 768 — `desktop: { min: 768 }`, `mobile: { max:
|
|
123
|
+
* 768 }` — while every other reader of the same block drew the line at 1024.
|
|
124
|
+
* `isNarrowBreakpoint` just below picks this slider's `slidesToSlide` at
|
|
125
|
+
* 1024, `generateElementCSS` emits the block's own padding and max-width
|
|
126
|
+
* against `MOBILE_MEDIA_QUERY` (max-width 1023), and the sibling renderer
|
|
127
|
+
* `posts-slider-section` already used the shared constants. So between 768
|
|
128
|
+
* and 1023 one slider showed the DESKTOP item count inside a box the
|
|
129
|
+
* stylesheet had already sized as mobile — the exact drift `utils/breakpoints`
|
|
130
|
+
* exists to end, and a disagreement the gated stylesheet below would have
|
|
131
|
+
* inherited had this stayed as it was.
|
|
132
|
+
*
|
|
133
|
+
* This does change what a 768-1023 viewport sees: the mobile item count,
|
|
134
|
+
* which is what the rest of the block was already telling it to expect.
|
|
135
|
+
*
|
|
136
|
+
* The band edges overlapped on the old numbers (768 satisfied both `min` and
|
|
137
|
+
* `max`, and `setItemsToShow` let the last matching key win). They are
|
|
138
|
+
* adjacent now, so exactly one band claims every width.
|
|
139
|
+
*/
|
|
118
140
|
const carouselResponsive = {
|
|
119
141
|
desktop: {
|
|
120
|
-
breakpoint: { max: 4000, min:
|
|
142
|
+
breakpoint: { max: 4000, min: DESKTOP_MIN_WIDTH },
|
|
121
143
|
items: desktopItems,
|
|
122
144
|
...(desktopPeek > 0 ? { partialVisibilityGutter: desktopPeek } : {})
|
|
123
145
|
},
|
|
124
146
|
mobile: {
|
|
125
|
-
breakpoint: { max:
|
|
147
|
+
breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: 0 },
|
|
126
148
|
items: mobileItems,
|
|
127
149
|
...(mobilePeek > 0 ? { partialVisibilityGutter: mobilePeek } : {})
|
|
128
150
|
}
|
|
@@ -156,6 +178,23 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
156
178
|
const slideGap = parseNumber(getConfigEntry('slide-gap'), 16);
|
|
157
179
|
const slidePadding = slideGap / 2;
|
|
158
180
|
|
|
181
|
+
/*
|
|
182
|
+
* Hoisted out of the JSX because the SSR gate has to be keyed to the exact
|
|
183
|
+
* same string: a change here remounts the carousel, a remount puts the
|
|
184
|
+
* library back into its pre-measurement state, and the gated stylesheet has
|
|
185
|
+
* to come back with it. Two copies of this template literal would eventually
|
|
186
|
+
* disagree.
|
|
187
|
+
*/
|
|
188
|
+
const carouselKey = `${desktopItems}-${mobileItems}-${desktopSlides}-${mobileSlides}-${visibleCount}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`;
|
|
189
|
+
|
|
190
|
+
// Server-render the slides. Without this the track ships empty and the whole
|
|
191
|
+
// band is 0px tall until hydration — see `utils/carousel-ssr`.
|
|
192
|
+
const carouselSsr = useCarouselSsr({
|
|
193
|
+
id: block.id,
|
|
194
|
+
remountKey: carouselKey,
|
|
195
|
+
responsive: carouselResponsive
|
|
196
|
+
});
|
|
197
|
+
|
|
159
198
|
/**
|
|
160
199
|
* Edge treatment, the same vocabulary the slider SECTIONS grew — this block
|
|
161
200
|
* is a second, independent renderer of the same carousel (a Featured Brand
|
|
@@ -668,17 +707,50 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
668
707
|
clsx('relative z-10 w-full', hasMaxWidth && 'mx-auto', maxWidthClass)
|
|
669
708
|
)}
|
|
670
709
|
style={sectionStyles}
|
|
710
|
+
{...carouselSsr.gateProps}
|
|
671
711
|
>
|
|
672
712
|
<div style={{ position: 'relative' }}>
|
|
673
713
|
<Carousel
|
|
674
714
|
ref={carouselRef}
|
|
675
|
-
key={
|
|
715
|
+
key={carouselKey}
|
|
676
716
|
responsive={carouselResponsive}
|
|
717
|
+
/*
|
|
718
|
+
* `deviceType` is a CONSTANT LITERAL and must stay one. It names a
|
|
719
|
+
* key of the responsive map above.
|
|
720
|
+
*
|
|
721
|
+
* NOT for the reason it looks like. `currentBreakpoint` starts at
|
|
722
|
+
* `'desktop'` on both sides, so the client's FIRST render agrees
|
|
723
|
+
* with the server either way — passing it would not trip React's
|
|
724
|
+
* hydration check, and a reader who tests only that will conclude it
|
|
725
|
+
* is safe. The harm lands after mount: the effect in
|
|
726
|
+
* `theme-placeholder-wrapper` measures the viewport and moves the
|
|
727
|
+
* prop, and the library reads `responsive[deviceType]` on its own
|
|
728
|
+
* schedule rather than on prop change. A moving `deviceType` also
|
|
729
|
+
* names a key that may not exist in a map the designer collapsed to
|
|
730
|
+
* one band. See `utils/carousel-ssr`.
|
|
731
|
+
*/
|
|
732
|
+
{...carouselSsr.carouselProps}
|
|
677
733
|
partialVisible={partialVisible}
|
|
678
734
|
slidesToSlide={currentSlidesToSlide}
|
|
679
735
|
swipeable={true}
|
|
680
736
|
draggable={true}
|
|
681
|
-
|
|
737
|
+
/*
|
|
738
|
+
* `visibleCount > itemsShown` is what keeps the server and the
|
|
739
|
+
* mounted library agreeing. The library decides for itself whether
|
|
740
|
+
* an arrow can move anything, but only once it has measured — so
|
|
741
|
+
* without this a slider holding fewer slides than it shows would
|
|
742
|
+
* paint an arrow in the SSR markup and then lose it at hydration,
|
|
743
|
+
* a flicker in exactly the window this file exists to clean up.
|
|
744
|
+
* `posts-slider-section` folds the same test into its own
|
|
745
|
+
* `showArrows`.
|
|
746
|
+
*/
|
|
747
|
+
arrows={
|
|
748
|
+
isDesigner
|
|
749
|
+
? false
|
|
750
|
+
: Boolean(showArrows) &&
|
|
751
|
+
!arrowsOutside &&
|
|
752
|
+
visibleCount > itemsShown
|
|
753
|
+
}
|
|
682
754
|
showDots={Boolean(showDots)}
|
|
683
755
|
renderDotsOutside={Boolean(showDots)}
|
|
684
756
|
infinite={Boolean(useInfinite)}
|
|
@@ -686,7 +758,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
686
758
|
rewindWithAnimation={Boolean(useInfinite)}
|
|
687
759
|
autoPlay={Boolean(useAutoPlay)}
|
|
688
760
|
autoPlaySpeed={autoPlaySpeed}
|
|
689
|
-
containerClass=
|
|
761
|
+
containerClass={`slider-carousel ${carouselSsr.scopeClass}`}
|
|
690
762
|
dotListClass="slider-dots"
|
|
691
763
|
/*
|
|
692
764
|
* Spread conditionally so a slider that has not opted in is handed
|
|
@@ -699,12 +771,18 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
699
771
|
}
|
|
700
772
|
: {})}
|
|
701
773
|
customLeftArrow={
|
|
702
|
-
!isDesigner &&
|
|
774
|
+
!isDesigner &&
|
|
775
|
+
showArrows &&
|
|
776
|
+
!arrowsOutside &&
|
|
777
|
+
visibleCount > itemsShown ? (
|
|
703
778
|
<CustomLeftArrow />
|
|
704
779
|
) : undefined
|
|
705
780
|
}
|
|
706
781
|
customRightArrow={
|
|
707
|
-
!isDesigner &&
|
|
782
|
+
!isDesigner &&
|
|
783
|
+
showArrows &&
|
|
784
|
+
!arrowsOutside &&
|
|
785
|
+
visibleCount > itemsShown ? (
|
|
708
786
|
<CustomRightArrow />
|
|
709
787
|
) : undefined
|
|
710
788
|
}
|
|
@@ -831,6 +909,15 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
831
909
|
</>
|
|
832
910
|
) : null}
|
|
833
911
|
</div>
|
|
912
|
+
|
|
913
|
+
{/*
|
|
914
|
+
LAST, not first. It is `display:none` either way and these rules are
|
|
915
|
+
order-independent, but rendered ahead of the carousel it would occupy
|
|
916
|
+
child index 0 for exactly one paint and shift every sibling index —
|
|
917
|
+
a hazard for anything reading children positionally, bought for
|
|
918
|
+
nothing.
|
|
919
|
+
*/}
|
|
920
|
+
{carouselSsr.gateStyle}
|
|
834
921
|
</div>
|
|
835
922
|
);
|
|
836
923
|
};
|
|
@@ -249,16 +249,55 @@ const VideoBlock = ({
|
|
|
249
249
|
);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Read through the breakpoint, the way `aspect-ratio` above already is.
|
|
254
|
+
*
|
|
255
|
+
* The editor stores every one of these per breakpoint — `{ desktop: false }`,
|
|
256
|
+
* not `false`. So `properties.controls !== false` was comparing an OBJECT to
|
|
257
|
+
* `false`, which is never equal, and controls were on for every video whatever
|
|
258
|
+
* the merchant chose. Nobody saw it because nothing painted a video before
|
|
259
|
+
* hydration; with the slides now server-rendered, a cold load would paint a
|
|
260
|
+
* full-width native control bar under the header.
|
|
261
|
+
*
|
|
262
|
+
* `poster` had the mirror of the same defect: `typeof properties.poster ===
|
|
263
|
+
* 'string'` is false for the per-breakpoint object the editor writes, so a
|
|
264
|
+
* poster a merchant typed was stored, shown in the panel and silently
|
|
265
|
+
* dropped.
|
|
266
|
+
*
|
|
267
|
+
* `autoplay`, `loop` and `muted` are DELIBERATELY left reading raw. They have
|
|
268
|
+
* the same defect pointing the other way: their stored value is an object,
|
|
269
|
+
* an object is truthy, and only the bug makes them true. Reading them
|
|
270
|
+
* correctly here would switch autoplay and loop OFF on every composition
|
|
271
|
+
* already published — a behaviour change that needs its own ticket, not a
|
|
272
|
+
* side effect of this one. `buildEmbedParams` reads all four raw for the
|
|
273
|
+
* same reason, and is left alone with them.
|
|
274
|
+
*
|
|
275
|
+
* `playsInline` is a FOURTH exclusion, and named here so its absence reads
|
|
276
|
+
* as a decision rather than an oversight. Its defect is identical to
|
|
277
|
+
* `controls` — same shape, same direction, same element — but the
|
|
278
|
+
* consequence is not: reading it correctly would let a merchant's stored
|
|
279
|
+
* `false` through, and an iPhone would then take the video fullscreen on
|
|
280
|
+
* play instead of keeping it in the page. That is a shopper-visible
|
|
281
|
+
* playback change on every placed composition, which belongs with the
|
|
282
|
+
* autoplay ticket. The control bar this ticket exists to remove is a
|
|
283
|
+
* painting problem; fullscreen playback is not.
|
|
284
|
+
*/
|
|
285
|
+
const controls =
|
|
286
|
+
getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
|
|
287
|
+
const poster = getResponsiveValue(
|
|
288
|
+
properties.poster,
|
|
289
|
+
currentBreakpoint,
|
|
290
|
+
undefined
|
|
291
|
+
);
|
|
292
|
+
|
|
252
293
|
return (
|
|
253
294
|
<video
|
|
254
295
|
src={parsed.src}
|
|
255
|
-
controls={
|
|
296
|
+
controls={controls}
|
|
256
297
|
autoPlay={properties.autoplay}
|
|
257
298
|
loop={properties.loop}
|
|
258
299
|
muted={properties.muted}
|
|
259
|
-
poster={
|
|
260
|
-
typeof properties.poster === 'string' ? properties.poster : undefined
|
|
261
|
-
}
|
|
300
|
+
poster={typeof poster === 'string' && poster ? poster : undefined}
|
|
262
301
|
// Without it iOS opens the video full screen, which is never what an
|
|
263
302
|
// inline background clip is for.
|
|
264
303
|
playsInline={properties.playsInline !== false}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
SECTION_CONTENT_ATTRIBUTE
|
|
20
20
|
} from '../utils';
|
|
21
21
|
import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../utils/breakpoints';
|
|
22
|
+
import { useCarouselSsr } from '../utils/carousel-ssr';
|
|
22
23
|
import {
|
|
23
24
|
findSliderArrowBlocks,
|
|
24
25
|
isSliderArrowIconBlock,
|
|
@@ -483,6 +484,31 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
483
484
|
const useInfinite = infinite && hasMultipleSlides;
|
|
484
485
|
const useAutoPlay = autoPlay && hasMultipleSlides;
|
|
485
486
|
|
|
487
|
+
/*
|
|
488
|
+
* Hoisted out of the JSX so the SSR gate can be keyed to the identical
|
|
489
|
+
* string. This key carries `currentBreakpoint`, which means switching device
|
|
490
|
+
* in the editor REMOUNTS the carousel — and a fresh mount is a fresh
|
|
491
|
+
* `domLoaded: false`, so the library is momentarily back in its SSR state and
|
|
492
|
+
* needs the gated stylesheet back with it. `slider-block`'s key does not
|
|
493
|
+
* carry the breakpoint, so only this file has that second window.
|
|
494
|
+
*
|
|
495
|
+
* The breakpoint stays in the key. Dropping it would be the smaller change on
|
|
496
|
+
* paper, but in the designer `carouselResponsive` collapses to a single band
|
|
497
|
+
* built FROM `currentBreakpoint`, and the library only re-reads `responsive`
|
|
498
|
+
* in `setItemsToShow` — which runs on mount and on resize, not on a prop
|
|
499
|
+
* change. Without the remount, switching device in the editor would leave the
|
|
500
|
+
* canvas showing the previous breakpoint's item count.
|
|
501
|
+
*/
|
|
502
|
+
const carouselKey = `${desktopItems}-${tabletItems}-${mobileItems}-${desktopSlides}-${tabletSlides}-${mobileSlides}-${visibleCount}-${currentBreakpoint}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`;
|
|
503
|
+
|
|
504
|
+
// Server-render the slides. Without this the strip ships an empty track and
|
|
505
|
+
// occupies no height until hydration — see `utils/carousel-ssr`.
|
|
506
|
+
const carouselSsr = useCarouselSsr({
|
|
507
|
+
id: section.id,
|
|
508
|
+
remountKey: carouselKey,
|
|
509
|
+
responsive: carouselResponsive
|
|
510
|
+
});
|
|
511
|
+
|
|
486
512
|
/**
|
|
487
513
|
* Edge treatment: cards flush to the band, and a lead-in only at the start.
|
|
488
514
|
*
|
|
@@ -832,10 +858,26 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
832
858
|
* `slides` part — `generatePartCSS` emits it against this attribute — so
|
|
833
859
|
* padding it does not move anything but the cards.
|
|
834
860
|
*/}
|
|
835
|
-
<div data-part="slides">
|
|
861
|
+
<div data-part="slides" {...carouselSsr.gateProps}>
|
|
836
862
|
<Carousel
|
|
837
|
-
key={
|
|
863
|
+
key={carouselKey}
|
|
838
864
|
responsive={carouselResponsive}
|
|
865
|
+
/*
|
|
866
|
+
* `deviceType` is a CONSTANT LITERAL and must stay one. It names a
|
|
867
|
+
* key of `carouselResponsive`.
|
|
868
|
+
*
|
|
869
|
+
* NOT for the reason it looks like. `currentBreakpoint` starts at
|
|
870
|
+
* `'desktop'` on both sides, so the client's FIRST render agrees
|
|
871
|
+
* with the server either way — passing it would not trip React's
|
|
872
|
+
* hydration check, and a reader who tests only that will conclude it
|
|
873
|
+
* is safe. The harm lands after mount: the effect in
|
|
874
|
+
* `theme-placeholder-wrapper` measures the viewport and moves the
|
|
875
|
+
* prop, and the library reads `responsive[deviceType]` on its own
|
|
876
|
+
* schedule rather than on prop change. A moving `deviceType` also
|
|
877
|
+
* names a key that may not exist in a map the designer collapsed to
|
|
878
|
+
* one band. See `utils/carousel-ssr`.
|
|
879
|
+
*/
|
|
880
|
+
{...carouselSsr.carouselProps}
|
|
839
881
|
{...(isDesigner ? { slidesToSlide: currentSlidesToSlide } : {})}
|
|
840
882
|
swipeable={true}
|
|
841
883
|
draggable={true}
|
|
@@ -851,7 +893,7 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
851
893
|
rewindWithAnimation={useInfinite}
|
|
852
894
|
autoPlay={useAutoPlay}
|
|
853
895
|
autoPlaySpeed={autoPlaySpeed}
|
|
854
|
-
containerClass=
|
|
896
|
+
containerClass={`posts-slider-carousel ${carouselSsr.scopeClass}`}
|
|
855
897
|
dotListClass="posts-slider-dots"
|
|
856
898
|
/*
|
|
857
899
|
* Spread conditionally so a slider that has not opted in is handed
|
|
@@ -978,6 +1020,14 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
|
|
|
978
1020
|
);
|
|
979
1021
|
})}
|
|
980
1022
|
</Carousel>
|
|
1023
|
+
|
|
1024
|
+
{/*
|
|
1025
|
+
LAST, not first — see the same note in `slider-block`. Rendered ahead
|
|
1026
|
+
of the carousel it would hold child index 0 for one paint and shift
|
|
1027
|
+
every sibling index, for no benefit a `display:none` element gains
|
|
1028
|
+
from being early.
|
|
1029
|
+
*/}
|
|
1030
|
+
{carouselSsr.gateStyle}
|
|
981
1031
|
</div>
|
|
982
1032
|
|
|
983
1033
|
{/*
|
|
@@ -349,3 +349,45 @@ describe('visibility rules survive the third breakpoint', () => {
|
|
|
349
349
|
expect(isBreakpointAllowed(['desktop', 'tablet'], 'mobile')).toBe(false);
|
|
350
350
|
});
|
|
351
351
|
});
|
|
352
|
+
|
|
353
|
+
describe('the carousel switches where the stylesheet switches', () => {
|
|
354
|
+
/**
|
|
355
|
+
* The last reader still drawing its own line.
|
|
356
|
+
*
|
|
357
|
+
* `slider-block` hard-coded `desktop: { min: 768 }` / `mobile: { max: 768 }`
|
|
358
|
+
* into the responsive map it hands `react-multi-carousel`, so between 768 and
|
|
359
|
+
* 1023 one slider showed the DESKTOP item count inside a box this generator
|
|
360
|
+
* had already sized as mobile. It reads the shared constants now, which is
|
|
361
|
+
* what lets the gated stylesheet in `utils/carousel-ssr` — the sheet that
|
|
362
|
+
* gives a slider its height before it hydrates — be generated from that same
|
|
363
|
+
* map without inheriting a disagreement.
|
|
364
|
+
*/
|
|
365
|
+
const sliderCarouselBands = {
|
|
366
|
+
desktop: { breakpoint: { max: 4000, min: DESKTOP_MIN_WIDTH }, items: 4 },
|
|
367
|
+
mobile: { breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: 0 }, items: 1 }
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
it('ends the narrow band exactly where the mobile query ends', () => {
|
|
371
|
+
expect(MOBILE_MEDIA_QUERY).toContain(
|
|
372
|
+
`max-width: ${sliderCarouselBands.mobile.breakpoint.max}px`
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('leaves no width unclaimed and no width claimed twice', () => {
|
|
377
|
+
expect(sliderCarouselBands.desktop.breakpoint.min).toBe(
|
|
378
|
+
sliderCarouselBands.mobile.breakpoint.max + 1
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('puts a tablet width in the band the cascade puts it in', () => {
|
|
383
|
+
// 900px reads the mobile value everywhere else in this file; the carousel
|
|
384
|
+
// used to call it desktop.
|
|
385
|
+
const width = 900;
|
|
386
|
+
expect(breakpointForWidth(width)).toBe('tablet');
|
|
387
|
+
expect(isNarrowBreakpoint(breakpointForWidth(width))).toBe(true);
|
|
388
|
+
expect(width).toBeLessThan(sliderCarouselBands.desktop.breakpoint.min);
|
|
389
|
+
expect(width).toBeLessThanOrEqual(
|
|
390
|
+
sliderCarouselBands.mobile.breakpoint.max
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gated stylesheet that lets a carousel have height before it hydrates.
|
|
3
|
+
*
|
|
4
|
+
* What is worth pinning here is not that a string was produced but that it
|
|
5
|
+
* agrees with two things it has no direct link to: the inline styles the
|
|
6
|
+
* carousel library writes on each slide, and the responsive map the same call
|
|
7
|
+
* site hands that library. Both agreements are silent when they break — the
|
|
8
|
+
* slides simply resize at hydration — so they are asserted against the
|
|
9
|
+
* library's OWN width function rather than against a number retyped here.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
buildCarouselSsrCss,
|
|
13
|
+
carouselSsrScopeId,
|
|
14
|
+
CAROUSEL_SSR_DEVICE_TYPE,
|
|
15
|
+
CAROUSEL_SSR_GATE_ATTRIBUTE,
|
|
16
|
+
CAROUSEL_SSR_SCOPE_PREFIX,
|
|
17
|
+
CarouselResponsiveMap
|
|
18
|
+
} from '../carousel-ssr';
|
|
19
|
+
import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../breakpoints';
|
|
20
|
+
|
|
21
|
+
// The library's own maths, imported rather than reimplemented.
|
|
22
|
+
const { getWidthFromDeviceType } =
|
|
23
|
+
require('react-multi-carousel/lib/utils/elementWidth') as {
|
|
24
|
+
getWidthFromDeviceType: (
|
|
25
|
+
deviceType: string,
|
|
26
|
+
responsive: CarouselResponsiveMap
|
|
27
|
+
) => string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `slider-block`'s two bands. */
|
|
31
|
+
const twoBand = (desktopItems = 4, mobileItems = 1): CarouselResponsiveMap => ({
|
|
32
|
+
desktop: {
|
|
33
|
+
breakpoint: { max: 4000, min: DESKTOP_MIN_WIDTH },
|
|
34
|
+
items: desktopItems
|
|
35
|
+
},
|
|
36
|
+
mobile: {
|
|
37
|
+
breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: 0 },
|
|
38
|
+
items: mobileItems
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** `posts-slider-section`'s three. */
|
|
43
|
+
const threeBand = (): CarouselResponsiveMap => ({
|
|
44
|
+
desktop: { breakpoint: { max: 3000, min: DESKTOP_MIN_WIDTH }, items: 4 },
|
|
45
|
+
tablet: {
|
|
46
|
+
breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: TABLET_MIN_WIDTH },
|
|
47
|
+
items: 3
|
|
48
|
+
},
|
|
49
|
+
mobile: { breakpoint: { max: TABLET_MIN_WIDTH - 1, min: 0 }, items: 2 }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Every `@media` prelude in the sheet, in source order. */
|
|
53
|
+
const mediaPreludes = (css: string): string[] =>
|
|
54
|
+
(css.match(/@media[^{]+/g) || []).map((prelude) => prelude.trim());
|
|
55
|
+
|
|
56
|
+
describe('the sheet is anchored on the gate, so it dies when the gate does', () => {
|
|
57
|
+
it('puts the gate attribute at the head of every selector it writes', () => {
|
|
58
|
+
const css = buildCarouselSsrCss('slider1', twoBand());
|
|
59
|
+
const selectors = css.match(/\[data-[^{]+?\{/g) || [];
|
|
60
|
+
|
|
61
|
+
expect(selectors.length).toBeGreaterThan(0);
|
|
62
|
+
selectors.forEach((selector) => {
|
|
63
|
+
expect(selector).toContain(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}="slider1"]`);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('narrows every selector to this carousel, so a nested slider is untouched', () => {
|
|
68
|
+
const css = buildCarouselSsrCss('slider1', twoBand());
|
|
69
|
+
expect(css).toContain(`.${CAROUSEL_SSR_SCOPE_PREFIX}slider1>`);
|
|
70
|
+
// A bare descendant match would also reach a slider nested inside one of
|
|
71
|
+
// this slider's own slides and restyle it with the wrong item count.
|
|
72
|
+
expect(css).not.toMatch(/\]\s+\.react-multi-carousel-track/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sanitises an authored id before it reaches a selector', () => {
|
|
76
|
+
// Block ids are authored data and land inside both an attribute selector
|
|
77
|
+
// and a class name, so what matters is that nothing survives that could
|
|
78
|
+
// close either one.
|
|
79
|
+
const escaped = carouselSsrScopeId('a"] , * {color:red} [x="b');
|
|
80
|
+
expect(escaped).toMatch(/^[a-zA-Z0-9_-]+$/);
|
|
81
|
+
expect(buildCarouselSsrCss(escaped, twoBand())).not.toContain('color:red');
|
|
82
|
+
|
|
83
|
+
// NULL, not a shared literal. A fallback id would hand every id-less
|
|
84
|
+
// carousel on a page the same gate value and the same scope class, so each
|
|
85
|
+
// one's stylesheet would restyle the other's slides with the wrong item
|
|
86
|
+
// count. Refusing to scope keeps the carousel on its pre-SSR behaviour,
|
|
87
|
+
// which is a visible bug rather than a wrong layout.
|
|
88
|
+
expect(carouselSsrScopeId(undefined)).toBeNull();
|
|
89
|
+
expect(carouselSsrScopeId('')).toBeNull();
|
|
90
|
+
expect(carouselSsrScopeId('!!!')).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('width:100% on the track, which is what gives the band its height', () => {
|
|
95
|
+
it('is the unconditional rule', () => {
|
|
96
|
+
expect(buildCarouselSsrCss('s', twoBand())).toContain(
|
|
97
|
+
'>.react-multi-carousel-track{width:100%;}'
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('the gated media queries and the responsive map agree', () => {
|
|
103
|
+
it('writes no rule for the band the library already sized inline', () => {
|
|
104
|
+
const css = buildCarouselSsrCss('s', twoBand());
|
|
105
|
+
// The desktop band's own width is the inline `flex` the library wrote.
|
|
106
|
+
expect(css).not.toContain(`(min-width:${DESKTOP_MIN_WIDTH}px)`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('starts desktop where the responsive map starts it, not at 768', () => {
|
|
110
|
+
const map = twoBand();
|
|
111
|
+
const css = buildCarouselSsrCss('s', map);
|
|
112
|
+
|
|
113
|
+
// The single non-desktop band ends one pixel below where desktop begins.
|
|
114
|
+
expect(mediaPreludes(css)).toEqual([
|
|
115
|
+
`@media (max-width:${map.desktop.breakpoint.min - 1}px)`
|
|
116
|
+
]);
|
|
117
|
+
expect(map.desktop.breakpoint.min).toBe(DESKTOP_MIN_WIDTH);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('derives each query from its own band, for all three of them', () => {
|
|
121
|
+
const map = threeBand();
|
|
122
|
+
expect(mediaPreludes(buildCarouselSsrCss('s', map))).toEqual([
|
|
123
|
+
`@media (min-width:${TABLET_MIN_WIDTH}px) and (max-width:${
|
|
124
|
+
DESKTOP_MIN_WIDTH - 1
|
|
125
|
+
}px)`,
|
|
126
|
+
`@media (max-width:${TABLET_MIN_WIDTH - 1}px)`
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('follows the map when the map moves, rather than a number typed here', () => {
|
|
131
|
+
const moved: CarouselResponsiveMap = {
|
|
132
|
+
desktop: { breakpoint: { max: 4000, min: 1280 }, items: 4 },
|
|
133
|
+
mobile: { breakpoint: { max: 1279, min: 0 }, items: 1 }
|
|
134
|
+
};
|
|
135
|
+
expect(mediaPreludes(buildCarouselSsrCss('s', moved))).toEqual([
|
|
136
|
+
'@media (max-width:1279px)'
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('the flex basis matches the library, and outranks the library', () => {
|
|
142
|
+
it('writes the width the library itself would compute for that band', () => {
|
|
143
|
+
const map = twoBand(4, 2);
|
|
144
|
+
const css = buildCarouselSsrCss('s', map);
|
|
145
|
+
|
|
146
|
+
expect(getWidthFromDeviceType('mobile', map)).toBe('50.0');
|
|
147
|
+
expect(css).toContain('flex:1 0 50.0% !important;');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('keeps `!important`, because the value it beats is an inline style', () => {
|
|
151
|
+
// `CarouselItems` writes `style={{ flex: '1 0 <basis>%' }}` on every <li>.
|
|
152
|
+
// Without `!important` the stylesheet loses to it and a phone paints
|
|
153
|
+
// desktop-width slides.
|
|
154
|
+
const css = buildCarouselSsrCss('s', twoBand(4, 1));
|
|
155
|
+
expect(css).toContain('flex:1 0 100.0% !important;');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('targets the slides, not the track, for the width override', () => {
|
|
159
|
+
expect(buildCarouselSsrCss('s', twoBand())).toContain(
|
|
160
|
+
'.react-multi-carousel-track>li{flex:'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('skips a band the library could not size either', () => {
|
|
165
|
+
const broken: CarouselResponsiveMap = {
|
|
166
|
+
desktop: { breakpoint: { max: 4000, min: DESKTOP_MIN_WIDTH }, items: 4 },
|
|
167
|
+
mobile: { breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: 0 }, items: 0 }
|
|
168
|
+
};
|
|
169
|
+
expect(getWidthFromDeviceType('mobile', broken)).toBe('Infinity');
|
|
170
|
+
expect(buildCarouselSsrCss('s', broken)).not.toContain('@media');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('the device type is a literal', () => {
|
|
175
|
+
it('names a band every call site actually declares', () => {
|
|
176
|
+
expect(CAROUSEL_SSR_DEVICE_TYPE).toBe('desktop');
|
|
177
|
+
expect(twoBand()[CAROUSEL_SSR_DEVICE_TYPE]).toBeDefined();
|
|
178
|
+
expect(threeBand()[CAROUSEL_SSR_DEVICE_TYPE]).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
});
|