@akinon/pz-theme 2.0.96-rc.0 → 2.0.96
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 +7 -33
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +11 -11
- package/src/blocks/__tests__/carousel-item-block.test.tsx +346 -0
- package/src/blocks/__tests__/container-block.test.tsx +149 -0
- package/src/blocks/__tests__/hover-showcase-item-block.test.tsx +315 -0
- package/src/blocks/__tests__/icon-button-block.test.tsx +231 -0
- package/src/blocks/__tests__/icon-group-block.test.tsx +186 -0
- package/src/blocks/__tests__/icon-source.test.tsx +226 -0
- package/src/blocks/__tests__/responsive-array.test.ts +77 -0
- package/src/blocks/__tests__/social-glyphs.test.tsx +108 -0
- package/src/blocks/__tests__/social-icons-list-block.test.tsx +243 -0
- package/src/blocks/__tests__/value-props-list-block.test.tsx +231 -0
- package/src/blocks/block-renderer-registry.tsx +21 -0
- package/src/blocks/carousel-item-block.tsx +168 -0
- package/src/blocks/container-block.tsx +80 -0
- package/src/blocks/hover-showcase-item-block.tsx +232 -0
- package/src/blocks/icon-block.tsx +82 -121
- package/src/blocks/icon-button-block.tsx +180 -0
- package/src/blocks/icon-group-block.tsx +119 -0
- package/src/blocks/icon-group-colored-block.tsx +47 -0
- package/src/blocks/icon-source.tsx +311 -0
- package/src/blocks/responsive-array.ts +54 -0
- package/src/blocks/social-glyphs.tsx +131 -0
- package/src/blocks/social-icons-list-block.tsx +159 -0
- package/src/blocks/social-links-block.tsx +1 -125
- package/src/blocks/value-props-list-block.tsx +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.96
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.96-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.96-rc.0
|
|
3
|
+
## 2.0.96
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 956fe4ea: ZERO-5007: Write renderers for the block types the picker offers but pz-theme cannot draw
|
|
8
|
+
- @akinon/next@2.0.96
|
|
9
|
+
- @akinon/theme-contract@2.0.96
|
|
36
10
|
|
|
37
11
|
## 2.0.95
|
|
38
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.96
|
|
4
|
+
"version": "2.0.96",
|
|
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.96
|
|
11
|
+
"@akinon/next": "2.0.96",
|
|
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.96
|
|
16
|
+
"@akinon/theme-contract": "2.0.96",
|
|
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.96
|
|
24
|
+
"@akinon/next": "2.0.96",
|
|
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",
|
|
@@ -34,28 +34,24 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
34
34
|
'applied-filters',
|
|
35
35
|
'auth-consent',
|
|
36
36
|
'auth-error',
|
|
37
|
-
'auth-flow',
|
|
38
37
|
'auth-field',
|
|
38
|
+
'auth-flow',
|
|
39
39
|
'auth-form',
|
|
40
40
|
'auth-step',
|
|
41
41
|
'auth-step-title',
|
|
42
|
-
// The confirmation as a placeable block, the success twin of 'auth-error':
|
|
43
|
-
// the two forms that have a success state publish their message and this
|
|
44
|
-
// draws it where the design put it. See `auth/form-success-context.tsx`.
|
|
45
42
|
'auth-success',
|
|
46
43
|
'basket-items',
|
|
47
44
|
'basket-summary',
|
|
48
45
|
'button',
|
|
49
|
-
// The security check commerce escalates to, placed inside an auth form.
|
|
50
46
|
'captcha',
|
|
51
|
-
|
|
52
|
-
'reset-password',
|
|
47
|
+
'carousel-item',
|
|
53
48
|
'change-password-form',
|
|
54
49
|
'checkout-flow-outlet',
|
|
55
50
|
'checkout-steps',
|
|
56
51
|
'checkout-steps-outlet',
|
|
57
52
|
'checkout-summary',
|
|
58
53
|
'checkout-summary-outlet',
|
|
54
|
+
'container',
|
|
59
55
|
'countdown',
|
|
60
56
|
'counter',
|
|
61
57
|
'coupon-list',
|
|
@@ -68,14 +64,15 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
68
64
|
'favorite-button',
|
|
69
65
|
'favourites-grid',
|
|
70
66
|
'forgot-password',
|
|
71
|
-
// One screen of a composed form — the confirmation, the refusal, the busy
|
|
72
|
-
// moment — as a container the form draws when it is on that screen. See
|
|
73
|
-
// `auth/form-state-context.tsx`.
|
|
74
67
|
'form-state',
|
|
75
68
|
'group',
|
|
76
69
|
'hamburger-menu',
|
|
77
70
|
'hotspot',
|
|
71
|
+
'hover-showcase-item',
|
|
78
72
|
'icon',
|
|
73
|
+
'icon-button',
|
|
74
|
+
'icon-group',
|
|
75
|
+
'icon-group-colored',
|
|
79
76
|
'image',
|
|
80
77
|
'image-gallery',
|
|
81
78
|
'input',
|
|
@@ -111,19 +108,22 @@ const EXPECTED_BLOCK_TYPES = [
|
|
|
111
108
|
'product-title',
|
|
112
109
|
'profile-form',
|
|
113
110
|
'register-form',
|
|
111
|
+
'reset-password',
|
|
114
112
|
'search-box',
|
|
115
113
|
'share',
|
|
116
114
|
'size-guide',
|
|
117
115
|
'slider',
|
|
116
|
+
'social-icons-list',
|
|
118
117
|
'social-links',
|
|
119
118
|
'sort-select',
|
|
120
119
|
'stock-status',
|
|
121
120
|
'store-locator',
|
|
122
121
|
'tab',
|
|
123
122
|
'text',
|
|
123
|
+
'value-props-list',
|
|
124
124
|
'variant-selector',
|
|
125
125
|
'video',
|
|
126
|
-
'wishlist-button'
|
|
126
|
+
'wishlist-button',
|
|
127
127
|
];
|
|
128
128
|
|
|
129
129
|
const EXPECTED_SECTION_TYPES = [
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive child renderer so the slide's children are countable
|
|
5
|
+
// without pulling in every block type.
|
|
6
|
+
jest.mock('../../theme-block', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
9
|
+
<div data-testid="tb" data-id={block.id} />
|
|
10
|
+
)
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import CarouselItemBlock from '../carousel-item-block';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The block exactly as `getDefaultCarouselItemBlockValue()` seeds it in
|
|
17
|
+
* pz-dynamic-widget: both properties bucketed per breakpoint, `url` seeded to
|
|
18
|
+
* `#`, and one image child.
|
|
19
|
+
*/
|
|
20
|
+
const seededSlide = (over: Record<string, unknown> = {}) => ({
|
|
21
|
+
id: 'slide',
|
|
22
|
+
type: 'carousel-item',
|
|
23
|
+
label: 'Carousel Item',
|
|
24
|
+
value: '',
|
|
25
|
+
properties: {
|
|
26
|
+
url: { desktop: '#', mobile: '#' },
|
|
27
|
+
target: { desktop: '_self', mobile: '_self' }
|
|
28
|
+
},
|
|
29
|
+
styles: {},
|
|
30
|
+
blocks: [
|
|
31
|
+
{
|
|
32
|
+
id: 'img',
|
|
33
|
+
type: 'image',
|
|
34
|
+
label: 'Carousel Image',
|
|
35
|
+
value: '',
|
|
36
|
+
properties: {
|
|
37
|
+
alt: { desktop: 'Carousel Image', mobile: 'Carousel Image' },
|
|
38
|
+
loadingStrategy: 'eager',
|
|
39
|
+
priority: 'high'
|
|
40
|
+
},
|
|
41
|
+
styles: {
|
|
42
|
+
width: { desktop: '100%', mobile: '100%' },
|
|
43
|
+
height: { desktop: '400px', mobile: '300px' },
|
|
44
|
+
'object-fit': { desktop: 'cover', mobile: 'cover' }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
...over
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
52
|
+
({
|
|
53
|
+
block: seededSlide(),
|
|
54
|
+
placeholderId: 'p',
|
|
55
|
+
sectionId: 's',
|
|
56
|
+
isDesigner: false,
|
|
57
|
+
currentBreakpoint: 'desktop',
|
|
58
|
+
...over
|
|
59
|
+
} as any);
|
|
60
|
+
|
|
61
|
+
const anchor = (container: HTMLElement) =>
|
|
62
|
+
container.querySelector('a') as HTMLAnchorElement | null;
|
|
63
|
+
|
|
64
|
+
const markers = (container: HTMLElement) =>
|
|
65
|
+
Array.from(container.querySelectorAll('[data-testid="tb"]'));
|
|
66
|
+
|
|
67
|
+
describe('CarouselItemBlock — the stored slide', () => {
|
|
68
|
+
it('draws the seeded image child', () => {
|
|
69
|
+
const { container } = render(<CarouselItemBlock {...props()} />);
|
|
70
|
+
expect(markers(container)).toHaveLength(1);
|
|
71
|
+
expect(markers(container)[0]).toHaveAttribute('data-id', 'img');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('draws NO anchor for the seeded "#" url — a fresh slide is not a link', () => {
|
|
75
|
+
const { container } = render(<CarouselItemBlock {...props()} />);
|
|
76
|
+
expect(anchor(container)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('draws no anchor for an empty or whitespace url either', () => {
|
|
80
|
+
const block = seededSlide({ properties: { url: ' ' } });
|
|
81
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
82
|
+
expect(anchor(container)).toBeNull();
|
|
83
|
+
expect(markers(container)).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('wraps the slide in an anchor once a real url is authored', () => {
|
|
87
|
+
const block = seededSlide({
|
|
88
|
+
properties: { url: '/campaigns/summer-sale', target: '_self' }
|
|
89
|
+
});
|
|
90
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
91
|
+
expect(anchor(container)).toHaveAttribute('href', '/campaigns/summer-sale');
|
|
92
|
+
expect(markers(container)).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('treats a real in-page target as a destination (only the bare hash is the seed)', () => {
|
|
96
|
+
const block = seededSlide({ properties: { url: '#size-guide' } });
|
|
97
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
98
|
+
expect(anchor(container)).toHaveAttribute('href', '#size-guide');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('CarouselItemBlock — per-breakpoint properties', () => {
|
|
103
|
+
it('reads the mobile url from the bucketed value the descriptor seeds', () => {
|
|
104
|
+
const block = seededSlide({
|
|
105
|
+
properties: {
|
|
106
|
+
url: { desktop: '/desktop-campaign', mobile: '/mobile-campaign' }
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<CarouselItemBlock {...props({ block, currentBreakpoint: 'mobile' })} />
|
|
111
|
+
);
|
|
112
|
+
expect(anchor(container)).toHaveAttribute('href', '/mobile-campaign');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('reads the desktop url on the desktop breakpoint', () => {
|
|
116
|
+
const block = seededSlide({
|
|
117
|
+
properties: {
|
|
118
|
+
url: { desktop: '/desktop-campaign', mobile: '/mobile-campaign' }
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
122
|
+
expect(anchor(container)).toHaveAttribute('href', '/desktop-campaign');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('opens a bucketed mobile-only "_blank" in a new tab', () => {
|
|
126
|
+
const block = seededSlide({
|
|
127
|
+
properties: {
|
|
128
|
+
url: '/campaigns/lookbook',
|
|
129
|
+
target: { desktop: '_self', mobile: '_blank' }
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const { container } = render(
|
|
133
|
+
<CarouselItemBlock {...props({ block, currentBreakpoint: 'mobile' })} />
|
|
134
|
+
);
|
|
135
|
+
expect(anchor(container)).toHaveAttribute('target', '_blank');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('CarouselItemBlock — rel', () => {
|
|
140
|
+
it('adds the security pair on _blank', () => {
|
|
141
|
+
const block = seededSlide({
|
|
142
|
+
properties: { url: 'https://example.com/lookbook', target: '_blank' }
|
|
143
|
+
});
|
|
144
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
145
|
+
expect(anchor(container)).toHaveAttribute('rel', 'noopener noreferrer');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('emits no rel at all on _self', () => {
|
|
149
|
+
const block = seededSlide({
|
|
150
|
+
properties: { url: '/campaigns/summer-sale', target: '_self' }
|
|
151
|
+
});
|
|
152
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
153
|
+
expect(anchor(container)?.hasAttribute('rel')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The descriptor declares `url` and `target` and nothing else, so a `rel`
|
|
158
|
+
* key can only be junk left by a hand-edited composition. Reading it (the way
|
|
159
|
+
* the link block reads its own declared one) would let that junk reach the
|
|
160
|
+
* anchor.
|
|
161
|
+
*/
|
|
162
|
+
it('ignores a rel property, which this block type does not declare', () => {
|
|
163
|
+
const block = seededSlide({
|
|
164
|
+
properties: {
|
|
165
|
+
url: '/campaigns/summer-sale',
|
|
166
|
+
target: '_self',
|
|
167
|
+
rel: 'me nofollow'
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
171
|
+
expect(anchor(container)?.hasAttribute('rel')).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('falls back to _self when the stored target is not one of the two options', () => {
|
|
175
|
+
const block = seededSlide({
|
|
176
|
+
properties: { url: '/campaigns/summer-sale', target: 'new-window' }
|
|
177
|
+
});
|
|
178
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
179
|
+
expect(anchor(container)).toHaveAttribute('target', '_self');
|
|
180
|
+
expect(anchor(container)?.hasAttribute('rel')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('CarouselItemBlock — merchant input on its way to href', () => {
|
|
185
|
+
it.each([
|
|
186
|
+
['javascript:alert(1)'],
|
|
187
|
+
['JavaScript:alert(1)'],
|
|
188
|
+
['java\tscript:alert(1)'],
|
|
189
|
+
['vbscript:msgbox(1)'],
|
|
190
|
+
['data:text/html,<script>alert(1)</script>']
|
|
191
|
+
])('never turns %s into a link', (url) => {
|
|
192
|
+
const block = seededSlide({ properties: { url } });
|
|
193
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
194
|
+
expect(anchor(container)).toBeNull();
|
|
195
|
+
// The slide itself is still drawn — a bad url costs the link, not the art.
|
|
196
|
+
expect(markers(container)).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('CarouselItemBlock — who owns the click', () => {
|
|
201
|
+
/**
|
|
202
|
+
* The wrapper fires an authored action and cancels this navigation on the
|
|
203
|
+
* way up (`with-designer-features.tsx` + `use-block-action.ts`), which only
|
|
204
|
+
* works while the renderer leaves the live click untouched. A
|
|
205
|
+
* `preventDefault` or `stopPropagation` here would either kill every slide
|
|
206
|
+
* link or hide the click from the central action wiring.
|
|
207
|
+
*/
|
|
208
|
+
it('leaves a live click for the wrapper: not prevented, still bubbling', () => {
|
|
209
|
+
const seen: MouseEvent[] = [];
|
|
210
|
+
const block = seededSlide({
|
|
211
|
+
properties: { url: '/campaigns/summer-sale' }
|
|
212
|
+
});
|
|
213
|
+
const { container } = render(
|
|
214
|
+
<div onClick={(event) => seen.push(event.nativeEvent)}>
|
|
215
|
+
<CarouselItemBlock {...props({ block })} />
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
fireEvent.click(anchor(container) as HTMLAnchorElement);
|
|
220
|
+
|
|
221
|
+
expect(seen).toHaveLength(1);
|
|
222
|
+
expect(seen[0].defaultPrevented).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('neutralises the link on the canvas but still lets the click reach the wrapper', () => {
|
|
226
|
+
const seen: MouseEvent[] = [];
|
|
227
|
+
const block = seededSlide({
|
|
228
|
+
properties: { url: '/campaigns/summer-sale', target: '_blank' }
|
|
229
|
+
});
|
|
230
|
+
const { container } = render(
|
|
231
|
+
<div onClick={(event) => seen.push(event.nativeEvent)}>
|
|
232
|
+
<CarouselItemBlock {...props({ block, isDesigner: true })} />
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const link = anchor(container) as HTMLAnchorElement;
|
|
237
|
+
expect(link).toHaveAttribute('href', '#');
|
|
238
|
+
// A _blank slide would otherwise open the campaign in a second tab from
|
|
239
|
+
// inside the editor.
|
|
240
|
+
expect(link.hasAttribute('target')).toBe(false);
|
|
241
|
+
|
|
242
|
+
fireEvent.click(link);
|
|
243
|
+
|
|
244
|
+
expect(seen).toHaveLength(1);
|
|
245
|
+
expect(seen[0].defaultPrevented).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('CarouselItemBlock — layout ownership', () => {
|
|
250
|
+
/**
|
|
251
|
+
* `collectBlockCSS` emits the merchant's styles onto the `[data-block-id]`
|
|
252
|
+
* wrapper. Inlining them here would apply them twice and outrank the media
|
|
253
|
+
* queries the same sheet emits.
|
|
254
|
+
*/
|
|
255
|
+
it('inlines none of the authored styles', () => {
|
|
256
|
+
const block = seededSlide({
|
|
257
|
+
properties: { url: '/campaigns/summer-sale' },
|
|
258
|
+
styles: {
|
|
259
|
+
display: { desktop: 'grid', mobile: 'block' },
|
|
260
|
+
gap: { desktop: '24px' },
|
|
261
|
+
'background-color': { desktop: '#111111' }
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
265
|
+
expect(anchor(container)?.hasAttribute('style')).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('gives the anchor display:contents so the wrapper layout reaches the children', () => {
|
|
269
|
+
const block = seededSlide({
|
|
270
|
+
properties: { url: '/campaigns/summer-sale' }
|
|
271
|
+
});
|
|
272
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
273
|
+
expect(anchor(container)).toHaveClass('contents');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('stamps no part attributes — the descriptor declares none', () => {
|
|
277
|
+
const block = seededSlide({
|
|
278
|
+
properties: { url: '/campaigns/summer-sale' }
|
|
279
|
+
});
|
|
280
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
281
|
+
expect(container.querySelector('[data-part]')).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('CarouselItemBlock — children', () => {
|
|
286
|
+
it('drops hidden children on the storefront and keeps them on the canvas', () => {
|
|
287
|
+
const block = seededSlide({
|
|
288
|
+
properties: { url: '/campaigns/summer-sale' },
|
|
289
|
+
blocks: [
|
|
290
|
+
{ id: 'img', type: 'image', label: 'img', order: 1 },
|
|
291
|
+
{
|
|
292
|
+
id: 'caption',
|
|
293
|
+
type: 'text',
|
|
294
|
+
label: 'caption',
|
|
295
|
+
order: 0,
|
|
296
|
+
hidden: true
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const live = render(<CarouselItemBlock {...props({ block })} />);
|
|
302
|
+
expect(
|
|
303
|
+
markers(live.container).map((m) => m.getAttribute('data-id'))
|
|
304
|
+
).toEqual(['img']);
|
|
305
|
+
|
|
306
|
+
const canvas = render(
|
|
307
|
+
<CarouselItemBlock {...props({ block, isDesigner: true })} />
|
|
308
|
+
);
|
|
309
|
+
expect(
|
|
310
|
+
markers(canvas.container).map((m) => m.getAttribute('data-id'))
|
|
311
|
+
).toEqual(['caption', 'img']);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('CarouselItemBlock — empty', () => {
|
|
316
|
+
it('shows the designer placeholder when the slide has no blocks', () => {
|
|
317
|
+
const block = seededSlide({ blocks: [] });
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<CarouselItemBlock {...props({ block, isDesigner: true })} />
|
|
320
|
+
);
|
|
321
|
+
expect(container.textContent).toContain('Carousel item (drop blocks here)');
|
|
322
|
+
expect(
|
|
323
|
+
container.querySelector('[data-carousel-item-placeholder]')
|
|
324
|
+
).toBeInTheDocument();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('renders nothing at all on the storefront when the slide is empty', () => {
|
|
328
|
+
const block = seededSlide({ blocks: [] });
|
|
329
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
330
|
+
expect(container).toBeEmptyDOMElement();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A slide whose only child is hidden is empty on the storefront. Drawing the
|
|
335
|
+
* anchor anyway would leave an empty link in the carousel track — a focus
|
|
336
|
+
* stop on nothing, still counted by the dots.
|
|
337
|
+
*/
|
|
338
|
+
it('draws no empty anchor when every child is hidden', () => {
|
|
339
|
+
const block = seededSlide({
|
|
340
|
+
properties: { url: '/campaigns/summer-sale' },
|
|
341
|
+
blocks: [{ id: 'img', type: 'image', label: 'img', hidden: true }]
|
|
342
|
+
});
|
|
343
|
+
const { container } = render(<CarouselItemBlock {...props({ block })} />);
|
|
344
|
+
expect(container).toBeEmptyDOMElement();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
// Stub the recursive ThemeBlock (default export) with a countable marker, the
|
|
5
|
+
// way group-block.test.tsx does — a container's whole job is which children it
|
|
6
|
+
// hands to ThemeBlock, not what those children draw.
|
|
7
|
+
jest.mock('../../theme-block', () => ({
|
|
8
|
+
__esModule: true,
|
|
9
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
10
|
+
<div data-testid="tb" data-id={block.id} />
|
|
11
|
+
)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import ContainerBlock from '../container-block';
|
|
15
|
+
|
|
16
|
+
const child = (id: string, order = 0, hidden = false) => ({
|
|
17
|
+
id,
|
|
18
|
+
type: 'text',
|
|
19
|
+
label: id,
|
|
20
|
+
value: '',
|
|
21
|
+
properties: {},
|
|
22
|
+
styles: {},
|
|
23
|
+
order,
|
|
24
|
+
hidden
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A container as the editor actually stores one: no properties at all, and the
|
|
29
|
+
* curated box styles the descriptor seeds through `getDefaultValue`, bucketed
|
|
30
|
+
* per breakpoint with a couple of authored values on top.
|
|
31
|
+
*/
|
|
32
|
+
const storedBlock = (over: Record<string, unknown> = {}) => ({
|
|
33
|
+
id: 'blk-top-row',
|
|
34
|
+
type: 'container',
|
|
35
|
+
label: 'Top row',
|
|
36
|
+
value: '',
|
|
37
|
+
properties: {},
|
|
38
|
+
styles: {
|
|
39
|
+
'background-color': { desktop: '#111827', mobile: '#000000' },
|
|
40
|
+
'padding-top': { desktop: '16px' },
|
|
41
|
+
'padding-bottom': { desktop: '16px' },
|
|
42
|
+
'padding-left': { desktop: '24px' },
|
|
43
|
+
'padding-right': { desktop: '24px' },
|
|
44
|
+
'border-width': { desktop: '1px' },
|
|
45
|
+
'border-color': { desktop: '#e5e7eb' },
|
|
46
|
+
'border-radius': { desktop: '8px' }
|
|
47
|
+
},
|
|
48
|
+
order: 0,
|
|
49
|
+
hidden: false,
|
|
50
|
+
blocks: [child('logo', 0), child('search', 1), child('basket', 2)],
|
|
51
|
+
...over
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const props = (over: Record<string, unknown> = {}) =>
|
|
55
|
+
({
|
|
56
|
+
block: storedBlock(),
|
|
57
|
+
placeholderId: 'header',
|
|
58
|
+
sectionId: 'sec-1',
|
|
59
|
+
isDesigner: false,
|
|
60
|
+
...over
|
|
61
|
+
} as any);
|
|
62
|
+
|
|
63
|
+
const markers = (host: HTMLElement) =>
|
|
64
|
+
Array.from(host.querySelectorAll('[data-testid="tb"]'));
|
|
65
|
+
|
|
66
|
+
describe('ContainerBlock — children', () => {
|
|
67
|
+
it('draws the children of a realistic stored container, in order', () => {
|
|
68
|
+
const { container } = render(<ContainerBlock {...props()} />);
|
|
69
|
+
expect(markers(container).map((m) => m.getAttribute('data-id'))).toEqual([
|
|
70
|
+
'logo',
|
|
71
|
+
'search',
|
|
72
|
+
'basket'
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('drops hidden children on the storefront and keeps them on the canvas', () => {
|
|
77
|
+
const block = storedBlock({
|
|
78
|
+
blocks: [child('logo', 0), child('promo', 1, true)]
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const live = render(<ContainerBlock {...props({ block })} />);
|
|
82
|
+
expect(markers(live.container)).toHaveLength(1);
|
|
83
|
+
|
|
84
|
+
const canvas = render(
|
|
85
|
+
<ContainerBlock {...props({ block, isDesigner: true })} />
|
|
86
|
+
);
|
|
87
|
+
expect(markers(canvas.container)).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('ContainerBlock — empty', () => {
|
|
92
|
+
it('shows the designer placeholder when there is nothing inside', () => {
|
|
93
|
+
const block = storedBlock({ blocks: [] });
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<ContainerBlock {...props({ block, isDesigner: true })} />
|
|
96
|
+
);
|
|
97
|
+
expect(container.textContent).toContain('Container (drop blocks here)');
|
|
98
|
+
expect(
|
|
99
|
+
container.querySelector('[data-container-placeholder]')
|
|
100
|
+
).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows nothing to a shopper when there is nothing inside', () => {
|
|
104
|
+
const block = storedBlock({ blocks: [] });
|
|
105
|
+
const { container } = render(<ContainerBlock {...props({ block })} />);
|
|
106
|
+
expect(container).toBeEmptyDOMElement();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('shows nothing to a shopper when every child is hidden', () => {
|
|
110
|
+
const block = storedBlock({ blocks: [child('promo', 0, true)] });
|
|
111
|
+
const { container } = render(<ContainerBlock {...props({ block })} />);
|
|
112
|
+
expect(container).toBeEmptyDOMElement();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('survives a container stored without a blocks array at all', () => {
|
|
116
|
+
const block = storedBlock({ blocks: undefined });
|
|
117
|
+
const { container } = render(<ContainerBlock {...props({ block })} />);
|
|
118
|
+
expect(container).toBeEmptyDOMElement();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('ContainerBlock — contract: it is not a box', () => {
|
|
123
|
+
/*
|
|
124
|
+
* The reason the type exists is that the merchant's background and padding
|
|
125
|
+
* paint on the `[data-block-id]` wrapper. A box added here would be a second
|
|
126
|
+
* box inside that one and would swallow an authored `display: flex` — every
|
|
127
|
+
* child would become one flex item. These two pin that it draws none.
|
|
128
|
+
*/
|
|
129
|
+
it('adds no element of its own — the children are the wrapper content', () => {
|
|
130
|
+
const { container } = render(<ContainerBlock {...props()} />);
|
|
131
|
+
expect(Array.from(container.children)).toEqual(markers(container));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('inlines nothing from block.styles, authored keys and junk keys alike', () => {
|
|
135
|
+
const block = storedBlock({
|
|
136
|
+
styles: {
|
|
137
|
+
...storedBlock().styles,
|
|
138
|
+
display: { desktop: 'flex' },
|
|
139
|
+
gap: { desktop: '32px' },
|
|
140
|
+
// A descriptor's made-up key: the CSS generator has no whitelist, so a
|
|
141
|
+
// renderer reaching into styles would happily inline this too.
|
|
142
|
+
'container-title-font-size': { desktop: '18px' }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const { container } = render(<ContainerBlock {...props({ block })} />);
|
|
146
|
+
expect(container.querySelector('[style]')).toBeNull();
|
|
147
|
+
expect(container.querySelector('[class]')).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|