@akinon/pz-theme 2.0.95-rc.1 → 2.0.95

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,72 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.95-rc.1
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
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 [b1111d7b]
34
- - @akinon/next@2.0.95-rc.1
35
- - @akinon/theme-contract@2.0.95-rc.1
36
-
37
- ## 2.0.95-rc.0
38
-
39
- ### Patch Changes
40
-
41
- - Updated dependencies [86187b1c]
42
- - Updated dependencies [0cf9ea239]
43
- - Updated dependencies [324f97d55]
44
- - Updated dependencies [51ea06888]
45
- - Updated dependencies
46
- - Updated dependencies [2fea41430]
47
- - Updated dependencies [b55acb768]
48
- - Updated dependencies [760258c1c]
49
- - Updated dependencies [143be2b9d]
50
- - Updated dependencies [7889b08fe]
51
- - Updated dependencies [9f8cd3bc5]
52
- - Updated dependencies [bfafa3f49]
53
- - Updated dependencies [57d7eb305]
54
- - Updated dependencies [d99a6a7d5]
55
- - Updated dependencies [9db81a714]
56
- - Updated dependencies [591e345e1]
57
- - Updated dependencies [4de5303c5]
58
- - Updated dependencies [95b139dc1]
59
- - Updated dependencies [1d00f2d06]
60
- - Updated dependencies [4ac7b2a1e]
61
- - Updated dependencies [e9598c71d]
62
- - Updated dependencies [4998a9631]
63
- - Updated dependencies [804d2bd6c]
64
- - Updated dependencies [3909d3224]
65
- - Updated dependencies [6a3d8a631]
66
- - Updated dependencies [e18836b20]
67
- - Updated dependencies [b1111d7b]
68
- - @akinon/next@2.0.95-rc.0
69
- - @akinon/theme-contract@2.0.95-rc.0
3
+ ## 2.0.95
4
+
5
+ ### Patch Changes
6
+
7
+ - 63dce4df: ZERO-4963: Name the slider's slide wrapper as a styleable part
8
+ - d7db835f: ZERO-4963: Rotate the slider arrows from the panel and add an arrows-on-hover option
9
+ - @akinon/next@2.0.95
10
+ - @akinon/theme-contract@2.0.95
70
11
 
71
12
  ## 2.0.94
72
13
 
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.95-rc.1",
4
+ "version": "2.0.95",
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.95-rc.1",
11
+ "@akinon/next": "2.0.95",
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.95-rc.1",
16
+ "@akinon/theme-contract": "2.0.95",
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.95-rc.1",
24
+ "@akinon/next": "2.0.95",
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",
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render } from '@testing-library/react';
2
+ import { fireEvent, render } from '@testing-library/react';
3
3
 
4
4
  // react-multi-carousel is an ESM/DOM-heavy dependency. Replace its default
5
5
  // export with a passthrough that simply renders its children so we can assert
@@ -365,3 +365,267 @@ describe('SliderBlock — arrow button placement', () => {
365
365
  expect(previous(container).className).toContain('z-10');
366
366
  });
367
367
  });
368
+
369
+ /**
370
+ * EVERY SLIDE IS THE SAME HEIGHT, OR THE DIVIDER BETWEEN TWO CARDS IS RAGGED.
371
+ *
372
+ * react-multi-carousel stretches its own `li` to the tallest slide, and a card
373
+ * inside one asks for `height: 100%`. Between the two sits this block's own
374
+ * per-slide wrapper, which had no height of its own — so the percentage
375
+ * resolved against an auto-height box, fell back to `auto`, and each card
376
+ * ended up as tall as its own text. The only lever a composition had was
377
+ * scoped custom CSS reaching into the library's class names, per section.
378
+ *
379
+ * Naming the wrapper makes it addressable from the panel (`slide/height`), so
380
+ * the marker is the contract: the editor's descriptor-conformance test fails
381
+ * the "Slide" part group the moment this attribute stops being emitted.
382
+ */
383
+ describe('SliderBlock — the slide wrapper is a styleable part', () => {
384
+ const slideWrappers = (container: HTMLElement) =>
385
+ Array.from(container.querySelectorAll('[data-part="slide"]'));
386
+
387
+ it('marks one slide wrapper per slide', () => {
388
+ const { container } = render(
389
+ <SliderBlock
390
+ {...props({
391
+ block: {
392
+ ...props().block,
393
+ blocks: [child('a'), child('b'), child('c')]
394
+ }
395
+ })}
396
+ />
397
+ );
398
+
399
+ expect(slideWrappers(container)).toHaveLength(3);
400
+ });
401
+
402
+ it("wraps each slide's card in its own marked box", () => {
403
+ const { container } = render(
404
+ <SliderBlock
405
+ {...props({
406
+ block: { ...props().block, blocks: [child('a'), child('b')] }
407
+ })}
408
+ />
409
+ );
410
+
411
+ expect(
412
+ slideWrappers(container).map((wrapper) =>
413
+ wrapper.querySelector('[data-testid="tb"]')?.getAttribute('data-id')
414
+ )
415
+ ).toEqual(['a', 'b']);
416
+ });
417
+
418
+ it('does not mark the arrow icons, which are not slides', () => {
419
+ const { container } = render(
420
+ <SliderBlock
421
+ {...props({
422
+ block: {
423
+ ...props().block,
424
+ blocks: [
425
+ child('left', { type: 'icon', label: 'Left Arrow Icon' }),
426
+ child('right', { type: 'icon', label: 'Right Arrow Icon' }),
427
+ child('a')
428
+ ]
429
+ }
430
+ })}
431
+ />
432
+ );
433
+
434
+ expect(slideWrappers(container)).toHaveLength(1);
435
+ });
436
+
437
+ it('keeps the authored slide padding on the marked box', () => {
438
+ const { container } = render(
439
+ <SliderBlock
440
+ {...props({
441
+ block: {
442
+ ...props().block,
443
+ properties: { 'slide-gap': 24 },
444
+ blocks: [child('a'), child('b')]
445
+ }
446
+ })}
447
+ />
448
+ );
449
+
450
+ expect(
451
+ (slideWrappers(container)[0] as HTMLElement).style.paddingLeft
452
+ ).toBe('12px');
453
+ });
454
+ });
455
+
456
+ /**
457
+ * TWO THINGS THE PANEL COULD ASK FOR AND THE ARROWS COULD NOT DO.
458
+ *
459
+ * The arrow buttons are not composed blocks — the slider draws its own
460
+ * <button> and reads a fixed handful of the arrow icon block's style keys — so
461
+ * a key the reader does not know reaches the theme JSON and stops there. Rotate
462
+ * was one of those: the same right-pointing SVG ships for both sides, and a
463
+ * designer turning the left one round got nothing.
464
+ *
465
+ * The reveal-on-hover option is state rather than a `group-hover:` class
466
+ * because a brand's Tailwind `content` list does not scan this package: a class
467
+ * only this file writes is never generated, so the arrows would go away and
468
+ * never come back.
469
+ */
470
+ describe('SliderBlock — arrow rotate and reveal-on-hover', () => {
471
+ // jest/setup.ts answers every media query with `matches: false`, which is a
472
+ // touch device — and the option is deliberately inert there. These cases are
473
+ // about the pointer that CAN hover, so they say so.
474
+ const setHoverCapability = (matches: boolean) => {
475
+ window.matchMedia = ((query: string) => ({
476
+ matches,
477
+ media: query,
478
+ onchange: null,
479
+ addListener: () => {},
480
+ removeListener: () => {},
481
+ addEventListener: () => {},
482
+ removeEventListener: () => {},
483
+ dispatchEvent: () => false
484
+ })) as unknown as typeof window.matchMedia;
485
+ };
486
+
487
+ beforeEach(() => setHoverCapability(true));
488
+
489
+ const arrow = (label: string, styles: Record<string, unknown>) =>
490
+ child(label, { type: 'icon', label, value: { url: '/a.svg' }, styles });
491
+
492
+ const glyphs = (container: HTMLElement) =>
493
+ Array.from(container.querySelectorAll('img')) as HTMLImageElement[];
494
+ const buttons = (container: HTMLElement) =>
495
+ Array.from(
496
+ container.querySelectorAll('button[aria-label="Previous"], button[aria-label="Next"]')
497
+ ) as HTMLElement[];
498
+
499
+ // An authored top/bottom is what moves the arrows out of the carousel, which
500
+ // is the branch a storefront render can assert on (the mocked Carousel drops
501
+ // its customArrow props).
502
+ const outside = (extra: Record<string, unknown> = {}) => ({
503
+ top: '0px',
504
+ ...extra
505
+ });
506
+
507
+ const renderSlider = (
508
+ arrowStyles: Record<string, unknown>,
509
+ over: Record<string, unknown> = {},
510
+ properties: Record<string, unknown> = {}
511
+ ) =>
512
+ render(
513
+ <SliderBlock
514
+ {...props({
515
+ block: {
516
+ ...props().block,
517
+ properties,
518
+ blocks: [
519
+ arrow('Left Arrow Icon', arrowStyles),
520
+ child('s1'),
521
+ arrow('Right Arrow Icon', arrowStyles)
522
+ ]
523
+ },
524
+ ...over
525
+ })}
526
+ />
527
+ );
528
+
529
+ it('turns an authored rotate into a rotation on the glyph', () => {
530
+ const { container } = renderSlider(outside({ rotate: { desktop: '180deg' } }));
531
+
532
+ expect(glyphs(container).map((img) => img.style.rotate)).toEqual([
533
+ '180deg',
534
+ '180deg'
535
+ ]);
536
+ });
537
+
538
+ it('reads the bare number the Rotate box stores as degrees', () => {
539
+ const { container } = renderSlider(outside({ rotate: { desktop: 180 } }));
540
+
541
+ expect(glyphs(container)[0].style.rotate).toBe('180deg');
542
+ });
543
+
544
+ // The Rotate box is free text, so the function spelling turns up as often as
545
+ // the value one; left alone it emits `rotate: rotate(45deg)` and is dropped.
546
+ it('unwraps the function spelling a free-text box invites', () => {
547
+ const { container } = renderSlider(outside({ rotate: { desktop: 'rotate(45deg)' } }));
548
+
549
+ expect(glyphs(container)[0].style.rotate).toBe('45deg');
550
+ });
551
+
552
+ it('reads a leading-dot number as degrees', () => {
553
+ const { container } = renderSlider(outside({ rotate: { desktop: '.5' } }));
554
+
555
+ expect(glyphs(container)[0].style.rotate).toBe('.5deg');
556
+ });
557
+
558
+ it('passes a unit through untouched', () => {
559
+ const { container } = renderSlider(outside({ rotate: { desktop: '0.5turn' } }));
560
+
561
+ expect(glyphs(container)[0].style.rotate).toBe('0.5turn');
562
+ });
563
+
564
+ it('leaves the glyph unrotated when nothing is authored', () => {
565
+ const { container } = renderSlider(outside());
566
+
567
+ expect(glyphs(container)[0].style.rotate).toBeFalsy();
568
+ });
569
+
570
+ // The rotation must not fight the button's own centring translate.
571
+ it('rotates the glyph, never the button', () => {
572
+ const { container } = renderSlider(outside({ rotate: { desktop: '180deg' } }));
573
+
574
+ expect(buttons(container)[0].style.rotate).toBeFalsy();
575
+ expect(buttons(container)[0].style.transform).toBeFalsy();
576
+ });
577
+
578
+ it('hides the arrows until the slider is hovered when asked to', () => {
579
+ const { container } = renderSlider(outside(), {}, {
580
+ 'arrows-on-hover': { desktop: true }
581
+ });
582
+ const root = container.firstElementChild as HTMLElement;
583
+
584
+ expect(buttons(container).map((b) => b.style.opacity)).toEqual(['0', '0']);
585
+ expect(buttons(container)[0].style.pointerEvents).toBe('none');
586
+
587
+ fireEvent.mouseEnter(root);
588
+ expect(buttons(container).map((b) => b.style.opacity)).toEqual(['1', '1']);
589
+ expect(buttons(container)[0].style.pointerEvents).toBe('auto');
590
+
591
+ fireEvent.mouseLeave(root);
592
+ expect(buttons(container)[0].style.opacity).toBe('0');
593
+ });
594
+
595
+ // Keyboard users never send a mouseenter.
596
+ it('reveals the arrows when focus lands inside', () => {
597
+ const { container } = renderSlider(outside(), {}, {
598
+ 'arrows-on-hover': { desktop: true }
599
+ });
600
+
601
+ fireEvent.focus(container.firstElementChild as HTMLElement);
602
+ expect(buttons(container)[0].style.opacity).toBe('1');
603
+ });
604
+
605
+ it('keeps the arrows visible in the designer, where they must stay selectable', () => {
606
+ const { container } = renderSlider(outside(), { isDesigner: true }, {
607
+ 'arrows-on-hover': { desktop: true }
608
+ });
609
+
610
+ expect(buttons(container)[0].style.opacity).toBe('');
611
+ expect(buttons(container)[0].style.pointerEvents).toBe('');
612
+ });
613
+
614
+ // "Hover only" on a phone would mean "never", so the option stands down.
615
+ it('keeps the arrows visible on a device that cannot hover', () => {
616
+ setHoverCapability(false);
617
+ const { container } = renderSlider(outside(), {}, {
618
+ 'arrows-on-hover': { desktop: true }
619
+ });
620
+
621
+ expect(buttons(container)[0].style.opacity).toBe('');
622
+ expect(buttons(container)[0].style.pointerEvents).toBe('');
623
+ });
624
+
625
+ it('writes no visibility style at all when the option is off', () => {
626
+ const { container } = renderSlider(outside());
627
+
628
+ expect(buttons(container)[0].style.opacity).toBe('');
629
+ expect(buttons(container)[0].style.transition).toBe('');
630
+ });
631
+ });
@@ -10,6 +10,7 @@ import {
10
10
  applyFirstSlideImageLoading,
11
11
  getCSSStyles,
12
12
  getResponsiveValue,
13
+ partAttrs,
13
14
  resolveThemeCssVariables
14
15
  } from '../utils';
15
16
  import { DESKTOP_MIN_WIDTH, isNarrowBreakpoint } from '../utils/breakpoints';
@@ -44,6 +45,51 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
44
45
  const themeSettings = useThemeSettingsContext();
45
46
  const carouselRef = React.useRef<Carousel>(null);
46
47
  const containerRef = React.useRef<HTMLDivElement>(null);
48
+ /**
49
+ * Whether the pointer (or the keyboard) is inside the slider, for the
50
+ * arrows-on-hover option below.
51
+ *
52
+ * REACT STATE RATHER THAN A `group-hover:` CLASS, FOR TWO REASONS THAT BOTH
53
+ * END WITH THE ARROWS NEVER COMING BACK.
54
+ *
55
+ * Tailwind 4 compiles `group-hover:` inside `@media (hover: hover)` while the
56
+ * `opacity-0` it pairs with is unconditional — so a phone would get the
57
+ * hiding half of the rule and never the revealing half. State can ask about
58
+ * hover capability directly, which is what `canHover` below does.
59
+ *
60
+ * And scanning is not guaranteed: the reference app lists this package in its
61
+ * Tailwind `content` (apps/projectzeronext/tailwind.config.js), but a brand
62
+ * whose config only lists `@akinon/next` plus its payment plugins does not —
63
+ * so a class only this file writes may never be generated there. The static
64
+ * classes the arrows already rely on (`left-4`, `-translate-y-1/2`) come from
65
+ * the brand's own source using them too.
66
+ */
67
+ const [pointerInside, setPointerInside] = React.useState(false);
68
+ /**
69
+ * AND WHETHER THIS DEVICE CAN HOVER AT ALL.
70
+ *
71
+ * A phone never sends the pointer event that brings the arrows back, so
72
+ * "arrows on hover" would mean "no arrows, ever" there — the option is
73
+ * therefore ignored wherever the pointer cannot hover. Assumed true until the
74
+ * client answers: the server has no media queries, and a desktop flashing its
75
+ * arrows before hiding them reads worse than a phone showing them a frame
76
+ * late.
77
+ */
78
+ const [canHover, setCanHover] = React.useState(true);
79
+
80
+ React.useEffect(() => {
81
+ if (typeof window === 'undefined' || !window.matchMedia) return;
82
+
83
+ const query = window.matchMedia('(hover: hover)');
84
+ setCanHover(query.matches);
85
+
86
+ const handleChange = (event: MediaQueryListEvent) =>
87
+ setCanHover(event.matches);
88
+
89
+ // A mouse plugged into a tablet flips this mid-session.
90
+ query.addEventListener?.('change', handleChange);
91
+ return () => query.removeEventListener?.('change', handleChange);
92
+ }, []);
47
93
 
48
94
  const leftArrowIconBlock = block.blocks?.find(
49
95
  (b: any) => b.type === 'icon' && b.label === 'Left Arrow Icon'
@@ -164,6 +210,11 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
164
210
  currentBreakpoint,
165
211
  false
166
212
  );
213
+ // Arrows that only appear while the slider is hovered. Off by default, so a
214
+ // slider that never authored it renders exactly as before.
215
+ const arrowsOnHover = Boolean(
216
+ getResponsiveValue(getConfigEntry('arrows-on-hover'), currentBreakpoint, false)
217
+ );
167
218
  const useAutoPlay = getResponsiveValue(
168
219
  getConfigEntry('autoplay'),
169
220
  currentBreakpoint,
@@ -437,6 +488,66 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
437
488
  )
438
489
  ) || 1;
439
490
 
491
+ /**
492
+ * The angle the panel's Rotate box holds. It stores a bare number, which is
493
+ * not valid CSS on its own, so a number becomes degrees and anything with a
494
+ * unit (deg/turn/rad/grad) is passed through untouched.
495
+ */
496
+ const arrowAngle = (value: unknown): string | undefined => {
497
+ let text = String(value ?? '').trim();
498
+ if (!text) return undefined;
499
+ // The panel's Rotate box is free text, so it invites the function spelling
500
+ // as readily as the value one. Left as typed it would emit
501
+ // `rotate: rotate(45deg)`, which the browser drops without a word.
502
+ const wrapped = /^rotate\(([^)]+)\)$/i.exec(text);
503
+ if (wrapped) text = wrapped[1].trim();
504
+ // A bare number is degrees — including the leading-dot spelling, which the
505
+ // earlier pattern rejected and then emitted as the invalid `rotate: .5`.
506
+ return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text) ? `${text}deg` : text;
507
+ };
508
+
509
+ /**
510
+ * ON THE GLYPH, NOT ON THE BUTTON, AND VIA `rotate` RATHER THAN `transform`.
511
+ *
512
+ * The rotation a designer wants is the arrow pointing the other way, not the
513
+ * button's background box turning with it — the same SVG ships for both
514
+ * sides, so mirroring the left one is the whole point.
515
+ *
516
+ * `rotate` rather than `transform` because of the centring class it has to
517
+ * live beside. Tailwind 4 compiles `-translate-y-1/2` to the individual
518
+ * `translate` property, and the individual properties are composed in a fixed
519
+ * order — translate, then rotate, then scale, then `transform` — so a rotation
520
+ * written here cannot displace the centring, and it turns the glyph about its
521
+ * own centre. A raw `transform` would have to restate the translate (and did
522
+ * clobber it under Tailwind 3).
523
+ *
524
+ * NOTE THE DIVERGENCE, which is a real one: the section arrows in
525
+ * utils/slider-arrows.tsx put `data-block-id` on the BUTTON, so there the
526
+ * generated rule for this same `rotate` key turns the plate and the glyph
527
+ * together. Here only the glyph turns.
528
+ *
529
+ * A rule for this icon block's `rotate` is in fact already in the generated
530
+ * stylesheet — `collectBlockCSS` recurses into every child block — and
531
+ * matches nothing only because this button carries no `data-block-id`. If one
532
+ * is ever added (to let borders, shadows or part keys reach the plate), that
533
+ * rule and the inline value below would both apply and the glyph would sit at
534
+ * twice the authored angle; drop one of the two when that happens.
535
+ */
536
+ const leftArrowIconRotate = arrowAngle(
537
+ getResponsiveValue(
538
+ leftArrowIconBlock?.styles?.['rotate'],
539
+ currentBreakpoint,
540
+ ''
541
+ )
542
+ );
543
+ const rightArrowIconRotate = arrowAngle(
544
+ getResponsiveValue(
545
+ rightArrowIconBlock?.styles?.['rotate'],
546
+ currentBreakpoint,
547
+ ''
548
+ )
549
+ );
550
+
440
551
  // Process SVGs with color
441
552
  const processedLeftArrowUrl = isInlineSvg(leftArrowIconUrl)
442
553
  ? processInlineSVG(leftArrowIconUrl, leftArrowIconColor)
@@ -569,6 +680,25 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
569
680
  const leftArrowButtonStyle = arrowGeometry(leftArrowIconBlock);
570
681
  const rightArrowButtonStyle = arrowGeometry(rightArrowIconBlock);
571
682
 
683
+ /**
684
+ * What the arrows-on-hover option adds to every arrow button.
685
+ *
686
+ * The designer canvas is exempt: an arrow a merchant cannot see is an arrow
687
+ * they cannot select, and the panel is where the option is being switched on
688
+ * in the first place. `pointer-events` follows the opacity so an invisible
689
+ * arrow cannot be clicked, and the transition is inline for the same reason
690
+ * the state above is React — no class this package writes reaches the brand's
691
+ * stylesheet.
692
+ */
693
+ const arrowRevealStyle: React.CSSProperties =
694
+ arrowsOnHover && canHover && !isDesigner
695
+ ? {
696
+ opacity: pointerInside ? 1 : 0,
697
+ pointerEvents: pointerInside ? 'auto' : 'none',
698
+ transition: 'opacity 200ms ease'
699
+ }
700
+ : {};
701
+
572
702
  /**
573
703
  * The class defaults step aside per axis for whatever the author set.
574
704
  * An inline `left` already outranks the `left-4` class, but the pair that
@@ -633,7 +763,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
633
763
  alignItems: 'center',
634
764
  justifyContent: 'center',
635
765
  cursor: 'pointer',
636
- padding: 0
766
+ padding: 0,
767
+ ...arrowRevealStyle
637
768
  }}
638
769
  aria-label="Previous"
639
770
  >
@@ -644,6 +775,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
644
775
  width: `${leftArrowIconSize}px`,
645
776
  height: `${leftArrowIconSize}px`,
646
777
  opacity: leftArrowIconOpacity,
778
+ rotate: leftArrowIconRotate,
647
779
  pointerEvents: 'none'
648
780
  }}
649
781
  />
@@ -673,7 +805,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
673
805
  alignItems: 'center',
674
806
  justifyContent: 'center',
675
807
  cursor: 'pointer',
676
- padding: 0
808
+ padding: 0,
809
+ ...arrowRevealStyle
677
810
  }}
678
811
  aria-label="Next"
679
812
  >
@@ -684,6 +817,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
684
817
  width: `${rightArrowIconSize}px`,
685
818
  height: `${rightArrowIconSize}px`,
686
819
  opacity: rightArrowIconOpacity,
820
+ rotate: rightArrowIconRotate,
687
821
  pointerEvents: 'none'
688
822
  }}
689
823
  />
@@ -708,6 +842,16 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
708
842
  )}
709
843
  style={sectionStyles}
710
844
  {...carouselSsr.gateProps}
845
+ // Focus rides along with the pointer so the arrows are reachable by
846
+ // keyboard; both events bubble in React, so tabbing INTO an arrow counts.
847
+ onMouseEnter={
848
+ arrowsOnHover ? () => setPointerInside(true) : undefined
849
+ }
850
+ onMouseLeave={
851
+ arrowsOnHover ? () => setPointerInside(false) : undefined
852
+ }
853
+ onFocus={arrowsOnHover ? () => setPointerInside(true) : undefined}
854
+ onBlur={arrowsOnHover ? () => setPointerInside(false) : undefined}
711
855
  >
712
856
  <div style={{ position: 'relative' }}>
713
857
  <Carousel
@@ -794,8 +938,26 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
794
938
  : childBlock;
795
939
 
796
940
  return (
941
+ /*
942
+ * THE ONLY BOX BETWEEN A SLIDE'S CARD AND THE TRACK, AND THE ONE
943
+ * LINK IN THE CHAIN THAT USED TO HAVE NO HEIGHT.
944
+ *
945
+ * react-multi-carousel stretches every `li` to the tallest slide,
946
+ * so a card asking for `height: 100%` has a definite height two
947
+ * levels up and an auto-height one directly above it — this
948
+ * wrapper. The card's own percentage therefore resolved to `auto`
949
+ * and every slide was as tall as its own text, which is what made
950
+ * a row of cards with a divider border look ragged.
951
+ *
952
+ * Named as a part so the panel can give it a height rather than
953
+ * a brand carrying scoped custom CSS per section. The inline
954
+ * padding stays: part declarations are emitted `!important`
955
+ * (generatePartCSS), so an authored `slide/padding-left` still
956
+ * wins over it.
957
+ */
797
958
  <div
798
959
  key={childBlock.id}
960
+ {...partAttrs('slide')}
799
961
  style={
800
962
  flushEdges
801
963
  ? {
@@ -860,7 +1022,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
860
1022
  alignItems: 'center',
861
1023
  justifyContent: 'center',
862
1024
  cursor: 'pointer',
863
- padding: 0
1025
+ padding: 0,
1026
+ ...arrowRevealStyle
864
1027
  }}
865
1028
  aria-label="Previous"
866
1029
  >
@@ -871,6 +1034,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
871
1034
  width: `${leftArrowIconSize}px`,
872
1035
  height: `${leftArrowIconSize}px`,
873
1036
  opacity: leftArrowIconOpacity,
1037
+ rotate: leftArrowIconRotate,
874
1038
  pointerEvents: 'none'
875
1039
  }}
876
1040
  />
@@ -891,7 +1055,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
891
1055
  alignItems: 'center',
892
1056
  justifyContent: 'center',
893
1057
  cursor: 'pointer',
894
- padding: 0
1058
+ padding: 0,
1059
+ ...arrowRevealStyle
895
1060
  }}
896
1061
  aria-label="Next"
897
1062
  >
@@ -902,6 +1067,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
902
1067
  width: `${rightArrowIconSize}px`,
903
1068
  height: `${rightArrowIconSize}px`,
904
1069
  opacity: rightArrowIconOpacity,
1070
+ rotate: rightArrowIconRotate,
905
1071
  pointerEvents: 'none'
906
1072
  }}
907
1073
  />