@akinon/pz-theme 2.0.94-rc.0 → 2.0.95-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,38 +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.94-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.94-rc.0
|
|
3
|
+
## 2.0.95-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.95-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.95-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.94
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 6f3189f3: Fix a hydration mismatch on `data-scroll-state`: mounting one chrome slot no longer stamps a sibling slot that has not hydrated yet under Suspense
|
|
16
|
+
- 57e09d65: ZERO-4988: Add mobile video/poster override and a custom play/pause button to the video block, and fix autoplay/loop/muted/controls/playsInline being read as raw per-breakpoint objects instead of resolved values (caused a hydration mismatch on `muted`)
|
|
17
|
+
- @akinon/next@2.0.94
|
|
18
|
+
- @akinon/theme-contract@2.0.94
|
|
36
19
|
|
|
37
20
|
## 2.0.93
|
|
38
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.95-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.95-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.95-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.95-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",
|
|
@@ -441,12 +441,257 @@ describe('VideoBlock', () => {
|
|
|
441
441
|
});
|
|
442
442
|
});
|
|
443
443
|
|
|
444
|
+
describe('filling a full-bleed hero (block Height + Object Fit)', () => {
|
|
445
|
+
// Mirrors image-block.tsx: the `<video>` reads the same `height` /
|
|
446
|
+
// `object-fit` styles as the block wrapper, so setting Height: 100% +
|
|
447
|
+
// Position: Absolute + inset 0 on the block makes the player actually
|
|
448
|
+
// fill it — previously the player was stuck at `height: auto`, sized by
|
|
449
|
+
// its own aspect ratio rather than the box the author placed it in.
|
|
450
|
+
it('applies the authored height to the player, not just the wrapper', () => {
|
|
451
|
+
const { container } = renderBlock(
|
|
452
|
+
'https://cdn.example.com/clip.mp4',
|
|
453
|
+
undefined,
|
|
454
|
+
{ height: { desktop: '100%' } }
|
|
455
|
+
);
|
|
456
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
457
|
+
expect(video.style.height).toBe('100%');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('defaults Object Fit to cover even without an authored aspect ratio', () => {
|
|
461
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
462
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
463
|
+
expect(video.style.objectFit).toBe('cover');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('honors an explicit Object Fit override', () => {
|
|
467
|
+
const { container } = renderBlock(
|
|
468
|
+
'https://cdn.example.com/clip.mp4',
|
|
469
|
+
undefined,
|
|
470
|
+
{ 'object-fit': { desktop: 'contain' } }
|
|
471
|
+
);
|
|
472
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
473
|
+
expect(video.style.objectFit).toBe('contain');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
444
477
|
describe('non-string value (BUG-4 fix)', () => {
|
|
445
478
|
it('renders the invalid placeholder instead of crashing on a non-string value', () => {
|
|
446
479
|
renderBlock(123 as any);
|
|
447
480
|
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
448
481
|
});
|
|
449
482
|
});
|
|
483
|
+
|
|
484
|
+
describe('mobile video / poster override', () => {
|
|
485
|
+
const renderAt = (
|
|
486
|
+
value: any,
|
|
487
|
+
properties: any,
|
|
488
|
+
currentBreakpoint: 'desktop' | 'tablet' | 'mobile'
|
|
489
|
+
) =>
|
|
490
|
+
render(
|
|
491
|
+
<VideoBlock
|
|
492
|
+
block={{ value, properties } as any}
|
|
493
|
+
placeholderId="p1"
|
|
494
|
+
sectionId="s1"
|
|
495
|
+
isDesigner={false}
|
|
496
|
+
currentBreakpoint={currentBreakpoint}
|
|
497
|
+
/>
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
it('plays the desktop file on mobile when useDifferentMobileVideo is off', () => {
|
|
501
|
+
const { container } = renderAt(
|
|
502
|
+
'https://cdn.example.com/desktop.mp4',
|
|
503
|
+
{ mobileUrl: 'https://cdn.example.com/mobile.mp4' },
|
|
504
|
+
'mobile'
|
|
505
|
+
);
|
|
506
|
+
expect(
|
|
507
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
508
|
+
).toBe('https://cdn.example.com/desktop.mp4');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('plays the mobile file on mobile when useDifferentMobileVideo is on', () => {
|
|
512
|
+
const { container } = renderAt(
|
|
513
|
+
'https://cdn.example.com/desktop.mp4',
|
|
514
|
+
{
|
|
515
|
+
useDifferentMobileVideo: true,
|
|
516
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
517
|
+
},
|
|
518
|
+
'mobile'
|
|
519
|
+
);
|
|
520
|
+
expect(
|
|
521
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
522
|
+
).toBe('https://cdn.example.com/mobile.mp4');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('still plays the desktop file on desktop even when a mobile override is set', () => {
|
|
526
|
+
const { container } = renderAt(
|
|
527
|
+
'https://cdn.example.com/desktop.mp4',
|
|
528
|
+
{
|
|
529
|
+
useDifferentMobileVideo: true,
|
|
530
|
+
mobileUrl: 'https://cdn.example.com/mobile.mp4'
|
|
531
|
+
},
|
|
532
|
+
'desktop'
|
|
533
|
+
);
|
|
534
|
+
expect(
|
|
535
|
+
(container.querySelector('video') as HTMLVideoElement).src
|
|
536
|
+
).toBe('https://cdn.example.com/desktop.mp4');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('uses the mobile poster on mobile when the override is on', () => {
|
|
540
|
+
const { container } = renderAt(
|
|
541
|
+
'https://cdn.example.com/desktop.mp4',
|
|
542
|
+
{
|
|
543
|
+
poster: 'https://cdn.example.com/desktop.jpg',
|
|
544
|
+
useDifferentMobileVideo: true,
|
|
545
|
+
mobilePoster: 'https://cdn.example.com/mobile.jpg'
|
|
546
|
+
},
|
|
547
|
+
'mobile'
|
|
548
|
+
);
|
|
549
|
+
expect(
|
|
550
|
+
(container.querySelector('video') as HTMLVideoElement).poster
|
|
551
|
+
).toBe('https://cdn.example.com/mobile.jpg');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('falls back to the desktop poster on mobile without an override', () => {
|
|
555
|
+
const { container } = renderAt(
|
|
556
|
+
'https://cdn.example.com/desktop.mp4',
|
|
557
|
+
{ poster: 'https://cdn.example.com/desktop.jpg' },
|
|
558
|
+
'mobile'
|
|
559
|
+
);
|
|
560
|
+
expect(
|
|
561
|
+
(container.querySelector('video') as HTMLVideoElement).poster
|
|
562
|
+
).toBe('https://cdn.example.com/desktop.jpg');
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
describe('custom play/pause button', () => {
|
|
567
|
+
beforeAll(() => {
|
|
568
|
+
// jsdom does not implement media playback.
|
|
569
|
+
window.HTMLMediaElement.prototype.play = jest.fn();
|
|
570
|
+
window.HTMLMediaElement.prototype.pause = jest.fn();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('is not rendered when native controls are on, even if requested', () => {
|
|
574
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
575
|
+
controls: true,
|
|
576
|
+
customPlayPause: true
|
|
577
|
+
});
|
|
578
|
+
expect(container.querySelector('button')).not.toBeInTheDocument();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('is not rendered when controls are off but the toggle is off', () => {
|
|
582
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
583
|
+
controls: false,
|
|
584
|
+
customPlayPause: false
|
|
585
|
+
});
|
|
586
|
+
expect(container.querySelector('button')).not.toBeInTheDocument();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('is rendered when controls are off and the toggle is on', () => {
|
|
590
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
591
|
+
controls: false,
|
|
592
|
+
customPlayPause: true
|
|
593
|
+
});
|
|
594
|
+
expect(container.querySelector('button')).toBeInTheDocument();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('toggles the underlying video on click', () => {
|
|
598
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
599
|
+
controls: false,
|
|
600
|
+
customPlayPause: true
|
|
601
|
+
});
|
|
602
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
603
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
604
|
+
|
|
605
|
+
button.click();
|
|
606
|
+
expect(video.play).toHaveBeenCalledTimes(1);
|
|
607
|
+
|
|
608
|
+
Object.defineProperty(video, 'paused', { value: false, configurable: true });
|
|
609
|
+
button.click();
|
|
610
|
+
expect(video.pause).toHaveBeenCalledTimes(1);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('stacks above a sibling content overlay (z-index 2) so its clicks are not swallowed', () => {
|
|
614
|
+
// A title/CTA content block placed over the video is a full-size
|
|
615
|
+
// `position: absolute; z-index: 1` box (see pz-theme's group content
|
|
616
|
+
// pattern) — its transparent area still captures pointer events. Without
|
|
617
|
+
// an explicit, higher z-index the button was visually and functionally
|
|
618
|
+
// underneath it, unclickable everywhere it overlapped.
|
|
619
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
620
|
+
controls: false,
|
|
621
|
+
customPlayPause: true
|
|
622
|
+
});
|
|
623
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
624
|
+
expect(Number(button.style.zIndex)).toBeGreaterThan(1);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('matches the design: a 20x20 button, not an oversized icon', () => {
|
|
628
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
629
|
+
controls: false,
|
|
630
|
+
customPlayPause: true
|
|
631
|
+
});
|
|
632
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
633
|
+
expect(button.style.width).toBe('20px');
|
|
634
|
+
expect(button.style.height).toBe('20px');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('draws the pause icon as two 1x8 bars', () => {
|
|
638
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
639
|
+
controls: false,
|
|
640
|
+
customPlayPause: true,
|
|
641
|
+
autoplay: true // starts in the "playing" state, so the Pause icon renders
|
|
642
|
+
});
|
|
643
|
+
const rects = container.querySelectorAll('button svg rect');
|
|
644
|
+
expect(rects).toHaveLength(2);
|
|
645
|
+
rects.forEach(rect => {
|
|
646
|
+
expect(rect.getAttribute('width')).toBe('1');
|
|
647
|
+
expect(rect.getAttribute('height')).toBe('8');
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('sits bottom-left, inset from the left edge, on desktop', () => {
|
|
652
|
+
const { container } = render(
|
|
653
|
+
<VideoBlock
|
|
654
|
+
block={
|
|
655
|
+
{
|
|
656
|
+
value: 'https://cdn.example.com/clip.mp4',
|
|
657
|
+
properties: { controls: false, customPlayPause: true }
|
|
658
|
+
} as any
|
|
659
|
+
}
|
|
660
|
+
placeholderId="p1"
|
|
661
|
+
sectionId="s1"
|
|
662
|
+
isDesigner={false}
|
|
663
|
+
currentBreakpoint="desktop"
|
|
664
|
+
/>
|
|
665
|
+
);
|
|
666
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
667
|
+
expect(button.style.bottom).toBe('0px');
|
|
668
|
+
expect(button.style.left).toBe('24px');
|
|
669
|
+
expect(button.style.top).toBe('');
|
|
670
|
+
expect(button.style.right).toBe('');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('moves to the top-right on mobile instead of just rescaling the desktop inset', () => {
|
|
674
|
+
const { container } = render(
|
|
675
|
+
<VideoBlock
|
|
676
|
+
block={
|
|
677
|
+
{
|
|
678
|
+
value: 'https://cdn.example.com/clip.mp4',
|
|
679
|
+
properties: { controls: false, customPlayPause: true }
|
|
680
|
+
} as any
|
|
681
|
+
}
|
|
682
|
+
placeholderId="p1"
|
|
683
|
+
sectionId="s1"
|
|
684
|
+
isDesigner={false}
|
|
685
|
+
currentBreakpoint="mobile"
|
|
686
|
+
/>
|
|
687
|
+
);
|
|
688
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
689
|
+
expect(button.style.top).toBe('18px');
|
|
690
|
+
expect(button.style.right).toBe('18px');
|
|
691
|
+
expect(button.style.bottom).toBe('');
|
|
692
|
+
expect(button.style.left).toBe('');
|
|
693
|
+
});
|
|
694
|
+
});
|
|
450
695
|
});
|
|
451
696
|
|
|
452
697
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState } from 'react';
|
|
2
4
|
import { getResponsiveValue } from '../utils';
|
|
3
5
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
4
6
|
|
|
@@ -29,8 +31,29 @@ interface VideoProperties {
|
|
|
29
31
|
* `…/watch?ref=clip.mp4` through as a playable file.
|
|
30
32
|
*/
|
|
31
33
|
sourceType?: string;
|
|
34
|
+
/** Gated on `controls === false` in the editor — it replaces native controls, not add to them. */
|
|
35
|
+
customPlayPause?: unknown;
|
|
36
|
+
/** Off by default: mobile plays the same file as desktop unless this is on. */
|
|
37
|
+
useDifferentMobileVideo?: unknown;
|
|
38
|
+
mobileUrl?: unknown;
|
|
39
|
+
mobilePoster?: unknown;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** Sized for a 20x20 button — the Figma control, not a generic icon glyph. */
|
|
43
|
+
const PlayIcon = () => (
|
|
44
|
+
<svg width="6" height="8" viewBox="0 0 6 8" fill="none" aria-hidden="true">
|
|
45
|
+
<path d="M0.5 0.5L5.5 4L0.5 7.5V0.5Z" fill="currentColor" />
|
|
46
|
+
</svg>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** Two 1x8 bars, centered in the button by its own flex layout. */
|
|
50
|
+
const PauseIcon = () => (
|
|
51
|
+
<svg width="5" height="8" viewBox="0 0 5 8" fill="none" aria-hidden="true">
|
|
52
|
+
<rect x="0" y="0" width="1" height="8" fill="currentColor" />
|
|
53
|
+
<rect x="4" y="0" width="1" height="8" fill="currentColor" />
|
|
54
|
+
</svg>
|
|
55
|
+
);
|
|
56
|
+
|
|
34
57
|
/** Hosts, not substrings: `evil.com/?u=youtube.com/watch?v=x` is not YouTube. */
|
|
35
58
|
const YOUTUBE_HOSTS = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/i;
|
|
36
59
|
|
|
@@ -168,11 +191,86 @@ const VideoBlock = ({
|
|
|
168
191
|
block,
|
|
169
192
|
currentBreakpoint = 'desktop'
|
|
170
193
|
}: BlockRendererProps) => {
|
|
171
|
-
const
|
|
194
|
+
const desktopUrl = block.value as string;
|
|
172
195
|
const properties = (block.properties || {}) as VideoProperties;
|
|
173
196
|
|
|
197
|
+
const isMobile = currentBreakpoint === 'mobile';
|
|
198
|
+
const useDifferentMobileVideo = !!getResponsiveValue(
|
|
199
|
+
properties.useDifferentMobileVideo,
|
|
200
|
+
currentBreakpoint,
|
|
201
|
+
false
|
|
202
|
+
);
|
|
203
|
+
const mobileUrl = getResponsiveValue(
|
|
204
|
+
properties.mobileUrl,
|
|
205
|
+
currentBreakpoint,
|
|
206
|
+
''
|
|
207
|
+
) as string;
|
|
208
|
+
const videoUrl =
|
|
209
|
+
isMobile && useDifferentMobileVideo && mobileUrl ? mobileUrl : desktopUrl;
|
|
210
|
+
|
|
211
|
+
// `properties` handed over RAW, not resolved: `buildEmbedParams` reads
|
|
212
|
+
// `autoplay`/`loop`/`muted`/`controls` the same way the native `<video>`
|
|
213
|
+
// below deliberately still does — see the note there.
|
|
174
214
|
const parsed = parseVideoSource(videoUrl, properties);
|
|
175
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Read through the breakpoint, the way `aspect-ratio` already is.
|
|
218
|
+
*
|
|
219
|
+
* The editor stores every one of these per breakpoint — `{ desktop: false }`,
|
|
220
|
+
* not `false`. So `properties.controls !== false` was comparing an OBJECT to
|
|
221
|
+
* `false`, which is never equal, and controls were on for every video whatever
|
|
222
|
+
* the merchant chose.
|
|
223
|
+
*
|
|
224
|
+
* `poster` had the mirror of the same defect: `typeof properties.poster ===
|
|
225
|
+
* 'string'` is false for the per-breakpoint object the editor writes, so a
|
|
226
|
+
* poster a merchant typed was stored, shown in the panel and silently
|
|
227
|
+
* dropped. Extended here with the mobile override this ticket adds.
|
|
228
|
+
*
|
|
229
|
+
* `autoplay`, `loop`, `muted` and `playsInline` are DELIBERATELY left
|
|
230
|
+
* reading raw, straight off `properties`, in the JSX below. They have the
|
|
231
|
+
* same defect pointing the other way: their stored value is an object, an
|
|
232
|
+
* object is truthy, and only the bug makes them true. Reading them
|
|
233
|
+
* correctly here would switch autoplay and loop OFF on every composition
|
|
234
|
+
* already published — a behaviour change that needs its own ticket, not a
|
|
235
|
+
* side effect of this one.
|
|
236
|
+
*/
|
|
237
|
+
const nativeControls =
|
|
238
|
+
getResponsiveValue(properties.controls, currentBreakpoint, true) !== false;
|
|
239
|
+
|
|
240
|
+
const desktopPoster = getResponsiveValue(
|
|
241
|
+
properties.poster,
|
|
242
|
+
currentBreakpoint,
|
|
243
|
+
''
|
|
244
|
+
) as string;
|
|
245
|
+
const mobilePoster = getResponsiveValue(
|
|
246
|
+
properties.mobilePoster,
|
|
247
|
+
currentBreakpoint,
|
|
248
|
+
''
|
|
249
|
+
) as string;
|
|
250
|
+
const posterUrl =
|
|
251
|
+
isMobile && useDifferentMobileVideo && mobilePoster
|
|
252
|
+
? mobilePoster
|
|
253
|
+
: desktopPoster;
|
|
254
|
+
|
|
255
|
+
// The custom button REPLACES native controls — it only ever shows up when
|
|
256
|
+
// native controls are off, matching the editor's `showWhen`.
|
|
257
|
+
const customPlayPause =
|
|
258
|
+
!nativeControls &&
|
|
259
|
+
!!getResponsiveValue(properties.customPlayPause, currentBreakpoint, false);
|
|
260
|
+
|
|
261
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
262
|
+
const [isPlaying, setIsPlaying] = useState(properties.autoplay === true);
|
|
263
|
+
|
|
264
|
+
const togglePlayback = () => {
|
|
265
|
+
const el = videoRef.current;
|
|
266
|
+
if (!el) return;
|
|
267
|
+
if (el.paused) {
|
|
268
|
+
el.play();
|
|
269
|
+
} else {
|
|
270
|
+
el.pause();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
176
274
|
/**
|
|
177
275
|
* The author's choice wins; a Short supplies its own when they made none.
|
|
178
276
|
* With neither, the historic fixed height is kept, so every composition
|
|
@@ -190,6 +288,25 @@ const VideoBlock = ({
|
|
|
190
288
|
? parsed.defaultRatio
|
|
191
289
|
: undefined;
|
|
192
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Read the same way `image-block.tsx` reads them, so a full-bleed hero is
|
|
293
|
+
* built identically for a video and an image: Height 100% + Position
|
|
294
|
+
* Absolute + inset 0 on the block, Object Fit Cover on the player. Without
|
|
295
|
+
* this the wrapper could be sized and positioned to fill its Group, but the
|
|
296
|
+
* `<video>` inside it stayed `height: auto` — sized by its own aspect
|
|
297
|
+
* ratio, not by the box it was placed in.
|
|
298
|
+
*/
|
|
299
|
+
const heightStyle = getResponsiveValue(
|
|
300
|
+
block.styles?.height,
|
|
301
|
+
currentBreakpoint,
|
|
302
|
+
'auto'
|
|
303
|
+
) as string;
|
|
304
|
+
const objectFit = getResponsiveValue(
|
|
305
|
+
block.styles?.['object-fit'],
|
|
306
|
+
currentBreakpoint,
|
|
307
|
+
'cover'
|
|
308
|
+
) as React.CSSProperties['objectFit'];
|
|
309
|
+
|
|
193
310
|
if (!videoUrl || !parsed) {
|
|
194
311
|
return (
|
|
195
312
|
<div
|
|
@@ -249,70 +366,97 @@ const VideoBlock = ({
|
|
|
249
366
|
);
|
|
250
367
|
}
|
|
251
368
|
|
|
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
|
-
|
|
293
|
-
return (
|
|
369
|
+
const videoElement = (
|
|
294
370
|
<video
|
|
371
|
+
ref={videoRef}
|
|
295
372
|
src={parsed.src}
|
|
296
|
-
controls={
|
|
373
|
+
controls={nativeControls}
|
|
297
374
|
autoPlay={properties.autoplay}
|
|
298
375
|
loop={properties.loop}
|
|
299
376
|
muted={properties.muted}
|
|
300
|
-
poster={
|
|
377
|
+
poster={posterUrl || undefined}
|
|
301
378
|
// Without it iOS opens the video full screen, which is never what an
|
|
302
|
-
// inline background clip is for.
|
|
379
|
+
// inline background clip is for. Left reading `properties.playsInline`
|
|
380
|
+
// raw for the same reason autoplay/loop/muted are above: fixing it
|
|
381
|
+
// would let a merchant's stored `false` through and take an iPhone
|
|
382
|
+
// fullscreen on play, on every composition already published — a
|
|
383
|
+
// shopper-visible playback change that belongs with that same,
|
|
384
|
+
// separate decision.
|
|
303
385
|
playsInline={properties.playsInline !== false}
|
|
386
|
+
onPlay={() => setIsPlaying(true)}
|
|
387
|
+
onPause={() => setIsPlaying(false)}
|
|
304
388
|
style={
|
|
305
389
|
ratio
|
|
306
390
|
? {
|
|
307
391
|
width: '100%',
|
|
308
392
|
aspectRatio: ratio,
|
|
309
|
-
objectFit
|
|
393
|
+
objectFit,
|
|
394
|
+
borderRadius: 'inherit'
|
|
395
|
+
}
|
|
396
|
+
: {
|
|
397
|
+
width: '100%',
|
|
398
|
+
height: heightStyle,
|
|
399
|
+
objectFit,
|
|
310
400
|
borderRadius: 'inherit'
|
|
311
401
|
}
|
|
312
|
-
: { width: '100%', height: 'auto', borderRadius: 'inherit' }
|
|
313
402
|
}
|
|
314
403
|
/>
|
|
315
404
|
);
|
|
405
|
+
|
|
406
|
+
if (!customPlayPause) {
|
|
407
|
+
return videoElement;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* No wrapper `<div>` around the video: `[data-block-id]` (rendered by
|
|
412
|
+
* `WithDesignerFeatures`) is already `position: relative` and already
|
|
413
|
+
* carries whatever height/position the author's block styles give it — a
|
|
414
|
+
* full-bleed hero video is sized THERE, not by the `<video>` tag's own
|
|
415
|
+
* intrinsic box. A wrapper here would only get the video's natural
|
|
416
|
+
* (usually much shorter) height, so `bottom: 0` landed the button near the
|
|
417
|
+
* top of the actually-visible video and clipped most of it away.
|
|
418
|
+
*
|
|
419
|
+
* `zIndex: 2` because a sibling content-overlay block (title/CTA on top of
|
|
420
|
+
* the video) is a full-size `position: absolute; z-index: 1` box — its
|
|
421
|
+
* unfilled area is still transparent, but not click-through, so without a
|
|
422
|
+
* higher z-index here the overlay wins hit-testing and swallows the click
|
|
423
|
+
* even where the button is visually on top of it.
|
|
424
|
+
*/
|
|
425
|
+
// Desktop: bottom-left, flush to the bottom edge, inset from the left.
|
|
426
|
+
// Mobile: top-right instead — the design moves it off the bottom-left
|
|
427
|
+
// corner entirely rather than just rescaling the same inset.
|
|
428
|
+
const buttonPosition: React.CSSProperties = isMobile
|
|
429
|
+
? { top: '18px', right: '18px' }
|
|
430
|
+
: { bottom: 0, left: '24px' };
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<>
|
|
434
|
+
{videoElement}
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={togglePlayback}
|
|
438
|
+
aria-label={isPlaying ? 'Pause video' : 'Play video'}
|
|
439
|
+
aria-pressed={isPlaying}
|
|
440
|
+
style={{
|
|
441
|
+
position: 'absolute',
|
|
442
|
+
...buttonPosition,
|
|
443
|
+
zIndex: 2,
|
|
444
|
+
width: '20px',
|
|
445
|
+
height: '20px',
|
|
446
|
+
display: 'flex',
|
|
447
|
+
alignItems: 'center',
|
|
448
|
+
justifyContent: 'center',
|
|
449
|
+
background: 'rgba(255, 255, 255, 0.9)',
|
|
450
|
+
border: 'none',
|
|
451
|
+
padding: 0,
|
|
452
|
+
cursor: 'pointer',
|
|
453
|
+
color: '#111'
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
|
457
|
+
</button>
|
|
458
|
+
</>
|
|
459
|
+
);
|
|
316
460
|
};
|
|
317
461
|
|
|
318
462
|
export default VideoBlock;
|
|
@@ -68,7 +68,7 @@ const slotState = (slug = 'header') =>
|
|
|
68
68
|
const renderSlot = (slug = 'header') =>
|
|
69
69
|
render(
|
|
70
70
|
<div data-chrome-slot={slug}>
|
|
71
|
-
<ChromeScrollState />
|
|
71
|
+
<ChromeScrollState slug={slug} />
|
|
72
72
|
</div>
|
|
73
73
|
);
|
|
74
74
|
|
|
@@ -172,6 +172,20 @@ describe('the chrome slot scroll signal', () => {
|
|
|
172
172
|
crossViewportEdge(false);
|
|
173
173
|
expect(slotState('header')).toBe('scrolled');
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
it('stamps only its own slot on mount, not a sibling still waiting to hydrate', () => {
|
|
177
|
+
// Under Suspense, a header and a footer can hydrate at different times.
|
|
178
|
+
// The footer's `ChromeScrollState` mounting first must not reach into a
|
|
179
|
+
// header slot that exists in the DOM but has not mounted its own instance
|
|
180
|
+
// yet — that footer-driven write is exactly what made the header's own
|
|
181
|
+
// (later) hydration pass find an attribute its server markup never had.
|
|
182
|
+
render(<div data-chrome-slot="header" />);
|
|
183
|
+
|
|
184
|
+
renderSlot('footer');
|
|
185
|
+
|
|
186
|
+
expect(slotState('footer')).toBe('top');
|
|
187
|
+
expect(slotState('header')).toBeNull();
|
|
188
|
+
});
|
|
175
189
|
});
|
|
176
190
|
|
|
177
191
|
describe('previewing the scroll state in the designer', () => {
|
|
@@ -127,7 +127,7 @@ export default async function ChromeSlot({
|
|
|
127
127
|
) : null}
|
|
128
128
|
{/* Publishes `data-scroll-state` on the wrapper above, so a composition
|
|
129
129
|
can style a header differently once the page is scrolled past it. */}
|
|
130
|
-
<ChromeScrollState />
|
|
130
|
+
<ChromeScrollState slug={slug} />
|
|
131
131
|
{pinned && <StickySlotOverflow />}
|
|
132
132
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
133
133
|
</div>
|
|
@@ -151,7 +151,7 @@ export default async function ChromeSlot({
|
|
|
151
151
|
// live site keeps rendering nothing.
|
|
152
152
|
<ChromeFallback slug={slug} emptyZone />
|
|
153
153
|
) : null}
|
|
154
|
-
<ChromeScrollState />
|
|
154
|
+
<ChromeScrollState slug={slug} />
|
|
155
155
|
{pinned && <StickySlotOverflow />}
|
|
156
156
|
{/* Empty until the designer overrides this area from the editor */}
|
|
157
157
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
@@ -65,6 +65,34 @@ const stampSlots = () => {
|
|
|
65
65
|
.forEach((slot) => slot.setAttribute(SCROLL_STATE_ATTRIBUTE, state));
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Stamps one named slot rather than every slot in the document.
|
|
70
|
+
*
|
|
71
|
+
* Used only for the stamp a slot gives itself the moment it mounts (see
|
|
72
|
+
* `observeScrollState` below) — never for the observer's own crossing
|
|
73
|
+
* callback, which must keep reaching every slot so a header and a footer
|
|
74
|
+
* mounted together still agree.
|
|
75
|
+
*
|
|
76
|
+
* The distinction matters under Suspense: a page can hydrate its slots at
|
|
77
|
+
* different times (a header outside a boundary, a footer inside one that
|
|
78
|
+
* still awaits data). The unscoped stamp used to run for every slot the
|
|
79
|
+
* moment ANY one of them mounted, writing the attribute onto a footer that
|
|
80
|
+
* had not hydrated yet — its own hydration pass then found an attribute in
|
|
81
|
+
* the real DOM that the server markup it was reconciling against never had,
|
|
82
|
+
* which React reports as a hydration mismatch even though nothing was
|
|
83
|
+
* actually wrong: the footer's own effect was about to write the same value
|
|
84
|
+
* moments later anyway.
|
|
85
|
+
*/
|
|
86
|
+
const stampSlot = (slug: string) => {
|
|
87
|
+
if (typeof document === 'undefined') return;
|
|
88
|
+
|
|
89
|
+
const state = pinnedState ?? observedState;
|
|
90
|
+
const slot = document.querySelector<HTMLElement>(
|
|
91
|
+
`div[data-chrome-slot="${CSS.escape(slug)}"]`
|
|
92
|
+
);
|
|
93
|
+
slot?.setAttribute(SCROLL_STATE_ATTRIBUTE, state);
|
|
94
|
+
};
|
|
95
|
+
|
|
68
96
|
const createSentinel = (): HTMLElement => {
|
|
69
97
|
const element = document.createElement('div');
|
|
70
98
|
|
|
@@ -116,13 +144,22 @@ const stop = () => {
|
|
|
116
144
|
/**
|
|
117
145
|
* Registers one slot with the shared observer and returns the release its
|
|
118
146
|
* effect has to run.
|
|
147
|
+
*
|
|
148
|
+
* `slug` scopes the mount-time stamp to just this slot — pass it whenever the
|
|
149
|
+
* caller knows which slot it is (`ChromeSlot` always does). Omit it only for
|
|
150
|
+
* a caller with no slug to give (the public export, used outside a
|
|
151
|
+
* `ChromeSlot`), which falls back to stamping every slot as before.
|
|
119
152
|
*/
|
|
120
|
-
export const observeScrollState = (): (() => void) => {
|
|
153
|
+
export const observeScrollState = (slug?: string): (() => void) => {
|
|
121
154
|
registrations += 1;
|
|
122
155
|
start();
|
|
123
156
|
// A slot that mounted after the last crossing — a client route change, a late
|
|
124
157
|
// hydration — would otherwise carry no attribute until the next one.
|
|
125
|
-
|
|
158
|
+
if (slug) {
|
|
159
|
+
stampSlot(slug);
|
|
160
|
+
} else {
|
|
161
|
+
stampSlots();
|
|
162
|
+
}
|
|
126
163
|
|
|
127
164
|
return () => {
|
|
128
165
|
registrations -= 1;
|
|
@@ -152,9 +189,14 @@ export const pinScrollState = (state: ScrollState | null): void => {
|
|
|
152
189
|
/**
|
|
153
190
|
* Mounted by every ChromeSlot. Renders nothing: the slot element it stamps is
|
|
154
191
|
* the server-rendered wrapper it sits inside.
|
|
192
|
+
*
|
|
193
|
+
* `slug` is the slot's own `data-chrome-slot` value — `ChromeSlot` always
|
|
194
|
+
* passes it, so mounting only ever stamps this slot, never one still waiting
|
|
195
|
+
* to hydrate elsewhere on the page (see `stampSlot`). Left unset only for a
|
|
196
|
+
* caller outside a `ChromeSlot`, where every slot is stamped as before.
|
|
155
197
|
*/
|
|
156
|
-
export const ChromeScrollState = () => {
|
|
157
|
-
useEffect(() => observeScrollState(), []);
|
|
198
|
+
export const ChromeScrollState = ({ slug }: { slug?: string } = {}) => {
|
|
199
|
+
useEffect(() => observeScrollState(slug), [slug]);
|
|
158
200
|
|
|
159
201
|
return null;
|
|
160
202
|
};
|