@akinon/pz-theme 2.0.96-rc.0 → 2.0.97-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import SocialIconsListBlock from '../social-icons-list-block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The block descriptor is pz-dynamic-widget
|
|
7
|
+
* `src/config/blocks/block-registry.ts` (BlockType.SOCIAL_ICONS_LIST): one
|
|
8
|
+
* property, `icons`, written by `social-icons-editor.tsx` as
|
|
9
|
+
* `{icon, link, size?, color?}`, plus one block-level style, `gap`.
|
|
10
|
+
*/
|
|
11
|
+
const props = (properties: Record<string, unknown>, extra = {}) =>
|
|
12
|
+
({
|
|
13
|
+
block: {
|
|
14
|
+
id: 'b1',
|
|
15
|
+
type: 'social-icons-list',
|
|
16
|
+
label: 'Social Icons',
|
|
17
|
+
value: '',
|
|
18
|
+
properties,
|
|
19
|
+
styles: {},
|
|
20
|
+
order: 0,
|
|
21
|
+
hidden: false
|
|
22
|
+
},
|
|
23
|
+
placeholderId: 'footer',
|
|
24
|
+
sectionId: 's1',
|
|
25
|
+
isDesigner: false,
|
|
26
|
+
currentBreakpoint: 'desktop',
|
|
27
|
+
...extra
|
|
28
|
+
} as never);
|
|
29
|
+
|
|
30
|
+
/** What the panel actually saves when a merchant adds two icons. */
|
|
31
|
+
const STORED_ICONS = [
|
|
32
|
+
{
|
|
33
|
+
icon: 'instagram',
|
|
34
|
+
link: 'https://instagram.com/superstep',
|
|
35
|
+
size: 24,
|
|
36
|
+
color: '#000000'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
icon: 'whatsapp',
|
|
40
|
+
link: 'https://wa.me/905550000000',
|
|
41
|
+
size: 20,
|
|
42
|
+
color: '#25d366'
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe('social-icons-list — a stored list', () => {
|
|
47
|
+
it('draws one link per entry, labelled by platform', () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<SocialIconsListBlock {...props({ icons: STORED_ICONS })} />
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const links = container.querySelectorAll('[data-social-icon]');
|
|
53
|
+
expect(links.length).toBe(2);
|
|
54
|
+
expect(links[0].getAttribute('aria-label')).toBe('Instagram');
|
|
55
|
+
expect(links[0].getAttribute('href')).toBe(
|
|
56
|
+
'https://instagram.com/superstep'
|
|
57
|
+
);
|
|
58
|
+
expect(links[1].getAttribute('aria-label')).toBe('Whatsapp');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('draws the platform glyph at the size stored on the entry', () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<SocialIconsListBlock {...props({ icons: STORED_ICONS })} />
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const icons = container.querySelectorAll('svg[data-part="icon"]');
|
|
67
|
+
expect(icons.length).toBe(2);
|
|
68
|
+
expect(icons[0].getAttribute('width')).toBe('24');
|
|
69
|
+
expect(icons[1].getAttribute('width')).toBe('20');
|
|
70
|
+
// Instagram is three shapes; an unresolved platform would have drawn an
|
|
71
|
+
// empty <svg>.
|
|
72
|
+
expect(icons[0].children.length).toBe(3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('carries the per-entry colour on the link, leaving the icon part free', () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<SocialIconsListBlock {...props({ icons: STORED_ICONS })} />
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const link = container.querySelector<HTMLElement>(
|
|
81
|
+
'[data-social-icon="whatsapp"]'
|
|
82
|
+
)!;
|
|
83
|
+
expect(link.style.color).toBe('rgb(37, 211, 102)');
|
|
84
|
+
// The glyph itself must stay uncoloured, or a `icon/color` part rule
|
|
85
|
+
// would have nothing left to override.
|
|
86
|
+
expect(
|
|
87
|
+
link.querySelector<HTMLElement>('svg')!.getAttribute('style')
|
|
88
|
+
).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('falls back to the globe glyph for a platform the table does not know', () => {
|
|
92
|
+
const { container } = render(
|
|
93
|
+
<SocialIconsListBlock
|
|
94
|
+
{...props({
|
|
95
|
+
icons: [{ icon: 'bluesky', link: 'https://bsky.app/profile/lulu' }]
|
|
96
|
+
})}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const icon = container.querySelector('[data-social-icon="bluesky"] svg')!;
|
|
101
|
+
// The globe is three shapes.
|
|
102
|
+
expect(icon.children.length).toBe(3);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('social-icons-list — the property key is `icons`', () => {
|
|
107
|
+
/**
|
|
108
|
+
* The sibling `social-links` block stores its entries under `items`. This
|
|
109
|
+
* descriptor does not, and reading the wrong key is invisible: the block
|
|
110
|
+
* renders as if the merchant had authored nothing.
|
|
111
|
+
*/
|
|
112
|
+
it('ignores an `items` list', () => {
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<SocialIconsListBlock {...props({ items: STORED_ICONS })} />
|
|
115
|
+
);
|
|
116
|
+
expect(container.querySelector('[data-social-icon]')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('social-icons-list — how the list is read', () => {
|
|
121
|
+
it('resolves a per-breakpoint list at the breakpoint being drawn', () => {
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<SocialIconsListBlock
|
|
124
|
+
{...props(
|
|
125
|
+
{
|
|
126
|
+
icons: {
|
|
127
|
+
desktop: STORED_ICONS,
|
|
128
|
+
mobile: [{ icon: 'facebook', link: 'https://facebook.com/lulu' }]
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{ currentBreakpoint: 'mobile' }
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const links = container.querySelectorAll('[data-social-icon]');
|
|
137
|
+
expect(links.length).toBe(1);
|
|
138
|
+
expect(links[0].getAttribute('data-social-icon')).toBe('facebook');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('treats a stored value that is not a list as no list', () => {
|
|
142
|
+
const { container } = render(
|
|
143
|
+
<SocialIconsListBlock {...props({ icons: 'instagram' })} />
|
|
144
|
+
);
|
|
145
|
+
expect(container.querySelector('[data-social-icon]')).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('social-icons-list — links', () => {
|
|
150
|
+
it('opens an off-site link in a new tab and keeps an in-site one in place', () => {
|
|
151
|
+
const { container } = render(
|
|
152
|
+
<SocialIconsListBlock
|
|
153
|
+
{...props({
|
|
154
|
+
icons: [
|
|
155
|
+
{ icon: 'facebook', link: 'https://facebook.com/lulu' },
|
|
156
|
+
{ icon: 'email', link: '/contact' }
|
|
157
|
+
]
|
|
158
|
+
})}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const external = container.querySelector('[data-social-icon="facebook"]')!;
|
|
163
|
+
expect(external.getAttribute('target')).toBe('_blank');
|
|
164
|
+
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
|
|
165
|
+
|
|
166
|
+
const internal = container.querySelector('[data-social-icon="email"]')!;
|
|
167
|
+
expect(internal.getAttribute('target')).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('is inert inside the designer, so an author cannot navigate away', () => {
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<SocialIconsListBlock
|
|
173
|
+
{...props({ icons: STORED_ICONS }, { isDesigner: true })}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const link = container.querySelector('[data-social-icon="instagram"]')!;
|
|
178
|
+
expect(link.getAttribute('href')).toBe('#');
|
|
179
|
+
expect(link.getAttribute('target')).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('draws an entry with no link as a labelled image, not a dead anchor', () => {
|
|
183
|
+
const { container } = render(
|
|
184
|
+
<SocialIconsListBlock {...props({ icons: [{ icon: 'spotify' }] })} />
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const entry = container.querySelector('[data-social-icon="spotify"]')!;
|
|
188
|
+
expect(entry.tagName).toBe('SPAN');
|
|
189
|
+
expect(entry.getAttribute('role')).toBe('img');
|
|
190
|
+
expect(entry.getAttribute('aria-label')).toBe('Spotify');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('social-icons-list — no box of its own', () => {
|
|
195
|
+
/**
|
|
196
|
+
* The row layout comes from the merchant's own `display`/`gap` on the block,
|
|
197
|
+
* and those rules land on the `[data-block-id]` wrapper. A container drawn
|
|
198
|
+
* here would swallow them and leave the icons unarranged inside it.
|
|
199
|
+
*/
|
|
200
|
+
it('returns the entries as siblings, with nothing wrapping them', () => {
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<SocialIconsListBlock {...props({ icons: STORED_ICONS })} />
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(container.children.length).toBe(2);
|
|
206
|
+
expect(
|
|
207
|
+
Array.from(container.children).every((child) =>
|
|
208
|
+
child.hasAttribute('data-social-icon')
|
|
209
|
+
)
|
|
210
|
+
).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('applies no layout style of its own to an entry', () => {
|
|
214
|
+
const { container } = render(
|
|
215
|
+
<SocialIconsListBlock
|
|
216
|
+
{...props({ icons: [{ icon: 'facebook', link: 'https://x.test' }] })}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const link = container.querySelector<HTMLElement>('[data-social-icon]')!;
|
|
221
|
+
expect(link.style.display).toBe('');
|
|
222
|
+
expect(link.style.gap).toBe('');
|
|
223
|
+
expect(link.style.alignItems).toBe('');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('social-icons-list — empty', () => {
|
|
228
|
+
it('shows a labelled band in the designer', () => {
|
|
229
|
+
const { container } = render(
|
|
230
|
+
<SocialIconsListBlock {...props({ icons: [] }, { isDesigner: true })} />
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(container.querySelector('[data-social-icons-list]')).not.toBeNull();
|
|
234
|
+
expect(container.textContent).toContain('Social icons');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('shows a shopper nothing at all', () => {
|
|
238
|
+
const { container } = render(
|
|
239
|
+
<SocialIconsListBlock {...props({ icons: [] })} />
|
|
240
|
+
);
|
|
241
|
+
expect(container.innerHTML).toBe('');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import ValuePropsListBlock from '../value-props-list-block';
|
|
4
|
+
import { ThemeSettingsProvider } from '../../theme-settings-context';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The block descriptor is pz-dynamic-widget
|
|
8
|
+
* `src/config/blocks/block-registry.ts` (BlockType.VALUE_PROPS_LIST): one
|
|
9
|
+
* property, `items`, written by `value-props-editor.tsx` as
|
|
10
|
+
* `{icon, title, description?}`, plus one block-level style, `gap`.
|
|
11
|
+
*/
|
|
12
|
+
const props = (properties: Record<string, unknown>, extra = {}) =>
|
|
13
|
+
({
|
|
14
|
+
block: {
|
|
15
|
+
id: 'b1',
|
|
16
|
+
type: 'value-props-list',
|
|
17
|
+
label: 'Value Propositions',
|
|
18
|
+
value: '',
|
|
19
|
+
properties,
|
|
20
|
+
styles: {},
|
|
21
|
+
order: 0,
|
|
22
|
+
hidden: false
|
|
23
|
+
},
|
|
24
|
+
placeholderId: 'footer',
|
|
25
|
+
sectionId: 's1',
|
|
26
|
+
isDesigner: false,
|
|
27
|
+
currentBreakpoint: 'desktop',
|
|
28
|
+
...extra
|
|
29
|
+
} as never);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Copied verbatim from the descriptor's `getDefaultValue()`. This is the whole
|
|
33
|
+
* reason the list read matters here: the block arrives on the canvas already
|
|
34
|
+
* carrying three rows, so a read that quietly produced nothing would ship as
|
|
35
|
+
* an empty strip in the footer rather than as an obvious blank block.
|
|
36
|
+
*/
|
|
37
|
+
const SEEDED_ITEMS = [
|
|
38
|
+
{ icon: 'home', title: 'Free Shipping', description: 'On orders over $50' },
|
|
39
|
+
{
|
|
40
|
+
icon: 'add',
|
|
41
|
+
title: 'Secure Payment',
|
|
42
|
+
description: '256-bit SSL encryption'
|
|
43
|
+
},
|
|
44
|
+
{ icon: 'search', title: 'Easy Returns', description: '30-day return policy' }
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
describe('value-props-list — the seeded default', () => {
|
|
48
|
+
it('draws the three rows the descriptor inserts the block with', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<ValuePropsListBlock {...props({ items: SEEDED_ITEMS })} />
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const rows = container.querySelectorAll('[data-value-prop]');
|
|
54
|
+
expect(rows.length).toBe(3);
|
|
55
|
+
expect(
|
|
56
|
+
Array.from(container.querySelectorAll('[data-part="title"]')).map(
|
|
57
|
+
(node) => node.textContent
|
|
58
|
+
)
|
|
59
|
+
).toEqual(['Free Shipping', 'Secure Payment', 'Easy Returns']);
|
|
60
|
+
expect(
|
|
61
|
+
container.querySelector('[data-part="description"]')!.textContent
|
|
62
|
+
).toBe('On orders over $50');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('resolves every seeded icon to a drawn glyph', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<ValuePropsListBlock {...props({ items: SEEDED_ITEMS })} />
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const icons = container.querySelectorAll('svg[data-part="icon"]');
|
|
71
|
+
expect(icons.length).toBe(3);
|
|
72
|
+
// Each glyph is at least one shape; an unresolved key draws no <svg>.
|
|
73
|
+
expect(Array.from(icons).every((icon) => icon.children.length > 0)).toBe(
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('value-props-list — how the list is read', () => {
|
|
80
|
+
it('resolves a per-breakpoint list at the breakpoint being drawn', () => {
|
|
81
|
+
const { container } = render(
|
|
82
|
+
<ValuePropsListBlock
|
|
83
|
+
{...props(
|
|
84
|
+
{
|
|
85
|
+
items: {
|
|
86
|
+
desktop: SEEDED_ITEMS,
|
|
87
|
+
mobile: [{ icon: 'gift', title: 'Gift wrapping' }]
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{ currentBreakpoint: 'mobile' }
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const rows = container.querySelectorAll('[data-value-prop]');
|
|
96
|
+
expect(rows.length).toBe(1);
|
|
97
|
+
expect(rows[0].textContent).toBe('Gift wrapping');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('treats a stored value that is not a list as no list', () => {
|
|
101
|
+
const { container } = render(
|
|
102
|
+
<ValuePropsListBlock {...props({ items: { title: 'Free Shipping' } })} />
|
|
103
|
+
);
|
|
104
|
+
expect(container.querySelector('[data-value-prop]')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('value-props-list — icons', () => {
|
|
109
|
+
/**
|
|
110
|
+
* The panel's add form starts on the icon name `truck`, which is in no
|
|
111
|
+
* catalogue and in no default theme settings, so a merchant who types a
|
|
112
|
+
* title and saves without opening the picker stores an icon nothing can
|
|
113
|
+
* draw. The row still has to carry its text.
|
|
114
|
+
*/
|
|
115
|
+
it('keeps the row when the stored icon key resolves to nothing', () => {
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<ValuePropsListBlock
|
|
118
|
+
{...props({
|
|
119
|
+
items: [{ icon: 'truck', title: 'Same-day delivery' }]
|
|
120
|
+
})}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const row = container.querySelector('[data-value-prop]')!;
|
|
125
|
+
expect(row.querySelector('[data-part="icon"]')).toBeNull();
|
|
126
|
+
expect(row.textContent).toBe('Same-day delivery');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("prefers the brand's uploaded mark over the catalogue glyph", () => {
|
|
130
|
+
const { container } = render(
|
|
131
|
+
<ThemeSettingsProvider
|
|
132
|
+
value={{ icons: { gift: '<svg id="brand-gift"><rect /></svg>' } }}
|
|
133
|
+
>
|
|
134
|
+
<ValuePropsListBlock
|
|
135
|
+
{...props({ items: [{ icon: 'gift', title: 'Gift wrapping' }] })}
|
|
136
|
+
/>
|
|
137
|
+
</ThemeSettingsProvider>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(container.querySelector('#brand-gift')).not.toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('draws a stored media path as an image, not as a missing glyph', () => {
|
|
144
|
+
const { container } = render(
|
|
145
|
+
<ValuePropsListBlock
|
|
146
|
+
{...props({
|
|
147
|
+
items: [{ icon: '/icons/lorry.svg', title: 'Free delivery' }]
|
|
148
|
+
})}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const image = container.querySelector<HTMLImageElement>(
|
|
153
|
+
'img[data-part="icon"]'
|
|
154
|
+
)!;
|
|
155
|
+
expect(image.getAttribute('src')).toBe('/icons/lorry.svg');
|
|
156
|
+
// The title beside it already names the row; a repeated alt announces the
|
|
157
|
+
// same thing twice.
|
|
158
|
+
expect(image.getAttribute('alt')).toBe('');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('value-props-list — row shape', () => {
|
|
163
|
+
it('keeps the title and description together in one text box', () => {
|
|
164
|
+
const { container } = render(
|
|
165
|
+
<ValuePropsListBlock {...props({ items: [SEEDED_ITEMS[0]] })} />
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const text = container.querySelector('[data-part="text"]')!;
|
|
169
|
+
expect(text.querySelector('[data-part="title"]')).not.toBeNull();
|
|
170
|
+
expect(text.querySelector('[data-part="description"]')).not.toBeNull();
|
|
171
|
+
// The icon stays outside it, so the row can be laid out as icon + column.
|
|
172
|
+
expect(text.querySelector('[data-part="icon"]')).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('omits the description when the entry has none', () => {
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<ValuePropsListBlock
|
|
178
|
+
{...props({ items: [{ icon: 'gift', title: 'Gift wrapping' }] })}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
expect(container.querySelector('[data-part="description"]')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('value-props-list — no box of its own', () => {
|
|
186
|
+
/**
|
|
187
|
+
* The three-across desktop grid and the stacked mobile list both come from
|
|
188
|
+
* the merchant's own `display`/`grid-template-columns`/`gap`, and those
|
|
189
|
+
* rules land on the `[data-block-id]` wrapper. A container drawn here would
|
|
190
|
+
* take them and leave the rows unarranged inside it.
|
|
191
|
+
*/
|
|
192
|
+
it('returns the rows as siblings, with nothing wrapping them', () => {
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<ValuePropsListBlock {...props({ items: SEEDED_ITEMS })} />
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(container.children.length).toBe(3);
|
|
198
|
+
expect(
|
|
199
|
+
Array.from(container.children).every((child) =>
|
|
200
|
+
child.hasAttribute('data-value-prop')
|
|
201
|
+
)
|
|
202
|
+
).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('applies no layout style of its own to a row', () => {
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<ValuePropsListBlock {...props({ items: [SEEDED_ITEMS[0]] })} />
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const row = container.querySelector<HTMLElement>('[data-value-prop]')!;
|
|
211
|
+
expect(row.getAttribute('style')).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('value-props-list — empty', () => {
|
|
216
|
+
it('shows a labelled band in the designer', () => {
|
|
217
|
+
const { container } = render(
|
|
218
|
+
<ValuePropsListBlock {...props({ items: [] }, { isDesigner: true })} />
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(container.querySelector('[data-value-props-list]')).not.toBeNull();
|
|
222
|
+
expect(container.textContent).toContain('Value propositions');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('shows a shopper nothing at all', () => {
|
|
226
|
+
const { container } = render(
|
|
227
|
+
<ValuePropsListBlock {...props({ items: [] })} />
|
|
228
|
+
);
|
|
229
|
+
expect(container.innerHTML).toBe('');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -90,6 +90,14 @@ import ResetPasswordBlock from './reset-password-block';
|
|
|
90
90
|
import StoreLocatorBlock from './store-locator-block';
|
|
91
91
|
import FaqAccordionBlock from './faq-accordion-block';
|
|
92
92
|
import ShareBlock from './share-block';
|
|
93
|
+
import ContainerBlock from './container-block';
|
|
94
|
+
import CarouselItemBlock from './carousel-item-block';
|
|
95
|
+
import HoverShowcaseItemBlock from './hover-showcase-item-block';
|
|
96
|
+
import IconGroupBlock from './icon-group-block';
|
|
97
|
+
import IconGroupColoredBlock from './icon-group-colored-block';
|
|
98
|
+
import IconButtonBlock from './icon-button-block';
|
|
99
|
+
import SocialIconsListBlock from './social-icons-list-block';
|
|
100
|
+
import ValuePropsListBlock from './value-props-list-block';
|
|
93
101
|
|
|
94
102
|
export interface BlockRendererProps {
|
|
95
103
|
block: Block;
|
|
@@ -240,4 +248,17 @@ blockRendererRegistry.register('store-locator', StoreLocatorBlock);
|
|
|
240
248
|
blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
|
|
241
249
|
blockRendererRegistry.register('share', ShareBlock);
|
|
242
250
|
|
|
251
|
+
|
|
252
|
+
// ZERO-5007: the types the Add Block picker offered while pz-theme had no
|
|
253
|
+
// renderer for them. Until this landed each drew a label in the designer and
|
|
254
|
+
// nothing at all on the live site.
|
|
255
|
+
blockRendererRegistry.register('container', ContainerBlock);
|
|
256
|
+
blockRendererRegistry.register('carousel-item', CarouselItemBlock);
|
|
257
|
+
blockRendererRegistry.register('hover-showcase-item', HoverShowcaseItemBlock);
|
|
258
|
+
blockRendererRegistry.register('icon-group', IconGroupBlock);
|
|
259
|
+
blockRendererRegistry.register('icon-group-colored', IconGroupColoredBlock);
|
|
260
|
+
blockRendererRegistry.register('icon-button', IconButtonBlock);
|
|
261
|
+
blockRendererRegistry.register('social-icons-list', SocialIconsListBlock);
|
|
262
|
+
blockRendererRegistry.register('value-props-list', ValuePropsListBlock);
|
|
263
|
+
|
|
243
264
|
export default blockRendererRegistry;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { getResponsiveValue, resolveOption } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import { DesignerPlaceholder } from './product-block-utils';
|
|
8
|
+
import { renderComposedChildren } from './render-composed-children';
|
|
9
|
+
|
|
10
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript|data):/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Browsers strip whitespace and control characters while parsing a url, so a
|
|
14
|
+
* tab wedged into "javascript:" still resolves to a live handler; the authored
|
|
15
|
+
* value is normalised the same way before it is judged.
|
|
16
|
+
*
|
|
17
|
+
* `[^!-~]` keeps printable ASCII and drops everything else, which covers every
|
|
18
|
+
* space and control character a url parser would drop. It also drops accented
|
|
19
|
+
* and non-Latin characters, which costs nothing here: the normalised string is
|
|
20
|
+
* only ever tested against the scheme pattern, never used as the href.
|
|
21
|
+
*/
|
|
22
|
+
const isScriptUrl = (value: string): boolean =>
|
|
23
|
+
SCRIPT_SCHEME.test(value.replace(/[^!-~]/g, '').toLowerCase());
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the authored url is a destination at all.
|
|
27
|
+
*
|
|
28
|
+
* `#` is not a typo to route around — it is what the descriptor SEEDS
|
|
29
|
+
* (`url: { desktop: '#', mobile: '#' }`), so every slide starts life carrying
|
|
30
|
+
* it and a campaign slide that is only a picture keeps it forever. That makes
|
|
31
|
+
* "not a link" the ordinary state of this block rather than an edge case, and
|
|
32
|
+
* it is why the anchor below is conditional: wrapping a non-link slide in
|
|
33
|
+
* `<a href="#">` would put a focus stop and a link announcement in front of
|
|
34
|
+
* every shopper on the carousel, and a click on the slide would jump the page
|
|
35
|
+
* to the top and leave a `#` in the address bar.
|
|
36
|
+
*
|
|
37
|
+
* A real in-page target (`#reviews`) is a destination and stays one — only the
|
|
38
|
+
* bare hash is the seed.
|
|
39
|
+
*/
|
|
40
|
+
const isNavigable = (url: string): boolean =>
|
|
41
|
+
Boolean(url) && url !== '#' && !isScriptUrl(url);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One slide of a carousel: a box the designer fills with blocks, optionally
|
|
45
|
+
* wrapped in a link to wherever the campaign points.
|
|
46
|
+
*
|
|
47
|
+
* Everything the slide LOOKS like is authored as style on the block, and the
|
|
48
|
+
* generated sheet already applies it to the `[data-block-id]` wrapper — the
|
|
49
|
+
* descriptor declares no style keys of its own, so `block.styles` is never
|
|
50
|
+
* read here. Everything it CONTAINS is authored as child blocks: the seed
|
|
51
|
+
* ships one image, but a slide is just as often an image plus a headline and a
|
|
52
|
+
* button, which is why the children go through the shared child renderer
|
|
53
|
+
* rather than through a special case for the seeded image.
|
|
54
|
+
*
|
|
55
|
+
* Properties: url, target.
|
|
56
|
+
* Parts: none — the descriptor declares `styles: []`, so there is no part for
|
|
57
|
+
* a `partAttrs()` stamp to address.
|
|
58
|
+
*/
|
|
59
|
+
const CarouselItemBlock = ({
|
|
60
|
+
block,
|
|
61
|
+
placeholderId,
|
|
62
|
+
sectionId,
|
|
63
|
+
isDesigner = false,
|
|
64
|
+
selectedBlockId,
|
|
65
|
+
currentBreakpoint = 'desktop'
|
|
66
|
+
}: BlockRendererProps) => {
|
|
67
|
+
/**
|
|
68
|
+
* Both properties are seeded PER BREAKPOINT by the descriptor
|
|
69
|
+
* (`{ desktop: '#', mobile: '#' }`), which is the shape a flat read hands
|
|
70
|
+
* straight to `href` as "[object Object]". A merchant pointing the mobile
|
|
71
|
+
* slide at a different campaign authors exactly that shape on purpose.
|
|
72
|
+
*/
|
|
73
|
+
const url = String(
|
|
74
|
+
getResponsiveValue(block.properties?.url, currentBreakpoint, '')
|
|
75
|
+
).trim();
|
|
76
|
+
|
|
77
|
+
// Explicit type argument: without it the fallback narrows the generic to
|
|
78
|
+
// '_self' alone and the option list stops type-checking against it.
|
|
79
|
+
const target = resolveOption<'_self' | '_blank'>(
|
|
80
|
+
block.properties?.target,
|
|
81
|
+
currentBreakpoint,
|
|
82
|
+
'_self',
|
|
83
|
+
['_self', '_blank']
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const children = renderComposedChildren({
|
|
87
|
+
blocks: block.blocks,
|
|
88
|
+
placeholderId,
|
|
89
|
+
sectionId,
|
|
90
|
+
isDesigner,
|
|
91
|
+
selectedBlockId,
|
|
92
|
+
currentBreakpoint
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Nothing to draw. The count is taken AFTER the shared renderer has filtered
|
|
97
|
+
* hidden children, so a slide whose only image is hidden ships nothing at all
|
|
98
|
+
* rather than an empty anchor sitting in the carousel track and claiming a
|
|
99
|
+
* slot in the dots.
|
|
100
|
+
*/
|
|
101
|
+
if (children.length === 0) {
|
|
102
|
+
return isDesigner ? (
|
|
103
|
+
<DesignerPlaceholder
|
|
104
|
+
label="Carousel item (drop blocks here)"
|
|
105
|
+
attribute="data-carousel-item-placeholder"
|
|
106
|
+
/>
|
|
107
|
+
) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* The ordinary case: no element of this block's own.
|
|
112
|
+
*
|
|
113
|
+
* The box the merchant paints already exists — `collectBlockCSS` routes the
|
|
114
|
+
* block's root styles to `[data-block-id]`, the WithDesignerFeatures div
|
|
115
|
+
* around this component. An element added here would be a second box inside
|
|
116
|
+
* the painted one, and an authored `display: flex` / `grid` with its `gap`
|
|
117
|
+
* and `grid-template-columns` lands on that same wrapper: every child would
|
|
118
|
+
* end up inside one single flex item and the layout controls would look
|
|
119
|
+
* inert.
|
|
120
|
+
*/
|
|
121
|
+
if (!isNavigable(url)) {
|
|
122
|
+
return <>{children}</>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Designer-only, and `preventDefault` only: navigating inside the canvas
|
|
127
|
+
* iframe would drop the author out of the page they are editing. No
|
|
128
|
+
* `stopPropagation` — the click still has to reach the wrapper that selects
|
|
129
|
+
* the block.
|
|
130
|
+
*/
|
|
131
|
+
const handleDesignerClick = (event: React.MouseEvent) => {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<NextLink
|
|
137
|
+
href={isDesigner ? '#' : url}
|
|
138
|
+
target={isDesigner ? undefined : target}
|
|
139
|
+
// The descriptor has no `rel` property, so there is no authored value to
|
|
140
|
+
// merge: `_blank` gets the security pair, and nothing else gets the
|
|
141
|
+
// attribute at all.
|
|
142
|
+
rel={
|
|
143
|
+
!isDesigner && target === '_blank' ? 'noopener noreferrer' : undefined
|
|
144
|
+
}
|
|
145
|
+
/*
|
|
146
|
+
* `display: contents` for the same reason the fragment above draws
|
|
147
|
+
* nothing: this anchor exists only to carry the href, and a slide that
|
|
148
|
+
* became a box would swallow the layout the merchant authored on the
|
|
149
|
+
* wrapper. Hit testing is unaffected — a click anywhere on the children
|
|
150
|
+
* still activates the anchor.
|
|
151
|
+
*/
|
|
152
|
+
className="contents"
|
|
153
|
+
/*
|
|
154
|
+
* No handler at all on the live site. An authored action is wired
|
|
155
|
+
* centrally onto the wrapper (`with-designer-features.tsx`), and every
|
|
156
|
+
* interactive action calls `preventDefault` on the click as it bubbles up
|
|
157
|
+
* from here — which cancels this navigation. So an action authored on a
|
|
158
|
+
* slide that also carries a url replaces the navigation instead of racing
|
|
159
|
+
* it, without this renderer reading a single action property.
|
|
160
|
+
*/
|
|
161
|
+
onClick={isDesigner ? handleDesignerClick : undefined}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</NextLink>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export default CarouselItemBlock;
|