@akinon/pz-theme 2.0.141-rc.0 → 2.0.142-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 CHANGED
@@ -1,40 +1,20 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.141-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d458e]
35
- - Updated dependencies [dca128112]
36
- - @akinon/next@2.0.141-rc.0
37
- - @akinon/theme-contract@2.0.141-rc.0
3
+ ## 2.0.142-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.142-beta.0
9
+ - @akinon/theme-contract@2.0.142-beta.0
10
+
11
+ ## 2.0.141
12
+
13
+ ### Patch Changes
14
+
15
+ - cd9f6c4: ZERO-5190: Hide carousel arrows with CSS instead of removing them from the markup
16
+ - @akinon/next@2.0.141
17
+ - @akinon/theme-contract@2.0.141
38
18
 
39
19
  ## 2.0.140
40
20
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.141-rc.0",
4
+ "version": "2.0.142-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.141-rc.0",
11
+ "@akinon/next": "2.0.142-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.141-rc.0",
16
+ "@akinon/theme-contract": "2.0.142-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.141-rc.0",
24
+ "@akinon/next": "2.0.142-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -18,7 +18,11 @@ import {
18
18
  isNarrowBreakpoint,
19
19
  setAtAnyBreakpoint
20
20
  } from '../utils/breakpoints';
21
- import { useCarouselSsr } from '../utils/carousel-ssr';
21
+ import {
22
+ buildCarouselArrowCss,
23
+ carouselSsrScopeId,
24
+ useCarouselSsr
25
+ } from '../utils/carousel-ssr';
22
26
  import {
23
27
  authoredSlideIndex,
24
28
  TIMER_SLIDE_CHANGE_CEILING
@@ -306,6 +310,29 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
306
310
  currentBreakpoint,
307
311
  true
308
312
  );
313
+ /**
314
+ * Whether the arrow EXISTS is settled without the breakpoint; which widths
315
+ * it is visible at is settled in CSS below. Unauthored still means on, the
316
+ * same default `showArrows` carries.
317
+ */
318
+ const arrowsConfigured = getConfigEntry('show-arrows');
319
+ const arrowsAnywhere =
320
+ arrowsConfigured === undefined || arrowsConfigured === null
321
+ ? true
322
+ : setAtAnyBreakpoint(arrowsConfigured);
323
+ // Not `itemsShown`: that is breakpoint-derived, so it would put the flip
324
+ // back. One slide can never overflow at any width.
325
+ const canOverflow = visibleCount > 1;
326
+ const renderArrows = arrowsAnywhere && canOverflow;
327
+
328
+ const arrowCss = buildCarouselArrowCss(
329
+ carouselSsrScopeId(block.id) || '',
330
+ carouselResponsive,
331
+ (band) =>
332
+ getResponsiveValue(arrowsConfigured, band, true) === true &&
333
+ visibleCount > Number(carouselResponsive?.[band]?.items || 1)
334
+ );
335
+
309
336
  const showDots = getResponsiveValue(
310
337
  getConfigEntry('show-dots'),
311
338
  currentBreakpoint,
@@ -1257,13 +1284,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1257
1284
  * `posts-slider-section` folds the same test into its own
1258
1285
  * `showArrows`.
1259
1286
  */
1260
- arrows={
1261
- isDesigner
1262
- ? false
1263
- : Boolean(showArrows) &&
1264
- !arrowsOutside &&
1265
- visibleCount > itemsShown
1266
- }
1287
+ arrows={isDesigner ? false : renderArrows && !arrowsOutside}
1267
1288
  showDots={Boolean(showDots)}
1268
1289
  customDot={<CustomDot onSelect={handleDotSelect} />}
1269
1290
  renderDotsOutside={Boolean(showDots)}
@@ -1299,18 +1320,12 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1299
1320
  */
1300
1321
  {...(useLeadIn ? { additionalTransfrom: edgeOffset } : {})}
1301
1322
  customLeftArrow={
1302
- !isDesigner &&
1303
- showArrows &&
1304
- !arrowsOutside &&
1305
- visibleCount > itemsShown ? (
1323
+ !isDesigner && renderArrows && !arrowsOutside ? (
1306
1324
  <CustomLeftArrow />
1307
1325
  ) : undefined
1308
1326
  }
1309
1327
  customRightArrow={
1310
- !isDesigner &&
1311
- showArrows &&
1312
- !arrowsOutside &&
1313
- visibleCount > itemsShown ? (
1328
+ !isDesigner && renderArrows && !arrowsOutside ? (
1314
1329
  <CustomRightArrow />
1315
1330
  ) : undefined
1316
1331
  }
@@ -1405,6 +1420,9 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
1405
1420
  nothing.
1406
1421
  */}
1407
1422
  {carouselSsr.gateStyle}
1423
+ {arrowCss ? (
1424
+ <style dangerouslySetInnerHTML={{ __html: arrowCss }} />
1425
+ ) : null}
1408
1426
  </div>
1409
1427
  );
1410
1428
  };
@@ -25,7 +25,11 @@ import {
25
25
  TABLET_MIN_WIDTH,
26
26
  setAtAnyBreakpoint
27
27
  } from '../utils/breakpoints';
28
- import { useCarouselSsr } from '../utils/carousel-ssr';
28
+ import {
29
+ buildCarouselArrowCss,
30
+ carouselSsrScopeId,
31
+ useCarouselSsr
32
+ } from '../utils/carousel-ssr';
29
33
  import {
30
34
  authoredSlideIndex,
31
35
  TIMER_SLIDE_CHANGE_CEILING
@@ -492,12 +496,21 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
492
496
  const gap = parseNumber(gapValue, 16);
493
497
  const slidePadding = gap > 0 ? gap / 2 : 0;
494
498
 
495
- const showArrows =
496
- getResponsiveValue(
497
- getConfigEntry('show-arrows'),
498
- currentBreakpoint,
499
- true
500
- ) === true && visibleCount > itemsPerView;
499
+ /**
500
+ * Whether the arrow EXISTS is settled without the breakpoint; which widths
501
+ * it is visible at is settled in CSS. Reading it through
502
+ * `currentBreakpoint` meant three nodes arrived or vanished one commit
503
+ * after hydration — arrows a phone is not meant to have, flashed and then
504
+ * removed, and a tablet getting them late with a layout shift.
505
+ */
506
+ const arrowsConfigured = getConfigEntry('show-arrows');
507
+ const arrowsAnywhere =
508
+ arrowsConfigured === undefined || arrowsConfigured === null
509
+ ? true
510
+ : setAtAnyBreakpoint(arrowsConfigured);
511
+ // Not `itemsPerView`: that is breakpoint-derived and would put the flip
512
+ // back. One slide can never overflow at any width.
513
+ const showArrows = arrowsAnywhere && visibleCount > 1;
501
514
  const showDots =
502
515
  getResponsiveValue(
503
516
  getConfigEntry('show-dots'),
@@ -548,6 +561,16 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
548
561
 
549
562
  // Server-render the slides. Without this the strip ships an empty track and
550
563
  // occupies no height until hydration — see `utils/carousel-ssr`.
564
+ const arrowScopeId = carouselSsrScopeId(section.id) || '';
565
+ const arrowCss = buildCarouselArrowCss(
566
+ arrowScopeId,
567
+ carouselResponsive,
568
+ (band) =>
569
+ getResponsiveValue(arrowsConfigured, band, true) === true &&
570
+ visibleCount > Number(carouselResponsive?.[band]?.items || 1),
571
+ `[data-section-id="${arrowScopeId}"]`
572
+ );
573
+
551
574
  const carouselSsr = useCarouselSsr({
552
575
  id: section.id,
553
576
  remountKey: carouselKey,
@@ -1128,6 +1151,9 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
1128
1151
  from being early.
1129
1152
  */}
1130
1153
  {carouselSsr.gateStyle}
1154
+ {arrowCss ? (
1155
+ <style dangerouslySetInnerHTML={{ __html: arrowCss }} />
1156
+ ) : null}
1131
1157
  </div>
1132
1158
 
1133
1159
  {/*
@@ -0,0 +1,75 @@
1
+ import { buildCarouselArrowCss } from '../carousel-ssr';
2
+
3
+ /**
4
+ * Arrow visibility moved out of the markup and into CSS.
5
+ *
6
+ * Deciding whether the arrow EXISTS from `currentBreakpoint` meant three
7
+ * nodes appeared or disappeared one commit after hydration, because that
8
+ * value is seeded 'desktop' and corrected only once the viewport is measured.
9
+ * Rendered at every width and hidden per band, the markup is settled before
10
+ * the first paint.
11
+ */
12
+ const RESPONSIVE = {
13
+ desktop: { items: 4, breakpoint: { min: 1024, max: 99999 } },
14
+ tablet: { items: 3, breakpoint: { min: 768, max: 1023 } },
15
+ mobile: { items: 1, breakpoint: { min: 0, max: 767 } }
16
+ } as any;
17
+
18
+ describe('buildCarouselArrowCss', () => {
19
+ it('emits nothing when the arrow is wanted at every band', () => {
20
+ expect(buildCarouselArrowCss('abc', RESPONSIVE, () => true)).toBe('');
21
+ });
22
+
23
+ it('hides only the bands that said no', () => {
24
+ const css = buildCarouselArrowCss(
25
+ 'abc',
26
+ RESPONSIVE,
27
+ (band) => band !== 'mobile'
28
+ );
29
+
30
+ expect(css).toContain('(max-width:767px)');
31
+ expect(css).not.toContain('(min-width:1024px)');
32
+ expect(css).toContain('display:none !important');
33
+ });
34
+
35
+ it('scopes the library arrow to this carousel', () => {
36
+ const css = buildCarouselArrowCss('abc', RESPONSIVE, () => false);
37
+
38
+ // A slider nested inside another slider's slide must not be restyled by
39
+ // its parent's answer.
40
+ expect(css).toContain('.pz-carousel-ssr-abc .react-multi-carousel-arrow');
41
+ });
42
+
43
+ it('covers a section-rendered arrow only when given its scope', () => {
44
+ const without = buildCarouselArrowCss('abc', RESPONSIVE, () => false);
45
+ expect(without).not.toContain('data-slider-arrow');
46
+
47
+ const withOwner = buildCarouselArrowCss(
48
+ 'abc',
49
+ RESPONSIVE,
50
+ () => false,
51
+ '[data-section-id="abc"]'
52
+ );
53
+ expect(withOwner).toContain('[data-section-id="abc"] [data-slider-arrow]');
54
+ });
55
+
56
+ it('skips a band with no width range rather than hiding everywhere', () => {
57
+ // Hiding the arrow at every width is the one outcome worse than showing
58
+ // it late, so a band that cannot be targeted is left alone.
59
+ const css = buildCarouselArrowCss(
60
+ 'abc',
61
+ { odd: { items: 2, breakpoint: {} } } as any,
62
+ () => false
63
+ );
64
+
65
+ expect(css).toBe('');
66
+ });
67
+
68
+ it('gives the same answer however many times it is asked', () => {
69
+ const once = buildCarouselArrowCss('abc', RESPONSIVE, (b) => b === 'desktop');
70
+ const twice = buildCarouselArrowCss('abc', RESPONSIVE, (b) => b === 'desktop');
71
+
72
+ // Server and first client render build this string independently.
73
+ expect(once).toBe(twice);
74
+ });
75
+ });
@@ -186,6 +186,54 @@ export const buildCarouselSsrCss = (
186
186
  return css;
187
187
  };
188
188
 
189
+ /**
190
+ * Arrow visibility for one carousel, as CSS instead of as markup.
191
+ *
192
+ * `show-arrows` is authored per breakpoint, but `currentBreakpoint` is
193
+ * seeded desktop and corrected only after the viewport is measured — so
194
+ * deciding whether the arrow EXISTS from it meant three nodes appeared or
195
+ * disappeared one commit after the page became interactive. A phone briefly
196
+ * showed arrows it is not meant to have, a tablet got them late with a layout
197
+ * shift, and anything holding a reference to one lost it.
198
+ *
199
+ * Rendered at every width and hidden per band, the answer is settled before
200
+ * the first paint and never changes. Anchored on the scope class rather than
201
+ * the gate attribute, because unlike the SSR stylesheet this one has to
202
+ * outlive hydration.
203
+ */
204
+ export const buildCarouselArrowCss = (
205
+ scopeId: string,
206
+ responsive: CarouselResponsiveMap,
207
+ isOnAtBand: (bandName: string) => boolean,
208
+ /** Scope for arrows a section renders OUTSIDE the carousel box. */
209
+ ownerSelector?: string
210
+ ): string => {
211
+ const scope = `.${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}`;
212
+ // The library writes its own arrows; `data-slider-arrow` is the hook for
213
+ // the ones a section renders itself, outside the carousel box.
214
+ const targets = [
215
+ `${scope} .react-multi-carousel-arrow`,
216
+ ownerSelector ? `${ownerSelector} [data-slider-arrow]` : null
217
+ ]
218
+ .filter(Boolean)
219
+ .join(',');
220
+
221
+ let css = "";
222
+
223
+ Object.keys(responsive || {}).forEach((name) => {
224
+ if (isOnAtBand(name)) return;
225
+
226
+ const query = bandMediaQuery(responsive[name]);
227
+ // A band with no width range cannot be targeted without hiding the arrow
228
+ // everywhere, which is the one outcome worse than showing it late.
229
+ if (!query) return;
230
+
231
+ css += `${query}{${targets}{display:none !important;}}`;
232
+ });
233
+
234
+ return css;
235
+ };
236
+
189
237
  export interface CarouselSsr {
190
238
  /**
191
239
  * Spread onto `<Carousel>`. Together these are what make the library emit
@@ -212,6 +212,10 @@ export const SliderArrow: React.FC<SliderArrowProps> = ({
212
212
  // The generated stylesheet finds the arrow through this, which is what
213
213
  // carries states, part keys and the custom-CSS hatch onto it.
214
214
  data-block-id={block?.id}
215
+ // Visibility is CSS, not markup: the arrow ships at every width and the
216
+ // carousel stylesheet hides it where it is not wanted, so nothing
217
+ // appears or disappears a commit after hydration.
218
+ data-slider-arrow=""
215
219
  onClick={(event) => {
216
220
  event.preventDefault();
217
221
  event.stopPropagation();