@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.
@@ -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: 768 },
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: 768, min: 0 },
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={`${desktopItems}-${mobileItems}-${desktopSlides}-${mobileSlides}-${visibleCount}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
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
- arrows={isDesigner ? false : Boolean(showArrows) && !arrowsOutside}
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="slider-carousel"
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 && showArrows && !arrowsOutside ? (
774
+ !isDesigner &&
775
+ showArrows &&
776
+ !arrowsOutside &&
777
+ visibleCount > itemsShown ? (
703
778
  <CustomLeftArrow />
704
779
  ) : undefined
705
780
  }
706
781
  customRightArrow={
707
- !isDesigner && showArrows && !arrowsOutside ? (
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={properties.controls !== false}
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={`${desktopItems}-${tabletItems}-${mobileItems}-${desktopSlides}-${tabletSlides}-${mobileSlides}-${visibleCount}-${currentBreakpoint}-${useInfinite}-${useAutoPlay}-${autoPlaySpeed}`}
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="posts-slider-carousel"
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
+ });