@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.
@@ -0,0 +1,315 @@
1
+ import React from 'react';
2
+ import { fireEvent, render } from '@testing-library/react';
3
+
4
+ // The recursive ThemeBlock is stubbed with a countable marker, the way
5
+ // group-block.test.tsx does it. `hover-showcase-section` renders its children
6
+ // through the same module, so one mock serves both halves of the parity test.
7
+ jest.mock('../../theme-block', () => ({
8
+ __esModule: true,
9
+ default: ({ block }: { block: { id: string } }) => (
10
+ <div data-testid="child" data-id={block.id} />
11
+ )
12
+ }));
13
+
14
+ import HoverShowcaseItemBlock from '../hover-showcase-item-block';
15
+ import HoverShowcaseSection from '../../sections/hover-showcase-section';
16
+
17
+ /** The image the descriptor's `getDefaultValue` drops inside every new item. */
18
+ const showcaseImage = {
19
+ id: 'img-1',
20
+ type: 'image',
21
+ label: 'Showcase Image',
22
+ value: {
23
+ desktop: 'https://placehold.co/800x600',
24
+ mobile: 'https://placehold.co/800x600'
25
+ },
26
+ properties: { alt: { desktop: 'Showcase Image' } },
27
+ styles: { width: { desktop: '100%' } },
28
+ order: 0,
29
+ hidden: false
30
+ };
31
+
32
+ /**
33
+ * A stored item exactly as `getDefaultHoverShowcaseItemBlockValue()` seeds it:
34
+ * localetext keys held as BREAKPOINT buckets, an empty `link`, one image child.
35
+ */
36
+ const storedItem = (over: Record<string, unknown> = {}) => ({
37
+ id: 'item-1',
38
+ type: 'hover-showcase-item',
39
+ label: 'Hover Showcase Item',
40
+ value: '',
41
+ properties: {
42
+ title: { desktop: 'Autumn Knitwear', mobile: 'Autumn Knitwear' },
43
+ subtitle: {
44
+ desktop: 'Merino and lambswool, new in',
45
+ mobile: 'Merino and lambswool, new in'
46
+ },
47
+ link: ''
48
+ },
49
+ styles: {},
50
+ order: 0,
51
+ hidden: false,
52
+ blocks: [showcaseImage],
53
+ ...over
54
+ });
55
+
56
+ const props = (over: Record<string, unknown> = {}) =>
57
+ ({
58
+ block: storedItem(),
59
+ placeholderId: 'header',
60
+ sectionId: 'sec-1',
61
+ isDesigner: false,
62
+ currentBreakpoint: 'desktop',
63
+ ...over
64
+ } as never);
65
+
66
+ const title = (container: HTMLElement) =>
67
+ container.querySelector('[data-hover-showcase-title]');
68
+ const subtitle = (container: HTMLElement) =>
69
+ container.querySelector('[data-hover-showcase-subtitle]');
70
+ const link = (container: HTMLElement) =>
71
+ container.querySelector('[data-hover-showcase-link]');
72
+
73
+ describe('hover showcase item — a realistic stored block', () => {
74
+ it('draws the title, the subtitle and the media inside it', () => {
75
+ const { container } = render(<HoverShowcaseItemBlock {...props()} />);
76
+
77
+ expect(title(container)!.tagName).toBe('H3');
78
+ expect(title(container)!.textContent).toBe('Autumn Knitwear');
79
+ expect(subtitle(container)!.textContent).toBe(
80
+ 'Merino and lambswool, new in'
81
+ );
82
+ expect(container.querySelector('[data-testid="child"]')).not.toBeNull();
83
+ });
84
+
85
+ it('stamps the parts a follow-up styles through', () => {
86
+ const { container } = render(
87
+ <HoverShowcaseItemBlock
88
+ {...props({ block: storedItem({ styles: { link: '/knitwear' } }) })}
89
+ />
90
+ );
91
+
92
+ expect(title(container)!.getAttribute('data-part')).toBe('title');
93
+ expect(subtitle(container)!.getAttribute('data-part')).toBe('subtitle');
94
+ expect(link(container)!.getAttribute('data-part')).toBe('link');
95
+ });
96
+
97
+ it('adds no box of its own, so the authored layout has more than one child', () => {
98
+ const { container } = render(<HoverShowcaseItemBlock {...props()} />);
99
+
100
+ // title, subtitle, image — siblings. A wrapper here would make the
101
+ // merchant's display/gap on the item lay out exactly one flex item.
102
+ expect(Array.from(container.children).map((el) => el.tagName)).toEqual([
103
+ 'H3',
104
+ 'P',
105
+ 'DIV'
106
+ ]);
107
+ });
108
+
109
+ it('resolves a localetext value stored as a locale map', () => {
110
+ const { container } = render(
111
+ <HoverShowcaseItemBlock
112
+ {...props({
113
+ block: storedItem({
114
+ properties: {
115
+ title: { 'en-us': 'Autumn Knitwear', 'tr-tr': 'Sonbahar Triko' },
116
+ subtitle: '',
117
+ link: ''
118
+ }
119
+ })
120
+ })}
121
+ />
122
+ );
123
+
124
+ expect(title(container)!.textContent).toBe('Autumn Knitwear');
125
+ });
126
+ });
127
+
128
+ describe('hover showcase item — the two places one value is stored', () => {
129
+ /**
130
+ * The descriptor declares title/subtitle/link without `target: 'property'`,
131
+ * so the panel writes them into `styles` while `getDefaultValue` seeds them
132
+ * into `properties`. The seeded `link` is an EMPTY STRING, which is why the
133
+ * fallback cannot be a `??` chain: '' is not nullish and would shadow the URL
134
+ * the merchant actually typed.
135
+ */
136
+ it('reads the URL the panel wrote into styles past the empty seeded property', () => {
137
+ const block = storedItem({ styles: { link: '/collections/knitwear' } });
138
+ expect(block.properties.link).toBe('');
139
+
140
+ const { container } = render(<HoverShowcaseItemBlock {...props({ block })} />);
141
+
142
+ expect(link(container)!.getAttribute('href')).toBe('/collections/knitwear');
143
+ // Only the title is linked, matching the section.
144
+ expect(link(container)!.querySelector('h3')).not.toBeNull();
145
+ expect(subtitle(container)!.closest('a')).toBeNull();
146
+ });
147
+
148
+ it('lets a non-empty property win over a stale styles value', () => {
149
+ const { container } = render(
150
+ <HoverShowcaseItemBlock
151
+ {...props({
152
+ block: storedItem({ styles: { title: 'Typed before the fix' } })
153
+ })}
154
+ />
155
+ );
156
+
157
+ expect(title(container)!.textContent).toBe('Autumn Knitwear');
158
+ });
159
+
160
+ it('does not follow the link in the designer', () => {
161
+ const { container } = render(
162
+ <HoverShowcaseItemBlock
163
+ {...props({
164
+ block: storedItem({ styles: { link: '/collections/knitwear' } }),
165
+ isDesigner: true
166
+ })}
167
+ />
168
+ );
169
+
170
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true });
171
+ fireEvent(link(container)!, event);
172
+ expect(event.defaultPrevented).toBe(true);
173
+ });
174
+ });
175
+
176
+ describe('hover showcase item — the two font sizes that are not CSS', () => {
177
+ it('inlines an authored title/subtitle font size, which no stylesheet can', () => {
178
+ const { container } = render(
179
+ <HoverShowcaseItemBlock
180
+ {...props({
181
+ block: storedItem({
182
+ styles: {
183
+ 'title-font-size': { desktop: '48px', mobile: '28px' },
184
+ 'subtitle-font-size': '18px'
185
+ }
186
+ })
187
+ })}
188
+ />
189
+ );
190
+
191
+ expect((title(container) as HTMLElement).style.fontSize).toBe('48px');
192
+ expect((subtitle(container) as HTMLElement).style.fontSize).toBe('18px');
193
+ });
194
+
195
+ it('resolves them per breakpoint', () => {
196
+ const { container } = render(
197
+ <HoverShowcaseItemBlock
198
+ {...props({
199
+ block: storedItem({
200
+ styles: { 'title-font-size': { desktop: '48px', mobile: '28px' } }
201
+ }),
202
+ currentBreakpoint: 'mobile'
203
+ })}
204
+ />
205
+ );
206
+
207
+ expect((title(container) as HTMLElement).style.fontSize).toBe('28px');
208
+ });
209
+
210
+ it('inlines no size when neither was authored, so a real font-size still wins', () => {
211
+ const { container } = render(<HoverShowcaseItemBlock {...props()} />);
212
+
213
+ expect((title(container) as HTMLElement).style.fontSize).toBe('');
214
+ expect((subtitle(container) as HTMLElement).style.fontSize).toBe('');
215
+ // The only thing inlined on the heading is the user-agent margin reset.
216
+ expect((title(container) as HTMLElement).getAttribute('style')).toBe(
217
+ 'margin: 0px;'
218
+ );
219
+ });
220
+
221
+ it('reads nothing else off styles — padding stays with the wrapper stylesheet', () => {
222
+ const { container } = render(
223
+ <HoverShowcaseItemBlock
224
+ {...props({
225
+ block: storedItem({
226
+ styles: { 'padding-top': 40, 'padding-bottom': 40, color: '#111' }
227
+ })
228
+ })}
229
+ />
230
+ );
231
+
232
+ const heading = title(container) as HTMLElement;
233
+ expect(heading.style.paddingTop).toBe('');
234
+ expect(heading.style.color).toBe('');
235
+ });
236
+ });
237
+
238
+ describe('hover showcase item — nothing to draw', () => {
239
+ const emptyBlock = storedItem({
240
+ properties: { title: '', subtitle: '', link: '' },
241
+ blocks: []
242
+ });
243
+
244
+ it('draws the labelled designer band instead of inventing a title', () => {
245
+ const { container } = render(
246
+ <HoverShowcaseItemBlock
247
+ {...props({ block: emptyBlock, isDesigner: true })}
248
+ />
249
+ );
250
+
251
+ expect(container.textContent).toContain('Hover showcase item');
252
+ // The section's `Item ${index + 1}` needs a loop index a lone block has not
253
+ // got, and the layer-tree label must never ship as a shopper-facing heading.
254
+ expect(container.textContent).not.toContain('Item 1');
255
+ expect(container.textContent).not.toContain('Hover Showcase Item');
256
+ expect(
257
+ container.querySelector('[data-hover-showcase-item]')
258
+ ).not.toBeNull();
259
+ });
260
+
261
+ it('draws nothing at all on the storefront', () => {
262
+ const { container } = render(
263
+ <HoverShowcaseItemBlock {...props({ block: emptyBlock })} />
264
+ );
265
+
266
+ expect(container.innerHTML).toBe('');
267
+ });
268
+
269
+ it('still draws when only the media is authored', () => {
270
+ const { container } = render(
271
+ <HoverShowcaseItemBlock
272
+ {...props({
273
+ block: storedItem({ properties: { title: '', subtitle: '', link: '' } })
274
+ })}
275
+ />
276
+ );
277
+
278
+ expect(title(container)).toBeNull();
279
+ expect(container.querySelector('[data-testid="child"]')).not.toBeNull();
280
+ });
281
+ });
282
+
283
+ describe('hover showcase item — parity with the section that draws it by hand', () => {
284
+ /**
285
+ * `hover-showcase-section.tsx` filters its children by type (:122) and draws
286
+ * them itself, never through ThemeBlock. The same stored block therefore has
287
+ * two renderers, and this pins that they read the same text out of it.
288
+ */
289
+ const section = {
290
+ id: 'sec-1',
291
+ type: 'hover-showcase',
292
+ name: 'Hover Showcase',
293
+ label: 'Hover Showcase',
294
+ properties: {},
295
+ styles: {},
296
+ blocks: [storedItem()],
297
+ order: 0,
298
+ hidden: false
299
+ };
300
+
301
+ it('reads the same title and subtitle the section reads', () => {
302
+ const { container: standalone } = render(
303
+ <HoverShowcaseItemBlock {...props()} />
304
+ );
305
+ const { container: inSection } = render(
306
+ <HoverShowcaseSection section={section as never} />
307
+ );
308
+
309
+ const headings = Array.from(inSection.querySelectorAll('h3')).map(
310
+ (el) => el.textContent
311
+ );
312
+ expect(headings).toContain(title(standalone)!.textContent);
313
+ expect(inSection.textContent).toContain(subtitle(standalone)!.textContent);
314
+ });
315
+ });
@@ -0,0 +1,231 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import IconButtonBlock from '../icon-button-block';
5
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
6
+
7
+ /**
8
+ * A block as the editor actually stores it: the descriptor's `getDefaultValue`
9
+ * seeds every property AND every style as a per-breakpoint bucket, and the
10
+ * shared action properties are appended to the same `properties` object.
11
+ */
12
+ const storedBlock = (over: Record<string, unknown> = {}) => ({
13
+ id: 'ib-1',
14
+ type: 'icon-button',
15
+ label: 'Icon Button',
16
+ properties: {
17
+ icon: { desktop: 'basket' },
18
+ iconSize: { desktop: '28' },
19
+ htmlId: '',
20
+ actionType: 'none',
21
+ ...((over.properties as Record<string, unknown>) ?? {})
22
+ },
23
+ styles: {
24
+ color: { desktop: 'currentColor' },
25
+ 'background-color': { desktop: 'transparent' },
26
+ 'margin-top': { desktop: '0px' },
27
+ 'margin-right': { desktop: '0px' },
28
+ 'margin-bottom': { desktop: '0px' },
29
+ 'margin-left': { desktop: '0px' }
30
+ },
31
+ order: 0,
32
+ hidden: false,
33
+ ...over
34
+ });
35
+
36
+ const props = (over: Record<string, unknown> = {}) =>
37
+ ({
38
+ block: storedBlock(),
39
+ placeholderId: 'p',
40
+ sectionId: 's',
41
+ isDesigner: false,
42
+ ...over
43
+ } as any);
44
+
45
+ describe('IconButtonBlock — drawing', () => {
46
+ it('draws the catalogue glyph at the authored size from a stored block', () => {
47
+ const { container } = render(<IconButtonBlock {...props()} />);
48
+
49
+ const svg = container.querySelector('svg[data-icon-button]');
50
+ expect(svg).not.toBeNull();
51
+ expect(svg!.getAttribute('width')).toBe('28');
52
+ expect(svg!.querySelector('path')).not.toBeNull();
53
+ });
54
+
55
+ it('reads icon and size from the breakpoint bucket the panel writes', () => {
56
+ const { container } = render(
57
+ <IconButtonBlock
58
+ {...props({
59
+ block: storedBlock({
60
+ properties: {
61
+ icon: { desktop: 'basket', mobile: 'search' },
62
+ iconSize: { desktop: '28', mobile: '16' }
63
+ }
64
+ }),
65
+ currentBreakpoint: 'mobile'
66
+ })}
67
+ />
68
+ );
69
+
70
+ const svg = container.querySelector('svg[data-icon-button]');
71
+ expect(svg!.getAttribute('width')).toBe('16');
72
+ // `search` is the only catalogue glyph drawn from a circle plus a line.
73
+ expect(svg!.querySelector('circle')).not.toBeNull();
74
+ expect(svg!.getAttribute('aria-label')).toBe('Search');
75
+ });
76
+
77
+ it('draws an uploaded brand mark from theme settings instead of the glyph', () => {
78
+ const { container } = render(
79
+ <ThemeSettingsProvider
80
+ value={{ icons: { basket: '<svg data-brand="1"><rect /></svg>' } }}
81
+ >
82
+ <IconButtonBlock {...props()} />
83
+ </ThemeSettingsProvider>
84
+ );
85
+
86
+ expect(container.querySelector('svg[data-brand="1"]')).not.toBeNull();
87
+ });
88
+
89
+ it('sizes uploaded SVG markup through a rule scoped to this block', () => {
90
+ const { container } = render(
91
+ <IconButtonBlock
92
+ {...props({
93
+ block: storedBlock({
94
+ properties: {
95
+ icon: { desktop: '<svg viewBox="0 0 24 24"><rect /></svg>' },
96
+ iconSize: { desktop: '32' }
97
+ }
98
+ })
99
+ })}
100
+ />
101
+ );
102
+
103
+ expect(container.querySelector('svg rect')).not.toBeNull();
104
+ const css = container.querySelector('style')!.textContent!;
105
+ expect(css).toBe(
106
+ '[data-icon-button-id="ib-1"] svg{width:32px;height:32px;}'
107
+ );
108
+ });
109
+
110
+ it('draws a stored media path through the image component', () => {
111
+ const { container } = render(
112
+ <IconButtonBlock
113
+ {...props({
114
+ block: storedBlock({
115
+ label: 'Open basket',
116
+ properties: {
117
+ icon: { desktop: '/icons/basket.svg' },
118
+ iconSize: { desktop: '24' }
119
+ }
120
+ })
121
+ })}
122
+ />
123
+ );
124
+
125
+ const img = container.querySelector('img[data-icon-button]');
126
+ expect(img!.getAttribute('src')).toBe('/icons/basket.svg');
127
+ expect(img!.getAttribute('alt')).toBe('Open basket');
128
+ });
129
+ });
130
+
131
+ describe('IconButtonBlock — accessible name', () => {
132
+ it('uses the layer-tree name once the designer has renamed the block', () => {
133
+ const { container } = render(
134
+ <IconButtonBlock
135
+ {...props({ block: storedBlock({ label: 'Open basket' }) })}
136
+ />
137
+ );
138
+
139
+ const svg = container.querySelector('svg[data-icon-button]')!;
140
+ expect(svg.getAttribute('role')).toBe('img');
141
+ expect(svg.getAttribute('aria-label')).toBe('Open basket');
142
+ expect(svg.getAttribute('aria-hidden')).toBeNull();
143
+ });
144
+
145
+ it('falls back to the picked icon key while the label is the descriptor default', () => {
146
+ const { container } = render(<IconButtonBlock {...props()} />);
147
+
148
+ expect(
149
+ container
150
+ .querySelector('svg[data-icon-button]')!
151
+ .getAttribute('aria-label')
152
+ ).toBe('Basket');
153
+ });
154
+
155
+ it('hides an unnamed icon from the accessibility tree rather than announcing nothing', () => {
156
+ const { container } = render(
157
+ <IconButtonBlock
158
+ {...props({
159
+ block: storedBlock({
160
+ label: 'Icon Button',
161
+ properties: { icon: { desktop: '<svg><rect /></svg>' } }
162
+ })
163
+ })}
164
+ />
165
+ );
166
+
167
+ const host = container.querySelector('[data-icon-button]')!;
168
+ expect(host.getAttribute('aria-hidden')).toBe('true');
169
+ expect(host.getAttribute('aria-label')).toBeNull();
170
+ });
171
+ });
172
+
173
+ describe('IconButtonBlock — empty state', () => {
174
+ it('shows a labelled band in the designer when no icon is picked', () => {
175
+ const { container } = render(
176
+ <IconButtonBlock
177
+ {...props({
178
+ block: storedBlock({ properties: { icon: { desktop: '' } } }),
179
+ isDesigner: true
180
+ })}
181
+ />
182
+ );
183
+
184
+ expect(container.textContent).toContain('Icon button (no icon)');
185
+ expect(
186
+ container.querySelector('[data-theme-block-placeholder]')
187
+ ).not.toBeNull();
188
+ });
189
+
190
+ it('renders nothing for a shopper when no icon is picked', () => {
191
+ const { container } = render(
192
+ <IconButtonBlock
193
+ {...props({
194
+ block: storedBlock({ properties: { icon: { desktop: '' } } }),
195
+ isDesigner: false
196
+ })}
197
+ />
198
+ );
199
+
200
+ expect(container.firstChild).toBeNull();
201
+ });
202
+ });
203
+
204
+ describe('IconButtonBlock — the five centrally read properties', () => {
205
+ it('draws the same markup with an action authored: the wrapper owns the click', () => {
206
+ const plain = render(<IconButtonBlock {...props()} />).container.innerHTML;
207
+
208
+ const withAction = render(
209
+ <IconButtonBlock
210
+ {...props({
211
+ block: storedBlock({
212
+ properties: {
213
+ icon: { desktop: 'basket' },
214
+ iconSize: { desktop: '28' },
215
+ htmlId: 'basket-trigger',
216
+ actionType: 'open-modal',
217
+ actionTarget: 'mini-basket',
218
+ actionAudience: 'all'
219
+ }
220
+ })
221
+ })}
222
+ />
223
+ ).container.innerHTML;
224
+
225
+ expect(withAction).toBe(plain);
226
+ // No anchor and no button of its own, which is what keeps the type out of
227
+ // NATIVELY_INTERACTIVE_BLOCK_TYPES.
228
+ expect(withAction).not.toContain('<a ');
229
+ expect(withAction).not.toContain('<button');
230
+ });
231
+ });