@akinon/pz-theme 2.0.164-rc.1 → 2.0.164

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 (46) hide show
  1. package/CHANGELOG.md +8 -58
  2. package/package.json +4 -4
  3. package/src/__tests__/editor-locale-bridge.test.tsx +1 -32
  4. package/src/__tests__/theme-settings-defaults.test.tsx +395 -0
  5. package/src/__tests__/theme-text-defaults-code-owned.test.tsx +340 -0
  6. package/src/blocks/__tests__/breadcrumb-theme-separator.test.tsx +148 -0
  7. package/src/blocks/__tests__/social-icons-theme-fallback.test.tsx +268 -0
  8. package/src/blocks/__tests__/text-block.test.tsx +6 -34
  9. package/src/blocks/accordion-block.tsx +10 -0
  10. package/src/blocks/hotspot-block.tsx +6 -1
  11. package/src/blocks/listing-breadcrumb-block.tsx +10 -1
  12. package/src/blocks/order-detail-block.tsx +95 -113
  13. package/src/blocks/product-breadcrumb-block.tsx +9 -1
  14. package/src/blocks/social-glyphs.tsx +31 -0
  15. package/src/blocks/social-icons-list-block.tsx +72 -8
  16. package/src/blocks/text-block.tsx +15 -6
  17. package/src/chrome/__tests__/theme-page-up.test.tsx +522 -0
  18. package/src/chrome/theme-page-up.tsx +303 -0
  19. package/src/chrome/use-editor-theme-settings.ts +104 -0
  20. package/src/components/with-designer-features.tsx +27 -0
  21. package/src/custom/README.md +219 -100
  22. package/src/custom/__tests__/use-theme-setting.test.tsx +0 -97
  23. package/src/custom/collect.ts +9 -4
  24. package/src/custom/property-types.ts +15 -15
  25. package/src/custom/use-theme-setting.ts +5 -21
  26. package/src/editor-locale-bridge.tsx +11 -39
  27. package/src/index.ts +15 -0
  28. package/src/overlays/theme-modals.tsx +10 -0
  29. package/src/sections/__tests__/section-wrapper.test.tsx +76 -0
  30. package/src/sections/coupon-banner-section.tsx +15 -1
  31. package/src/sections/section-wrapper.tsx +7 -0
  32. package/src/sections/tabs-section.tsx +13 -0
  33. package/src/theme-placeholder-wrapper.tsx +50 -3
  34. package/src/theme-section.tsx +15 -0
  35. package/src/utils/__tests__/svg-image-source.test.ts +123 -0
  36. package/src/utils/__tests__/theme-settings-css.test.ts +801 -0
  37. package/src/utils/__tests__/theme-settings-readers.test.ts +213 -0
  38. package/src/utils/__tests__/theme-settings-resolver.test.ts +232 -0
  39. package/src/utils/index.ts +36 -1
  40. package/src/utils/svg-image-source.ts +84 -0
  41. package/src/utils/theme-settings-css.ts +717 -0
  42. package/src/utils/theme-settings-readers.ts +205 -0
  43. package/src/blocks/__tests__/order-line-actions.test.tsx +0 -54
  44. package/src/blocks/order-line-actions.ts +0 -45
  45. package/src/utils/__tests__/sanitize-rich-text.test.ts +0 -80
  46. package/src/utils/sanitize-rich-text.ts +0 -215
@@ -0,0 +1,340 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ import CouponBannerSection from '../sections/coupon-banner-section';
5
+ import TabsSection from '../sections/tabs-section';
6
+ import AccordionBlock from '../blocks/accordion-block';
7
+ import HotspotBlock from '../blocks/hotspot-block';
8
+ import { ThemeSettingsProvider } from '../theme-settings-context';
9
+ import { buildThemeElementDefaultsCss } from '../utils/theme-settings-css';
10
+ import type { Section } from '../theme-section';
11
+
12
+ /**
13
+ * Boxes drawn by a renderer that paint the text of the blocks inside them —
14
+ * a colour from a section setting, a size from a class list, a part colour —
15
+ * must keep that text out of the theme's rich-text defaults, or the theme's
16
+ * Paragraf colour and style repaint it (a light Koyu text colour on the
17
+ * coupon's white code box is about 1.6:1).
18
+ */
19
+
20
+ /** The Koyu preset's text roles plus an edited Paragraf style. */
21
+ const THEMED = {
22
+ textColor: '#C8C8C8',
23
+ headingColor: '#FFFFFF',
24
+ linkColor: '#F2C35B',
25
+ typography: [
26
+ {
27
+ key: 'body',
28
+ fontFamily: 'Jost',
29
+ fontWeight: '400',
30
+ fontSize: 14,
31
+ lineHeight: 22,
32
+ letterSpacing: 0,
33
+ textTransform: 'none'
34
+ }
35
+ ]
36
+ };
37
+
38
+ /** Saved by the old settings page: no text default at all. */
39
+ const LEGACY = {
40
+ primaryColor: '#000000',
41
+ typography: [{ key: 'body', fontFamily: 'Jost', fontSize: 12 }]
42
+ };
43
+
44
+ const rules = buildThemeElementDefaultsCss(THEMED)
45
+ .split('}')
46
+ .filter(Boolean)
47
+ .map((rule) => {
48
+ const [selector, declarations] = rule.split('{');
49
+ return { selector, declarations };
50
+ });
51
+
52
+ const selectorsSetting = (property: string) =>
53
+ rules
54
+ .filter(({ declarations }) => declarations.startsWith(`${property}:`))
55
+ .map(({ selector }) => selector);
56
+
57
+ const COLOR_SELECTORS = selectorsSetting('color');
58
+ const TYPE_SELECTORS = selectorsSetting('font-family');
59
+
60
+ /** Whether a theme colour default reaches `el`. */
61
+ const painted = (el: Element) =>
62
+ COLOR_SELECTORS.some((selector) => el.matches(selector));
63
+ /** Whether the theme's managed typography reaches `el`. */
64
+ const typeset = (el: Element) =>
65
+ TYPE_SELECTORS.some((selector) => el.matches(selector));
66
+
67
+ const text = (
68
+ id: string,
69
+ value: string,
70
+ extra: Record<string, unknown> = {}
71
+ ) => ({
72
+ id,
73
+ type: 'text',
74
+ label: id,
75
+ order: 0,
76
+ hidden: false,
77
+ properties: {},
78
+ styles: {},
79
+ value,
80
+ blocks: [],
81
+ ...extra
82
+ });
83
+
84
+ const mount = (settings: Record<string, unknown>, node: React.ReactNode) =>
85
+ render(
86
+ <ThemeSettingsProvider value={settings}>
87
+ <div className="theme-placeholder">{node}</div>
88
+ </ThemeSettingsProvider>
89
+ );
90
+
91
+ /** The paragraph of text block `id`. */
92
+ const paragraphOf = (container: HTMLElement, id: string) => {
93
+ const p = container.querySelector(`[data-block-id="${id}"] p`);
94
+ if (!p) throw new Error(`no paragraph in ${id}`);
95
+ return p;
96
+ };
97
+
98
+ describe('the coupon banner code box', () => {
99
+ const section = (withBoxBlock: boolean) =>
100
+ ({
101
+ id: 'coupon',
102
+ type: 'coupon-banner',
103
+ name: 'coupon',
104
+ label: 'Coupon',
105
+ order: 0,
106
+ hidden: false,
107
+ properties: { 'coupon-code': 'SAVE20' },
108
+ styles: {},
109
+ blocks: [
110
+ text('title', '<p>Weekend sale</p>'),
111
+ ...(withBoxBlock
112
+ ? [
113
+ {
114
+ ...text('box', ''),
115
+ type: 'group',
116
+ order: 1,
117
+ properties: { couponBannerRole: 'coupon-code-box' }
118
+ }
119
+ ]
120
+ : []),
121
+ text('code', '', {
122
+ order: 2,
123
+ properties: { couponBannerRole: 'coupon-code-text' }
124
+ })
125
+ ]
126
+ } as unknown as Section);
127
+
128
+ it.each([
129
+ ['a box block', true],
130
+ ['the built-in box', false]
131
+ ])('keeps its colour and type on the code inside %s', (_label, withBox) => {
132
+ const { container } = mount(
133
+ THEMED,
134
+ <CouponBannerSection
135
+ section={section(withBox)}
136
+ currentBreakpoint="desktop"
137
+ />
138
+ );
139
+ const code = paragraphOf(container, 'code');
140
+ expect(code).toHaveTextContent('SAVE20');
141
+
142
+ // The box paints the colour inline and the type with its classes.
143
+ const box = code.closest('[data-pz-own-type]');
144
+ expect(box).toHaveClass('uppercase', 'tracking-[0.12em]');
145
+ expect(box).toHaveAttribute('data-pz-own-color', '');
146
+ expect(painted(code)).toBe(false);
147
+ expect(typeset(code)).toBe(false);
148
+
149
+ // The banner's own copy still takes the theme defaults.
150
+ const title = paragraphOf(container, 'title');
151
+ expect(painted(title)).toBe(true);
152
+ expect(typeset(title)).toBe(true);
153
+ });
154
+
155
+ it('adds no marker for a theme without text defaults', () => {
156
+ const { container } = mount(
157
+ LEGACY,
158
+ <CouponBannerSection
159
+ section={section(true)}
160
+ currentBreakpoint="desktop"
161
+ />
162
+ );
163
+ expect(container.innerHTML).not.toContain('data-pz-');
164
+ });
165
+ });
166
+
167
+ describe('the tabs section', () => {
168
+ const pane = (
169
+ id: string,
170
+ headerStyles: Record<string, unknown>,
171
+ contentStyles: Record<string, unknown>,
172
+ order: number
173
+ ) => ({
174
+ id,
175
+ type: 'tab',
176
+ label: id,
177
+ order,
178
+ hidden: false,
179
+ properties: {},
180
+ styles: {},
181
+ blocks: [
182
+ {
183
+ ...text(`${id}-header`, ''),
184
+ type: 'group',
185
+ label: 'Tab Header',
186
+ styles: headerStyles,
187
+ blocks: [text(`${id}-title`, `<p>${id}</p>`)]
188
+ },
189
+ {
190
+ ...text(`${id}-content`, ''),
191
+ type: 'group',
192
+ label: 'Tab Content',
193
+ order: 1,
194
+ styles: contentStyles,
195
+ blocks: [text(`${id}-body`, '<p>Body</p>')]
196
+ }
197
+ ]
198
+ });
199
+
200
+ const section = {
201
+ id: 'tabs',
202
+ type: 'tabs',
203
+ name: 'tabs',
204
+ label: 'Tabs',
205
+ order: 0,
206
+ hidden: false,
207
+ properties: {},
208
+ styles: {},
209
+ blocks: [
210
+ pane(
211
+ 'one',
212
+ { 'font-size': { desktop: '18px' } },
213
+ { color: { desktop: '#ff0000' } },
214
+ 0
215
+ ),
216
+ pane('two', {}, {}, 1)
217
+ ]
218
+ } as unknown as Section;
219
+
220
+ it('lets the active colour and the header and content styles reach their text', () => {
221
+ const { container } = mount(
222
+ THEMED,
223
+ <TabsSection section={section} currentBreakpoint="desktop" />
224
+ );
225
+
226
+ // Active: the tab paints the title with its active colour, and the
227
+ // header block's size reaches it.
228
+ expect(painted(paragraphOf(container, 'one-title'))).toBe(false);
229
+ expect(typeset(paragraphOf(container, 'one-title'))).toBe(false);
230
+ // Inactive, with nothing authored: the theme's defaults.
231
+ expect(painted(paragraphOf(container, 'two-title'))).toBe(true);
232
+ expect(typeset(paragraphOf(container, 'two-title'))).toBe(true);
233
+ // The content block's own colour, painted on the pane.
234
+ expect(painted(paragraphOf(container, 'one-body'))).toBe(false);
235
+ expect(typeset(paragraphOf(container, 'one-body'))).toBe(true);
236
+
237
+ fireEvent.click(paragraphOf(container, 'two-title'));
238
+
239
+ expect(painted(paragraphOf(container, 'two-title'))).toBe(false);
240
+ expect(painted(paragraphOf(container, 'one-title'))).toBe(true);
241
+ expect(typeset(paragraphOf(container, 'one-title'))).toBe(false);
242
+ expect(painted(paragraphOf(container, 'two-body'))).toBe(true);
243
+ });
244
+ });
245
+
246
+ describe('the accordion parts', () => {
247
+ const accordion = (styles: Record<string, unknown>) => ({
248
+ id: 'acc',
249
+ type: 'accordion',
250
+ label: 'acc',
251
+ order: 0,
252
+ hidden: false,
253
+ properties: { defaultOpen: true },
254
+ styles,
255
+ blocks: [
256
+ {
257
+ ...text('acc-heading', ''),
258
+ type: 'group',
259
+ label: 'Accordion Heading',
260
+ blocks: [text('question', '<p>Question</p>')]
261
+ },
262
+ {
263
+ ...text('acc-content', ''),
264
+ type: 'group',
265
+ label: 'Accordion Content',
266
+ order: 1,
267
+ blocks: [text('answer', '<p>Answer</p>')]
268
+ }
269
+ ]
270
+ });
271
+
272
+ const mountAccordion = (styles: Record<string, unknown>) =>
273
+ mount(
274
+ THEMED,
275
+ <AccordionBlock
276
+ block={accordion(styles) as never}
277
+ placeholderId="p"
278
+ sectionId="s"
279
+ isDesigner={false}
280
+ currentBreakpoint="desktop"
281
+ />
282
+ );
283
+
284
+ it('keep a part colour or type, at rest or open, on the blocks inside', () => {
285
+ const { container } = mountAccordion({
286
+ 'header/open/color': { desktop: '#ff0000' },
287
+ 'content/color': { desktop: '#00aa00' },
288
+ 'content/font-size': { desktop: '18px' }
289
+ });
290
+
291
+ expect(container.querySelector('[data-part="header"]')).toHaveAttribute(
292
+ 'data-pz-own-color',
293
+ ''
294
+ );
295
+ expect(painted(paragraphOf(container, 'question'))).toBe(false);
296
+ expect(typeset(paragraphOf(container, 'question'))).toBe(true);
297
+ expect(painted(paragraphOf(container, 'answer'))).toBe(false);
298
+ expect(typeset(paragraphOf(container, 'answer'))).toBe(false);
299
+ });
300
+
301
+ it('mark no part without an authored part colour or type', () => {
302
+ const { container } = mountAccordion({
303
+ 'header/background-color': { desktop: '#000000' }
304
+ });
305
+
306
+ expect(container.querySelector('[data-part="header"]')).not.toHaveAttribute(
307
+ 'data-pz-own-color'
308
+ );
309
+ expect(painted(paragraphOf(container, 'question'))).toBe(true);
310
+ expect(painted(paragraphOf(container, 'answer'))).toBe(true);
311
+ });
312
+ });
313
+
314
+ describe('the hotspot tooltip', () => {
315
+ it('keeps its class-set size on the blocks inside', () => {
316
+ const { container } = mount(
317
+ THEMED,
318
+ <HotspotBlock
319
+ block={
320
+ {
321
+ ...text('spot', ''),
322
+ type: 'hotspot',
323
+ properties: { title: 'Look' },
324
+ blocks: [text('tip', '<p>Tip</p>')]
325
+ } as never
326
+ }
327
+ placeholderId="p"
328
+ sectionId="s"
329
+ isDesigner={false}
330
+ currentBreakpoint="desktop"
331
+ />
332
+ );
333
+ fireEvent.click(screen.getByRole('button', { name: 'Look' }));
334
+
335
+ const tip = paragraphOf(container, 'tip');
336
+ expect(typeset(tip)).toBe(false);
337
+ // Its text colour is not the tooltip's: the theme's still applies.
338
+ expect(painted(tip)).toBe(true);
339
+ });
340
+ });
@@ -0,0 +1,148 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react';
3
+
4
+ import type { BlockRendererProps } from '../block-renderer-registry';
5
+ import ListingBreadcrumbBlock from '../listing-breadcrumb-block';
6
+ import ProductBreadcrumbBlock from '../product-breadcrumb-block';
7
+ import { ThemePageContextProvider } from '../../theme-page-context';
8
+ import { ThemeSettingsProvider } from '../../theme-settings-context';
9
+
10
+ /**
11
+ * The settings page's "Breadcrumb ayırıcı" (`breadcrumbSeparator`)
12
+ * is the theme-wide default for both breadcrumb blocks. It only fills a block
13
+ * whose own `separator` is absent — the editor stops persisting '/' for new
14
+ * blocks — so every block saved before keeps the separator it renders today.
15
+ */
16
+
17
+ const LISTING_PAGE = {
18
+ listing: {
19
+ products: [],
20
+ breadcrumbs: [
21
+ { label: 'Women', url: '/women' },
22
+ { label: 'Dresses', url: '/women/dresses' }
23
+ ]
24
+ }
25
+ };
26
+
27
+ const PRODUCT_PAGE = {
28
+ product: {
29
+ pk: 7,
30
+ name: 'Linen dress',
31
+ categories: [{ name: 'Women', url: '/women' }]
32
+ }
33
+ };
34
+
35
+ const blockProps = (
36
+ type: string,
37
+ properties: Record<string, unknown>
38
+ ): BlockRendererProps =>
39
+ ({
40
+ block: {
41
+ id: `${type}-1`,
42
+ type,
43
+ label: 'Breadcrumb',
44
+ value: '',
45
+ properties,
46
+ styles: {},
47
+ order: 0,
48
+ hidden: false
49
+ },
50
+ placeholderId: 'p',
51
+ sectionId: 's',
52
+ isDesigner: false,
53
+ currentBreakpoint: 'desktop'
54
+ } as unknown as BlockRendererProps);
55
+
56
+ type Variant = {
57
+ name: string;
58
+ Block: React.ComponentType<BlockRendererProps>;
59
+ type: string;
60
+ page: Record<string, unknown>;
61
+ };
62
+
63
+ const VARIANTS: Variant[] = [
64
+ {
65
+ name: 'listing breadcrumb',
66
+ Block: ListingBreadcrumbBlock,
67
+ type: 'listing-breadcrumb',
68
+ page: LISTING_PAGE
69
+ },
70
+ {
71
+ name: 'product breadcrumb',
72
+ Block: ProductBreadcrumbBlock,
73
+ type: 'product-breadcrumb',
74
+ page: PRODUCT_PAGE
75
+ }
76
+ ];
77
+
78
+ const separators = (
79
+ variant: Variant,
80
+ properties: Record<string, unknown>,
81
+ settings: Record<string, unknown> | null
82
+ ) => {
83
+ const { Block, type, page } = variant;
84
+ const { container } = render(
85
+ <ThemeSettingsProvider value={settings}>
86
+ <ThemePageContextProvider value={page}>
87
+ <Block {...blockProps(type, properties)} />
88
+ </ThemePageContextProvider>
89
+ </ThemeSettingsProvider>
90
+ );
91
+
92
+ return Array.from(
93
+ container.querySelectorAll('[data-breadcrumb-separator]')
94
+ ).map((node) => node.textContent);
95
+ };
96
+
97
+ describe.each(VARIANTS)('$name — theme breadcrumb separator', (variant) => {
98
+ it('uses the theme separator when the block sets none', () => {
99
+ const seen = separators(variant, {}, { breadcrumbSeparator: '›' });
100
+
101
+ expect(seen.length).toBeGreaterThan(0);
102
+ expect(seen.every((text) => text === '›')).toBe(true);
103
+ });
104
+
105
+ it('keeps a stored separator, including the old default "/"', () => {
106
+ const settings = { breadcrumbSeparator: '›' };
107
+
108
+ expect(
109
+ separators(variant, { separator: { desktop: '/' } }, settings)
110
+ ).toEqual(expect.arrayContaining(['/']));
111
+ expect(
112
+ separators(variant, { separator: { desktop: '/' } }, settings).includes(
113
+ '›'
114
+ )
115
+ ).toBe(false);
116
+ expect(separators(variant, { separator: '|' }, settings)[0]).toBe('|');
117
+ });
118
+
119
+ it('keeps a stored empty separator empty', () => {
120
+ const seen = separators(
121
+ variant,
122
+ { separator: { desktop: '' } },
123
+ { breadcrumbSeparator: '›' }
124
+ );
125
+
126
+ expect(seen.length).toBeGreaterThan(0);
127
+ expect(seen.every((text) => text === '')).toBe(true);
128
+ });
129
+
130
+ it('falls back to "/" with no theme separator', () => {
131
+ expect(separators(variant, {}, null)[0]).toBe('/');
132
+ expect(separators(variant, {}, {})[0]).toBe('/');
133
+ expect(separators(variant, {}, { breadcrumbSeparator: '' })[0]).toBe('/');
134
+ });
135
+
136
+ it('ignores a theme separator that is not a separator', () => {
137
+ // Too long, blank, or not a string: none of them may reach the trail.
138
+ for (const breadcrumbSeparator of ['a very long separator', ' ', 42]) {
139
+ expect(separators(variant, {}, { breadcrumbSeparator })[0]).toBe('/');
140
+ }
141
+ });
142
+
143
+ it('trims the theme separator', () => {
144
+ expect(separators(variant, {}, { breadcrumbSeparator: ' > ' })[0]).toBe(
145
+ '>'
146
+ );
147
+ });
148
+ });