@akinon/pz-theme 2.0.62-rc.0 → 2.0.62
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/__tests__/section-content-box.test.tsx +92 -0
- package/src/blocks/__tests__/slider-block.test.tsx +105 -0
- package/src/blocks/slider-block.tsx +34 -4
- package/src/sections/section-wrapper.tsx +10 -1
- package/src/theme-section.tsx +19 -1
- package/src/utils/__tests__/section-content-width.test.ts +319 -0
- package/src/utils/index.ts +35 -11
- package/src/utils/section-content-width.ts +337 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.62
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies
|
|
11
|
-
- Updated dependencies [2fea4143]
|
|
12
|
-
- Updated dependencies [b55acb768]
|
|
13
|
-
- Updated dependencies [760258c1c]
|
|
14
|
-
- Updated dependencies [143be2b9d]
|
|
15
|
-
- Updated dependencies [7889b08fe]
|
|
16
|
-
- Updated dependencies [9f8cd3bc5]
|
|
17
|
-
- Updated dependencies [bfafa3f49]
|
|
18
|
-
- Updated dependencies [57d7eb305]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a714]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c5]
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d06]
|
|
25
|
-
- Updated dependencies [4ac7b2a1e]
|
|
26
|
-
- Updated dependencies [e9598c71]
|
|
27
|
-
- Updated dependencies [4998a9631]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b20]
|
|
32
|
-
- @akinon/next@2.0.62-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.62-rc.0
|
|
3
|
+
## 2.0.62
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 583e69c: ZERO-4778: Implement content width feature for sections and add related tests
|
|
8
|
+
- @akinon/next@2.0.62
|
|
9
|
+
- @akinon/theme-contract@2.0.62
|
|
34
10
|
|
|
35
11
|
## 2.0.61
|
|
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.62
|
|
4
|
+
"version": "2.0.62",
|
|
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.62
|
|
11
|
+
"@akinon/next": "2.0.62",
|
|
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.62
|
|
16
|
+
"@akinon/theme-contract": "2.0.62",
|
|
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.62
|
|
23
|
+
"@akinon/next": "2.0.62",
|
|
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,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import ThemeSection from '../theme-section';
|
|
5
|
+
import { ThemeSettingsProvider } from '../theme-settings-context';
|
|
6
|
+
import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The element the content-width rule is written against.
|
|
10
|
+
*
|
|
11
|
+
* A section holds its blocks in a wrapper that is `display: contents` — it
|
|
12
|
+
* generates no box, so the blocks are the section's own children as far as
|
|
13
|
+
* layout is concerned. That is load-bearing for every page already published: a
|
|
14
|
+
* section set to Flex arranges its blocks directly through it.
|
|
15
|
+
*
|
|
16
|
+
* It is also why the wrapper cannot simply be given a width. These hold the
|
|
17
|
+
* switch: a box only when the section constrains its content, `contents`
|
|
18
|
+
* otherwise, and the attribute in both cases so the selector has something
|
|
19
|
+
* stable to name.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const section = (styles: Record<string, unknown>) =>
|
|
23
|
+
({
|
|
24
|
+
id: 'section-1',
|
|
25
|
+
type: 'custom',
|
|
26
|
+
name: 'Band',
|
|
27
|
+
label: 'Band',
|
|
28
|
+
order: 0,
|
|
29
|
+
hidden: false,
|
|
30
|
+
properties: {},
|
|
31
|
+
styles,
|
|
32
|
+
blocks: [
|
|
33
|
+
{
|
|
34
|
+
id: 'text-1',
|
|
35
|
+
type: 'text',
|
|
36
|
+
label: 'Heading',
|
|
37
|
+
value: 'Spring',
|
|
38
|
+
properties: {},
|
|
39
|
+
styles: {},
|
|
40
|
+
order: 0,
|
|
41
|
+
hidden: false
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
} as never);
|
|
45
|
+
|
|
46
|
+
const renderSection = (styles: Record<string, unknown>) => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<ThemeSettingsProvider value={null}>
|
|
49
|
+
<ThemeSection
|
|
50
|
+
section={section(styles)}
|
|
51
|
+
placeholderId="home-body"
|
|
52
|
+
currentBreakpoint="desktop"
|
|
53
|
+
isDesigner={false}
|
|
54
|
+
/>
|
|
55
|
+
</ThemeSettingsProvider>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return container.querySelector(`[${SECTION_CONTENT_ATTRIBUTE}]`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe('the box a content width is applied to', () => {
|
|
62
|
+
it('is always named, so the rule has something to select', () => {
|
|
63
|
+
const box = renderSection({});
|
|
64
|
+
|
|
65
|
+
expect(box).not.toBeNull();
|
|
66
|
+
expect(box?.parentElement?.tagName).toBe('SECTION');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('generates no box until the section constrains its content', () => {
|
|
70
|
+
// `display: contents` is what today's compositions lay out through.
|
|
71
|
+
expect(renderSection({})).toHaveClass('contents');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('becomes a real box the moment one is set', () => {
|
|
75
|
+
const box = renderSection({ 'content-width': '1024px' });
|
|
76
|
+
|
|
77
|
+
expect(box).not.toHaveClass('contents');
|
|
78
|
+
expect(box).toHaveClass('w-full');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reads a width set on one breakpoint only', () => {
|
|
82
|
+
expect(renderSection({ 'content-width': { mobile: '640px' } })).toHaveClass(
|
|
83
|
+
'w-full'
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('stays out of the way when Custom was chosen but nothing typed', () => {
|
|
88
|
+
expect(renderSection({ 'content-width': 'custom' })).toHaveClass(
|
|
89
|
+
'contents'
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -260,3 +260,108 @@ describe('SliderBlock — arrow button geometry', () => {
|
|
|
260
260
|
).toBe('28px');
|
|
261
261
|
});
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The offsets the panel offers on the arrow's icon block used to reach the
|
|
266
|
+
* theme JSON and stop there — the button was pinned by `left-4 top-1/2
|
|
267
|
+
* -translate-y-1/2` and only its size was read. These cover both halves of
|
|
268
|
+
* honouring them: the value lands inline, and the class default for that axis
|
|
269
|
+
* gets out of the way instead of fighting it.
|
|
270
|
+
*/
|
|
271
|
+
describe('SliderBlock — arrow button placement', () => {
|
|
272
|
+
const arrow = (label: string, styles: Record<string, unknown>) =>
|
|
273
|
+
child(label === 'Left Arrow Icon' ? 'left' : 'right', {
|
|
274
|
+
type: 'icon',
|
|
275
|
+
label,
|
|
276
|
+
styles
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const renderArrows = (
|
|
280
|
+
leftStyles: Record<string, unknown>,
|
|
281
|
+
rightStyles: Record<string, unknown> = {}
|
|
282
|
+
) =>
|
|
283
|
+
render(
|
|
284
|
+
<SliderBlock
|
|
285
|
+
{...props({
|
|
286
|
+
isDesigner: true,
|
|
287
|
+
block: {
|
|
288
|
+
...props().block,
|
|
289
|
+
blocks: [
|
|
290
|
+
arrow('Left Arrow Icon', leftStyles),
|
|
291
|
+
child('s1'),
|
|
292
|
+
arrow('Right Arrow Icon', rightStyles)
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
})}
|
|
296
|
+
/>
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const previous = (container: HTMLElement) =>
|
|
300
|
+
container.querySelector('button[aria-label="Previous"]') as HTMLElement;
|
|
301
|
+
const next = (container: HTMLElement) =>
|
|
302
|
+
container.querySelector('button[aria-label="Next"]') as HTMLElement;
|
|
303
|
+
|
|
304
|
+
it('falls back to the class defaults when no offset is authored', () => {
|
|
305
|
+
const { container } = renderArrows({});
|
|
306
|
+
const button = previous(container);
|
|
307
|
+
|
|
308
|
+
expect(button.className).toContain('left-4');
|
|
309
|
+
expect(button.className).toContain('top-1/2');
|
|
310
|
+
expect(button.className).toContain('-translate-y-1/2');
|
|
311
|
+
expect(button.style.left).toBe('');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('honours an authored left, including 0', () => {
|
|
315
|
+
const { container } = renderArrows({ left: 0 });
|
|
316
|
+
const button = previous(container);
|
|
317
|
+
|
|
318
|
+
expect(button.style.left).toBe('0px');
|
|
319
|
+
// The class would not override the inline value, but leaving it in place
|
|
320
|
+
// is what made `right` unreachable below — so it goes.
|
|
321
|
+
expect(button.className).not.toContain('left-4');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('drops the horizontal default so an authored right is not over-constrained', () => {
|
|
325
|
+
// left-4 + an inline right + a width is three constraints on one axis, and
|
|
326
|
+
// CSS resolves that by ignoring the right — the arrow would not move.
|
|
327
|
+
const { container } = renderArrows({ right: '24px' });
|
|
328
|
+
const button = previous(container);
|
|
329
|
+
|
|
330
|
+
expect(button.style.right).toBe('24px');
|
|
331
|
+
expect(button.className).not.toContain('left-4');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('drops the centering translate when a vertical offset is authored', () => {
|
|
335
|
+
const { container } = renderArrows({ top: '0px' });
|
|
336
|
+
const button = previous(container);
|
|
337
|
+
|
|
338
|
+
expect(button.style.top).toBe('0px');
|
|
339
|
+
expect(button.className).not.toContain('-translate-y-1/2');
|
|
340
|
+
expect(button.className).not.toContain('top-1/2');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('keeps the vertical default when only a horizontal offset is authored', () => {
|
|
344
|
+
const { container } = renderArrows({ left: '8px' });
|
|
345
|
+
const button = previous(container);
|
|
346
|
+
|
|
347
|
+
expect(button.className).toContain('top-1/2');
|
|
348
|
+
expect(button.className).toContain('-translate-y-1/2');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('places each arrow from its own block', () => {
|
|
352
|
+
const { container } = renderArrows({ left: '10px' }, { right: '90px' });
|
|
353
|
+
|
|
354
|
+
expect(previous(container).style.left).toBe('10px');
|
|
355
|
+
expect(next(container).style.right).toBe('90px');
|
|
356
|
+
expect(next(container).className).not.toContain('right-4');
|
|
357
|
+
// The left arrow's own axis default is untouched by the right arrow.
|
|
358
|
+
expect(previous(container).className).not.toContain('left-4');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('still positions absolutely with a z-index', () => {
|
|
362
|
+
const { container } = renderArrows({ left: '10px' });
|
|
363
|
+
|
|
364
|
+
expect(previous(container).className).toContain('absolute');
|
|
365
|
+
expect(previous(container).className).toContain('z-10');
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -437,12 +437,42 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
437
437
|
if (radius) geometry.borderRadius = radius;
|
|
438
438
|
else if (Object.keys(corners).length === 0) geometry.borderRadius = '50%';
|
|
439
439
|
|
|
440
|
+
// The editor offers these four on the arrow's icon block, but only the
|
|
441
|
+
// sizes above were ever read — so an authored offset reached the theme
|
|
442
|
+
// JSON and stopped there, leaving the arrow pinned to the class default
|
|
443
|
+
// with no feedback that the field did nothing.
|
|
444
|
+
(['left', 'right', 'top', 'bottom'] as const).forEach((key) => {
|
|
445
|
+
const value = read(key);
|
|
446
|
+
if (value) geometry[key] = value;
|
|
447
|
+
});
|
|
448
|
+
|
|
440
449
|
return { ...geometry, ...corners };
|
|
441
450
|
};
|
|
442
451
|
|
|
443
452
|
const leftArrowButtonStyle = arrowGeometry(leftArrowIconBlock);
|
|
444
453
|
const rightArrowButtonStyle = arrowGeometry(rightArrowIconBlock);
|
|
445
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The class defaults step aside per axis for whatever the author set.
|
|
457
|
+
* An inline `left` already outranks the `left-4` class, but the pair that
|
|
458
|
+
* would survive is the harmful one: `left-4` alongside an authored `right`
|
|
459
|
+
* over-constrains the box and CSS drops the `right`, and the centering
|
|
460
|
+
* translate would shift an authored `top` by half the button's height.
|
|
461
|
+
*/
|
|
462
|
+
const arrowPlacementClass = (
|
|
463
|
+
style: React.CSSProperties,
|
|
464
|
+
horizontalFallback: string
|
|
465
|
+
) =>
|
|
466
|
+
clsx(
|
|
467
|
+
'absolute z-10',
|
|
468
|
+
style.left === undefined && style.right === undefined
|
|
469
|
+
? horizontalFallback
|
|
470
|
+
: undefined,
|
|
471
|
+
style.top === undefined && style.bottom === undefined
|
|
472
|
+
? 'top-1/2 -translate-y-1/2'
|
|
473
|
+
: undefined
|
|
474
|
+
);
|
|
475
|
+
|
|
446
476
|
const handleLeftArrowClick = (e: React.MouseEvent) => {
|
|
447
477
|
e.preventDefault();
|
|
448
478
|
e.stopPropagation();
|
|
@@ -467,7 +497,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
467
497
|
handleLeftArrowClick(e);
|
|
468
498
|
}}
|
|
469
499
|
className={clsx(
|
|
470
|
-
'
|
|
500
|
+
arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
|
|
471
501
|
isDesigner &&
|
|
472
502
|
selectedBlockId === leftArrowIconBlock?.id &&
|
|
473
503
|
'ring-2 ring-blue-500 ring-offset-2'
|
|
@@ -507,7 +537,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
507
537
|
handleRightArrowClick(e);
|
|
508
538
|
}}
|
|
509
539
|
className={clsx(
|
|
510
|
-
'
|
|
540
|
+
arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
|
|
511
541
|
isDesigner &&
|
|
512
542
|
selectedBlockId === rightArrowIconBlock?.id &&
|
|
513
543
|
'ring-2 ring-blue-500 ring-offset-2'
|
|
@@ -631,7 +661,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
631
661
|
<button
|
|
632
662
|
onClick={handleLeftArrowClick}
|
|
633
663
|
className={clsx(
|
|
634
|
-
'
|
|
664
|
+
arrowPlacementClass(leftArrowButtonStyle, 'left-4'),
|
|
635
665
|
selectedBlockId === leftArrowIconBlock?.id &&
|
|
636
666
|
'ring-2 ring-blue-500 ring-offset-2'
|
|
637
667
|
)}
|
|
@@ -662,7 +692,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
662
692
|
<button
|
|
663
693
|
onClick={handleRightArrowClick}
|
|
664
694
|
className={clsx(
|
|
665
|
-
'
|
|
695
|
+
arrowPlacementClass(rightArrowButtonStyle, 'right-4'),
|
|
666
696
|
selectedBlockId === rightArrowIconBlock?.id &&
|
|
667
697
|
'ring-2 ring-blue-500 ring-offset-2'
|
|
668
698
|
)}
|
|
@@ -5,6 +5,7 @@ import ActionToolbar from '../components/action-toolbar';
|
|
|
5
5
|
import { twMerge } from 'tailwind-merge';
|
|
6
6
|
import clsx from 'clsx';
|
|
7
7
|
import { Section } from '../theme-section';
|
|
8
|
+
import { SECTION_CONTENT_ATTRIBUTE } from '../utils/section-content-width';
|
|
8
9
|
|
|
9
10
|
import { isTrustedEditorEvent, postToEditor } from '../editor-bridge';
|
|
10
11
|
interface SectionWrapperProps {
|
|
@@ -153,7 +154,15 @@ export default function SectionWrapper({
|
|
|
153
154
|
onRename={onRename}
|
|
154
155
|
/>
|
|
155
156
|
)}
|
|
156
|
-
|
|
157
|
+
{/*
|
|
158
|
+
* Already a real box, so unlike the generic path this one needs no
|
|
159
|
+
* switch — only the attribute the content-width rule is written against.
|
|
160
|
+
* A section that renders through its own component also keeps its own
|
|
161
|
+
* `display`: this box has always been the thing that carried it.
|
|
162
|
+
*/}
|
|
163
|
+
<div {...{ [SECTION_CONTENT_ATTRIBUTE]: '' }} className="relative z-10">
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
157
166
|
</section>
|
|
158
167
|
);
|
|
159
168
|
}
|
package/src/theme-section.tsx
CHANGED
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
import { useLocalization } from '@akinon/next/hooks';
|
|
16
16
|
import { devWarn } from './utils/dev-warn';
|
|
17
17
|
import { isNarrowBreakpoint } from './utils/breakpoints';
|
|
18
|
+
import {
|
|
19
|
+
SECTION_CONTENT_ATTRIBUTE,
|
|
20
|
+
hasSectionContentWidth
|
|
21
|
+
} from './utils/section-content-width';
|
|
18
22
|
import { safeDeepClone } from './utils/safe-clone';
|
|
19
23
|
|
|
20
24
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
@@ -120,6 +124,8 @@ export default function ThemeSection({
|
|
|
120
124
|
|
|
121
125
|
const sectionRef = useRef<HTMLElement>(null);
|
|
122
126
|
|
|
127
|
+
const constrainsContent = hasSectionContentWidth(effectiveSection.styles);
|
|
128
|
+
|
|
123
129
|
// Bound prices are formatted deep inside the binding walk, which has no
|
|
124
130
|
// React context to read. Publishing the storefront's locale before the
|
|
125
131
|
// bindings resolve is what stops an en-GB brand rendering Turkish number
|
|
@@ -1002,7 +1008,19 @@ export default function ThemeSection({
|
|
|
1002
1008
|
/>
|
|
1003
1009
|
)}
|
|
1004
1010
|
|
|
1005
|
-
|
|
1011
|
+
{/*
|
|
1012
|
+
* `display: contents` until the section constrains its content, and a
|
|
1013
|
+
* real box the moment it does. The wrapper has to generate a box to be
|
|
1014
|
+
* given a width at all, but making it one unconditionally would change
|
|
1015
|
+
* every page already published: today the blocks are the section's own
|
|
1016
|
+
* children for layout purposes, so a section set to Flex arranges them
|
|
1017
|
+
* directly. When the box does appear, the section's child layout moves
|
|
1018
|
+
* onto it (splitSectionContentWidth) so that arrangement survives.
|
|
1019
|
+
*/}
|
|
1020
|
+
<div
|
|
1021
|
+
{...{ [SECTION_CONTENT_ATTRIBUTE]: '' }}
|
|
1022
|
+
className={twMerge(clsx(constrainsContent ? 'w-full' : 'contents'))}
|
|
1023
|
+
>
|
|
1006
1024
|
{effectiveSection.blocks
|
|
1007
1025
|
.sort((a, b) => a.order - b.order)
|
|
1008
1026
|
.filter((block) => (isDesigner ? true : !block.hidden))
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import {
|
|
3
|
+
SECTION_CONTENT_ATTRIBUTE,
|
|
4
|
+
hasSectionContentWidth,
|
|
5
|
+
splitSectionContentWidth
|
|
6
|
+
} from '../section-content-width';
|
|
7
|
+
import { MOBILE_MEDIA_QUERY, TABLET_MEDIA_QUERY } from '../breakpoints';
|
|
8
|
+
import type { Section } from '../../theme-section';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A band's colour and its measure are two decisions, and a section only had one
|
|
12
|
+
* control for them. `max-width` lands on the `<section>` itself, so holding the
|
|
13
|
+
* words to 1024px held the background to 1024px as well; the way round it was
|
|
14
|
+
* to add one of five prebuilt container sections and rebuild the band inside.
|
|
15
|
+
*
|
|
16
|
+
* `content-width` is the second decision. What these assert is the split: the
|
|
17
|
+
* constraint reaches the box inside the band and nothing else, everything that
|
|
18
|
+
* draws the band stays on it, and a section that does not use the setting emits
|
|
19
|
+
* the bytes it always emitted.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const section = (
|
|
23
|
+
styles: Record<string, unknown>,
|
|
24
|
+
over: Partial<Section> = {}
|
|
25
|
+
): Section[] => [
|
|
26
|
+
{
|
|
27
|
+
id: 'sec-1',
|
|
28
|
+
type: 'custom',
|
|
29
|
+
name: 'Section',
|
|
30
|
+
label: 'Section',
|
|
31
|
+
order: 0,
|
|
32
|
+
hidden: false,
|
|
33
|
+
properties: {},
|
|
34
|
+
styles,
|
|
35
|
+
blocks: [],
|
|
36
|
+
...over
|
|
37
|
+
} as unknown as Section
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const BAND_SELECTOR = '[data-section-id="sec-1"]';
|
|
41
|
+
const CONTENT_SELECTOR = `${BAND_SELECTOR} > [${SECTION_CONTENT_ATTRIBUTE}]`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The declarations of one UNCONDITIONAL rule.
|
|
45
|
+
*
|
|
46
|
+
* Anchored to the start of a line, which is what keeps it from returning a
|
|
47
|
+
* rule nested inside a media query — those are written indented, and matching
|
|
48
|
+
* one of them would quietly turn "the desktop band declares no width" into
|
|
49
|
+
* "some breakpoint somewhere does not".
|
|
50
|
+
*/
|
|
51
|
+
const ruleFor = (css: string, selector: string): string => {
|
|
52
|
+
const escaped = selector.replace(/[[\]().*+?^$|\\{}]/g, '\\$&');
|
|
53
|
+
return new RegExp(`^${escaped} \\{([^}]*)\\}`, 'm').exec(css)?.[1] ?? '';
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('content width constrains the content and nothing else', () => {
|
|
57
|
+
it('writes the width onto the box inside the band, not the band', () => {
|
|
58
|
+
const css = generateThemeCSS(section({ 'content-width': '1024px' }));
|
|
59
|
+
|
|
60
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
|
|
61
|
+
// The band is what a background paints across. A `max-width` here is the
|
|
62
|
+
// whole bug this setting exists to avoid.
|
|
63
|
+
expect(ruleFor(css, BAND_SELECTOR)).not.toContain('max-width');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('centres it — a constrained column against the left edge is not what the control means', () => {
|
|
67
|
+
const rule = ruleFor(
|
|
68
|
+
generateThemeCSS(section({ 'content-width': '768px' })),
|
|
69
|
+
CONTENT_SELECTOR
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(rule).toContain('margin-left: auto');
|
|
73
|
+
expect(rule).toContain('margin-right: auto');
|
|
74
|
+
// Without it the box is a flex item in any section that arranges its
|
|
75
|
+
// children, and a flex item shrinks to its content rather than reaching
|
|
76
|
+
// the width it is being capped at.
|
|
77
|
+
expect(rule).toContain('width: 100%');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('leaves the background, the border and the padding on the full-width band', () => {
|
|
81
|
+
const css = generateThemeCSS(
|
|
82
|
+
section({
|
|
83
|
+
'content-width': '1024px',
|
|
84
|
+
'background-color': '#101010',
|
|
85
|
+
'background-image': 'url(/hero.jpg)',
|
|
86
|
+
'border-bottom': '1px solid #333',
|
|
87
|
+
'padding-left': '16px',
|
|
88
|
+
'padding-right': '16px'
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
const band = ruleFor(css, BAND_SELECTOR);
|
|
92
|
+
|
|
93
|
+
// Written as rgba: the generator folds the section's background opacity
|
|
94
|
+
// into the colour before it emits it.
|
|
95
|
+
expect(band).toContain('background-color: rgba(16, 16, 16, 1)');
|
|
96
|
+
expect(band).toContain('background-image: url(/hero.jpg)');
|
|
97
|
+
expect(band).toContain('border-bottom: 1px solid #333');
|
|
98
|
+
// The padding stays on the band on purpose: the constrained box sits
|
|
99
|
+
// inside the padding box, so on a screen narrower than the measure it is
|
|
100
|
+
// the padding that keeps the words off the edge of the phone.
|
|
101
|
+
expect(band).toContain('padding-left: 16px');
|
|
102
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).not.toContain('padding');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never emits the helper keys themselves — neither is CSS', () => {
|
|
106
|
+
const css = generateThemeCSS(
|
|
107
|
+
section({ 'content-width': 'custom', 'content-width-custom': '900px' })
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(css).not.toContain('content-width:');
|
|
111
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 900px');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reads the camelCase spellings published JSON also carries', () => {
|
|
115
|
+
const css = generateThemeCSS(
|
|
116
|
+
section({ contentWidth: 'custom', contentWidthCustom: '60rem' })
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 60rem');
|
|
120
|
+
expect(css).not.toContain('contentWidth');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('treats a half-finished custom value as not authored yet', () => {
|
|
124
|
+
// Choosing Custom and typing nothing is mid-edit, not a decision to go
|
|
125
|
+
// full width — so nothing is written and the band renders as it did.
|
|
126
|
+
const css = generateThemeCSS(
|
|
127
|
+
section({ 'content-width': 'custom', 'content-width-custom': '' })
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(css).not.toContain(SECTION_CONTENT_ATTRIBUTE);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('content width per breakpoint', () => {
|
|
135
|
+
it('constrains a desktop and lets a phone use the whole screen', () => {
|
|
136
|
+
const css = generateThemeCSS(
|
|
137
|
+
section({ 'content-width': { desktop: '1280px', mobile: 'full' } })
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1280px');
|
|
141
|
+
expect(css).toContain(MOBILE_MEDIA_QUERY);
|
|
142
|
+
// `full` is the editor's word for it and is not CSS. It has to be emitted
|
|
143
|
+
// as something rather than dropped: a dropped declaration would leave the
|
|
144
|
+
// desktop rule cascading down onto the breakpoint that opted out of it.
|
|
145
|
+
expect(css).toContain('max-width: none');
|
|
146
|
+
expect(css).not.toContain('max-width: full');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('lets a tablet take a measure of its own', () => {
|
|
150
|
+
const css = generateThemeCSS(
|
|
151
|
+
section({
|
|
152
|
+
'content-width': { desktop: '1280px', tablet: '768px', mobile: 'full' }
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(css).toContain(TABLET_MEDIA_QUERY);
|
|
157
|
+
// After the mobile query, which is the entire mechanism by which a tablet
|
|
158
|
+
// beats the value it would otherwise inherit.
|
|
159
|
+
expect(css.indexOf(TABLET_MEDIA_QUERY)).toBeGreaterThan(
|
|
160
|
+
css.indexOf(MOBILE_MEDIA_QUERY)
|
|
161
|
+
);
|
|
162
|
+
expect(css).toContain('max-width: 768px');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('takes the free-value box per breakpoint too', () => {
|
|
166
|
+
const css = generateThemeCSS(
|
|
167
|
+
section({
|
|
168
|
+
'content-width': { desktop: 'custom', mobile: '640px' },
|
|
169
|
+
'content-width-custom': { desktop: '1100px' }
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1100px');
|
|
174
|
+
expect(css).toContain('max-width: 640px');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('emits one rule, not three, when one measure covers every width', () => {
|
|
178
|
+
const css = generateThemeCSS(section({ 'content-width': '1024px' }));
|
|
179
|
+
|
|
180
|
+
expect(css).not.toContain(MOBILE_MEDIA_QUERY);
|
|
181
|
+
expect(css).not.toContain(TABLET_MEDIA_QUERY);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('resolves to one width on the editor canvas, which has no viewport to query', () => {
|
|
185
|
+
// The canvas is a desktop-width iframe simulating a phone, so the media
|
|
186
|
+
// queries a phone would match never fire there. It asks for one breakpoint
|
|
187
|
+
// and gets unconditional rules — which is the whole reason the constraint
|
|
188
|
+
// is emitted through the same generator as everything else.
|
|
189
|
+
const styles = { 'content-width': { desktop: '1280px', mobile: 'full' } };
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
ruleFor(generateThemeCSS(section(styles), 'mobile'), CONTENT_SELECTOR)
|
|
193
|
+
).toContain('max-width: none');
|
|
194
|
+
expect(
|
|
195
|
+
ruleFor(generateThemeCSS(section(styles), 'desktop'), CONTENT_SELECTOR)
|
|
196
|
+
).toContain('max-width: 1280px');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('a section that arranges its children keeps arranging them', () => {
|
|
201
|
+
it('moves the layout onto the box that now holds the blocks', () => {
|
|
202
|
+
// On the generic path the wrapper is `display: contents` until a content
|
|
203
|
+
// width appears, so the blocks are the section's own children for layout
|
|
204
|
+
// purposes. Turning the wrapper into a real box without this move would
|
|
205
|
+
// stack a row the designer already had — a break at the exact moment they
|
|
206
|
+
// first touch the setting.
|
|
207
|
+
const css = generateThemeCSS(
|
|
208
|
+
section({
|
|
209
|
+
'content-width': '1024px',
|
|
210
|
+
display: 'flex',
|
|
211
|
+
'justify-content': 'space-between',
|
|
212
|
+
gap: '24px'
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const content = ruleFor(css, CONTENT_SELECTOR);
|
|
217
|
+
expect(content).toContain('display: flex');
|
|
218
|
+
expect(content).toContain('justify-content: space-between');
|
|
219
|
+
expect(content).toContain('gap: 24px');
|
|
220
|
+
expect(ruleFor(css, BAND_SELECTOR)).not.toContain('display');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('leaves a hidden breakpoint hidden on the band itself', () => {
|
|
224
|
+
// `display: none` hides the whole band. Moved onto the inner box it would
|
|
225
|
+
// hide the content and leave the background painting over the gap.
|
|
226
|
+
const css = generateThemeCSS(
|
|
227
|
+
section({
|
|
228
|
+
'content-width': '1024px',
|
|
229
|
+
display: { desktop: 'grid', mobile: 'none' }
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(ruleFor(css, BAND_SELECTOR)).toBe('');
|
|
234
|
+
expect(css).toContain(`${MOBILE_MEDIA_QUERY} {`);
|
|
235
|
+
expect(css).toContain('display: none');
|
|
236
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('display: grid');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('leaves a plain display where it was', () => {
|
|
240
|
+
const css = generateThemeCSS(
|
|
241
|
+
section({ 'content-width': '1024px', display: 'block' })
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(ruleFor(css, BAND_SELECTOR)).toContain('display: block');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('moves nothing on a section that renders through its own component', () => {
|
|
248
|
+
// Those already draw a real box around their children, so the section's
|
|
249
|
+
// display already applies to it. Moving would change a page that renders
|
|
250
|
+
// correctly today.
|
|
251
|
+
const css = generateThemeCSS(
|
|
252
|
+
section(
|
|
253
|
+
{ 'content-width': '1024px', display: 'flex' },
|
|
254
|
+
{ type: 'stats-counter' }
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(ruleFor(css, BAND_SELECTOR)).toContain('display: flex');
|
|
259
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('the setting is additive', () => {
|
|
264
|
+
it('changes nothing for a section that does not use it', () => {
|
|
265
|
+
const styles = {
|
|
266
|
+
'background-color': '#fff',
|
|
267
|
+
display: 'flex',
|
|
268
|
+
gap: '16px',
|
|
269
|
+
'padding-top': '40px'
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
expect(generateThemeCSS(section(styles))).not.toContain(
|
|
273
|
+
SECTION_CONTENT_ATTRIBUTE
|
|
274
|
+
);
|
|
275
|
+
// Returned by reference, so the generator is handed the same object it
|
|
276
|
+
// always was and produces the same bytes.
|
|
277
|
+
expect(splitSectionContentWidth(styles).section).toBe(styles);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('leaves the section-level Max Width doing exactly what it did', () => {
|
|
281
|
+
// Two independent decisions, both honoured: the band held to 80rem and the
|
|
282
|
+
// content to 1024px inside it. Pages published against the older control
|
|
283
|
+
// must not move.
|
|
284
|
+
const css = generateThemeCSS(
|
|
285
|
+
section({ 'max-width': 'normal', 'content-width': '1024px' })
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const band = ruleFor(css, BAND_SELECTOR);
|
|
289
|
+
expect(band).toContain('max-width: 80rem');
|
|
290
|
+
expect(band).toContain('margin-left: auto');
|
|
291
|
+
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('scopes the rule to a direct child, so a nested band is never caught', () => {
|
|
295
|
+
expect(generateThemeCSS(section({ 'content-width': '640px' }))).toContain(
|
|
296
|
+
`> [${SECTION_CONTENT_ATTRIBUTE}]`
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('hasSectionContentWidth', () => {
|
|
302
|
+
it.each([
|
|
303
|
+
['nothing authored', undefined, false],
|
|
304
|
+
['an empty selection', { 'content-width': '' }, false],
|
|
305
|
+
['a length', { 'content-width': '768px' }, true],
|
|
306
|
+
['full width', { 'content-width': 'full' }, true],
|
|
307
|
+
[
|
|
308
|
+
'custom with a value',
|
|
309
|
+
{ 'content-width': 'custom', 'content-width-custom': '900px' },
|
|
310
|
+
true
|
|
311
|
+
],
|
|
312
|
+
['custom with nothing typed', { 'content-width': 'custom' }, false],
|
|
313
|
+
['a phone only', { 'content-width': { mobile: '640px' } }, true]
|
|
314
|
+
])('answers %s', (_label, styles, expected) => {
|
|
315
|
+
expect(hasSectionContentWidth(styles as Record<string, unknown>)).toBe(
|
|
316
|
+
expected
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -27,6 +27,10 @@ import {
|
|
|
27
27
|
isSectionMaxWidthToken,
|
|
28
28
|
resolveSectionMaxWidthValue
|
|
29
29
|
} from './section-max-width';
|
|
30
|
+
import {
|
|
31
|
+
SECTION_CONTENT_ATTRIBUTE,
|
|
32
|
+
splitSectionContentWidth
|
|
33
|
+
} from './section-content-width';
|
|
30
34
|
import { hasCustomSectionRenderer } from '../sections/custom-renderer-section-types';
|
|
31
35
|
|
|
32
36
|
export * from './publish-window';
|
|
@@ -35,6 +39,7 @@ export * from './custom-css';
|
|
|
35
39
|
export * from './breakpoints';
|
|
36
40
|
export * from './checkout-safety';
|
|
37
41
|
export * from './section-max-width';
|
|
42
|
+
export * from './section-content-width';
|
|
38
43
|
|
|
39
44
|
export type ImageLoadingStrategy = 'eager' | 'lazy';
|
|
40
45
|
export type ImageFetchPriority = 'high' | 'auto' | 'low';
|
|
@@ -1426,13 +1431,36 @@ export const generateThemeCSS = (
|
|
|
1426
1431
|
const { root: sectionRoot } = splitPartStyles(sectionStyles);
|
|
1427
1432
|
|
|
1428
1433
|
if (Object.keys(sectionRoot).length > 0) {
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1434
|
+
const customRenderer = hasCustomSectionRenderer(section);
|
|
1435
|
+
|
|
1436
|
+
// The content width leaves the band before anything else is decided
|
|
1437
|
+
// about it: it is not a declaration a section ever carries, and on the
|
|
1438
|
+
// generic path it takes the section's child layout with it — see
|
|
1439
|
+
// utils/section-content-width.ts for why that move exists.
|
|
1440
|
+
const { section: band, content } = splitSectionContentWidth(
|
|
1441
|
+
sectionRoot,
|
|
1442
|
+
{ moveLayout: !customRenderer }
|
|
1435
1443
|
);
|
|
1444
|
+
|
|
1445
|
+
if (Object.keys(band).length > 0) {
|
|
1446
|
+
css += generateElementCSS(
|
|
1447
|
+
sectionSelector,
|
|
1448
|
+
customRenderer ? band : resolveSectionMaxWidthTokens(band),
|
|
1449
|
+
targetBreakpoint
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Direct child, so a nested section's wrapper is never caught by an
|
|
1454
|
+
// ancestor's rule. Emitted after the band on purpose: the two address
|
|
1455
|
+
// different elements, but reading the sheet, the band is the thing the
|
|
1456
|
+
// constraint is being carved out of.
|
|
1457
|
+
if (content) {
|
|
1458
|
+
css += generateElementCSS(
|
|
1459
|
+
`${sectionSelector} > [${SECTION_CONTENT_ATTRIBUTE}]`,
|
|
1460
|
+
content as ResponsiveStyles,
|
|
1461
|
+
targetBreakpoint
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1436
1464
|
}
|
|
1437
1465
|
|
|
1438
1466
|
css += generateRootStateCSS(
|
|
@@ -1441,11 +1469,7 @@ export const generateThemeCSS = (
|
|
|
1441
1469
|
targetBreakpoint
|
|
1442
1470
|
);
|
|
1443
1471
|
|
|
1444
|
-
css += generatePartCSS(
|
|
1445
|
-
sectionSelector,
|
|
1446
|
-
sectionStyles,
|
|
1447
|
-
targetBreakpoint
|
|
1448
|
-
);
|
|
1472
|
+
css += generatePartCSS(sectionSelector, sectionStyles, targetBreakpoint);
|
|
1449
1473
|
}
|
|
1450
1474
|
|
|
1451
1475
|
if (section.properties) {
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { BREAKPOINTS, BREAKPOINT_FALLBACKS } from './breakpoints';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How wide the CONTENT of a band is allowed to get, as opposed to how wide the
|
|
5
|
+
* band is.
|
|
6
|
+
*
|
|
7
|
+
* A section already had a `max-width` (Narrow / Normal / None), but that key
|
|
8
|
+
* lands on the `<section>` itself: constraining it pulls the background colour,
|
|
9
|
+
* the background image and the border in with the text. What a design almost
|
|
10
|
+
* always wants is the other split — colour from edge to edge, words held to a
|
|
11
|
+
* measure and centred — and the only way to express it was to add one of five
|
|
12
|
+
* prebuilt "container" starters and rebuild the band inside it.
|
|
13
|
+
*
|
|
14
|
+
* `content-width` is that split as a setting. It never reaches the section's
|
|
15
|
+
* own box: it is resolved here into a rule for the wrapper every section
|
|
16
|
+
* already renders around its blocks (`data-section-content`), so the band stays
|
|
17
|
+
* full-bleed and only what is inside it is constrained. The section's
|
|
18
|
+
* horizontal padding keeps working, because the wrapper sits inside the padding
|
|
19
|
+
* box — which is what stops content touching the screen edge on a phone.
|
|
20
|
+
*
|
|
21
|
+
* The value is a real CSS length (`1024px`), not a token: the editor writes the
|
|
22
|
+
* scale it offers, so there is no vocabulary for the two repos to keep in sync.
|
|
23
|
+
* Only three spellings mean something here — `full` and `none` for "not
|
|
24
|
+
* constrained", and `custom`, which defers to the free-length box beside the
|
|
25
|
+
* select.
|
|
26
|
+
*/
|
|
27
|
+
export const CONTENT_WIDTH_STYLE_KEY = 'content-width';
|
|
28
|
+
export const CONTENT_WIDTH_CUSTOM_STYLE_KEY = 'content-width-custom';
|
|
29
|
+
|
|
30
|
+
/** camelCase spellings, because published JSON carries both. */
|
|
31
|
+
const CONTENT_WIDTH_KEYS = [CONTENT_WIDTH_STYLE_KEY, 'contentWidth'] as const;
|
|
32
|
+
const CONTENT_WIDTH_CUSTOM_KEYS = [
|
|
33
|
+
CONTENT_WIDTH_CUSTOM_STYLE_KEY,
|
|
34
|
+
'contentWidthCustom'
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** The attribute both render paths stamp on the box that holds the blocks. */
|
|
38
|
+
export const SECTION_CONTENT_ATTRIBUTE = 'data-section-content';
|
|
39
|
+
|
|
40
|
+
/** The option the free-length box is gated on. */
|
|
41
|
+
const CUSTOM = 'custom';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two spellings of "do not constrain me". `full` is the editor's word and
|
|
45
|
+
* is not CSS, so it resolves to the one the browser understands — and it has to
|
|
46
|
+
* resolve to something rather than be dropped, because dropping it would let a
|
|
47
|
+
* desktop width cascade down to the breakpoint that explicitly opted out.
|
|
48
|
+
*/
|
|
49
|
+
const UNCONSTRAINED = new Set(['full', 'none']);
|
|
50
|
+
|
|
51
|
+
type Styles = Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
const isResponsive = (value: unknown): value is Record<string, unknown> =>
|
|
54
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
55
|
+
|
|
56
|
+
const read = (styles: Styles, keys: readonly string[]): unknown => {
|
|
57
|
+
for (const key of keys) {
|
|
58
|
+
const value = styles[key];
|
|
59
|
+
if (value !== undefined) return value;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One breakpoint's value out of a responsive entry, following the same chain as
|
|
66
|
+
* `getResponsiveValue` — a tablet reads the mobile value before the desktop one
|
|
67
|
+
* — so what this resolves and what the rest of the renderer resolves cannot
|
|
68
|
+
* disagree.
|
|
69
|
+
*/
|
|
70
|
+
const at = (value: unknown, breakpoint: string): unknown => {
|
|
71
|
+
if (!isResponsive(value)) return value;
|
|
72
|
+
|
|
73
|
+
const own = value[breakpoint];
|
|
74
|
+
if (own !== undefined) return own;
|
|
75
|
+
|
|
76
|
+
for (const next of BREAKPOINT_FALLBACKS[breakpoint] ?? []) {
|
|
77
|
+
if (value[next] !== undefined) return value[next];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return value.desktop;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `max-width` one breakpoint should carry, or `''` for "nothing authored
|
|
85
|
+
* here" — which is not the same as "unconstrained": an unauthored breakpoint
|
|
86
|
+
* inherits, an explicitly full one overrides.
|
|
87
|
+
*
|
|
88
|
+
* A `custom` selection with an empty box is treated as unauthored rather than
|
|
89
|
+
* as full width, so a half-typed value behaves like the setting not being there
|
|
90
|
+
* yet instead of silently widening the band.
|
|
91
|
+
*/
|
|
92
|
+
export const resolveContentWidthValue = (
|
|
93
|
+
choice: unknown,
|
|
94
|
+
customValue: unknown
|
|
95
|
+
): string => {
|
|
96
|
+
if (choice === undefined || choice === null || choice === '') return '';
|
|
97
|
+
|
|
98
|
+
const normalized = String(choice).trim();
|
|
99
|
+
if (!normalized) return '';
|
|
100
|
+
if (UNCONSTRAINED.has(normalized.toLowerCase())) return 'none';
|
|
101
|
+
|
|
102
|
+
if (normalized.toLowerCase() === CUSTOM) {
|
|
103
|
+
const custom =
|
|
104
|
+
customValue === undefined || customValue === null
|
|
105
|
+
? ''
|
|
106
|
+
: String(customValue).trim();
|
|
107
|
+
return custom;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return normalized;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Whether this section constrains its content at any breakpoint. */
|
|
114
|
+
export const hasSectionContentWidth = (
|
|
115
|
+
styles: Styles | undefined | null
|
|
116
|
+
): boolean => {
|
|
117
|
+
if (!styles) return false;
|
|
118
|
+
|
|
119
|
+
const choice = read(styles, CONTENT_WIDTH_KEYS);
|
|
120
|
+
const custom = read(styles, CONTENT_WIDTH_CUSTOM_KEYS);
|
|
121
|
+
if (choice === undefined) return false;
|
|
122
|
+
|
|
123
|
+
if (!isResponsive(choice)) {
|
|
124
|
+
return resolveContentWidthValue(choice, at(custom, 'desktop')) !== '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return BREAKPOINTS.some(
|
|
128
|
+
(breakpoint) =>
|
|
129
|
+
choice[breakpoint] !== undefined &&
|
|
130
|
+
resolveContentWidthValue(choice[breakpoint], at(custom, breakpoint)) !==
|
|
131
|
+
''
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Declarations that arrange a section's CHILDREN rather than draw the section.
|
|
137
|
+
*
|
|
138
|
+
* They matter here because of what the wrapper is: on the generic path it is
|
|
139
|
+
* `display: contents` today, so a section set to Flex lays its blocks out
|
|
140
|
+
* directly. Turning that wrapper into a real box to constrain it would make the
|
|
141
|
+
* blocks stack — the row a designer already had would break the moment they
|
|
142
|
+
* touched the new setting. Moving these onto the wrapper keeps the arrangement
|
|
143
|
+
* and changes only where it is declared; the section is left a plain block,
|
|
144
|
+
* which is what a `<section>` is anyway.
|
|
145
|
+
*
|
|
146
|
+
* The list is the arranging half of the box model only. Padding, background,
|
|
147
|
+
* border and position stay on the band, which is the entire point of the
|
|
148
|
+
* setting.
|
|
149
|
+
*/
|
|
150
|
+
const CHILD_LAYOUT_KEYS = new Set([
|
|
151
|
+
'flex-direction',
|
|
152
|
+
'flex-wrap',
|
|
153
|
+
'flex-flow',
|
|
154
|
+
'justify-content',
|
|
155
|
+
'justify-items',
|
|
156
|
+
'align-items',
|
|
157
|
+
'align-content',
|
|
158
|
+
'place-items',
|
|
159
|
+
'place-content',
|
|
160
|
+
'gap',
|
|
161
|
+
'row-gap',
|
|
162
|
+
'column-gap',
|
|
163
|
+
'grid-template-columns',
|
|
164
|
+
'grid-template-rows',
|
|
165
|
+
'grid-template-areas',
|
|
166
|
+
'grid-auto-flow',
|
|
167
|
+
'grid-auto-columns',
|
|
168
|
+
'grid-auto-rows'
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
/** The `display` values that make an element arrange its children. */
|
|
172
|
+
const CHILD_LAYOUT_DISPLAYS = new Set([
|
|
173
|
+
'flex',
|
|
174
|
+
'inline-flex',
|
|
175
|
+
'grid',
|
|
176
|
+
'inline-grid'
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const kebab = (key: string): string =>
|
|
180
|
+
key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Splits `display` between the band and the wrapper, breakpoint by breakpoint.
|
|
184
|
+
*
|
|
185
|
+
* Only the arranging values move. `none` in particular must stay behind: it
|
|
186
|
+
* hides the whole band, and moving it would leave the background painting over
|
|
187
|
+
* a breakpoint the designer switched off.
|
|
188
|
+
*/
|
|
189
|
+
const splitDisplay = (
|
|
190
|
+
value: unknown
|
|
191
|
+
): { section?: unknown; content?: unknown } => {
|
|
192
|
+
const moves = (candidate: unknown): boolean =>
|
|
193
|
+
typeof candidate === 'string' &&
|
|
194
|
+
CHILD_LAYOUT_DISPLAYS.has(candidate.trim().toLowerCase());
|
|
195
|
+
|
|
196
|
+
if (!isResponsive(value)) {
|
|
197
|
+
return moves(value) ? { content: value } : { section: value };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const section: Record<string, unknown> = {};
|
|
201
|
+
const content: Record<string, unknown> = {};
|
|
202
|
+
|
|
203
|
+
Object.entries(value).forEach(([breakpoint, breakpointValue]) => {
|
|
204
|
+
if (moves(breakpointValue)) content[breakpoint] = breakpointValue;
|
|
205
|
+
else section[breakpoint] = breakpointValue;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
section: Object.keys(section).length > 0 ? section : undefined,
|
|
210
|
+
content: Object.keys(content).length > 0 ? content : undefined
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The `max-width` entry the wrapper should carry, in whichever shape the
|
|
216
|
+
* authored value was in — a scalar stays a scalar, so a section constrained at
|
|
217
|
+
* every width emits one rule rather than three.
|
|
218
|
+
*/
|
|
219
|
+
const buildMaxWidth = (styles: Styles): string | Record<string, string> => {
|
|
220
|
+
const choice = read(styles, CONTENT_WIDTH_KEYS);
|
|
221
|
+
const custom = read(styles, CONTENT_WIDTH_CUSTOM_KEYS);
|
|
222
|
+
|
|
223
|
+
if (!isResponsive(choice)) {
|
|
224
|
+
return resolveContentWidthValue(choice, at(custom, 'desktop'));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entries: Array<[string, string]> = [];
|
|
228
|
+
|
|
229
|
+
BREAKPOINTS.forEach((breakpoint) => {
|
|
230
|
+
if (choice[breakpoint] === undefined) return;
|
|
231
|
+
|
|
232
|
+
const resolved = resolveContentWidthValue(
|
|
233
|
+
choice[breakpoint],
|
|
234
|
+
at(custom, breakpoint)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (resolved !== '') entries.push([breakpoint, resolved]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return Object.fromEntries(entries);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export interface SectionContentWidthSplit {
|
|
244
|
+
/** What the band itself still declares — always returned, never null. */
|
|
245
|
+
section: Styles;
|
|
246
|
+
/** The wrapper's own rule, or null when this section constrains nothing. */
|
|
247
|
+
content: Styles | null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Takes a section root's styles apart into the band and the box inside it.
|
|
252
|
+
*
|
|
253
|
+
* `moveLayout` is false for a section that renders through its own component:
|
|
254
|
+
* those already draw a real box around their children, so the section's
|
|
255
|
+
* `display` already applies to that box and moving it would change a page that
|
|
256
|
+
* renders correctly today.
|
|
257
|
+
*
|
|
258
|
+
* A section with no content width authored gets its styles back by reference,
|
|
259
|
+
* so every composition that renders today produces byte-identical CSS.
|
|
260
|
+
*/
|
|
261
|
+
export const splitSectionContentWidth = (
|
|
262
|
+
styles: Styles,
|
|
263
|
+
options: { moveLayout?: boolean } = {}
|
|
264
|
+
): SectionContentWidthSplit => {
|
|
265
|
+
const maxWidth = hasSectionContentWidth(styles) ? buildMaxWidth(styles) : '';
|
|
266
|
+
const constrained =
|
|
267
|
+
typeof maxWidth === 'string'
|
|
268
|
+
? maxWidth !== ''
|
|
269
|
+
: Object.keys(maxWidth).length > 0;
|
|
270
|
+
|
|
271
|
+
const carriesHelperKey = Object.keys(styles).some((key) => {
|
|
272
|
+
const normalized = kebab(key);
|
|
273
|
+
return (
|
|
274
|
+
normalized === CONTENT_WIDTH_STYLE_KEY ||
|
|
275
|
+
normalized === CONTENT_WIDTH_CUSTOM_STYLE_KEY
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!constrained && !carriesHelperKey) {
|
|
280
|
+
return { section: styles, content: null };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const section: Styles = {};
|
|
284
|
+
const moved: Styles = {};
|
|
285
|
+
|
|
286
|
+
Object.entries(styles).forEach(([key, value]) => {
|
|
287
|
+
const normalized = kebab(key);
|
|
288
|
+
|
|
289
|
+
// Neither is a CSS property. Emitted as-is they would be a declaration the
|
|
290
|
+
// browser throws away, which is exactly the failure the old `max-width`
|
|
291
|
+
// token had on the default render path.
|
|
292
|
+
if (
|
|
293
|
+
normalized === CONTENT_WIDTH_STYLE_KEY ||
|
|
294
|
+
normalized === CONTENT_WIDTH_CUSTOM_STYLE_KEY
|
|
295
|
+
) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!constrained || !options.moveLayout) {
|
|
300
|
+
section[key] = value;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (normalized === 'display') {
|
|
305
|
+
const split = splitDisplay(value);
|
|
306
|
+
if (split.section !== undefined) section[key] = split.section;
|
|
307
|
+
if (split.content !== undefined) moved[normalized] = split.content;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (CHILD_LAYOUT_KEYS.has(normalized)) {
|
|
312
|
+
moved[normalized] = value;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
section[key] = value;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!constrained) return { section, content: null };
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
section,
|
|
323
|
+
// `width` first, and always: a wrapper that is a flex item — which is what
|
|
324
|
+
// it becomes inside a section that arranges its children — would otherwise
|
|
325
|
+
// shrink to its content and never reach the width it is being capped at.
|
|
326
|
+
// Auto margins are what centre it; they are inert wherever the width is
|
|
327
|
+
// unconstrained, so a desktop-only constraint still leaves a phone
|
|
328
|
+
// full-bleed.
|
|
329
|
+
content: {
|
|
330
|
+
width: '100%',
|
|
331
|
+
'max-width': maxWidth,
|
|
332
|
+
'margin-left': 'auto',
|
|
333
|
+
'margin-right': 'auto',
|
|
334
|
+
...moved
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
};
|