@akinon/pz-theme 2.0.37-rc.0 → 2.0.37

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,229 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import ThemeBlock from '../theme-block';
4
+ import { generateThemeCSS } from '../utils';
5
+ import { buildPartSelectors } from '../utils/part-styles';
6
+ import type { Section } from '../theme-section';
7
+
8
+ /**
9
+ * A part style must reach only the block that declares the part.
10
+ *
11
+ * The generated rule is a descendant selector, and part names are generic on
12
+ * purpose (`header`, `panel`, `content`, `title`, `icon`, …) so that a
13
+ * designer who learned one block's panel knows the next one's. Those two facts
14
+ * collide the moment a container block holds another block that names a part
15
+ * the same way: styling the outer block's header also restyled the inner
16
+ * block's header, which no panel said would happen and no panel could undo.
17
+ *
18
+ * The composition below is built entirely from real renderers — an accordion
19
+ * inside an accordion — because both ends of the collision then come from
20
+ * shipped code rather than from a fixture written to collide.
21
+ */
22
+
23
+ const heading = (id: string, children: unknown[] = []) => ({
24
+ id: `${id}-heading`,
25
+ type: 'group',
26
+ label: 'Accordion Heading',
27
+ properties: {},
28
+ styles: {},
29
+ order: 0,
30
+ hidden: false,
31
+ blocks: children
32
+ });
33
+
34
+ const content = (id: string, children: unknown[] = []) => ({
35
+ id: `${id}-content`,
36
+ type: 'group',
37
+ label: 'Accordion Content',
38
+ properties: {},
39
+ styles: {},
40
+ order: 1,
41
+ hidden: false,
42
+ blocks: children
43
+ });
44
+
45
+ const text = (id: string) => ({
46
+ id,
47
+ type: 'text',
48
+ label: 'Text',
49
+ value: '<p>copy</p>',
50
+ properties: {},
51
+ styles: {},
52
+ order: 0,
53
+ hidden: false
54
+ });
55
+
56
+ const accordion = (id: string, styles: Record<string, unknown>, inner?: unknown) => ({
57
+ id,
58
+ type: 'accordion',
59
+ label: 'Accordion',
60
+ value: '',
61
+ properties: { defaultOpen: true },
62
+ styles,
63
+ order: 0,
64
+ hidden: false,
65
+ blocks: [
66
+ heading(id, [text(`${id}-title`)]),
67
+ content(id, inner ? [inner] : [text(`${id}-copy`)])
68
+ ]
69
+ });
70
+
71
+ /** The outer accordion carries the styles; the inner one carries the same parts. */
72
+ const composition = (styles: Record<string, unknown>) =>
73
+ accordion('outer', styles, accordion('inner', {}));
74
+
75
+ const sectionWith = (styles: Record<string, unknown>): Section[] =>
76
+ [
77
+ {
78
+ id: 'sec-1',
79
+ type: 'custom',
80
+ name: 'Section',
81
+ label: 'Section',
82
+ order: 0,
83
+ hidden: false,
84
+ properties: {},
85
+ styles: {},
86
+ blocks: [composition(styles)]
87
+ }
88
+ ] as unknown as Section[];
89
+
90
+ const renderComposition = (styles: Record<string, unknown>) =>
91
+ render(
92
+ <ThemeBlock
93
+ block={composition(styles) as never}
94
+ placeholderId="p"
95
+ sectionId="sec-1"
96
+ isDesigner={false}
97
+ />
98
+ );
99
+
100
+ /** Every selector the generated stylesheet aims at the given part. */
101
+ const selectorsFor = (css: string, part: string): string[] =>
102
+ css
103
+ .split('}')
104
+ .map((chunk) => chunk.slice(0, chunk.indexOf('{')))
105
+ .filter((selector) => selector.includes(`[data-part="${part}"]`))
106
+ .flatMap((selector) => selector.split(',').map((one) => one.trim()))
107
+ .filter(Boolean);
108
+
109
+ const matchIds = (root: HTMLElement, selectors: string[]): string[] => {
110
+ const owners = new Set<string>();
111
+
112
+ selectors.forEach((selector) => {
113
+ root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
114
+ owners.add(
115
+ element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
116
+ );
117
+ });
118
+ });
119
+
120
+ return Array.from(owners).sort();
121
+ };
122
+
123
+ describe('a part style stays inside the block that declares the part', () => {
124
+ it('renders both accordions, each with its own parts', () => {
125
+ // Guards the premise: if the inner accordion stopped emitting these parts
126
+ // the leak tests below would pass for the wrong reason.
127
+ const { container } = renderComposition({});
128
+
129
+ expect(container.querySelectorAll('[data-part="header"]')).toHaveLength(2);
130
+ expect(container.querySelectorAll('[data-part="panel"]')).toHaveLength(2);
131
+ expect(container.querySelectorAll('[data-part="content"]')).toHaveLength(2);
132
+ });
133
+
134
+ it.each(['header', 'panel', 'content'])(
135
+ 'does not reach the nested block that names its %s the same way',
136
+ (part) => {
137
+ const css = generateThemeCSS(
138
+ sectionWith({ [`${part}/color`]: { desktop: '#ff0000' } })
139
+ );
140
+ const { container } = renderComposition({});
141
+ const selectors = selectorsFor(css, part);
142
+
143
+ expect(selectors.length).toBeGreaterThan(0);
144
+ expect(matchIds(container, selectors)).toEqual(['outer']);
145
+ }
146
+ );
147
+
148
+ it('still reaches the declaring block through a state selector', () => {
149
+ const css = generateThemeCSS(
150
+ sectionWith({ 'header/open/color': { desktop: '#00ff00' } })
151
+ );
152
+ const { container } = renderComposition({});
153
+ const selectors = selectorsFor(css, 'header');
154
+
155
+ // defaultOpen is on, so both accordions carry data-state="open".
156
+ expect(matchIds(container, selectors)).toEqual(['outer']);
157
+ });
158
+ });
159
+
160
+ describe('the selector shape published compositions already rely on', () => {
161
+ it('still matches the declaring block from outside a nested block', () => {
162
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
163
+ part: 'dropdown',
164
+ state: null,
165
+ styles: {}
166
+ });
167
+
168
+ document.body.innerHTML =
169
+ '<div data-block-id="blk-1"><select data-part="dropdown"></select></div>';
170
+
171
+ expect(document.querySelectorAll(selector)).toHaveLength(1);
172
+ });
173
+
174
+ it('keeps the same specificity, so nothing re-orders in the cascade', () => {
175
+ // A rule that lost to another !important rule yesterday has to lose to it
176
+ // today; only the leaked elements may drop out. That holds because the
177
+ // guard sits inside :where(), which contributes zero specificity —
178
+ // :not() would otherwise take the specificity of its argument.
179
+ const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
180
+ part: 'dropdown',
181
+ state: null,
182
+ styles: {}
183
+ });
184
+
185
+ expect(selector).toContain(':not(:where(');
186
+ // Everything outside the guard is the selector that shipped before.
187
+ expect(selector.replace(/:not\(:where\([^)]*\)\)/, '')).toBe(
188
+ '[data-block-id="blk-1"] [data-part="dropdown"]'
189
+ );
190
+ });
191
+
192
+ it('leaves a state selector list intact', () => {
193
+ const selectors = buildPartSelectors('[data-block-id="blk-1"]', {
194
+ part: 'option',
195
+ state: 'selected',
196
+ styles: {}
197
+ });
198
+
199
+ document.body.innerHTML =
200
+ '<div data-block-id="blk-1">' +
201
+ '<a id="a" data-part="option" data-state="selected"></a>' +
202
+ '<a id="b" data-part="option" aria-selected="true"></a>' +
203
+ '<a id="c" data-part="option" aria-current="true"></a>' +
204
+ '</div>';
205
+
206
+ const matched = new Set<string>();
207
+ selectors.forEach((selector) =>
208
+ document
209
+ .querySelectorAll(selector)
210
+ .forEach((element) => matched.add(element.id))
211
+ );
212
+
213
+ expect(Array.from(matched).sort()).toEqual(['a', 'b', 'c']);
214
+ });
215
+
216
+ it('rejects a part name that tries to break out of the selector', () => {
217
+ // Part names reach CSS from merchant-authored style keys.
218
+ const selectors = buildPartSelectors('[data-block-id="blk-1"]', {
219
+ part: 'a"] , * { color: red } [x="',
220
+ state: null,
221
+ styles: {}
222
+ });
223
+
224
+ selectors.forEach((selector) => {
225
+ expect(selector).not.toContain('{');
226
+ expect(() => document.querySelectorAll(selector)).not.toThrow();
227
+ });
228
+ });
229
+ });
@@ -0,0 +1,311 @@
1
+ import { generateThemeCSS } from '../utils';
2
+ import {
3
+ buildPartStyleKey,
4
+ parsePartStyleKey,
5
+ partAttrs,
6
+ splitPartStyles
7
+ } from '../utils/part-styles';
8
+ import type { Section } from '../theme-section';
9
+
10
+ const sectionWith = (styles: Record<string, unknown>): Section[] =>
11
+ [
12
+ {
13
+ id: 'sec-1',
14
+ type: 'custom',
15
+ name: 'Section',
16
+ label: 'Section',
17
+ order: 0,
18
+ hidden: false,
19
+ properties: {},
20
+ styles: {},
21
+ blocks: [
22
+ {
23
+ id: 'blk-1',
24
+ type: 'variant-selector',
25
+ label: 'Variant selector',
26
+ order: 0,
27
+ hidden: false,
28
+ value: '',
29
+ properties: {},
30
+ styles,
31
+ blocks: []
32
+ }
33
+ ]
34
+ }
35
+ ] as unknown as Section[];
36
+
37
+ describe('part style keys', () => {
38
+ it('reads a part key with and without a state', () => {
39
+ expect(parsePartStyleKey('dropdown/border-color')).toEqual({
40
+ part: 'dropdown',
41
+ state: null,
42
+ property: 'border-color'
43
+ });
44
+ expect(parsePartStyleKey('dropdown/focus/border-color')).toEqual({
45
+ part: 'dropdown',
46
+ state: 'focus',
47
+ property: 'border-color'
48
+ });
49
+ });
50
+
51
+ it('leaves a plain style key alone', () => {
52
+ expect(parsePartStyleKey('border-color')).toBeNull();
53
+ expect(parsePartStyleKey('background-color')).toBeNull();
54
+ });
55
+
56
+ it('treats an unknown middle segment as not a part key', () => {
57
+ // Better to render it on the root than to drop it silently.
58
+ expect(parsePartStyleKey('dropdown/wobble/border-color')).toBeNull();
59
+ });
60
+
61
+ it('round-trips through the builder', () => {
62
+ expect(parsePartStyleKey(buildPartStyleKey('option', 'color'))).toEqual({
63
+ part: 'option',
64
+ state: null,
65
+ property: 'color'
66
+ });
67
+ expect(
68
+ parsePartStyleKey(buildPartStyleKey('option', 'color', 'selected'))
69
+ ).toEqual({ part: 'option', state: 'selected', property: 'color' });
70
+ });
71
+
72
+ it('separates root styles from part styles', () => {
73
+ const { root, parts } = splitPartStyles({
74
+ 'margin-top': { desktop: 10 },
75
+ 'dropdown/border-color': { desktop: '#000' },
76
+ 'dropdown/focus/border-color': { desktop: '#f00' },
77
+ 'option/color': { desktop: '#333' }
78
+ });
79
+
80
+ expect(Object.keys(root)).toEqual(['margin-top']);
81
+ expect(parts).toHaveLength(3);
82
+ expect(parts[0]).toEqual({
83
+ part: 'dropdown',
84
+ state: null,
85
+ styles: { 'border-color': { desktop: '#000' } }
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('partAttrs', () => {
91
+ it('emits the part name', () => {
92
+ expect(partAttrs('dropdown')).toEqual({ 'data-part': 'dropdown' });
93
+ });
94
+
95
+ it('lists only the states that are on', () => {
96
+ expect(partAttrs('option', { selected: true, disabled: false })).toEqual({
97
+ 'data-part': 'option',
98
+ 'data-state': 'selected'
99
+ });
100
+ expect(partAttrs('option', { selected: true, disabled: true })[
101
+ 'data-state'
102
+ ]).toBe('selected disabled');
103
+ });
104
+
105
+ it('omits data-state entirely when nothing is on', () => {
106
+ expect(partAttrs('option', { selected: false })).toEqual({
107
+ 'data-part': 'option'
108
+ });
109
+ });
110
+ });
111
+
112
+ describe('generated CSS for parts', () => {
113
+ it('targets the part inside the block, not the block root', () => {
114
+ const css = generateThemeCSS(
115
+ sectionWith({ 'dropdown/border-color': { desktop: '#111111' } })
116
+ );
117
+
118
+ expect(css).toContain('[data-block-id="blk-1"] [data-part="dropdown"]');
119
+ expect(css).toContain('#111111');
120
+ });
121
+
122
+ it('wins over the inline default the renderer ships', () => {
123
+ // Inline styles outrank any stylesheet, so without !important the
124
+ // designer's value would be saved, displayed and ignored.
125
+ const css = generateThemeCSS(
126
+ sectionWith({ 'dropdown/border-color': { desktop: '#111111' } })
127
+ );
128
+
129
+ expect(css).toMatch(/border-color:\s*#111111\s*!important/);
130
+ });
131
+
132
+ it('keeps part styles off the block wrapper', () => {
133
+ const css = generateThemeCSS(
134
+ sectionWith({ 'dropdown/padding': { desktop: '20px' } })
135
+ );
136
+
137
+ // A padding meant for the dropdown must not move the whole block.
138
+ expect(css).not.toMatch(/\[data-block-id="blk-1"\]\s*\{[^}]*padding/);
139
+ });
140
+
141
+ it('still renders root styles next to part styles', () => {
142
+ const css = generateThemeCSS(
143
+ sectionWith({
144
+ 'margin-top': { desktop: '24px' },
145
+ 'dropdown/border-color': { desktop: '#111111' }
146
+ })
147
+ );
148
+
149
+ expect(css).toMatch(/\[data-block-id="blk-1"\]\s*\{[^}]*margin-top/);
150
+ expect(css).toContain('[data-part="dropdown"]');
151
+ });
152
+
153
+ it('expands a state to every expression a renderer might use', () => {
154
+ const css = generateThemeCSS(
155
+ sectionWith({ 'option/disabled/opacity': { desktop: '0.4' } })
156
+ );
157
+
158
+ expect(css).toContain('[data-part="option"]:disabled');
159
+ expect(css).toContain('[data-part="option"][aria-disabled="true"]');
160
+ expect(css).toContain('[data-part="option"][data-state~="disabled"]');
161
+ });
162
+
163
+ it('supports focus and hover on a part', () => {
164
+ const css = generateThemeCSS(
165
+ sectionWith({
166
+ 'dropdown/hover/background-color': { desktop: '#eeeeee' },
167
+ 'dropdown/focus/border-color': { desktop: '#0000ff' }
168
+ })
169
+ );
170
+
171
+ expect(css).toContain('[data-part="dropdown"]:hover');
172
+ expect(css).toContain('[data-part="dropdown"]:focus-visible');
173
+ });
174
+
175
+ it('honours the mobile value when a breakpoint is targeted', () => {
176
+ const css = generateThemeCSS(
177
+ sectionWith({
178
+ 'dropdown/border-color': { desktop: '#111111', mobile: '#222222' }
179
+ }),
180
+ 'mobile'
181
+ );
182
+
183
+ expect(css).toContain('#222222');
184
+ expect(css).not.toContain('#111111');
185
+ });
186
+
187
+ it('cannot be used to break out of the selector', () => {
188
+ const css = generateThemeCSS(
189
+ sectionWith({ 'drop"]{color:red}[x/color': { desktop: '#000' } })
190
+ );
191
+
192
+ expect(css).not.toContain('color:red');
193
+ expect(css).not.toContain('"]{');
194
+ });
195
+ });
196
+
197
+ describe('routing a block\'s own styles to its real root', () => {
198
+ const blockWith = (
199
+ styles: Record<string, unknown>,
200
+ stylesTargetRoot?: boolean
201
+ ): Section[] =>
202
+ [
203
+ {
204
+ id: 'sec-1',
205
+ type: 'custom',
206
+ name: 'Section',
207
+ label: 'Section',
208
+ order: 0,
209
+ hidden: false,
210
+ properties: {},
211
+ styles: {},
212
+ blocks: [
213
+ {
214
+ id: 'blk-1',
215
+ type: 'search-box',
216
+ label: 'Search',
217
+ order: 0,
218
+ hidden: false,
219
+ value: '',
220
+ properties: {},
221
+ styles,
222
+ stylesTargetRoot,
223
+ blocks: []
224
+ }
225
+ ]
226
+ }
227
+ ] as unknown as Section[];
228
+
229
+ it('stays on the wrapper for a block that has not opted in', () => {
230
+ const css = generateThemeCSS(blockWith({ padding: { desktop: '12px' } }));
231
+
232
+ expect(css).toMatch(/\[data-block-id="blk-1"\]\s*\{[^}]*padding/);
233
+ expect(css).not.toContain('[data-part="root"]');
234
+ });
235
+
236
+ it('reaches the real root once the block opts in', () => {
237
+ // data-block-id sits on the designer wrapper, so a padding set here used
238
+ // to stop at a transparent div and never reach the control.
239
+ const css = generateThemeCSS(
240
+ blockWith({ padding: { desktop: '12px' } }, true)
241
+ );
242
+
243
+ expect(css).toContain('[data-block-id="blk-1"] [data-part="root"]');
244
+ expect(css).not.toMatch(/\[data-block-id="blk-1"\]\s*\{[^}]*padding/);
245
+ });
246
+ });
247
+
248
+ describe('sections get the same treatment as blocks', () => {
249
+ const sectionWith = (
250
+ styles: Record<string, unknown>,
251
+ properties: Record<string, unknown> = {}
252
+ ): Section[] =>
253
+ [
254
+ {
255
+ id: 'sec-1',
256
+ type: 'custom',
257
+ name: 'Section',
258
+ label: 'Section',
259
+ order: 0,
260
+ hidden: false,
261
+ properties,
262
+ styles,
263
+ blocks: []
264
+ }
265
+ ] as unknown as Section[];
266
+
267
+ it('emits a part rule instead of an invalid declaration', () => {
268
+ // A part key on a section used to be written out as `tab/color:` — an
269
+ // invalid declaration that killed the whole rule.
270
+ const css = generateThemeCSS(
271
+ sectionWith({ 'tab/color': { desktop: '#123456' } })
272
+ );
273
+
274
+ expect(css).toContain('[data-section-id="sec-1"] [data-part="tab"]');
275
+ expect(css).not.toContain('tab/color:');
276
+ });
277
+
278
+ it('accepts scoped custom CSS', () => {
279
+ const css = generateThemeCSS(
280
+ sectionWith({}, { customCss: { desktop: '& { outline: 1px solid; }' } })
281
+ );
282
+
283
+ expect(css).toContain('[data-section-id="sec-1"] {');
284
+ expect(css).toContain('outline: 1px solid');
285
+ });
286
+ });
287
+
288
+ describe('pinning a state so it can be styled', () => {
289
+ it('matches a written state as well as the real pseudo-class', () => {
290
+ // A designer cannot hold :hover while reaching for the colour picker, so
291
+ // the canvas writes the state onto the element and the same rule applies.
292
+ const css = generateThemeCSS(
293
+ sectionWith({ 'dropdown/hover/background-color': { desktop: '#eee' } })
294
+ );
295
+
296
+ expect(css).toContain('[data-part="dropdown"]:hover');
297
+ expect(css).toContain('[data-part="dropdown"][data-state~="hover"]');
298
+ });
299
+
300
+ it('does the same for focus and active', () => {
301
+ const css = generateThemeCSS(
302
+ sectionWith({
303
+ 'dropdown/focus/border-color': { desktop: '#00f' },
304
+ 'option/active/opacity': { desktop: '0.8' }
305
+ })
306
+ );
307
+
308
+ expect(css).toContain('[data-part="dropdown"][data-state~="focus"]');
309
+ expect(css).toContain('[data-part="option"][data-state~="active"]');
310
+ });
311
+ });
@@ -26,10 +26,27 @@ const extractRegisteredTypes = (source: string): string[] => {
26
26
 
27
27
  const EXPECTED_BLOCK_TYPES = [
28
28
  'accordion',
29
+ 'account-menu',
30
+ 'add-to-cart',
31
+ 'address-list',
32
+ 'applied-filters',
33
+ 'auth-form',
34
+ 'basket-items',
35
+ 'basket-summary',
29
36
  'button',
37
+ 'change-password-form',
38
+ 'checkout-steps',
39
+ 'checkout-summary',
40
+ 'countdown',
30
41
  'counter',
42
+ 'coupon-list',
31
43
  'divider',
32
44
  'embed',
45
+ 'facet-filters',
46
+ 'faq-accordion',
47
+ 'favorite-button',
48
+ 'favourites-grid',
49
+ 'forgot-password',
33
50
  'group',
34
51
  'hotspot',
35
52
  'icon',
@@ -37,11 +54,36 @@ const EXPECTED_BLOCK_TYPES = [
37
54
  'image-gallery',
38
55
  'input',
39
56
  'link',
57
+ 'listing-breadcrumb',
58
+ 'listing-pagination',
59
+ 'listing-result-count',
60
+ 'listing-title',
40
61
  'lottie',
41
62
  'map',
63
+ 'oauth-buttons',
64
+ 'order-detail',
65
+ 'order-list',
66
+ 'order-success',
67
+ 'order-tracking',
68
+ 'product-attributes',
69
+ 'product-breadcrumb',
70
+ 'product-description',
71
+ 'product-gallery',
72
+ 'product-grid',
73
+ 'product-price',
74
+ 'product-recommendations',
75
+ 'product-title',
76
+ 'profile-form',
77
+ 'register-form',
78
+ 'share',
79
+ 'size-guide',
42
80
  'slider',
81
+ 'sort-select',
82
+ 'stock-status',
83
+ 'store-locator',
43
84
  'tab',
44
85
  'text',
86
+ 'variant-selector',
45
87
  'video'
46
88
  ];
47
89