@akinon/pz-theme 2.0.147-beta.0 → 2.0.147
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 +4 -4
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +4 -3
- package/src/action-capabilities.ts +12 -2
- package/src/blocks/__tests__/scoped-style-text.test.tsx +34 -1
- package/src/blocks/__tests__/video-block.test.tsx +697 -1
- package/src/blocks/video-block.tsx +299 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.147
|
|
3
|
+
## 2.0.147
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.147
|
|
9
|
-
- @akinon/theme-contract@2.0.147
|
|
7
|
+
- 0ff4179: ZERO-5177: place the video block's play/pause and sound buttons by Button Position and two distances, and paint the default inset per breakpoint from the first frame
|
|
8
|
+
- @akinon/next@2.0.147
|
|
9
|
+
- @akinon/theme-contract@2.0.147
|
|
10
10
|
|
|
11
11
|
## 2.0.146
|
|
12
12
|
|
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.147
|
|
4
|
+
"version": "2.0.147",
|
|
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.147
|
|
11
|
+
"@akinon/next": "2.0.147",
|
|
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.147
|
|
16
|
+
"@akinon/theme-contract": "2.0.147",
|
|
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.147
|
|
24
|
+
"@akinon/next": "2.0.147",
|
|
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",
|
|
@@ -377,7 +377,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
377
377
|
// rename would silently break.
|
|
378
378
|
expect(frames()[0].actionCapabilities).toEqual([
|
|
379
379
|
'add-to-cart-after-add',
|
|
380
|
-
'localized-video'
|
|
380
|
+
'localized-video',
|
|
381
|
+
'video-control-position'
|
|
381
382
|
]);
|
|
382
383
|
});
|
|
383
384
|
|
|
@@ -418,8 +419,8 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
418
419
|
// panel back to "On Click" halfway through a session.
|
|
419
420
|
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
420
421
|
expect(frames().map((frame) => frame.actionCapabilities)).toEqual([
|
|
421
|
-
['add-to-cart-after-add', 'localized-video'],
|
|
422
|
-
['add-to-cart-after-add', 'localized-video']
|
|
422
|
+
['add-to-cart-after-add', 'localized-video', 'video-control-position'],
|
|
423
|
+
['add-to-cart-after-add', 'localized-video', 'video-control-position']
|
|
423
424
|
]);
|
|
424
425
|
|
|
425
426
|
// The reporting list rides every frame for the same reason: the editor
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* "On Click", to trim the option list, and to stop
|
|
17
17
|
* offering Focus and Pressed root chips on a
|
|
18
18
|
* wrapper that is no longer a control.
|
|
19
|
+
* - `video-control-position` the Video block places its play/pause and sound
|
|
20
|
+
* buttons by `controlPosition`, `controlOffsetX`
|
|
21
|
+
* and `controlOffsetY`. The editor offers those
|
|
22
|
+
* controls only to a storefront that says so —
|
|
23
|
+
* on an older theme they would save and move
|
|
24
|
+
* nothing.
|
|
19
25
|
*
|
|
20
26
|
* Names rather than a version number, for the reason the state pins are: the
|
|
21
27
|
* editor must not have to know which release added what. A build that does not
|
|
@@ -31,9 +37,13 @@
|
|
|
31
37
|
* the placeholder registry announces this, and it must not have to pull a
|
|
32
38
|
* client component in to say one list of strings.
|
|
33
39
|
*/
|
|
34
|
-
export type ActionCapability =
|
|
40
|
+
export type ActionCapability =
|
|
41
|
+
| 'add-to-cart-after-add'
|
|
42
|
+
| 'localized-video'
|
|
43
|
+
| 'video-control-position';
|
|
35
44
|
|
|
36
45
|
export const ACTION_CAPABILITIES: readonly ActionCapability[] = [
|
|
37
46
|
'add-to-cart-after-add',
|
|
38
|
-
'localized-video'
|
|
47
|
+
'localized-video',
|
|
48
|
+
'video-control-position'
|
|
39
49
|
];
|
|
@@ -91,6 +91,7 @@ import ProductGalleryBlock from '../product-gallery-block';
|
|
|
91
91
|
import ProductGridBlock from '../product-grid-block';
|
|
92
92
|
import ProductRecommendationsBlock from '../product-recommendations-block';
|
|
93
93
|
import StoreLocatorBlock from '../store-locator-block';
|
|
94
|
+
import VideoBlock from '../video-block';
|
|
94
95
|
import { ThemePageContextProvider } from '../../theme-page-context';
|
|
95
96
|
|
|
96
97
|
const block = (
|
|
@@ -138,6 +139,20 @@ const iconGroup = (id: string) =>
|
|
|
138
139
|
styles: { size: { desktop: '24' } }
|
|
139
140
|
});
|
|
140
141
|
|
|
142
|
+
/** A file-backed video drawing its own control layer, which is what writes the placement sheet. */
|
|
143
|
+
const videoBlock = (id: string, controlPosition?: unknown) =>
|
|
144
|
+
block(
|
|
145
|
+
id,
|
|
146
|
+
'video',
|
|
147
|
+
{
|
|
148
|
+
controls: { desktop: false },
|
|
149
|
+
customPlayPause: { desktop: true },
|
|
150
|
+
...(controlPosition ? { controlPosition } : {})
|
|
151
|
+
},
|
|
152
|
+
[],
|
|
153
|
+
{ value: 'https://cdn.example.com/campaign.mp4' }
|
|
154
|
+
);
|
|
155
|
+
|
|
141
156
|
const withPage = (value: Record<string, unknown> | null, element: React.ReactElement) => (
|
|
142
157
|
<ThemePageContextProvider value={value}>{element}</ThemePageContextProvider>
|
|
143
158
|
);
|
|
@@ -290,6 +305,20 @@ const cases: Array<[string, () => React.ReactElement, string[]]> = [
|
|
|
290
305
|
[
|
|
291
306
|
'[data-store-locator-id="nearby-stores"]{grid-template-columns:repeat(2,minmax(0,1fr));}'
|
|
292
307
|
]
|
|
308
|
+
],
|
|
309
|
+
[
|
|
310
|
+
'video control placement',
|
|
311
|
+
() => (
|
|
312
|
+
<VideoBlock
|
|
313
|
+
{...base}
|
|
314
|
+
block={videoBlock('campaign-film', { desktop: 'top-left' })}
|
|
315
|
+
/>
|
|
316
|
+
),
|
|
317
|
+
[
|
|
318
|
+
':where([data-pz-video-controls="campaign-film"]){padding:24px 24px!important;justify-content:flex-start!important}',
|
|
319
|
+
':where([data-pz-video-controls="campaign-film"]>div){justify-content:flex-start!important}',
|
|
320
|
+
'@media (max-width:1023px){:where([data-pz-video-controls="campaign-film"]){padding:18px 18px!important}'
|
|
321
|
+
]
|
|
293
322
|
]
|
|
294
323
|
];
|
|
295
324
|
|
|
@@ -336,7 +365,11 @@ describe('an id that tries to close the tag stays inside the sheet', () => {
|
|
|
336
365
|
)
|
|
337
366
|
],
|
|
338
367
|
['icon button', () => <IconButtonBlock {...base} block={iconButton(HOSTILE_ID)} />],
|
|
339
|
-
['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />]
|
|
368
|
+
['icon group', () => <IconGroupBlock {...base} block={iconGroup(HOSTILE_ID)} />],
|
|
369
|
+
[
|
|
370
|
+
'video controls',
|
|
371
|
+
() => <VideoBlock {...base} block={videoBlock(HOSTILE_ID)} />
|
|
372
|
+
]
|
|
340
373
|
])('%s', (_name, element) => {
|
|
341
374
|
// The collapsible's `<noscript>` copy is a separate raw-text context this
|
|
342
375
|
// change did not touch, so only the sheets outside it are asserted.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { act, render, screen } from '@testing-library/react';
|
|
3
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { renderToStaticMarkup, renderToString } from 'react-dom/server';
|
|
4
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
4
5
|
import VideoBlock from '../video-block';
|
|
6
|
+
import { __resetBrowseModeForTests } from '../../browse-mode';
|
|
7
|
+
import { registerCustomBreakpoints } from '../../utils/breakpoints';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Stand the test in a viewport band.
|
|
@@ -1601,3 +1604,696 @@ describe('properties the editor stores per breakpoint', () => {
|
|
|
1601
1604
|
expect(video.loop).toBe(true);
|
|
1602
1605
|
});
|
|
1603
1606
|
});
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* WHERE THE CONTROL LAYER SITS (ZERO-5177).
|
|
1610
|
+
*
|
|
1611
|
+
* Button Position and the two distances are per-breakpoint properties, and
|
|
1612
|
+
* they reach the page as a stylesheet rather than as inline values resolved at
|
|
1613
|
+
* `currentBreakpoint` — that prop is `'desktop'` on the server and through a
|
|
1614
|
+
* phone's first render, so an inline answer paints every phone with the
|
|
1615
|
+
* desktop corner first. What is pinned here is the sheet itself: where it
|
|
1616
|
+
* sits, what each band writes, and what it may never take away.
|
|
1617
|
+
*/
|
|
1618
|
+
describe('where the control layer sits', () => {
|
|
1619
|
+
const FILE = 'https://cdn.example.com/campaign.mp4';
|
|
1620
|
+
const customControls = {
|
|
1621
|
+
controls: { desktop: false },
|
|
1622
|
+
customPlayPause: { desktop: true }
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
const renderControls = (
|
|
1626
|
+
properties: Record<string, unknown> = {},
|
|
1627
|
+
options: {
|
|
1628
|
+
id?: string | null;
|
|
1629
|
+
isDesigner?: boolean;
|
|
1630
|
+
currentBreakpoint?: string;
|
|
1631
|
+
value?: string;
|
|
1632
|
+
styles?: Record<string, unknown>;
|
|
1633
|
+
} = {}
|
|
1634
|
+
) =>
|
|
1635
|
+
render(
|
|
1636
|
+
<VideoBlock
|
|
1637
|
+
block={
|
|
1638
|
+
{
|
|
1639
|
+
id: options.id === null ? undefined : options.id ?? 'campaign-film',
|
|
1640
|
+
value: options.value ?? FILE,
|
|
1641
|
+
properties: { ...customControls, ...properties },
|
|
1642
|
+
styles: options.styles
|
|
1643
|
+
} as any
|
|
1644
|
+
}
|
|
1645
|
+
placeholderId="p1"
|
|
1646
|
+
sectionId="s1"
|
|
1647
|
+
isDesigner={options.isDesigner ?? false}
|
|
1648
|
+
currentBreakpoint={options.currentBreakpoint ?? 'desktop'}
|
|
1649
|
+
/>
|
|
1650
|
+
);
|
|
1651
|
+
|
|
1652
|
+
const sheetOf = (container: HTMLElement) =>
|
|
1653
|
+
container.querySelector('style')?.innerHTML ?? '';
|
|
1654
|
+
|
|
1655
|
+
const scope = '[data-pz-video-controls="campaign-film"]';
|
|
1656
|
+
const overlayRule = (declarations: string) =>
|
|
1657
|
+
`:where(${scope}){${declarations}}`;
|
|
1658
|
+
const columnRule = (declarations: string) =>
|
|
1659
|
+
`:where(${scope}>div){${declarations}}`;
|
|
1660
|
+
const railRule = (declarations: string) =>
|
|
1661
|
+
`:where(${scope} [data-part="control-rail"]){${declarations}}`;
|
|
1662
|
+
const MOBILE = '@media (max-width:1023px)';
|
|
1663
|
+
const TABLET = '@media (min-width:768px) and (max-width:1023px)';
|
|
1664
|
+
|
|
1665
|
+
describe('the sheet and the element it styles', () => {
|
|
1666
|
+
it('sits right before the overlay, which stays the last child', () => {
|
|
1667
|
+
// The generator's root hover rule reaches the buttons through
|
|
1668
|
+
// `> div:last-child button`; a sheet appended after the overlay would
|
|
1669
|
+
// have taken that away from every video with a hover colour.
|
|
1670
|
+
const { container } = renderControls();
|
|
1671
|
+
const { overlay } = controlBoxes(container);
|
|
1672
|
+
const sheet = container.querySelector('style') as HTMLStyleElement;
|
|
1673
|
+
|
|
1674
|
+
expect(sheet).not.toBeNull();
|
|
1675
|
+
expect(sheet.nextElementSibling).toBe(overlay);
|
|
1676
|
+
expect(overlay.parentElement?.lastElementChild).toBe(overlay);
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
it('names the overlay by the block id the selectors use', () => {
|
|
1680
|
+
const { container } = renderControls();
|
|
1681
|
+
expect(
|
|
1682
|
+
controlBoxes(container).overlay.getAttribute('data-pz-video-controls')
|
|
1683
|
+
).toBe('campaign-film');
|
|
1684
|
+
expect(sheetOf(container)).toContain(scope);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it('keeps the raw id on the element and escapes it only in the selector', () => {
|
|
1688
|
+
const { container } = renderControls({}, { id: 'film"x' });
|
|
1689
|
+
|
|
1690
|
+
expect(
|
|
1691
|
+
controlBoxes(container).overlay.getAttribute('data-pz-video-controls')
|
|
1692
|
+
).toBe('film"x');
|
|
1693
|
+
expect(sheetOf(container)).toContain(
|
|
1694
|
+
'[data-pz-video-controls="film\\"x"]'
|
|
1695
|
+
);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it('is only there when the custom control is', () => {
|
|
1699
|
+
expect(
|
|
1700
|
+
renderControls({ controls: { desktop: true } }).container.querySelector(
|
|
1701
|
+
'style'
|
|
1702
|
+
)
|
|
1703
|
+
).toBeNull();
|
|
1704
|
+
expect(
|
|
1705
|
+
renderControls({
|
|
1706
|
+
customPlayPause: { desktop: false }
|
|
1707
|
+
}).container.querySelector('style')
|
|
1708
|
+
).toBeNull();
|
|
1709
|
+
// An embed never draws the custom control at all.
|
|
1710
|
+
expect(
|
|
1711
|
+
renderControls(
|
|
1712
|
+
{},
|
|
1713
|
+
{ value: 'https://www.youtube.com/watch?v=abc123' }
|
|
1714
|
+
).container.querySelector('style')
|
|
1715
|
+
).toBeNull();
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it('outranks only the inline styles and unflagged rules, never an authored !important', () => {
|
|
1719
|
+
// Specificity 0 (`:where`) with `!important`: beats the inline defaults,
|
|
1720
|
+
// loses to every authored `!important` with any specificity — the
|
|
1721
|
+
// Control Overlay part keys and a brand's own override alike.
|
|
1722
|
+
const sheet = sheetOf(
|
|
1723
|
+
renderControls({
|
|
1724
|
+
controlPosition: { desktop: 'top-left', mobile: 'middle-center' },
|
|
1725
|
+
controlOffsetX: { desktop: 40 }
|
|
1726
|
+
}).container
|
|
1727
|
+
);
|
|
1728
|
+
const rules = sheet
|
|
1729
|
+
.replace(/@media[^{]*\{/g, '')
|
|
1730
|
+
.split('}')
|
|
1731
|
+
.filter((rule) => rule.includes('{'));
|
|
1732
|
+
|
|
1733
|
+
expect(rules.length).toBeGreaterThan(0);
|
|
1734
|
+
rules.forEach((rule) => {
|
|
1735
|
+
const [selector, body] = rule.split('{');
|
|
1736
|
+
expect(selector.startsWith(':where(')).toBe(true);
|
|
1737
|
+
body.split(';').forEach((declaration) => {
|
|
1738
|
+
expect(declaration.endsWith('!important')).toBe(true);
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it('leaves the inline styles exactly as they were', () => {
|
|
1744
|
+
const { overlay, column, rail } = controlBoxes(
|
|
1745
|
+
renderControls({
|
|
1746
|
+
controlPosition: { desktop: 'top-left' },
|
|
1747
|
+
controlOffsetY: { desktop: 60 }
|
|
1748
|
+
}).container
|
|
1749
|
+
);
|
|
1750
|
+
|
|
1751
|
+
expect(overlay.style.justifyContent).toBe('flex-end');
|
|
1752
|
+
expect(overlay.style.padding).toBe('24px');
|
|
1753
|
+
expect(column.style.justifyContent).toBe('flex-end');
|
|
1754
|
+
expect(rail.style.position).toBe('sticky');
|
|
1755
|
+
expect(rail.style.bottom).toBe('24px');
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
describe('with nothing authored', () => {
|
|
1760
|
+
it('writes the inset per band, so a phone paints 18px from the first frame', () => {
|
|
1761
|
+
const sheet = sheetOf(renderControls().container);
|
|
1762
|
+
|
|
1763
|
+
expect(sheet).toBe(
|
|
1764
|
+
overlayRule('padding:24px 24px!important') +
|
|
1765
|
+
railRule('bottom:24px!important') +
|
|
1766
|
+
`${MOBILE}{` +
|
|
1767
|
+
overlayRule('padding:18px 18px!important') +
|
|
1768
|
+
railRule('bottom:18px!important') +
|
|
1769
|
+
'}'
|
|
1770
|
+
);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it('writes no corner and no direction of its own', () => {
|
|
1774
|
+
// Nothing to move, so nothing that could take a merchant's Custom CSS
|
|
1775
|
+
// `flex-direction` (which wins today: the rail inlines none) away.
|
|
1776
|
+
const sheet = sheetOf(renderControls().container);
|
|
1777
|
+
|
|
1778
|
+
expect(sheet).not.toContain('justify-content');
|
|
1779
|
+
expect(sheet).not.toContain('flex-direction');
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it('reaches the server HTML, not only the client', () => {
|
|
1783
|
+
const html = renderToStaticMarkup(
|
|
1784
|
+
<VideoBlock
|
|
1785
|
+
block={
|
|
1786
|
+
{
|
|
1787
|
+
id: 'campaign-film',
|
|
1788
|
+
value: FILE,
|
|
1789
|
+
properties: customControls
|
|
1790
|
+
} as any
|
|
1791
|
+
}
|
|
1792
|
+
placeholderId="p1"
|
|
1793
|
+
sectionId="s1"
|
|
1794
|
+
isDesigner={false}
|
|
1795
|
+
/>
|
|
1796
|
+
);
|
|
1797
|
+
|
|
1798
|
+
expect(html).toContain(
|
|
1799
|
+
`${MOBILE}{${overlayRule('padding:18px 18px!important')}`
|
|
1800
|
+
);
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
describe('Button Position', () => {
|
|
1805
|
+
it.each([
|
|
1806
|
+
['top-left', 'flex-start', 'flex-start', true],
|
|
1807
|
+
['top-center', 'center', 'flex-start', false],
|
|
1808
|
+
['top-right', 'flex-end', 'flex-start', false],
|
|
1809
|
+
['middle-left', 'flex-start', 'center', true],
|
|
1810
|
+
['middle-center', 'center', 'center', false],
|
|
1811
|
+
['middle-right', 'flex-end', 'center', false],
|
|
1812
|
+
['bottom-left', 'flex-start', 'flex-end', true],
|
|
1813
|
+
['bottom-center', 'center', 'flex-end', false],
|
|
1814
|
+
['bottom-right', 'flex-end', 'flex-end', false]
|
|
1815
|
+
])('places %s', (position, justify, align, mirrored) => {
|
|
1816
|
+
const sheet = sheetOf(
|
|
1817
|
+
renderControls({ controlPosition: { desktop: position } }).container
|
|
1818
|
+
);
|
|
1819
|
+
const desktop = sheet.split('@media')[0];
|
|
1820
|
+
|
|
1821
|
+
expect(desktop).toContain(
|
|
1822
|
+
overlayRule(
|
|
1823
|
+
`padding:24px 24px!important;justify-content:${justify}!important`
|
|
1824
|
+
)
|
|
1825
|
+
);
|
|
1826
|
+
expect(desktop).toContain(
|
|
1827
|
+
columnRule(`justify-content:${align}!important`)
|
|
1828
|
+
);
|
|
1829
|
+
if (mirrored) {
|
|
1830
|
+
// play/pause keeps the outer corner on either side; reading-flow keeps
|
|
1831
|
+
// the Tab order with what the eye sees.
|
|
1832
|
+
expect(desktop).toContain(
|
|
1833
|
+
railRule(
|
|
1834
|
+
'bottom:24px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
|
|
1835
|
+
)
|
|
1836
|
+
);
|
|
1837
|
+
} else {
|
|
1838
|
+
expect(sheet).not.toContain('flex-direction');
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it('moves the buttons on a phone without touching desktop', () => {
|
|
1843
|
+
const sheet = sheetOf(
|
|
1844
|
+
renderControls({
|
|
1845
|
+
controlPosition: { desktop: 'top-left', mobile: 'bottom-center' }
|
|
1846
|
+
}).container
|
|
1847
|
+
);
|
|
1848
|
+
const [desktop, mobile] = sheet.split(MOBILE);
|
|
1849
|
+
|
|
1850
|
+
expect(desktop).toContain('justify-content:flex-start!important');
|
|
1851
|
+
expect(mobile).toContain(
|
|
1852
|
+
overlayRule(
|
|
1853
|
+
'padding:18px 18px!important;justify-content:center!important'
|
|
1854
|
+
)
|
|
1855
|
+
);
|
|
1856
|
+
expect(mobile).toContain(
|
|
1857
|
+
columnRule('justify-content:flex-end!important')
|
|
1858
|
+
);
|
|
1859
|
+
// The desktop mirror is undone where the phone is not on the left.
|
|
1860
|
+
expect(mobile).toContain(
|
|
1861
|
+
'flex-direction:row!important;reading-flow:normal!important'
|
|
1862
|
+
);
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
it('writes a band only where it differs from the one it inherits', () => {
|
|
1866
|
+
const sheet = sheetOf(
|
|
1867
|
+
renderControls({ controlPosition: { desktop: 'top-right' } }).container
|
|
1868
|
+
);
|
|
1869
|
+
const mobile = sheet.split(MOBILE)[1];
|
|
1870
|
+
|
|
1871
|
+
// Mobile inherits the corner; only its own inset is written.
|
|
1872
|
+
expect(mobile).not.toContain('justify-content');
|
|
1873
|
+
expect(sheet).not.toContain(TABLET);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
it('gives a tablet the phone answer unless it has its own', () => {
|
|
1877
|
+
const inherited = sheetOf(
|
|
1878
|
+
renderControls({ controlPosition: { mobile: 'top-left' } }).container
|
|
1879
|
+
);
|
|
1880
|
+
expect(inherited).not.toContain(TABLET);
|
|
1881
|
+
|
|
1882
|
+
const own = sheetOf(
|
|
1883
|
+
renderControls({
|
|
1884
|
+
controlPosition: { mobile: 'top-left', tablet: 'top-right' }
|
|
1885
|
+
}).container
|
|
1886
|
+
);
|
|
1887
|
+
// The order is the answer: both bands are specificity 0 and !important,
|
|
1888
|
+
// so a tablet only gets its own value because its query comes AFTER the
|
|
1889
|
+
// phone's (which also matches 768-1023px).
|
|
1890
|
+
expect(own).toBe(
|
|
1891
|
+
overlayRule(
|
|
1892
|
+
'padding:24px 24px!important;justify-content:flex-end!important'
|
|
1893
|
+
) +
|
|
1894
|
+
columnRule('justify-content:flex-end!important') +
|
|
1895
|
+
railRule('bottom:24px!important') +
|
|
1896
|
+
`${MOBILE}{` +
|
|
1897
|
+
overlayRule(
|
|
1898
|
+
'padding:18px 18px!important;justify-content:flex-start!important'
|
|
1899
|
+
) +
|
|
1900
|
+
columnRule('justify-content:flex-start!important') +
|
|
1901
|
+
railRule(
|
|
1902
|
+
'bottom:18px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
|
|
1903
|
+
) +
|
|
1904
|
+
'}' +
|
|
1905
|
+
`${TABLET}{` +
|
|
1906
|
+
overlayRule('justify-content:flex-end!important') +
|
|
1907
|
+
railRule(
|
|
1908
|
+
'flex-direction:row!important;reading-flow:normal!important'
|
|
1909
|
+
) +
|
|
1910
|
+
'}'
|
|
1911
|
+
);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it('reads a flat value, the shape templates write', () => {
|
|
1915
|
+
expect(
|
|
1916
|
+
sheetOf(renderControls({ controlPosition: 'middle-center' }).container)
|
|
1917
|
+
).toContain(columnRule('justify-content:center!important'));
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
it('treats anything it does not know as the corner it always had', () => {
|
|
1921
|
+
const sheet = sheetOf(
|
|
1922
|
+
renderControls({ controlPosition: { desktop: 'sideways-up' } })
|
|
1923
|
+
.container
|
|
1924
|
+
);
|
|
1925
|
+
expect(sheet).toContain('justify-content:flex-end!important');
|
|
1926
|
+
expect(sheet).toContain(columnRule('justify-content:flex-end!important'));
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
describe('the two distances', () => {
|
|
1931
|
+
it('sets each axis on its own', () => {
|
|
1932
|
+
const sheet = sheetOf(
|
|
1933
|
+
renderControls({
|
|
1934
|
+
controlOffsetX: { desktop: 40 },
|
|
1935
|
+
controlOffsetY: { desktop: 12 }
|
|
1936
|
+
}).container
|
|
1937
|
+
);
|
|
1938
|
+
|
|
1939
|
+
expect(sheet).toContain(overlayRule('padding:12px 40px!important'));
|
|
1940
|
+
// The same vertical distance is kept from the screen's foot while the
|
|
1941
|
+
// rail follows a tall clip — one value for resting and travelling.
|
|
1942
|
+
expect(sheet).toContain(railRule('bottom:12px!important'));
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
it('reads numeric strings, the shape a template, an import or a hand-edited theme carries', () => {
|
|
1946
|
+
expect(
|
|
1947
|
+
sheetOf(renderControls({ controlOffsetX: { desktop: '40' } }).container)
|
|
1948
|
+
).toContain(overlayRule('padding:24px 40px!important'));
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
it('keeps an authored 0', () => {
|
|
1952
|
+
expect(
|
|
1953
|
+
sheetOf(renderControls({ controlOffsetY: { desktop: 0 } }).container)
|
|
1954
|
+
).toContain(overlayRule('padding:0px 24px!important'));
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it('clamps to the range the editor offers', () => {
|
|
1958
|
+
expect(
|
|
1959
|
+
sheetOf(renderControls({ controlOffsetX: { desktop: 999 } }).container)
|
|
1960
|
+
).toContain('padding:24px 200px!important');
|
|
1961
|
+
expect(
|
|
1962
|
+
sheetOf(renderControls({ controlOffsetX: { desktop: -8 } }).container)
|
|
1963
|
+
).toContain('padding:24px 0px!important');
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
it('falls back to the inset for a value it cannot read', () => {
|
|
1967
|
+
expect(
|
|
1968
|
+
sheetOf(
|
|
1969
|
+
renderControls({ controlOffsetX: { desktop: 'wide' } }).container
|
|
1970
|
+
)
|
|
1971
|
+
).toContain(overlayRule('padding:24px 24px!important'));
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
it('keeps the desktop inset when only the phone was set', () => {
|
|
1975
|
+
// The authoring order that broke a breakpoint-object default in the
|
|
1976
|
+
// panel: phone first, then desktop looked at.
|
|
1977
|
+
const sheet = sheetOf(
|
|
1978
|
+
renderControls({ controlOffsetX: { mobile: '10' } }).container
|
|
1979
|
+
);
|
|
1980
|
+
const [desktop, mobile] = sheet.split(MOBILE);
|
|
1981
|
+
|
|
1982
|
+
expect(desktop).toContain(overlayRule('padding:24px 24px!important'));
|
|
1983
|
+
expect(mobile).toContain(overlayRule('padding:18px 10px!important'));
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
it('lets a narrower band inherit a desktop distance, the way every style does', () => {
|
|
1987
|
+
const sheet = sheetOf(
|
|
1988
|
+
renderControls({ controlOffsetX: { desktop: 40 } }).container
|
|
1989
|
+
);
|
|
1990
|
+
expect(sheet.split(MOBILE)[1]).toContain(
|
|
1991
|
+
overlayRule('padding:18px 40px!important')
|
|
1992
|
+
);
|
|
1993
|
+
});
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
describe('in the editor', () => {
|
|
1997
|
+
it('writes the one band the editor is forcing, without a query', () => {
|
|
1998
|
+
const sheet = sheetOf(
|
|
1999
|
+
renderControls(
|
|
2000
|
+
{ controlPosition: { desktop: 'top-left', mobile: 'middle-center' } },
|
|
2001
|
+
{ isDesigner: true, currentBreakpoint: 'mobile' }
|
|
2002
|
+
).container
|
|
2003
|
+
);
|
|
2004
|
+
|
|
2005
|
+
expect(sheet).not.toContain('@media');
|
|
2006
|
+
expect(sheet).toContain(
|
|
2007
|
+
overlayRule(
|
|
2008
|
+
'padding:18px 18px!important;justify-content:center!important'
|
|
2009
|
+
)
|
|
2010
|
+
);
|
|
2011
|
+
expect(sheet).toContain(columnRule('justify-content:center!important'));
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
it('answers for the storefront the same way whatever breakpoint the prop says', () => {
|
|
2015
|
+
// The server and a phone's first render both say 'desktop'; a sheet that
|
|
2016
|
+
// listened to the prop would not hydrate.
|
|
2017
|
+
const atDesktop = sheetOf(
|
|
2018
|
+
renderControls(
|
|
2019
|
+
{ controlPosition: { mobile: 'top-left' } },
|
|
2020
|
+
{ currentBreakpoint: 'desktop' }
|
|
2021
|
+
).container
|
|
2022
|
+
);
|
|
2023
|
+
const atMobile = sheetOf(
|
|
2024
|
+
renderControls(
|
|
2025
|
+
{ controlPosition: { mobile: 'top-left' } },
|
|
2026
|
+
{ currentBreakpoint: 'mobile' }
|
|
2027
|
+
).container
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
expect(atMobile).toBe(atDesktop);
|
|
2031
|
+
});
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
describe('mirroring follows where the rail actually ends up', () => {
|
|
2035
|
+
it('follows a Control Overlay Horizontal Corner over the preset', () => {
|
|
2036
|
+
// The part key outranks the preset's justify-content, so a preset Left
|
|
2037
|
+
// under a stored Right puts the rail on the right — and mirroring by the
|
|
2038
|
+
// preset would hand the sound button the corner.
|
|
2039
|
+
const rightAfterAll = sheetOf(
|
|
2040
|
+
renderControls(
|
|
2041
|
+
{ controlPosition: { desktop: 'bottom-left' } },
|
|
2042
|
+
{ styles: { 'controls/justify-content': { desktop: 'flex-end' } } }
|
|
2043
|
+
).container
|
|
2044
|
+
);
|
|
2045
|
+
expect(rightAfterAll).not.toContain('row-reverse');
|
|
2046
|
+
|
|
2047
|
+
const leftAfterAll = sheetOf(
|
|
2048
|
+
renderControls(
|
|
2049
|
+
{ controlPosition: { desktop: 'bottom-right' } },
|
|
2050
|
+
{ styles: { 'controls/justify-content': { desktop: 'flex-start' } } }
|
|
2051
|
+
).container
|
|
2052
|
+
);
|
|
2053
|
+
expect(leftAfterAll).toContain('flex-direction:row-reverse!important');
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it('leaves a block with nothing but a legacy Left in the order it renders today', () => {
|
|
2057
|
+
const sheet = sheetOf(
|
|
2058
|
+
renderControls(
|
|
2059
|
+
{},
|
|
2060
|
+
{ styles: { 'controls/justify-content': { desktop: 'flex-start' } } }
|
|
2061
|
+
).container
|
|
2062
|
+
);
|
|
2063
|
+
expect(sheet).not.toContain('flex-direction');
|
|
2064
|
+
});
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
describe('in browse mode', () => {
|
|
2068
|
+
afterEach(() => __resetBrowseModeForTests());
|
|
2069
|
+
|
|
2070
|
+
it('still writes the one band the editor forces', () => {
|
|
2071
|
+
// Blocks get isDesigner false while browsing, but the canvas is still
|
|
2072
|
+
// the editor's zoomed frame, where WebKit answers a real media query by
|
|
2073
|
+
// the zoomed width — a phone answer on a desktop canvas.
|
|
2074
|
+
(
|
|
2075
|
+
window as { __themeEditorBrowseMode?: boolean }
|
|
2076
|
+
).__themeEditorBrowseMode = true;
|
|
2077
|
+
|
|
2078
|
+
const sheet = sheetOf(
|
|
2079
|
+
renderControls({}, { isDesigner: false, currentBreakpoint: 'desktop' })
|
|
2080
|
+
.container
|
|
2081
|
+
);
|
|
2082
|
+
|
|
2083
|
+
expect(sheet).not.toContain('@media');
|
|
2084
|
+
expect(sheet).toContain(overlayRule('padding:24px 24px!important'));
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
describe('a block with no id', () => {
|
|
2089
|
+
it('still scopes its own sheet, apart from every other', () => {
|
|
2090
|
+
const first = controlBoxes(
|
|
2091
|
+
renderControls({}, { id: null }).container
|
|
2092
|
+
).overlay;
|
|
2093
|
+
const second = controlBoxes(
|
|
2094
|
+
renderControls({}, { id: null }).container
|
|
2095
|
+
).overlay;
|
|
2096
|
+
const firstScope = first.getAttribute('data-pz-video-controls') ?? '';
|
|
2097
|
+
const secondScope = second.getAttribute('data-pz-video-controls') ?? '';
|
|
2098
|
+
|
|
2099
|
+
expect(firstScope).not.toBe('');
|
|
2100
|
+
expect(firstScope).not.toBe('undefined');
|
|
2101
|
+
expect(secondScope).not.toBe(firstScope);
|
|
2102
|
+
// The sheet has to name the scope the element carries, or it styles
|
|
2103
|
+
// nothing at all.
|
|
2104
|
+
[first, second].forEach((overlay, index) => {
|
|
2105
|
+
const scope = index === 0 ? firstScope : secondScope;
|
|
2106
|
+
const sheet =
|
|
2107
|
+
(overlay.previousElementSibling as HTMLStyleElement | null)
|
|
2108
|
+
?.innerHTML ?? '';
|
|
2109
|
+
expect(sheet).toContain(`[data-pz-video-controls="${scope}"]`);
|
|
2110
|
+
});
|
|
2111
|
+
});
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
describe('mirroring and undoing it between bands', () => {
|
|
2115
|
+
it('writes exactly this for a desktop left and a phone right', () => {
|
|
2116
|
+
const sheet = sheetOf(
|
|
2117
|
+
renderControls({
|
|
2118
|
+
controlPosition: { desktop: 'bottom-left', mobile: 'bottom-right' }
|
|
2119
|
+
}).container
|
|
2120
|
+
);
|
|
2121
|
+
|
|
2122
|
+
expect(sheet).toBe(
|
|
2123
|
+
overlayRule(
|
|
2124
|
+
'padding:24px 24px!important;justify-content:flex-start!important'
|
|
2125
|
+
) +
|
|
2126
|
+
columnRule('justify-content:flex-end!important') +
|
|
2127
|
+
railRule(
|
|
2128
|
+
'bottom:24px!important;flex-direction:row-reverse!important;reading-flow:flex-visual!important'
|
|
2129
|
+
) +
|
|
2130
|
+
`${MOBILE}{` +
|
|
2131
|
+
overlayRule(
|
|
2132
|
+
'padding:18px 18px!important;justify-content:flex-end!important'
|
|
2133
|
+
) +
|
|
2134
|
+
railRule(
|
|
2135
|
+
'bottom:18px!important;flex-direction:row!important;reading-flow:normal!important'
|
|
2136
|
+
) +
|
|
2137
|
+
'}'
|
|
2138
|
+
);
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it('leaves a merchant’s own flex-direction alone on a block anchored right', () => {
|
|
2142
|
+
// The rail inlines no flex-direction, so Custom CSS that stacks the
|
|
2143
|
+
// buttons works today; only a left anchor (or undoing one) may touch it.
|
|
2144
|
+
const sheet = sheetOf(
|
|
2145
|
+
renderControls({
|
|
2146
|
+
controlPosition: { desktop: 'top-right', mobile: 'bottom-center' },
|
|
2147
|
+
customCss: {
|
|
2148
|
+
desktop: '& [data-part="control-rail"] { flex-direction: column; }'
|
|
2149
|
+
}
|
|
2150
|
+
}).container
|
|
2151
|
+
);
|
|
2152
|
+
|
|
2153
|
+
expect(sheet).not.toContain('flex-direction');
|
|
2154
|
+
});
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
describe('custom breakpoints in the editor', () => {
|
|
2158
|
+
beforeEach(() =>
|
|
2159
|
+
registerCustomBreakpoints([
|
|
2160
|
+
{ name: 'wide', label: 'Wide', width: 1440, minWidth: 1440 },
|
|
2161
|
+
{
|
|
2162
|
+
name: 'phablet',
|
|
2163
|
+
label: 'Phablet',
|
|
2164
|
+
width: 600,
|
|
2165
|
+
minWidth: 600,
|
|
2166
|
+
maxWidth: 767
|
|
2167
|
+
}
|
|
2168
|
+
])
|
|
2169
|
+
);
|
|
2170
|
+
afterEach(() => registerCustomBreakpoints([]));
|
|
2171
|
+
|
|
2172
|
+
it('draws the built-in band a custom breakpoint stands on, which is all the storefront sheet carries', () => {
|
|
2173
|
+
// Live has no custom band for this sheet, so a value stored under the
|
|
2174
|
+
// custom key would show in the canvas and never on the page.
|
|
2175
|
+
const wide = sheetOf(
|
|
2176
|
+
renderControls(
|
|
2177
|
+
{ controlPosition: { desktop: 'bottom-right', wide: 'top-left' } },
|
|
2178
|
+
{ isDesigner: true, currentBreakpoint: 'wide' }
|
|
2179
|
+
).container
|
|
2180
|
+
);
|
|
2181
|
+
expect(wide).toBe(
|
|
2182
|
+
overlayRule(
|
|
2183
|
+
'padding:24px 24px!important;justify-content:flex-end!important'
|
|
2184
|
+
) +
|
|
2185
|
+
columnRule('justify-content:flex-end!important') +
|
|
2186
|
+
railRule('bottom:24px!important')
|
|
2187
|
+
);
|
|
2188
|
+
|
|
2189
|
+
const phablet = sheetOf(
|
|
2190
|
+
renderControls(
|
|
2191
|
+
{ controlPosition: { mobile: 'bottom-center', phablet: 'top-left' } },
|
|
2192
|
+
{ isDesigner: true, currentBreakpoint: 'phablet' }
|
|
2193
|
+
).container
|
|
2194
|
+
);
|
|
2195
|
+
expect(phablet).toContain(
|
|
2196
|
+
overlayRule(
|
|
2197
|
+
'padding:18px 18px!important;justify-content:center!important'
|
|
2198
|
+
)
|
|
2199
|
+
);
|
|
2200
|
+
expect(phablet).not.toContain('flex-direction');
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
describe('across the server and the client', () => {
|
|
2205
|
+
const element = (id: string | undefined, currentBreakpoint = 'desktop') => (
|
|
2206
|
+
<VideoBlock
|
|
2207
|
+
block={
|
|
2208
|
+
{
|
|
2209
|
+
id,
|
|
2210
|
+
value: FILE,
|
|
2211
|
+
properties: {
|
|
2212
|
+
...customControls,
|
|
2213
|
+
soundToggle: { desktop: true },
|
|
2214
|
+
controlPosition: { desktop: 'bottom-right', mobile: 'top-left' },
|
|
2215
|
+
controlOffsetX: { desktop: 40 }
|
|
2216
|
+
}
|
|
2217
|
+
} as any
|
|
2218
|
+
}
|
|
2219
|
+
placeholderId="p1"
|
|
2220
|
+
sectionId="s1"
|
|
2221
|
+
isDesigner={false}
|
|
2222
|
+
currentBreakpoint={currentBreakpoint}
|
|
2223
|
+
/>
|
|
2224
|
+
);
|
|
2225
|
+
|
|
2226
|
+
it.each([
|
|
2227
|
+
['with a block id', 'campaign-film'],
|
|
2228
|
+
['without one, on the useId scope', undefined]
|
|
2229
|
+
])('hydrates %s, sheet and scope byte for byte', async (_label, id) => {
|
|
2230
|
+
const container = document.createElement('div');
|
|
2231
|
+
container.innerHTML = renderToString(element(id));
|
|
2232
|
+
const serverSheet = container.querySelector('style')?.innerHTML;
|
|
2233
|
+
const serverScope = container
|
|
2234
|
+
.querySelector('[data-part="controls"]')
|
|
2235
|
+
?.getAttribute('data-pz-video-controls');
|
|
2236
|
+
document.body.appendChild(container);
|
|
2237
|
+
|
|
2238
|
+
const recoverable: unknown[] = [];
|
|
2239
|
+
const errors = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
2240
|
+
let root: ReturnType<typeof hydrateRoot> | undefined;
|
|
2241
|
+
await act(async () => {
|
|
2242
|
+
root = hydrateRoot(container, element(id), {
|
|
2243
|
+
onRecoverableError: (error) => recoverable.push(error)
|
|
2244
|
+
});
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
expect(recoverable).toEqual([]);
|
|
2248
|
+
expect(errors).not.toHaveBeenCalled();
|
|
2249
|
+
expect(container.querySelector('style')?.innerHTML).toBe(serverSheet);
|
|
2250
|
+
expect(
|
|
2251
|
+
container
|
|
2252
|
+
.querySelector('[data-part="controls"]')
|
|
2253
|
+
?.getAttribute('data-pz-video-controls')
|
|
2254
|
+
).toBe(serverScope);
|
|
2255
|
+
expect(serverSheet).toContain(
|
|
2256
|
+
`[data-pz-video-controls="${serverScope}"]`
|
|
2257
|
+
);
|
|
2258
|
+
|
|
2259
|
+
// A phone corrects the prop after mount; the storefront sheet never
|
|
2260
|
+
// listened to it, so nothing moves.
|
|
2261
|
+
await act(async () => root?.render(element(id, 'mobile')));
|
|
2262
|
+
expect(container.querySelector('style')?.innerHTML).toBe(serverSheet);
|
|
2263
|
+
|
|
2264
|
+
errors.mockRestore();
|
|
2265
|
+
await act(async () => root?.unmount());
|
|
2266
|
+
container.remove();
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
it('survives the layer coming and going', () => {
|
|
2271
|
+
// Nothing here is a hook: VideoBlock returns early after its hooks, so a
|
|
2272
|
+
// hook added beside the overlay would change the count whenever the custom
|
|
2273
|
+
// control or the source kind changes.
|
|
2274
|
+
const view = renderControls({ controlPosition: { desktop: 'top-left' } });
|
|
2275
|
+
const rerender = (properties: Record<string, unknown>, value = FILE) =>
|
|
2276
|
+
view.rerender(
|
|
2277
|
+
<VideoBlock
|
|
2278
|
+
block={
|
|
2279
|
+
{
|
|
2280
|
+
id: 'campaign-film',
|
|
2281
|
+
value,
|
|
2282
|
+
properties: { ...customControls, ...properties }
|
|
2283
|
+
} as any
|
|
2284
|
+
}
|
|
2285
|
+
placeholderId="p1"
|
|
2286
|
+
sectionId="s1"
|
|
2287
|
+
isDesigner={false}
|
|
2288
|
+
/>
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
expect(() => {
|
|
2292
|
+
rerender({ customPlayPause: { desktop: false } });
|
|
2293
|
+
rerender({ customPlayPause: { desktop: true } });
|
|
2294
|
+
rerender({}, 'https://www.youtube.com/watch?v=abc123');
|
|
2295
|
+
rerender({});
|
|
2296
|
+
}).not.toThrow();
|
|
2297
|
+
expect(view.container.querySelector('style')).not.toBeNull();
|
|
2298
|
+
});
|
|
2299
|
+
});
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { useBrowseMode } from '../browse-mode';
|
|
4
5
|
import {
|
|
5
6
|
BREAKPOINTS,
|
|
7
|
+
MOBILE_MEDIA_QUERY_COMPACT,
|
|
6
8
|
PHONE_AND_TABLET_MEDIA_CONDITION,
|
|
9
|
+
TABLET_MEDIA_QUERY_COMPACT,
|
|
10
|
+
builtInBreakpointOf,
|
|
7
11
|
getResponsiveValue,
|
|
8
12
|
isNarrowBreakpoint,
|
|
9
13
|
partAttrs
|
|
10
14
|
} from '../utils';
|
|
15
|
+
import {
|
|
16
|
+
escapeCssAttributeValue,
|
|
17
|
+
neutralizeStyleTagBreakout
|
|
18
|
+
} from '../utils/css-safety';
|
|
11
19
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
12
20
|
import {
|
|
13
21
|
resolveLocalizedValue,
|
|
@@ -59,6 +67,16 @@ interface VideoProperties {
|
|
|
59
67
|
useDifferentMobileVideo?: unknown;
|
|
60
68
|
mobileUrl?: unknown;
|
|
61
69
|
mobilePoster?: unknown;
|
|
70
|
+
/**
|
|
71
|
+
* Where the control layer sits, per breakpoint: `top|middle|bottom` and
|
|
72
|
+
* `left|center|right`, joined (`bottom-right`). Absent means bottom-right,
|
|
73
|
+
* the corner it has always had.
|
|
74
|
+
*/
|
|
75
|
+
controlPosition?: unknown;
|
|
76
|
+
/** px from the side it is anchored to, per breakpoint. Absent means the inset. */
|
|
77
|
+
controlOffsetX?: unknown;
|
|
78
|
+
/** px from the top or bottom edge, and from the screen's foot while it follows. */
|
|
79
|
+
controlOffsetY?: unknown;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
/**
|
|
@@ -155,9 +173,22 @@ const SoundOnIcon = () => (
|
|
|
155
173
|
*/
|
|
156
174
|
const VISIBILITY_PLAY_THRESHOLD = 0.1;
|
|
157
175
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The inset the control sits at, from the two corners it is anchored to, and
|
|
178
|
+
* the ceiling an authored distance is clamped to. The placement sheet paints
|
|
179
|
+
* these; the editor's distance sliders stop at the same 200 and show these two
|
|
180
|
+
* numbers for a breakpoint nothing was set at (`VIDEO_CONTROL_INSET` in
|
|
181
|
+
* pz-dynamic-widget) — change them together.
|
|
182
|
+
*/
|
|
183
|
+
const CONTROL_INSET_PX = { desktop: 24, narrow: 18 };
|
|
184
|
+
const CONTROL_INSET_MAX_PX = 200;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The same inset as the inline fallback the sheet overrides — what the control
|
|
188
|
+
* still sits at where the sheet does not reach (a browser without `:where()`).
|
|
189
|
+
*/
|
|
190
|
+
const CONTROL_INSET_DESKTOP = `${CONTROL_INSET_PX.desktop}px`;
|
|
191
|
+
const CONTROL_INSET_NARROW = `${CONTROL_INSET_PX.narrow}px`;
|
|
161
192
|
|
|
162
193
|
/**
|
|
163
194
|
* Asked at the moment it matters rather than held in state: the answer is only
|
|
@@ -338,12 +369,226 @@ const firstUrlAtAnyBreakpoint = (value: unknown): string => {
|
|
|
338
369
|
return '';
|
|
339
370
|
};
|
|
340
371
|
|
|
372
|
+
/** One half of `controlPosition`, as the flex keyword that puts the rail there. */
|
|
373
|
+
const CONTROL_JUSTIFY: Record<string, string> = {
|
|
374
|
+
left: 'flex-start',
|
|
375
|
+
center: 'center',
|
|
376
|
+
right: 'flex-end'
|
|
377
|
+
};
|
|
378
|
+
const CONTROL_ALIGN: Record<string, string> = {
|
|
379
|
+
top: 'flex-start',
|
|
380
|
+
middle: 'center',
|
|
381
|
+
bottom: 'flex-end'
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const isOwnKey = (record: Record<string, string>, key: string | undefined) =>
|
|
385
|
+
key !== undefined && Object.prototype.hasOwnProperty.call(record, key);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The side a Control Overlay `justify-content` part key puts the rail on, or
|
|
389
|
+
* `undefined` when none is authored (or it is not a side this can name).
|
|
390
|
+
*/
|
|
391
|
+
const sideOfJustify = (value: unknown): string | undefined => {
|
|
392
|
+
if (value === 'flex-start' || value === 'start' || value === 'left') {
|
|
393
|
+
return 'left';
|
|
394
|
+
}
|
|
395
|
+
if (value === 'center') return 'center';
|
|
396
|
+
if (value === 'flex-end' || value === 'end' || value === 'right') {
|
|
397
|
+
return 'right';
|
|
398
|
+
}
|
|
399
|
+
return undefined;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** `bottom-right` and its eight siblings; anything else is the corner it always had. */
|
|
403
|
+
const readControlPosition = (value: unknown) => {
|
|
404
|
+
const [vertical, horizontal] =
|
|
405
|
+
typeof value === 'string' ? value.split('-') : [];
|
|
406
|
+
return {
|
|
407
|
+
vertical: isOwnKey(CONTROL_ALIGN, vertical) ? vertical : 'bottom',
|
|
408
|
+
horizontal: isOwnKey(CONTROL_JUSTIFY, horizontal) ? horizontal : 'right'
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* An authored distance in px, or the fallback.
|
|
414
|
+
*
|
|
415
|
+
* Numbers and numeric strings alike — the editor writes numbers, but a
|
|
416
|
+
* template, an import or a hand-edited theme can carry `'40'`, and a reader
|
|
417
|
+
* that only took `typeof === 'number'` would drop every one of them silently.
|
|
418
|
+
* A 0 is a distance, not an absence. Clamped to what the editor offers, so the
|
|
419
|
+
* page never draws a distance the panel cannot show.
|
|
420
|
+
*/
|
|
421
|
+
const readControlInset = (value: unknown, fallback: number): number => {
|
|
422
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
423
|
+
const parsed = typeof value === 'number' ? value : parseFloat(String(value));
|
|
424
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
425
|
+
return Math.min(CONTROL_INSET_MAX_PX, Math.max(0, Math.round(parsed)));
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/** What one breakpoint band asks of the control layer. */
|
|
429
|
+
interface ControlBand {
|
|
430
|
+
/** Overlay padding: the distance from the edges it is anchored to. */
|
|
431
|
+
padding: string;
|
|
432
|
+
/** The rail's sticky offset — the same distance, kept from the screen's foot. */
|
|
433
|
+
bottom: string;
|
|
434
|
+
/** Overlay `justify-content` (horizontal), only once a position is authored. */
|
|
435
|
+
justify?: string;
|
|
436
|
+
/** Column `justify-content` (vertical), only once a position is authored. */
|
|
437
|
+
align?: string;
|
|
438
|
+
/** Left anchors mirror the pair so play/pause keeps the outer corner. */
|
|
439
|
+
mirrored?: boolean;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const controlBandAt = (
|
|
443
|
+
properties: VideoProperties,
|
|
444
|
+
styles: Record<string, unknown> | undefined,
|
|
445
|
+
band: string,
|
|
446
|
+
placed: boolean
|
|
447
|
+
): ControlBand => {
|
|
448
|
+
const inset = isNarrowBreakpoint(band)
|
|
449
|
+
? CONTROL_INSET_PX.narrow
|
|
450
|
+
: CONTROL_INSET_PX.desktop;
|
|
451
|
+
const x = readControlInset(
|
|
452
|
+
getResponsiveValue(properties.controlOffsetX, band, undefined),
|
|
453
|
+
inset
|
|
454
|
+
);
|
|
455
|
+
const y = readControlInset(
|
|
456
|
+
getResponsiveValue(properties.controlOffsetY, band, undefined),
|
|
457
|
+
inset
|
|
458
|
+
);
|
|
459
|
+
const result: ControlBand = { padding: `${y}px ${x}px`, bottom: `${y}px` };
|
|
460
|
+
if (!placed) return result;
|
|
461
|
+
|
|
462
|
+
const { vertical, horizontal } = readControlPosition(
|
|
463
|
+
getResponsiveValue(properties.controlPosition, band, undefined)
|
|
464
|
+
);
|
|
465
|
+
result.justify = CONTROL_JUSTIFY[horizontal];
|
|
466
|
+
result.align = CONTROL_ALIGN[vertical];
|
|
467
|
+
/*
|
|
468
|
+
* Mirrored by the side the rail actually ends up on. A Horizontal Corner
|
|
469
|
+
* stored on the Control Overlay part outranks the preset's justify-content,
|
|
470
|
+
* so mirroring by the preset alone would put the sound button in the corner
|
|
471
|
+
* on exactly the blocks that have one. Only once a position is authored,
|
|
472
|
+
* though: a block that has nothing but a legacy Left keeps the order it
|
|
473
|
+
* renders in today.
|
|
474
|
+
*/
|
|
475
|
+
const side =
|
|
476
|
+
sideOfJustify(
|
|
477
|
+
getResponsiveValue(styles?.['controls/justify-content'], band, undefined)
|
|
478
|
+
) ?? horizontal;
|
|
479
|
+
if (side === 'left') result.mirrored = true;
|
|
480
|
+
return result;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The rules one band writes — only what differs from the band it inherits in
|
|
485
|
+
* the cascade, so a phone value equal to the desktop one is not written twice.
|
|
486
|
+
*/
|
|
487
|
+
const controlBandRules = (
|
|
488
|
+
scope: string,
|
|
489
|
+
band: ControlBand,
|
|
490
|
+
inherited?: ControlBand
|
|
491
|
+
): string => {
|
|
492
|
+
const changed = (key: keyof ControlBand) =>
|
|
493
|
+
!inherited || band[key] !== inherited[key];
|
|
494
|
+
|
|
495
|
+
const overlay: string[] = [];
|
|
496
|
+
const column: string[] = [];
|
|
497
|
+
const rail: string[] = [];
|
|
498
|
+
|
|
499
|
+
if (changed('padding')) overlay.push(`padding:${band.padding}!important`);
|
|
500
|
+
if (band.justify && changed('justify')) {
|
|
501
|
+
overlay.push(`justify-content:${band.justify}!important`);
|
|
502
|
+
}
|
|
503
|
+
if (band.align && changed('align')) {
|
|
504
|
+
column.push(`justify-content:${band.align}!important`);
|
|
505
|
+
}
|
|
506
|
+
if (changed('bottom')) rail.push(`bottom:${band.bottom}!important`);
|
|
507
|
+
if (band.mirrored && changed('mirrored')) {
|
|
508
|
+
rail.push(
|
|
509
|
+
'flex-direction:row-reverse!important',
|
|
510
|
+
'reading-flow:flex-visual!important'
|
|
511
|
+
);
|
|
512
|
+
} else if (!band.mirrored && inherited?.mirrored) {
|
|
513
|
+
// Undo a wider band's mirror. Never written otherwise: the rail carries no
|
|
514
|
+
// inline flex-direction, so a merchant's own Custom CSS one wins today and
|
|
515
|
+
// an unconditional `row` here would silently take that away.
|
|
516
|
+
rail.push('flex-direction:row!important', 'reading-flow:normal!important');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const root = `[data-pz-video-controls="${scope}"]`;
|
|
520
|
+
return (
|
|
521
|
+
(overlay.length ? `:where(${root}){${overlay.join(';')}}` : '') +
|
|
522
|
+
(column.length ? `:where(${root}>div){${column.join(';')}}` : '') +
|
|
523
|
+
(rail.length
|
|
524
|
+
? `:where(${root} [data-part="control-rail"]){${rail.join(';')}}`
|
|
525
|
+
: '')
|
|
526
|
+
);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* WHERE THE CONTROL LAYER SITS, AS A STYLESHEET (ZERO-5177).
|
|
531
|
+
*
|
|
532
|
+
* `controlPosition` and the two distances are properties stored per
|
|
533
|
+
* breakpoint, and resolving them at `currentBreakpoint` into the inline styles
|
|
534
|
+
* would paint a phone with the desktop answer: that prop is `'desktop'` on the
|
|
535
|
+
* server and through a phone's first render. So they become CSS — every band at
|
|
536
|
+
* once with real media queries on the storefront, and the one band the editor
|
|
537
|
+
* is forcing whenever it is attached, editing or browsing (a custom breakpoint
|
|
538
|
+
* as the built-in band it stands on, since the storefront sheet only carries
|
|
539
|
+
* the built-in three — the canvas must not show what the page will not).
|
|
540
|
+
*
|
|
541
|
+
* Every selector is wrapped in `:where()` and every declaration is
|
|
542
|
+
* `!important`: specificity 0 beats nothing, so what this outranks is the
|
|
543
|
+
* renderer's own inline defaults — which no stylesheet rule can reach otherwise
|
|
544
|
+
* — and, for the properties it writes, any rule without `!important`, exactly
|
|
545
|
+
* as those inline values already did. Everything authored with an
|
|
546
|
+
* `!important` of its own still wins — the Control Overlay part keys
|
|
547
|
+
* (`[data-block-id] [data-part]`, 0,2,0), which are the fine-tuning on top of
|
|
548
|
+
* this, and any brand override that beats the inline styles today. The one
|
|
549
|
+
* property it writes that is NOT inline, the rail's flex-direction (with
|
|
550
|
+
* reading-flow), is therefore written only around a left anchor.
|
|
551
|
+
*
|
|
552
|
+
* The distances are written whatever was authored, because the default inset
|
|
553
|
+
* is per band too: the inline 24px is chosen from `currentBreakpoint` and a
|
|
554
|
+
* phone used to paint it before jumping to 18px.
|
|
555
|
+
*/
|
|
556
|
+
export const buildVideoControlSheet = (
|
|
557
|
+
scope: string,
|
|
558
|
+
properties: VideoProperties,
|
|
559
|
+
styles: Record<string, unknown> | undefined,
|
|
560
|
+
designerBand?: string
|
|
561
|
+
): string => {
|
|
562
|
+
const placed = setAtAnyBreakpoint(properties.controlPosition);
|
|
563
|
+
|
|
564
|
+
if (designerBand) {
|
|
565
|
+
return controlBandRules(
|
|
566
|
+
scope,
|
|
567
|
+
controlBandAt(properties, styles, designerBand, placed)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const desktop = controlBandAt(properties, styles, 'desktop', placed);
|
|
572
|
+
const mobile = controlBandAt(properties, styles, 'mobile', placed);
|
|
573
|
+
const tablet = controlBandAt(properties, styles, 'tablet', placed);
|
|
574
|
+
const mobileRules = controlBandRules(scope, mobile, desktop);
|
|
575
|
+
// Tablet is written after mobile and inherits it, the generator's cascade.
|
|
576
|
+
const tabletRules = controlBandRules(scope, tablet, mobile);
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
controlBandRules(scope, desktop) +
|
|
580
|
+
(mobileRules ? `${MOBILE_MEDIA_QUERY_COMPACT}{${mobileRules}}` : '') +
|
|
581
|
+
(tabletRules ? `${TABLET_MEDIA_QUERY_COMPACT}{${tabletRules}}` : '')
|
|
582
|
+
);
|
|
583
|
+
};
|
|
584
|
+
|
|
341
585
|
/**
|
|
342
586
|
* A video: a YouTube or Vimeo embed, or a file the browser plays itself.
|
|
343
587
|
*
|
|
344
588
|
* Properties: url (on `block.value`), controls, autoplay, loop, muted,
|
|
345
589
|
* playsInline, poster, sourceType, preload, playWhenVisible, customPlayPause,
|
|
346
|
-
* soundToggle. Style:
|
|
590
|
+
* soundToggle, controlPosition, controlOffsetX, controlOffsetY. Style:
|
|
591
|
+
* `aspect-ratio`.
|
|
347
592
|
*/
|
|
348
593
|
const VideoBlock = ({
|
|
349
594
|
block,
|
|
@@ -351,6 +596,20 @@ const VideoBlock = ({
|
|
|
351
596
|
currentBreakpoint = 'desktop'
|
|
352
597
|
}: BlockRendererProps) => {
|
|
353
598
|
const valueLocale = useValueLocale();
|
|
599
|
+
/*
|
|
600
|
+
* Two reads for the placement sheet, taken here with the other hooks:
|
|
601
|
+
* VideoBlock returns early further down (no URL, an embed, no custom
|
|
602
|
+
* control), so a hook beside the sheet would change the count whenever one
|
|
603
|
+
* of those flips.
|
|
604
|
+
*
|
|
605
|
+
* Browse mode is the editor still attached with its affordances off. Blocks
|
|
606
|
+
* get `isDesigner === false` there, but the canvas is still the editor's
|
|
607
|
+
* zoomed frame, and WebKit lays a zoomed desktop frame out at its zoomed
|
|
608
|
+
* width — a real media query answers "phone" on a desktop canvas. False on
|
|
609
|
+
* the server and through hydration, so it cannot split the markup.
|
|
610
|
+
*/
|
|
611
|
+
const browsing = useBrowseMode();
|
|
612
|
+
const fallbackControlScope = useId();
|
|
354
613
|
const localizedValue = resolveLocalizedValue(block.value, valueLocale);
|
|
355
614
|
const desktopUrl = (
|
|
356
615
|
typeof localizedValue === 'string' ? localizedValue : block.value
|
|
@@ -1011,6 +1270,13 @@ const VideoBlock = ({
|
|
|
1011
1270
|
/**
|
|
1012
1271
|
* THE CONTROL LAYER — BOTTOM RIGHT, AND STILL THERE ON A TALL CLIP.
|
|
1013
1272
|
*
|
|
1273
|
+
* Bottom right is the default, and what the inline styles below still draw.
|
|
1274
|
+
* An author can move it (Button Position and the two distances, ZERO-5177):
|
|
1275
|
+
* that arrives as the placement sheet, which overrides these inline values —
|
|
1276
|
+
* see `buildVideoControlSheet`. A middle anchor keeps the sticky rail, so it
|
|
1277
|
+
* rides the viewport floor until its own spot scrolls into view; a top
|
|
1278
|
+
* anchor is already at the top of its column, where sticky has no room.
|
|
1279
|
+
*
|
|
1014
1280
|
* Still no wrapper around the VIDEO: `[data-block-id]` (rendered by
|
|
1015
1281
|
* `WithDesignerFeatures`) is already `position: relative` and already carries
|
|
1016
1282
|
* whatever height/position the author's block styles give it — a full-bleed
|
|
@@ -1032,7 +1298,8 @@ const VideoBlock = ({
|
|
|
1032
1298
|
* being watched. Sticky keeps it the same inset above the viewport floor
|
|
1033
1299
|
* until the real bottom comes into range, then parks it there. Where an
|
|
1034
1300
|
* ancestor's `overflow: hidden` defeats sticky, it degrades to its static
|
|
1035
|
-
* position
|
|
1301
|
+
* position — the anchor it was placed at anyway (bottom right unless Button
|
|
1302
|
+
* Position says otherwise).
|
|
1036
1303
|
*
|
|
1037
1304
|
* Both outer boxes are `pointer-events: none` — the overlay covers the whole
|
|
1038
1305
|
* player, and the shopper must still be able to click a link sitting under
|
|
@@ -1062,11 +1329,37 @@ const VideoBlock = ({
|
|
|
1062
1329
|
pointerEvents: 'auto'
|
|
1063
1330
|
};
|
|
1064
1331
|
|
|
1332
|
+
/*
|
|
1333
|
+
* Where the author put the layer — see `buildVideoControlSheet`. Scoped by
|
|
1334
|
+
* the block id the way every other scoped sheet in this package is: data, so
|
|
1335
|
+
* the server and the first client render name the same scope. A block with
|
|
1336
|
+
* no id (only tests and hand-built trees render one) falls back to `useId`,
|
|
1337
|
+
* so two of them never share a sheet. The raw id goes on the element and the
|
|
1338
|
+
* escaped one in the selector.
|
|
1339
|
+
*
|
|
1340
|
+
* The `<style>` sits BEFORE the overlay so the overlay stays the wrapper's
|
|
1341
|
+
* last child — the generator's root hover rule reaches the buttons through
|
|
1342
|
+
* `> div:last-child button`.
|
|
1343
|
+
*/
|
|
1344
|
+
const controlScope = String(block.id || fallbackControlScope);
|
|
1345
|
+
const controlSheet = buildVideoControlSheet(
|
|
1346
|
+
escapeCssAttributeValue(controlScope),
|
|
1347
|
+
properties,
|
|
1348
|
+
block.styles as Record<string, unknown> | undefined,
|
|
1349
|
+
isDesigner || browsing ? builtInBreakpointOf(currentBreakpoint) : undefined
|
|
1350
|
+
);
|
|
1351
|
+
|
|
1065
1352
|
return (
|
|
1066
1353
|
<>
|
|
1067
1354
|
{videoElement}
|
|
1355
|
+
<style
|
|
1356
|
+
dangerouslySetInnerHTML={{
|
|
1357
|
+
__html: neutralizeStyleTagBreakout(controlSheet)
|
|
1358
|
+
}}
|
|
1359
|
+
/>
|
|
1068
1360
|
<div
|
|
1069
1361
|
{...partAttrs('controls')}
|
|
1362
|
+
data-pz-video-controls={controlScope}
|
|
1070
1363
|
style={{
|
|
1071
1364
|
position: 'absolute',
|
|
1072
1365
|
inset: 0,
|