@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.
@@ -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;