@akinon/pz-theme 2.0.82-beta.0 → 2.0.82
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 +8 -4
- package/package.json +4 -4
- package/src/blocks/__tests__/link-block.test.tsx +87 -0
- package/src/blocks/__tests__/text-block.test.tsx +88 -0
- package/src/blocks/__tests__/video-block.test.tsx +207 -2
- package/src/blocks/item-template-utils.ts +51 -5
- package/src/blocks/link-block.tsx +43 -0
- package/src/blocks/slider-block.tsx +92 -4
- package/src/blocks/text-block.tsx +42 -4
- package/src/blocks/video-block.tsx +208 -80
- package/src/sections/__tests__/posts-slider-arrows.test.tsx +470 -0
- package/src/sections/__tests__/posts-slider-edges.test.tsx +394 -0
- package/src/sections/__tests__/posts-slider-quick-view-no-fetch.test.tsx +205 -0
- package/src/sections/__tests__/posts-slider-quick-view.test.tsx +660 -0
- package/src/sections/posts-slider-section.tsx +482 -115
- package/src/sections/slider-quick-view.tsx +375 -0
- package/src/utils/__tests__/line-breaks.test.ts +215 -0
- package/src/utils/index.ts +12 -1
- package/src/utils/iterator-utils.ts +46 -0
- package/src/utils/line-breaks.ts +142 -0
- package/src/utils/slider-arrows.tsx +252 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.82
|
|
3
|
+
## 2.0.82
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- d4a89926: ZERO-4816: enhance PostsSliderSection to utilize custom arrow blocks
|
|
8
|
+
- e6ba4d0a: ZERO-4816: add an optional quick view panel to the product slider sections
|
|
9
|
+
- f358b65b: Line breaks that apply only at chosen widths: a `<br data-pz-break="mobile tablet">` in a text block breaks the line at 1023px and under and disappears on the desktop, so one authored sentence can be two lines narrow and one line wide.
|
|
10
|
+
- b0722874: ZERO-4816: run a slider flush to its edges and offset the track only at the start position
|
|
11
|
+
- c41ea578: ZERO-4816: play YouTube Shorts and direct video files whose address carries a query
|
|
12
|
+
- @akinon/next@2.0.82
|
|
13
|
+
- @akinon/theme-contract@2.0.82
|
|
10
14
|
|
|
11
15
|
## 2.0.81
|
|
12
16
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
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.82
|
|
4
|
+
"version": "2.0.82",
|
|
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.82
|
|
11
|
+
"@akinon/next": "2.0.82",
|
|
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.82
|
|
16
|
+
"@akinon/theme-contract": "2.0.82",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.82
|
|
23
|
+
"@akinon/next": "2.0.82",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -182,3 +182,90 @@ describe('LinkBlock', () => {
|
|
|
182
182
|
expect(anchor(container)).toHaveAttribute('href', '#');
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The double-drawn card box.
|
|
188
|
+
*
|
|
189
|
+
* `data-block-id` rides the designer wrapper this anchor renders inside, and the
|
|
190
|
+
* generated stylesheet paints the block's box on that wrapper. The wrapper is
|
|
191
|
+
* the grid item, so it stretches to its row; the anchor only hugs its content.
|
|
192
|
+
* A card with one border in the inspector therefore showed two on the page —
|
|
193
|
+
* one closing under the label, one at the bottom of the row.
|
|
194
|
+
*/
|
|
195
|
+
describe('LinkBlock leaves the block box to the wrapper', () => {
|
|
196
|
+
const boxStyles = {
|
|
197
|
+
border: { desktop: '1px solid #d4d4d4' },
|
|
198
|
+
'border-radius': { desktop: '12px' },
|
|
199
|
+
padding: { desktop: '16px' },
|
|
200
|
+
'margin-bottom': { desktop: '8px' },
|
|
201
|
+
'box-shadow': { desktop: '0 1px 2px #0001' },
|
|
202
|
+
outline: { desktop: '1px solid red' }
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
it('writes none of the box keys inline', () => {
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<LinkBlock
|
|
208
|
+
{...props({
|
|
209
|
+
block: {
|
|
210
|
+
id: 'l',
|
|
211
|
+
properties: { href: '/foo' },
|
|
212
|
+
styles: boxStyles,
|
|
213
|
+
blocks: [child({ id: 'a', order: 0 })]
|
|
214
|
+
}
|
|
215
|
+
})}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
const style = anchor(container).getAttribute('style') || '';
|
|
219
|
+
['border', 'padding', 'margin', 'box-shadow', 'outline'].forEach(
|
|
220
|
+
(fragment) => expect(style).not.toContain(fragment)
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('still writes the layout keys inline, since the wrapper has one child', () => {
|
|
225
|
+
const { container } = render(
|
|
226
|
+
<LinkBlock
|
|
227
|
+
{...props({
|
|
228
|
+
block: {
|
|
229
|
+
id: 'l',
|
|
230
|
+
properties: { href: '/foo' },
|
|
231
|
+
styles: {
|
|
232
|
+
...boxStyles,
|
|
233
|
+
display: { desktop: 'flex' },
|
|
234
|
+
'flex-direction': { desktop: 'column' },
|
|
235
|
+
gap: { desktop: '8px' }
|
|
236
|
+
},
|
|
237
|
+
blocks: [child({ id: 'a', order: 0 })]
|
|
238
|
+
}
|
|
239
|
+
})}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
242
|
+
const el = anchor(container);
|
|
243
|
+
expect(el.style.display).toBe('flex');
|
|
244
|
+
expect(el.style.flexDirection).toBe('column');
|
|
245
|
+
expect(el.style.gap).toBe('8px');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Painting the same colour on the wrapper and on the anchor leaves no
|
|
250
|
+
* artifact, and an iterator resolves a bound colour per row into this inline
|
|
251
|
+
* copy — the one shared stylesheet rule cannot hold a value per row.
|
|
252
|
+
*/
|
|
253
|
+
it('still writes background inline, which duplication cannot spoil', () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<LinkBlock
|
|
256
|
+
{...props({
|
|
257
|
+
block: {
|
|
258
|
+
id: 'l',
|
|
259
|
+
properties: { href: '/foo' },
|
|
260
|
+
styles: {
|
|
261
|
+
...boxStyles,
|
|
262
|
+
'background-color': { desktop: 'rgb(255, 0, 0)' }
|
|
263
|
+
},
|
|
264
|
+
blocks: [child({ id: 'a', order: 0 })]
|
|
265
|
+
}
|
|
266
|
+
})}
|
|
267
|
+
/>
|
|
268
|
+
);
|
|
269
|
+
expect(anchor(container).style.backgroundColor).toBe('rgb(255, 0, 0)');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -92,3 +92,91 @@ describe('TextBlock rendering', () => {
|
|
|
92
92
|
expect(div.getAttribute('style')).toBeNull();
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Which half of a text block's style map this div wears.
|
|
98
|
+
*
|
|
99
|
+
* Not none of it. A bound value (`{{item.badgeColor}}`) is deliberately stripped
|
|
100
|
+
* from the shared `[data-block-id]` rule — every row of an iterator shares that
|
|
101
|
+
* id, and one rule cannot hold a different colour per row — so inline is the
|
|
102
|
+
* only place a per-row value can land. `iterator-bindings.test.tsx` is where
|
|
103
|
+
* that contract is asserted.
|
|
104
|
+
*
|
|
105
|
+
* Margin and padding are the exception, and they were the bug. They describe the
|
|
106
|
+
* block's box, the stylesheet already puts them on the wrapper this div renders
|
|
107
|
+
* inside, and a box inside a box compounds: measured on a live page, nine blocks
|
|
108
|
+
* carried the same margin at both levels, so 29px under a heading was applied
|
|
109
|
+
* twice with nothing in the inspector to explain the second copy.
|
|
110
|
+
*/
|
|
111
|
+
describe('TextBlock leaves the block box geometry to the wrapper', () => {
|
|
112
|
+
it('writes no margin inline, at any side', () => {
|
|
113
|
+
const { container } = renderBlock('hello', {
|
|
114
|
+
'margin-bottom': { desktop: '29px' },
|
|
115
|
+
'margin-top': { desktop: '12px' },
|
|
116
|
+
'margin-left': { desktop: '4px' },
|
|
117
|
+
'margin-right': { desktop: '4px' }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
121
|
+
|
|
122
|
+
expect(div.getAttribute('style')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('writes no padding inline, shorthand or longhand', () => {
|
|
126
|
+
const { container } = renderBlock('hello', {
|
|
127
|
+
padding: { desktop: '20px' },
|
|
128
|
+
'padding-left': { desktop: '8px' }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
132
|
+
|
|
133
|
+
expect(div.getAttribute('style')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('recognises the camelCase spelling published JSON may carry', () => {
|
|
137
|
+
const { container } = renderBlock('hello', {
|
|
138
|
+
marginBottom: { desktop: '29px' },
|
|
139
|
+
paddingTop: { desktop: '10px' }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
143
|
+
|
|
144
|
+
expect(div.getAttribute('style')).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('still wears everything else, which per-row bindings depend on', () => {
|
|
148
|
+
const { container } = renderBlock('hello', {
|
|
149
|
+
'background-color': { desktop: '#00a86b' },
|
|
150
|
+
color: { desktop: '#610529' },
|
|
151
|
+
'border-radius': { desktop: '4px' },
|
|
152
|
+
'margin-bottom': { desktop: '29px' }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
156
|
+
|
|
157
|
+
expect(div.style.backgroundColor).toBe('rgb(0, 168, 107)');
|
|
158
|
+
expect(div.style.color).toBe('rgb(97, 5, 41)');
|
|
159
|
+
expect(div.style.borderRadius).toBe('4px');
|
|
160
|
+
expect(div.style.marginBottom).toBe('');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not leave an empty style attribute when only geometry was set', () => {
|
|
164
|
+
const { container } = renderBlock('hello', {
|
|
165
|
+
'margin-bottom': { desktop: '29px' }
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
169
|
+
|
|
170
|
+
expect(div.getAttribute('style')).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('still hosts the content, which is the only job this div has', () => {
|
|
174
|
+
const { container } = renderBlock('<p>hello</p>', {
|
|
175
|
+
padding: { desktop: '20px' }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const div = container.querySelector('div') as HTMLDivElement;
|
|
179
|
+
|
|
180
|
+
expect(div.querySelector('p')?.textContent).toBe('hello');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -2,10 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import VideoBlock from '../video-block';
|
|
4
4
|
|
|
5
|
-
const renderBlock = (value: any, properties?: any) =>
|
|
5
|
+
const renderBlock = (value: any, properties?: any, styles?: any) =>
|
|
6
6
|
render(
|
|
7
7
|
<VideoBlock
|
|
8
|
-
block={{ value, properties } as any}
|
|
8
|
+
block={{ value, properties, styles } as any}
|
|
9
9
|
placeholderId="p1"
|
|
10
10
|
sectionId="s1"
|
|
11
11
|
isDesigner={false}
|
|
@@ -194,6 +194,211 @@ describe('VideoBlock', () => {
|
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A Shorts link used to render the grey "Invalid video URL" box: it contains
|
|
199
|
+
* `youtube.com`, so it entered the YouTube branch, matched none of the three
|
|
200
|
+
* shapes the parser knew, and fell out with an empty id.
|
|
201
|
+
*/
|
|
202
|
+
describe('YouTube Shorts', () => {
|
|
203
|
+
it('embeds a Shorts link', () => {
|
|
204
|
+
const { container } = renderBlock(
|
|
205
|
+
'https://www.youtube.com/shorts/AbC123xyz'
|
|
206
|
+
);
|
|
207
|
+
expect(container.querySelector('iframe')).toHaveAttribute(
|
|
208
|
+
'src',
|
|
209
|
+
'https://www.youtube.com/embed/AbC123xyz'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('carries the playback properties a watch URL carries', () => {
|
|
214
|
+
const { container } = renderBlock(
|
|
215
|
+
'https://www.youtube.com/shorts/AbC123xyz',
|
|
216
|
+
{ autoplay: true, loop: true }
|
|
217
|
+
);
|
|
218
|
+
expect(container.querySelector('iframe')).toHaveAttribute(
|
|
219
|
+
'src',
|
|
220
|
+
'https://www.youtube.com/embed/AbC123xyz?autoplay=1&loop=1&playlist=AbC123xyz'
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('drops a share query rather than baking it into the id', () => {
|
|
225
|
+
const { container } = renderBlock(
|
|
226
|
+
'https://youtube.com/shorts/AbC123xyz?feature=share'
|
|
227
|
+
);
|
|
228
|
+
expect(container.querySelector('iframe')).toHaveAttribute(
|
|
229
|
+
'src',
|
|
230
|
+
'https://www.youtube.com/embed/AbC123xyz'
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('stands itself up vertical without the author saying so', () => {
|
|
235
|
+
// A Short is filmed 9:16; the historic 400px box letterboxes it.
|
|
236
|
+
const { container } = renderBlock(
|
|
237
|
+
'https://www.youtube.com/shorts/AbC123xyz'
|
|
238
|
+
);
|
|
239
|
+
const iframe = container.querySelector('iframe') as HTMLIFrameElement;
|
|
240
|
+
expect(iframe.style.aspectRatio).toBe('9/16');
|
|
241
|
+
expect(iframe.style.height).not.toBe('400px');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The direct-file test was anchored at the end of the whole URL, so every
|
|
247
|
+
* address carrying a query — the ordinary shape of an uploaded asset — was
|
|
248
|
+
* rejected.
|
|
249
|
+
*/
|
|
250
|
+
describe('direct video files', () => {
|
|
251
|
+
it('plays a file whose URL carries a signature query', () => {
|
|
252
|
+
const url = 'https://cdn.example.com/clip.mp4?sig=abc&exp=123';
|
|
253
|
+
const { container } = renderBlock(url);
|
|
254
|
+
expect(container.querySelector('video')).toHaveAttribute('src', url);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('reads the extension case-insensitively, on a dotted path', () => {
|
|
258
|
+
const url = 'https://cdn.example.com/a.b.c/clip.WEBM?x=1';
|
|
259
|
+
expect(renderBlock(url).container.querySelector('video')).toHaveAttribute(
|
|
260
|
+
'src',
|
|
261
|
+
url
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('refuses a page that merely mentions a file in its query', () => {
|
|
266
|
+
// The trap an unanchored regex would fall into.
|
|
267
|
+
renderBlock('https://example.com/watch?ref=clip.mp4');
|
|
268
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('plays an extensionless URL when the author says it is a file', () => {
|
|
272
|
+
const url = 'https://cdn.example.com/asset/9f2a';
|
|
273
|
+
const { container } = renderBlock(url, { sourceType: 'file' });
|
|
274
|
+
expect(container.querySelector('video')).toHaveAttribute('src', url);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('refuses the same URL when they have not', () => {
|
|
278
|
+
renderBlock('https://cdn.example.com/asset/9f2a');
|
|
279
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('plays a clip served from the storefront itself', () => {
|
|
283
|
+
const { container } = renderBlock('/media/clip.mp4');
|
|
284
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
285
|
+
'src',
|
|
286
|
+
'/media/clip.mp4'
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('other YouTube shapes', () => {
|
|
292
|
+
it('embeds a /live/ link', () => {
|
|
293
|
+
const { container } = renderBlock(
|
|
294
|
+
'https://www.youtube.com/live/LiveIdAbc'
|
|
295
|
+
);
|
|
296
|
+
expect(container.querySelector('iframe')).toHaveAttribute(
|
|
297
|
+
'src',
|
|
298
|
+
'https://www.youtube.com/embed/LiveIdAbc'
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('accepts a nocookie link as input', () => {
|
|
303
|
+
const { container } = renderBlock(
|
|
304
|
+
'https://www.youtube-nocookie.com/embed/AbC123xyz'
|
|
305
|
+
);
|
|
306
|
+
expect(container.querySelector('iframe')).toHaveAttribute(
|
|
307
|
+
'src',
|
|
308
|
+
'https://www.youtube.com/embed/AbC123xyz'
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('is not fooled by a host that merely mentions youtube', () => {
|
|
313
|
+
renderBlock('https://evil.example.com/?u=youtube.com/watch?v=abc123');
|
|
314
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Both of these satisfied the old extension test and were handed straight to
|
|
320
|
+
* `<video src>`.
|
|
321
|
+
*/
|
|
322
|
+
describe('schemes it refuses', () => {
|
|
323
|
+
it.each([
|
|
324
|
+
'javascript:alert(1)//x.mp4',
|
|
325
|
+
'data:text/html,<script>a</script>//x.mp4'
|
|
326
|
+
])('refuses %s', (url) => {
|
|
327
|
+
renderBlock(url);
|
|
328
|
+
expect(screen.getByText('Invalid video URL')).toBeInTheDocument();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('aspect ratio', () => {
|
|
333
|
+
it('takes the authored ratio', () => {
|
|
334
|
+
const { container } = renderBlock(
|
|
335
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
336
|
+
undefined,
|
|
337
|
+
{ 'aspect-ratio': { desktop: '16/9' } }
|
|
338
|
+
);
|
|
339
|
+
const iframe = container.querySelector('iframe') as HTMLIFrameElement;
|
|
340
|
+
expect(iframe.style.aspectRatio).toBe('16/9');
|
|
341
|
+
// Not `100%`: inside an auto-height box that resolves to zero, which is
|
|
342
|
+
// exactly the box a ratio-styled block has.
|
|
343
|
+
expect(iframe.style.height).toBe('auto');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('keeps the historic box when nothing is authored', () => {
|
|
347
|
+
// The backwards-compatibility lock: every published video keeps its size.
|
|
348
|
+
const { container } = renderBlock(
|
|
349
|
+
'https://www.youtube.com/watch?v=abc123'
|
|
350
|
+
);
|
|
351
|
+
const iframe = container.querySelector('iframe') as HTMLIFrameElement;
|
|
352
|
+
expect(iframe.style.height).toBe('400px');
|
|
353
|
+
expect(iframe.style.aspectRatio).toBeFalsy();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('lets the author override a Short’s vertical default', () => {
|
|
357
|
+
const { container } = renderBlock(
|
|
358
|
+
'https://www.youtube.com/shorts/AbC123xyz',
|
|
359
|
+
undefined,
|
|
360
|
+
{ 'aspect-ratio': { desktop: '1/1' } }
|
|
361
|
+
);
|
|
362
|
+
expect(
|
|
363
|
+
(container.querySelector('iframe') as HTMLIFrameElement).style
|
|
364
|
+
.aspectRatio
|
|
365
|
+
).toBe('1/1');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('treats `auto` as nothing authored', () => {
|
|
369
|
+
const { container } = renderBlock(
|
|
370
|
+
'https://www.youtube.com/watch?v=abc123',
|
|
371
|
+
undefined,
|
|
372
|
+
{ 'aspect-ratio': { desktop: 'auto' } }
|
|
373
|
+
);
|
|
374
|
+
expect(
|
|
375
|
+
(container.querySelector('iframe') as HTMLIFrameElement).style.height
|
|
376
|
+
).toBe('400px');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('never collapses to nothing inside an auto-height box', () => {
|
|
380
|
+
// `height: 100%` of an auto-height parent is zero. A ratio-styled block
|
|
381
|
+
// is exactly that box, so the player rendered but was invisible.
|
|
382
|
+
const { container } = renderBlock(
|
|
383
|
+
'https://www.youtube.com/shorts/AbC123xyz'
|
|
384
|
+
);
|
|
385
|
+
const iframe = container.querySelector('iframe') as HTMLIFrameElement;
|
|
386
|
+
expect(iframe.style.height).not.toBe('100%');
|
|
387
|
+
expect(iframe.style.height).toBe('auto');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('shapes a direct file too', () => {
|
|
391
|
+
const { container } = renderBlock(
|
|
392
|
+
'https://cdn.example.com/clip.mp4',
|
|
393
|
+
undefined,
|
|
394
|
+
{ 'aspect-ratio': { desktop: '9/16' } }
|
|
395
|
+
);
|
|
396
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
397
|
+
expect(video.style.aspectRatio).toBe('9/16');
|
|
398
|
+
expect(video.style.objectFit).toBe('cover');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
197
402
|
describe('non-string value (BUG-4 fix)', () => {
|
|
198
403
|
it('renders the invalid placeholder instead of crashing on a non-string value', () => {
|
|
199
404
|
renderBlock(123 as any);
|
|
@@ -20,7 +20,7 @@ export const findItemTemplate = (
|
|
|
20
20
|
currentBreakpoint = 'desktop'
|
|
21
21
|
): Block | null =>
|
|
22
22
|
(block.blocks || []).find(
|
|
23
|
-
child => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
|
|
23
|
+
(child) => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
|
|
24
24
|
) ?? null;
|
|
25
25
|
|
|
26
26
|
export const hasItemTemplate = (
|
|
@@ -54,9 +54,7 @@ export const publishesProductItems = (blockType?: string): boolean =>
|
|
|
54
54
|
*/
|
|
55
55
|
const PRODUCT_PK_FIELDS = ['productPk', 'pk'];
|
|
56
56
|
|
|
57
|
-
const readProductPk = (
|
|
58
|
-
item: Record<string, unknown>
|
|
59
|
-
): number | undefined => {
|
|
57
|
+
const readProductPk = (item: Record<string, unknown>): number | undefined => {
|
|
60
58
|
for (const field of PRODUCT_PK_FIELDS) {
|
|
61
59
|
const value = item[field];
|
|
62
60
|
if (value === undefined || value === null || value === '') continue;
|
|
@@ -94,6 +92,48 @@ export const resolveItemProduct = (
|
|
|
94
92
|
return { ...item, pk } as ThemeProduct;
|
|
95
93
|
};
|
|
96
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The section types whose SLIDES are products.
|
|
97
|
+
*
|
|
98
|
+
* The same declared-not-sniffed rule as `PRODUCT_ITEM_LIST_TYPES`, one level
|
|
99
|
+
* up: a slider repeats its rows from the section's own data source rather
|
|
100
|
+
* than through `ItemTemplateList`, so no item scope is opened for it and the
|
|
101
|
+
* list-type allow-list above never sees it. `flash-deals-slider` is the type
|
|
102
|
+
* both the Flash Deals and the Product Slider templates publish;
|
|
103
|
+
* `posts-slider` shares the same renderer but its rows are blog posts, and a
|
|
104
|
+
* favourite heart pointed at a post id is precisely the bug the declaration
|
|
105
|
+
* exists to prevent.
|
|
106
|
+
*/
|
|
107
|
+
const PRODUCT_SLIDER_SECTION_TYPES = new Set(['flash-deals-slider']);
|
|
108
|
+
|
|
109
|
+
export const publishesProductSlides = (sectionType?: string): boolean =>
|
|
110
|
+
typeof sectionType === 'string' &&
|
|
111
|
+
PRODUCT_SLIDER_SECTION_TYPES.has(sectionType);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A slider row seen as the ambient product, or null when this section
|
|
115
|
+
* publishes no products or the row is one of the empty placeholders the
|
|
116
|
+
* designer draws against.
|
|
117
|
+
*
|
|
118
|
+
* "Is there a product here" cannot be a truthiness test: the slider's own
|
|
119
|
+
* expansion writes `products[index] || {}`, so every clone carries an object
|
|
120
|
+
* whether or not the data source returned a row for it. Falling back to the
|
|
121
|
+
* pk rule is what keeps an unmatched slide behaving as the plain link it is
|
|
122
|
+
* today instead of opening a panel with nothing in it.
|
|
123
|
+
*/
|
|
124
|
+
export const resolveSliderRowProduct = (
|
|
125
|
+
sectionType: string | undefined,
|
|
126
|
+
item: Record<string, unknown> | null | undefined
|
|
127
|
+
): ThemeProduct | null => {
|
|
128
|
+
if (!publishesProductSlides(sectionType)) return null;
|
|
129
|
+
if (!item || Object.keys(item).length === 0) return null;
|
|
130
|
+
|
|
131
|
+
const pk = readProductPk(item);
|
|
132
|
+
if (pk === undefined) return null;
|
|
133
|
+
|
|
134
|
+
return { ...item, pk } as ThemeProduct;
|
|
135
|
+
};
|
|
136
|
+
|
|
97
137
|
/**
|
|
98
138
|
* What a product row shows in the editor when the page has no live data.
|
|
99
139
|
*
|
|
@@ -123,7 +163,13 @@ export const DESIGNER_SAMPLE_PRODUCT: ThemeProduct = {
|
|
|
123
163
|
attribute_name: 'Size',
|
|
124
164
|
options: [
|
|
125
165
|
{ label: 'S', value: 's', in_stock: true, is_selectable: true },
|
|
126
|
-
{
|
|
166
|
+
{
|
|
167
|
+
label: 'M',
|
|
168
|
+
value: 'm',
|
|
169
|
+
in_stock: true,
|
|
170
|
+
is_selectable: true,
|
|
171
|
+
is_selected: true
|
|
172
|
+
},
|
|
127
173
|
{ label: 'L', value: 'l', in_stock: false, is_selectable: false }
|
|
128
174
|
]
|
|
129
175
|
}
|
|
@@ -10,6 +10,45 @@ import { useBlockAction } from '../hooks/use-block-action';
|
|
|
10
10
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
11
11
|
import ThemeBlock from '../theme-block';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The lines and gaps of the block's box, which this anchor must not draw a
|
|
15
|
+
* second time.
|
|
16
|
+
*
|
|
17
|
+
* `data-block-id` sits on the designer wrapper this anchor renders inside, and
|
|
18
|
+
* the generated stylesheet already paints the block's box there. Copying these
|
|
19
|
+
* keys inline as well draws them twice at two different sizes: the wrapper is
|
|
20
|
+
* the grid item and stretches to fill its row, while the anchor only hugs its
|
|
21
|
+
* content. Measured on a live page: one border set on a card rendered as two,
|
|
22
|
+
* one closing under the label and another at the bottom of the row. Padding
|
|
23
|
+
* and margin compound the same way — the spacing came out doubled.
|
|
24
|
+
*
|
|
25
|
+
* `background` is deliberately NOT here. Painting the same colour on both
|
|
26
|
+
* boxes is idempotent, so there is no artifact to fix, and an iterator resolves
|
|
27
|
+
* a bound colour per row into this inline copy — the shared stylesheet rule
|
|
28
|
+
* cannot hold a different value per row, so stripping it would lose it.
|
|
29
|
+
*
|
|
30
|
+
* Layout keys stay inline too. The wrapper's only child is this anchor, so
|
|
31
|
+
* `display: flex` up there arranges nothing — the anchor is what lays out the
|
|
32
|
+
* card's image and label.
|
|
33
|
+
*
|
|
34
|
+
* A row-bound `border-color` is the one thing this gives up: it would tint a
|
|
35
|
+
* border the anchor no longer has. Row data carries prices and colours for the
|
|
36
|
+
* content, not the outline of the box it sits in.
|
|
37
|
+
*/
|
|
38
|
+
const BOX_STYLE_PREFIXES = [
|
|
39
|
+
'margin',
|
|
40
|
+
'padding',
|
|
41
|
+
'border',
|
|
42
|
+
'outline',
|
|
43
|
+
'box-shadow'
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const isBoxStyleKey = (normalizedKey: string): boolean =>
|
|
47
|
+
BOX_STYLE_PREFIXES.some(
|
|
48
|
+
(prefix) =>
|
|
49
|
+
normalizedKey === prefix || normalizedKey.startsWith(`${prefix}-`)
|
|
50
|
+
);
|
|
51
|
+
|
|
13
52
|
import { postToEditor } from '../editor-bridge';
|
|
14
53
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
15
54
|
const LinkBlock = ({
|
|
@@ -137,6 +176,10 @@ const LinkBlock = ({
|
|
|
137
176
|
if (styleEntry) {
|
|
138
177
|
const val = getResponsiveValue(styleEntry, currentBreakpoint);
|
|
139
178
|
if (val !== undefined && val !== null) {
|
|
179
|
+
if (isBoxStyleKey(normalizedKey)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
140
183
|
const camelKey = normalizedKey.replace(/-([a-z])/g, (_, letter) =>
|
|
141
184
|
letter.toUpperCase()
|
|
142
185
|
);
|