@akinon/pz-theme 2.0.53-rc.0 → 2.0.53
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 +7 -31
- package/package.json +4 -4
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +70 -0
- package/src/sections/custom-renderer-section-types.ts +51 -0
- package/src/sections/section-renderer-registry.tsx +4 -0
- package/src/sections/stats-counter-section.tsx +6 -7
- package/src/utils/__tests__/section-max-width.test.ts +283 -0
- package/src/utils/index.ts +129 -1
- package/src/utils/section-max-width.ts +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.53
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies
|
|
11
|
-
- Updated dependencies [2fea4143]
|
|
12
|
-
- Updated dependencies [b55acb768]
|
|
13
|
-
- Updated dependencies [760258c]
|
|
14
|
-
- Updated dependencies [143be2b9d]
|
|
15
|
-
- Updated dependencies [7889b08f]
|
|
16
|
-
- Updated dependencies [9f8cd3bc5]
|
|
17
|
-
- Updated dependencies [bfafa3f4]
|
|
18
|
-
- Updated dependencies [57d7eb30]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a71]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c5]
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d0]
|
|
25
|
-
- Updated dependencies [4ac7b2a1]
|
|
26
|
-
- Updated dependencies [e9598c71]
|
|
27
|
-
- Updated dependencies [4998a963]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b2]
|
|
32
|
-
- @akinon/next@2.0.53-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.53-rc.0
|
|
3
|
+
## 2.0.53
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a406d24: ZERO-4748: Translate section max-width tokens into CSS lengths for default-rendered sections, and centre the section a token constrains unless the composition already sets a horizontal margin
|
|
8
|
+
- @akinon/next@2.0.53
|
|
9
|
+
- @akinon/theme-contract@2.0.53
|
|
34
10
|
|
|
35
11
|
## 2.0.52
|
|
36
12
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.53
|
|
4
|
+
"version": "2.0.53",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.53
|
|
11
|
+
"@akinon/next": "2.0.53",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.53
|
|
16
|
+
"@akinon/theme-contract": "2.0.53",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.53
|
|
23
|
+
"@akinon/next": "2.0.53",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import sectionRendererRegistry from '../section-renderer-registry';
|
|
2
|
+
import {
|
|
3
|
+
CUSTOM_RENDERER_SECTION_TYPES,
|
|
4
|
+
hasCustomSectionRenderer
|
|
5
|
+
} from '../custom-renderer-section-types';
|
|
6
|
+
import type { Section } from '../../theme-section';
|
|
7
|
+
|
|
8
|
+
const section = (over: Partial<Section> = {}): Section =>
|
|
9
|
+
({
|
|
10
|
+
id: 's1',
|
|
11
|
+
type: 'custom',
|
|
12
|
+
name: 'S',
|
|
13
|
+
label: 'S',
|
|
14
|
+
order: 0,
|
|
15
|
+
hidden: false,
|
|
16
|
+
properties: {},
|
|
17
|
+
styles: {},
|
|
18
|
+
blocks: [],
|
|
19
|
+
...over
|
|
20
|
+
} as unknown as Section);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The CSS generator cannot import the registry without closing a module cycle,
|
|
24
|
+
* so it reads a plain copy of the registered types. This is the lock that keeps
|
|
25
|
+
* the copy true: registering a renderer without listing it here would hand that
|
|
26
|
+
* section's outer element a max-width its own component already applies.
|
|
27
|
+
*/
|
|
28
|
+
describe('the custom-renderer type list matches the registry', () => {
|
|
29
|
+
it('lists exactly the registered types', () => {
|
|
30
|
+
expect([...CUSTOM_RENDERER_SECTION_TYPES].sort()).toEqual(
|
|
31
|
+
sectionRendererRegistry.registeredTypes().sort()
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('agrees with the registry section by section', () => {
|
|
36
|
+
CUSTOM_RENDERER_SECTION_TYPES.forEach((type) => {
|
|
37
|
+
expect(sectionRendererRegistry.hasCustomRenderer(type)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('hasCustomSectionRenderer', () => {
|
|
43
|
+
it('is false for the types the generic block loop renders', () => {
|
|
44
|
+
['custom', 'default', 'hero', 'newsletter-signup-banner'].forEach(
|
|
45
|
+
(type) => {
|
|
46
|
+
expect(hasCustomSectionRenderer(section({ type }))).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is true for a registered type', () => {
|
|
52
|
+
expect(hasCustomSectionRenderer(section({ type: 'stats-counter' }))).toBe(
|
|
53
|
+
true
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('follows the tab hatch, which overrides the section type', () => {
|
|
58
|
+
// theme-section hands any section holding `tab` blocks to TabsSection
|
|
59
|
+
// whatever its own type says, so it owns its container like a registered
|
|
60
|
+
// type does.
|
|
61
|
+
expect(
|
|
62
|
+
hasCustomSectionRenderer(
|
|
63
|
+
section({
|
|
64
|
+
type: 'custom',
|
|
65
|
+
blocks: [{ id: 'b1', type: 'tab' }] as unknown as Section['blocks']
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The section types that render through their own component instead of the
|
|
3
|
+
* generic block loop, as plain data.
|
|
4
|
+
*
|
|
5
|
+
* The registry itself cannot answer this question for the CSS generator: the
|
|
6
|
+
* generator is a leaf module that every section component imports, so importing
|
|
7
|
+
* the registry back into it closes a cycle in which the registry evaluates
|
|
8
|
+
* before the components it registers and silently stores `undefined` for them.
|
|
9
|
+
* The list is duplicated here on purpose and pinned to the registry by
|
|
10
|
+
* `__tests__/custom-renderer-section-types.test.ts`, which fails the moment a
|
|
11
|
+
* renderer is registered or removed without this file following.
|
|
12
|
+
*/
|
|
13
|
+
export const CUSTOM_RENDERER_SECTION_TYPES: readonly string[] = [
|
|
14
|
+
'before-after',
|
|
15
|
+
'contact-form',
|
|
16
|
+
'countdown-banner',
|
|
17
|
+
'coupon-banner',
|
|
18
|
+
'divider',
|
|
19
|
+
'featured-product-spotlight',
|
|
20
|
+
'find-in-store',
|
|
21
|
+
'flash-deals-slider',
|
|
22
|
+
'hover-showcase',
|
|
23
|
+
'image-hotspot',
|
|
24
|
+
'installment-options',
|
|
25
|
+
'notification-banner',
|
|
26
|
+
'order-tracking-lookup',
|
|
27
|
+
'posts-slider',
|
|
28
|
+
'pre-order-launch-banner',
|
|
29
|
+
'shipping-threshold-progress',
|
|
30
|
+
'stats-counter',
|
|
31
|
+
'tabs'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mirrors how `theme-section` picks a renderer, including the tab hatch: a
|
|
36
|
+
* section holding `tab` blocks is rendered by `TabsSection` whatever its own
|
|
37
|
+
* type says, so it owns its layout container the same way a registered type
|
|
38
|
+
* does.
|
|
39
|
+
*
|
|
40
|
+
* Typed by shape rather than against `Section`, because the CSS generator
|
|
41
|
+
* carries its own structural copy of that interface and the two are not
|
|
42
|
+
* assignable to one another.
|
|
43
|
+
*/
|
|
44
|
+
export const hasCustomSectionRenderer = (section: {
|
|
45
|
+
type?: string;
|
|
46
|
+
blocks?: { type?: string }[];
|
|
47
|
+
}): boolean => {
|
|
48
|
+
if (section.blocks?.some((block) => block.type === 'tab')) return true;
|
|
49
|
+
|
|
50
|
+
return CUSTOM_RENDERER_SECTION_TYPES.includes(section.type ?? '');
|
|
51
|
+
};
|
|
@@ -42,6 +42,10 @@ class SectionRendererRegistry {
|
|
|
42
42
|
hasCustomRenderer(type: string): boolean {
|
|
43
43
|
return this.renderers.has(type);
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
registeredTypes(): string[] {
|
|
47
|
+
return [...this.renderers.keys()];
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
const sectionRendererRegistry = new SectionRendererRegistry();
|
|
@@ -7,7 +7,11 @@ import { twMerge } from 'tailwind-merge';
|
|
|
7
7
|
import ThemeBlock, { Block } from '../theme-block';
|
|
8
8
|
import { Section } from '../theme-section';
|
|
9
9
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getCSSStyles,
|
|
12
|
+
getResponsiveValue,
|
|
13
|
+
getSectionMaxWidthClass
|
|
14
|
+
} from '../utils';
|
|
11
15
|
|
|
12
16
|
import { postToEditor } from '../editor-bridge';
|
|
13
17
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
@@ -446,12 +450,7 @@ const StatsCounterSection: React.FC<StatsCounterSectionProps> = ({
|
|
|
446
450
|
currentBreakpoint,
|
|
447
451
|
'normal'
|
|
448
452
|
);
|
|
449
|
-
const maxWidthClass =
|
|
450
|
-
maxWidth === 'narrow'
|
|
451
|
-
? 'max-w-4xl'
|
|
452
|
-
: maxWidth === 'normal'
|
|
453
|
-
? 'max-w-7xl'
|
|
454
|
-
: '';
|
|
453
|
+
const maxWidthClass = getSectionMaxWidthClass(maxWidth);
|
|
455
454
|
const hasMaxWidth = maxWidth !== 'none' && maxWidth !== 'full';
|
|
456
455
|
|
|
457
456
|
const filteredStyles = Object.fromEntries(
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import { SECTION_MAX_WIDTH_SCALE } from '../section-max-width';
|
|
3
|
+
import type { Section } from '../../theme-section';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `max-width` is offered on a section as Narrow / Normal / Full / None, and
|
|
7
|
+
* those tokens used to reach the stylesheet verbatim: `max-width: narrow` is a
|
|
8
|
+
* declaration the browser drops, so the control did nothing on every section
|
|
9
|
+
* the generic block loop renders — `custom`, `hero`, `newsletter-signup-banner`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const section = (
|
|
13
|
+
styles: Record<string, unknown>,
|
|
14
|
+
over: Partial<Section> = {}
|
|
15
|
+
): Section[] => [
|
|
16
|
+
{
|
|
17
|
+
id: 'sec-1',
|
|
18
|
+
type: 'custom',
|
|
19
|
+
name: 'Section',
|
|
20
|
+
label: 'Section',
|
|
21
|
+
order: 0,
|
|
22
|
+
hidden: false,
|
|
23
|
+
properties: {},
|
|
24
|
+
styles,
|
|
25
|
+
blocks: [],
|
|
26
|
+
...over
|
|
27
|
+
} as unknown as Section
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe('section max-width tokens reach CSS as lengths', () => {
|
|
31
|
+
it('translates narrow', () => {
|
|
32
|
+
const css = generateThemeCSS(section({ 'max-width': 'narrow' }));
|
|
33
|
+
|
|
34
|
+
expect(css).toContain('max-width: 56rem');
|
|
35
|
+
expect(css).not.toContain('max-width: narrow');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('translates normal', () => {
|
|
39
|
+
const css = generateThemeCSS(section({ 'max-width': 'normal' }));
|
|
40
|
+
|
|
41
|
+
expect(css).toContain('max-width: 80rem');
|
|
42
|
+
expect(css).not.toContain('max-width: normal');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('translates the camelCase spelling of the key too', () => {
|
|
46
|
+
const css = generateThemeCSS(section({ maxWidth: 'narrow' }));
|
|
47
|
+
|
|
48
|
+
expect(css).toContain('max-width: 56rem');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('resolves `full` to the only real CSS spelling of "no constraint"', () => {
|
|
52
|
+
const css = generateThemeCSS(section({ 'max-width': 'full' }));
|
|
53
|
+
|
|
54
|
+
expect(css).toContain('max-width: none');
|
|
55
|
+
expect(css).not.toContain('max-width: full');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('leaves `none` alone — it is already real CSS', () => {
|
|
59
|
+
expect(generateThemeCSS(section({ 'max-width': 'none' }))).toContain(
|
|
60
|
+
'max-width: none'
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('translates per breakpoint, inside the media query', () => {
|
|
65
|
+
const css = generateThemeCSS(
|
|
66
|
+
section({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(css).toContain('max-width: 56rem');
|
|
70
|
+
expect(css).toContain('max-width: none');
|
|
71
|
+
expect(css).not.toContain('max-width: narrow');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('translates for the breakpoint the designer canvas is showing', () => {
|
|
75
|
+
const css = generateThemeCSS(
|
|
76
|
+
section({ 'max-width': { desktop: 'none', mobile: 'normal' } }),
|
|
77
|
+
'mobile'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(css).toContain('max-width: 80rem');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('only the known tokens are translated', () => {
|
|
85
|
+
it.each(['640px', '75%', '48rem', 'calc(100% - 2rem)', 'var(--shell)'])(
|
|
86
|
+
'passes the real CSS value %s through untouched',
|
|
87
|
+
(value) => {
|
|
88
|
+
expect(generateThemeCSS(section({ 'max-width': value }))).toContain(
|
|
89
|
+
`max-width: ${value}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
it('passes an unrecognized word through rather than guessing', () => {
|
|
95
|
+
expect(generateThemeCSS(section({ 'max-width': 'wide' }))).toContain(
|
|
96
|
+
'max-width: wide'
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('touches no other property that happens to carry a token word', () => {
|
|
101
|
+
const css = generateThemeCSS(
|
|
102
|
+
section({ width: 'narrow', 'min-width': 'normal' })
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(css).toContain('width: narrow');
|
|
106
|
+
expect(css).toContain('min-width: normal');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('a custom-renderer section keeps resolving its own max-width', () => {
|
|
111
|
+
// Its component applies the token to the container it owns. Putting the
|
|
112
|
+
// length on the outer <section> as well would pull that element in from
|
|
113
|
+
// full-bleed and shrink the inner container by the section padding.
|
|
114
|
+
it('leaves the token on the section root untranslated', () => {
|
|
115
|
+
const css = generateThemeCSS(
|
|
116
|
+
section({ 'max-width': 'narrow' }, { type: 'stats-counter' })
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(css).toContain('max-width: narrow');
|
|
120
|
+
expect(css).not.toContain('max-width: 56rem');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('leaves it alone for a section reached through the tab hatch', () => {
|
|
124
|
+
const css = generateThemeCSS(
|
|
125
|
+
section(
|
|
126
|
+
{ 'max-width': 'narrow' },
|
|
127
|
+
{
|
|
128
|
+
type: 'custom',
|
|
129
|
+
blocks: [{ id: 'b1', type: 'tab' }] as unknown as Section['blocks']
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(css).toContain('max-width: narrow');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('the scale is one table, not two', () => {
|
|
139
|
+
// The class and the length are the same measurement: Tailwind's max-w-4xl is
|
|
140
|
+
// 56rem and max-w-7xl is 80rem. A test would not catch them drifting if they
|
|
141
|
+
// were declared in two places, which is why they are declared in one.
|
|
142
|
+
it('pairs each Tailwind class with the length that class means', () => {
|
|
143
|
+
expect(SECTION_MAX_WIDTH_SCALE).toEqual({
|
|
144
|
+
narrow: { className: 'max-w-4xl', length: '56rem' },
|
|
145
|
+
normal: { className: 'max-w-7xl', length: '80rem' }
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A constrained width and `mx-auto` are one decision everywhere else in the
|
|
152
|
+
* product — every custom-renderer section pairs them — so the default path
|
|
153
|
+
* producing a left-aligned constrained column reads as a bug the first time a
|
|
154
|
+
* designer uses the control.
|
|
155
|
+
*/
|
|
156
|
+
describe('a resolved token also centres the section', () => {
|
|
157
|
+
it('centres a narrow section', () => {
|
|
158
|
+
const css = generateThemeCSS(section({ 'max-width': 'narrow' }));
|
|
159
|
+
|
|
160
|
+
expect(css).toContain('max-width: 56rem');
|
|
161
|
+
expect(css).toContain('margin-left: auto');
|
|
162
|
+
expect(css).toContain('margin-right: auto');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('centres a normal section', () => {
|
|
166
|
+
const css = generateThemeCSS(section({ 'max-width': 'normal' }));
|
|
167
|
+
|
|
168
|
+
expect(css).toContain('margin-left: auto');
|
|
169
|
+
expect(css).toContain('margin-right: auto');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('centres the camelCase spelling of the key too', () => {
|
|
173
|
+
expect(generateThemeCSS(section({ maxWidth: 'narrow' }))).toContain(
|
|
174
|
+
'margin-left: auto'
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('centres when only one breakpoint is constrained', () => {
|
|
179
|
+
// The auto margin is inert wherever the width is unconstrained, so mobile
|
|
180
|
+
// stays full-bleed without needing a rule of its own.
|
|
181
|
+
const css = generateThemeCSS(
|
|
182
|
+
section({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(css).toContain('margin-left: auto');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it.each(['none', 'full'])(
|
|
189
|
+
'does not centre %s — it is not held to a width',
|
|
190
|
+
(value) => {
|
|
191
|
+
const css = generateThemeCSS(section({ 'max-width': value }));
|
|
192
|
+
|
|
193
|
+
expect(css).not.toContain('margin-left: auto');
|
|
194
|
+
expect(css).not.toContain('margin-right: auto');
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it('does not centre a real CSS length it did not resolve', () => {
|
|
199
|
+
// Only a token is ours to interpret; a length the author typed carries no
|
|
200
|
+
// instruction about where the section sits.
|
|
201
|
+
const css = generateThemeCSS(section({ 'max-width': '640px' }));
|
|
202
|
+
|
|
203
|
+
expect(css).toContain('max-width: 640px');
|
|
204
|
+
expect(css).not.toContain('margin-left: auto');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('leaves a custom-renderer section uncentred here', () => {
|
|
208
|
+
// Its own component pairs the token with mx-auto on the container it owns.
|
|
209
|
+
const css = generateThemeCSS(
|
|
210
|
+
section({ 'max-width': 'narrow' }, { type: 'stats-counter' })
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(css).not.toContain('margin-left: auto');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('an authored horizontal margin wins over centring', () => {
|
|
218
|
+
it.each([
|
|
219
|
+
['margin-left', '0px'],
|
|
220
|
+
['margin-right', '0px'],
|
|
221
|
+
['marginLeft', '24px'],
|
|
222
|
+
['margin', '0 0 0 40px'],
|
|
223
|
+
['margin-inline', '0']
|
|
224
|
+
])('keeps the author %s', (key, value) => {
|
|
225
|
+
const css = generateThemeCSS(
|
|
226
|
+
section({ 'max-width': 'narrow', [key]: value })
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(css).toContain('max-width: 56rem');
|
|
230
|
+
expect(css).not.toContain('margin-left: auto');
|
|
231
|
+
expect(css).not.toContain('margin-right: auto');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('keeps an authored margin written per breakpoint', () => {
|
|
235
|
+
const css = generateThemeCSS(
|
|
236
|
+
section({
|
|
237
|
+
'max-width': 'narrow',
|
|
238
|
+
'margin-left': { desktop: '80px' }
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(css).toContain('margin-left: 80px');
|
|
243
|
+
expect(css).not.toContain('margin-left: auto');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('still centres when a vertical margin is the only one authored', () => {
|
|
247
|
+
const css = generateThemeCSS(
|
|
248
|
+
section({ 'max-width': 'narrow', 'margin-top': '40px' })
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(css).toContain('margin-top: 40px');
|
|
252
|
+
expect(css).toContain('margin-left: auto');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('centres past a horizontal margin key carrying no value', () => {
|
|
256
|
+
// The generator drops an empty value before it reaches CSS, so it is not
|
|
257
|
+
// the author having decided anything.
|
|
258
|
+
const css = generateThemeCSS(
|
|
259
|
+
section({ 'max-width': 'narrow', 'margin-left': '' })
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(css).toContain('margin-left: auto');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('untouched styles are returned by reference', () => {
|
|
267
|
+
it('emits byte-identical CSS for a section with no max-width', () => {
|
|
268
|
+
const styles = { 'padding-top': '40px', 'background-color': '#fff' };
|
|
269
|
+
|
|
270
|
+
expect(generateThemeCSS(section(styles))).toBe(
|
|
271
|
+
generateThemeCSS(section(styles))
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('adds no margin to a section with no max-width', () => {
|
|
276
|
+
const css = generateThemeCSS(
|
|
277
|
+
section({ 'padding-top': '40px', 'background-color': '#fff' })
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(css).not.toContain('margin-left');
|
|
281
|
+
expect(css).not.toContain('margin-right');
|
|
282
|
+
});
|
|
283
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -19,12 +19,18 @@ import {
|
|
|
19
19
|
MOBILE_MEDIA_QUERY,
|
|
20
20
|
TABLET_MEDIA_QUERY
|
|
21
21
|
} from './breakpoints';
|
|
22
|
+
import {
|
|
23
|
+
isSectionMaxWidthToken,
|
|
24
|
+
resolveSectionMaxWidthValue
|
|
25
|
+
} from './section-max-width';
|
|
26
|
+
import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
|
|
22
27
|
|
|
23
28
|
export * from './publish-window';
|
|
24
29
|
export * from './part-styles';
|
|
25
30
|
export * from './custom-css';
|
|
26
31
|
export * from './breakpoints';
|
|
27
32
|
export * from './checkout-safety';
|
|
33
|
+
export * from './section-max-width';
|
|
28
34
|
|
|
29
35
|
export type ImageLoadingStrategy = 'eager' | 'lazy';
|
|
30
36
|
export type ImageFetchPriority = 'high' | 'auto' | 'low';
|
|
@@ -349,6 +355,126 @@ const stripBoundStyleValues = <T extends Record<string, unknown>>(
|
|
|
349
355
|
return changed ? (stripped as T) : styles;
|
|
350
356
|
};
|
|
351
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The ways a composition can already have placed the section horizontally, in
|
|
360
|
+
* both spellings the generator accepts. The `margin` shorthand is included
|
|
361
|
+
* because it sets the horizontal pair too.
|
|
362
|
+
*/
|
|
363
|
+
const HORIZONTAL_MARGIN_KEYS = new Set([
|
|
364
|
+
'margin',
|
|
365
|
+
'margin-left',
|
|
366
|
+
'margin-right',
|
|
367
|
+
'margin-inline',
|
|
368
|
+
'margin-inline-start',
|
|
369
|
+
'margin-inline-end'
|
|
370
|
+
]);
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether the author positioned the section themselves. An empty value is not
|
|
374
|
+
* an answer — the generator drops those before they reach CSS — so only a real
|
|
375
|
+
* value counts as the author having decided where the section sits.
|
|
376
|
+
*/
|
|
377
|
+
const authorsHorizontalMargin = (styles: Record<string, unknown>): boolean =>
|
|
378
|
+
Object.entries(styles).some(([key, value]) => {
|
|
379
|
+
if (!HORIZONTAL_MARGIN_KEYS.has(camelToKebab(key))) return false;
|
|
380
|
+
|
|
381
|
+
const isSet = (candidate: unknown): boolean =>
|
|
382
|
+
candidate !== undefined && candidate !== null && candidate !== '';
|
|
383
|
+
|
|
384
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
385
|
+
return Object.values(value as Record<string, unknown>).some(isSet);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return isSet(value);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `max-width` is the one section style the editor writes as a token rather than
|
|
393
|
+
* a CSS value, so it is the one that has to be translated before emission —
|
|
394
|
+
* `max-width: narrow` is a declaration the browser throws away, which is why
|
|
395
|
+
* the control appeared to do nothing on every section that renders through the
|
|
396
|
+
* default path.
|
|
397
|
+
*
|
|
398
|
+
* Resolving the token also centres the section, because a constrained width and
|
|
399
|
+
* `mx-auto` are one decision everywhere else in the product — every
|
|
400
|
+
* custom-renderer section pairs them. An authored horizontal margin wins: the
|
|
401
|
+
* designer who set one has already said where the section sits.
|
|
402
|
+
*
|
|
403
|
+
* Only the section root is rewritten, and only for sections the generic block
|
|
404
|
+
* loop renders. A custom-renderer section resolves the same token itself, onto
|
|
405
|
+
* the container it owns; giving its outer `<section>` the length as well would
|
|
406
|
+
* pull that element in from full-bleed and narrow the inner container by the
|
|
407
|
+
* section padding, changing pages that render correctly today.
|
|
408
|
+
*
|
|
409
|
+
* Styles with no token are returned by reference, so every composition that
|
|
410
|
+
* renders today produces byte-identical CSS.
|
|
411
|
+
*/
|
|
412
|
+
const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
|
|
413
|
+
styles: T
|
|
414
|
+
): T => {
|
|
415
|
+
let changed = false;
|
|
416
|
+
// `full` and `none` resolve too, but they mean "no constraint" and must not
|
|
417
|
+
// centre anything — only a width the section is actually held to.
|
|
418
|
+
let constrained = false;
|
|
419
|
+
const resolved: Record<string, unknown> = {};
|
|
420
|
+
|
|
421
|
+
Object.entries(styles).forEach(([key, value]) => {
|
|
422
|
+
if (key !== 'max-width' && key !== 'maxWidth') {
|
|
423
|
+
resolved[key] = value;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
428
|
+
const rawEntries = Object.entries(value as Record<string, unknown>);
|
|
429
|
+
|
|
430
|
+
if (rawEntries.some(([, breakpointValue]) =>
|
|
431
|
+
isSectionMaxWidthToken(breakpointValue)
|
|
432
|
+
)) {
|
|
433
|
+
constrained = true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const entries = rawEntries.map(
|
|
437
|
+
([breakpoint, breakpointValue]) =>
|
|
438
|
+
[breakpoint, resolveSectionMaxWidthValue(breakpointValue)] as const
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
if (
|
|
442
|
+
entries.some(
|
|
443
|
+
([breakpoint, breakpointValue]) =>
|
|
444
|
+
breakpointValue !== (value as Record<string, unknown>)[breakpoint]
|
|
445
|
+
)
|
|
446
|
+
) {
|
|
447
|
+
changed = true;
|
|
448
|
+
resolved[key] = Object.fromEntries(entries);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
resolved[key] = value;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (isSectionMaxWidthToken(value)) constrained = true;
|
|
457
|
+
|
|
458
|
+
const resolvedValue = resolveSectionMaxWidthValue(value);
|
|
459
|
+
if (resolvedValue !== value) changed = true;
|
|
460
|
+
resolved[key] = resolvedValue;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// A constrained section that is not centred reads as a bug the first time a
|
|
464
|
+
// designer uses the control: every custom-renderer section pairs the same
|
|
465
|
+
// token with `mx-auto`, so a left-aligned column is not what the control is
|
|
466
|
+
// understood to mean. Emitted as auto margins rather than per breakpoint
|
|
467
|
+
// because an auto margin is inert wherever the width is unconstrained — a
|
|
468
|
+
// token on desktop only still leaves mobile full-bleed.
|
|
469
|
+
if (constrained && !authorsHorizontalMargin(styles)) {
|
|
470
|
+
resolved['margin-left'] = 'auto';
|
|
471
|
+
resolved['margin-right'] = 'auto';
|
|
472
|
+
changed = true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return changed ? (resolved as T) : styles;
|
|
476
|
+
};
|
|
477
|
+
|
|
352
478
|
export const getCSSStyles = (
|
|
353
479
|
rawStyles?: Record<string, unknown>,
|
|
354
480
|
themeSettings?: ThemeSettings,
|
|
@@ -1260,7 +1386,9 @@ export const generateThemeCSS = (
|
|
|
1260
1386
|
if (Object.keys(sectionRoot).length > 0) {
|
|
1261
1387
|
css += generateElementCSS(
|
|
1262
1388
|
sectionSelector,
|
|
1263
|
-
|
|
1389
|
+
hasCustomSectionRenderer(section)
|
|
1390
|
+
? sectionRoot
|
|
1391
|
+
: resolveSectionMaxWidthTokens(sectionRoot),
|
|
1264
1392
|
targetBreakpoint
|
|
1265
1393
|
);
|
|
1266
1394
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A section's `max-width` is authored as a token, not a length: the editor's
|
|
3
|
+
* section configs offer Narrow / Normal / Full / None. Every custom-renderer
|
|
4
|
+
* section translated those tokens itself, each carrying its own inline copy of
|
|
5
|
+
* the same two values, while sections that render through the default path had
|
|
6
|
+
* no translation anywhere — the token reached the generated stylesheet
|
|
7
|
+
* verbatim and the browser dropped the declaration, so the control did nothing.
|
|
8
|
+
*
|
|
9
|
+
* The scale lives here once, in both forms its two consumers need: a Tailwind
|
|
10
|
+
* class for the section components that compose one, and a CSS length for the
|
|
11
|
+
* stylesheet generator, which has no classes to compose. The two columns are
|
|
12
|
+
* the same measurement — `max-w-4xl` is 56rem and `max-w-7xl` is 80rem — and
|
|
13
|
+
* holding them in one table is what keeps them from drifting apart.
|
|
14
|
+
*/
|
|
15
|
+
export const SECTION_MAX_WIDTH_SCALE = {
|
|
16
|
+
narrow: { className: 'max-w-4xl', length: '56rem' },
|
|
17
|
+
normal: { className: 'max-w-7xl', length: '80rem' }
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type SectionMaxWidthToken = keyof typeof SECTION_MAX_WIDTH_SCALE;
|
|
21
|
+
|
|
22
|
+
export const isSectionMaxWidthToken = (
|
|
23
|
+
value: unknown
|
|
24
|
+
): value is SectionMaxWidthToken =>
|
|
25
|
+
typeof value === 'string' &&
|
|
26
|
+
Object.prototype.hasOwnProperty.call(SECTION_MAX_WIDTH_SCALE, value);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Tailwind class a constrained token maps to. `full`, `none`, a real CSS
|
|
30
|
+
* length and anything unrecognized all produce no class: they either mean "no
|
|
31
|
+
* constraint" or they are a value only the stylesheet can express.
|
|
32
|
+
*/
|
|
33
|
+
export const getSectionMaxWidthClass = (value: unknown): string =>
|
|
34
|
+
isSectionMaxWidthToken(value) ? SECTION_MAX_WIDTH_SCALE[value].className : '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The CSS value a section `max-width` should actually carry. `full` and `none`
|
|
38
|
+
* both mean "no constraint" in the editor's vocabulary but only `none` is real
|
|
39
|
+
* CSS, so `full` resolves to it rather than staying a dropped declaration.
|
|
40
|
+
* Everything else — `640px`, a `calc()`, a custom property — is returned
|
|
41
|
+
* untouched, because only the known tokens are ours to translate.
|
|
42
|
+
*/
|
|
43
|
+
export const resolveSectionMaxWidthValue = (value: unknown): unknown => {
|
|
44
|
+
if (isSectionMaxWidthToken(value)) {
|
|
45
|
+
return SECTION_MAX_WIDTH_SCALE[value].length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return value === 'full' ? 'none' : value;
|
|
49
|
+
};
|