@akinon/pz-theme 2.0.95-rc.1 → 2.0.96-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 +16 -67
- package/package.json +4 -4
- package/src/blocks/__tests__/slider-block.test.tsx +265 -1
- package/src/blocks/slider-block.tsx +170 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,72 +1,21 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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.96-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.96-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.96-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.95
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 63dce4df: ZERO-4963: Name the slider's slide wrapper as a styleable part
|
|
16
|
+
- d7db835f: ZERO-4963: Rotate the slider arrows from the panel and add an arrows-on-hover option
|
|
17
|
+
- @akinon/next@2.0.95
|
|
18
|
+
- @akinon/theme-contract@2.0.95
|
|
70
19
|
|
|
71
20
|
## 2.0.94
|
|
72
21
|
|
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.
|
|
4
|
+
"version": "2.0.96-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.
|
|
11
|
+
"@akinon/next": "2.0.96-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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.96-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.
|
|
24
|
+
"@akinon/next": "2.0.96-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",
|
|
@@ -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
|
/>
|