@akinon/pz-theme 2.0.78-rc.2 → 2.0.79-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -37
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +73 -0
- package/src/chrome/__tests__/chrome-mode.test.ts +174 -0
- package/src/chrome/chrome-mode.ts +61 -0
- package/src/custom/README-sections.md +289 -0
- package/src/custom/README.md +7 -0
- package/src/custom/__tests__/custom-section-bindings.test.tsx +327 -0
- package/src/custom/__tests__/define-section.test.tsx +481 -0
- package/src/custom/__tests__/module-boundaries.test.ts +2 -0
- package/src/custom/__tests__/public-api.test.tsx +123 -0
- package/src/custom/__tests__/region-node-wire.test.tsx +302 -0
- package/src/custom/__tests__/region-nodes.test.ts +161 -0
- package/src/custom/__tests__/theme-editable.test.tsx +22 -10
- package/src/custom/__tests__/theme-region.test.tsx +331 -0
- package/src/custom/__tests__/theme-slot.test.tsx +18 -10
- package/src/custom/__tests__/use-theme-setting.test.tsx +17 -9
- package/src/custom/block-context.ts +53 -10
- package/src/custom/block-host.tsx +13 -1
- package/src/custom/collect.ts +26 -5
- package/src/custom/define-section.tsx +163 -0
- package/src/custom/index.ts +83 -1
- package/src/custom/region-nodes.ts +72 -0
- package/src/custom/register.ts +419 -48
- package/src/custom/section-catalog.ts +98 -0
- package/src/custom/section-definitions.ts +59 -0
- package/src/custom/section-host.tsx +125 -0
- package/src/custom/section-types.ts +158 -0
- package/src/custom/theme-editable.tsx +3 -1
- package/src/custom/theme-region.tsx +213 -0
- package/src/custom/theme-slot.tsx +8 -7
- package/src/custom/types.ts +10 -1
- package/src/custom/use-theme-setting.ts +6 -4
- package/src/hooks/use-designer-features.tsx +41 -0
- package/src/index.ts +25 -1
- package/src/placeholder-registry.ts +34 -5
- package/src/sections/__tests__/custom-renderer-section-types.test.ts +29 -0
- package/src/sections/__tests__/section-part-selection.test.tsx +277 -0
- package/src/sections/custom-renderer-section-types.ts +19 -0
- package/src/sections/section-wrapper.tsx +12 -1
- package/src/theme-section.tsx +162 -3
- package/src/utils/__tests__/docked-state-styles.test.ts +249 -0
- package/src/utils/__tests__/section-content-width.test.ts +28 -0
- package/src/utils/__tests__/section-max-width.test.ts +53 -0
- package/src/utils/index.ts +31 -3
- package/src/utils/part-styles.ts +17 -1
package/src/theme-section.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge';
|
|
|
6
6
|
import clsx from 'clsx';
|
|
7
7
|
import { useEffect, useMemo, useRef } from 'react';
|
|
8
8
|
import SectionWrapper from './sections/section-wrapper';
|
|
9
|
+
import { resolveSectionPartName } from './hooks/use-designer-features';
|
|
9
10
|
import sectionRendererRegistry from './sections/section-renderer-registry';
|
|
10
11
|
import {
|
|
11
12
|
buildIteratorBlock,
|
|
@@ -22,6 +23,10 @@ import {
|
|
|
22
23
|
import { safeDeepClone } from './utils/safe-clone';
|
|
23
24
|
|
|
24
25
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
26
|
+
import { DesignerPlaceholder } from './blocks/product-block-utils';
|
|
27
|
+
import { CustomSectionHost } from './custom/section-host';
|
|
28
|
+
import { getCustomSectionDefinition } from './custom/section-definitions';
|
|
29
|
+
import { isCustomThemeType } from './custom/types';
|
|
25
30
|
import { ThemeSectionDataProvider } from './theme-section-data-context';
|
|
26
31
|
import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
27
32
|
export interface Section {
|
|
@@ -159,12 +164,22 @@ export default function ThemeSection({
|
|
|
159
164
|
e.stopPropagation();
|
|
160
165
|
onSelect(effectiveSection.id);
|
|
161
166
|
|
|
167
|
+
// The same helper the wrapper uses. A generic section carries no
|
|
168
|
+
// developer-authored parts today, but the two paths posting different
|
|
169
|
+
// payload shapes for one message is how a receiver ends up written
|
|
170
|
+
// against whichever one its author happened to read.
|
|
171
|
+
const partName = resolveSectionPartName(
|
|
172
|
+
e.target as Element | null,
|
|
173
|
+
e.currentTarget as Element
|
|
174
|
+
);
|
|
175
|
+
|
|
162
176
|
if (window.parent) {
|
|
163
177
|
postToEditor({
|
|
164
178
|
type: 'SELECT_SECTION',
|
|
165
179
|
data: {
|
|
166
180
|
placeholderId,
|
|
167
|
-
sectionId: effectiveSection.id
|
|
181
|
+
sectionId: effectiveSection.id,
|
|
182
|
+
...(partName ? { partName } : {})
|
|
168
183
|
}
|
|
169
184
|
});
|
|
170
185
|
}
|
|
@@ -456,6 +471,135 @@ export default function ThemeSection({
|
|
|
456
471
|
return null;
|
|
457
472
|
};
|
|
458
473
|
|
|
474
|
+
// A DEVELOPER-AUTHORED SECTION WINS OVER EVERYTHING BELOW, including the tab
|
|
475
|
+
// hatch two lines down. That hatch claims any section containing a `tab`
|
|
476
|
+
// block, and a custom section's regions may legitimately contain one — a tab
|
|
477
|
+
// strip inside a brand's own product page is ordinary content, and letting
|
|
478
|
+
// TabsSection take the whole section over it would replace the brand's markup
|
|
479
|
+
// with a tab strip and nothing would say why.
|
|
480
|
+
//
|
|
481
|
+
// It renders inside the SAME `SectionWrapper` a registered renderer gets.
|
|
482
|
+
// That wrapper is what supplies `<section data-section-id>`, the designer
|
|
483
|
+
// outline, the action toolbar and the `data-section-content` element the
|
|
484
|
+
// content-width rule targets — i.e. it is exactly what gives a custom section
|
|
485
|
+
// the full section root panel, which is the whole reason this is a section
|
|
486
|
+
// rather than a block.
|
|
487
|
+
//
|
|
488
|
+
// The blocks inside it get the SAME resolution the generic loop gives its own
|
|
489
|
+
// top-level blocks. This branch used to skip it on the reasoning that no
|
|
490
|
+
// registered renderer resolves bindings either — which read the situation
|
|
491
|
+
// backwards. A registered renderer draws its own content; a custom section's
|
|
492
|
+
// regions and slots hold the MERCHANT's blocks, the same blocks they authored
|
|
493
|
+
// under a generic section root. Skipping the pass meant a `{{...}}` token, a
|
|
494
|
+
// `properties.dataBinding` and an `isIterator` block all fell back to their
|
|
495
|
+
// authored value the moment a brand rewrote a working composed header as a
|
|
496
|
+
// custom section — no console output in the designer, none on the storefront,
|
|
497
|
+
// nothing in the diff. `useThemePageContext()` is still how the section's own
|
|
498
|
+
// JSX reads commerce data; it was never a route for the merchant's blocks.
|
|
499
|
+
if (isCustomThemeType(effectiveSection.type)) {
|
|
500
|
+
const sectionDefinition = getCustomSectionDefinition(
|
|
501
|
+
effectiveSection.type
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
if (!sectionDefinition) {
|
|
505
|
+
devWarn(
|
|
506
|
+
'ThemeSection',
|
|
507
|
+
`No renderer registered for custom section type: "${effectiveSection.type}". ` +
|
|
508
|
+
`The composition holds a section this build cannot draw — check that the barrel calling ` +
|
|
509
|
+
`registerCustomSections() is imported from the CLIENT ROOT, and that the section name ` +
|
|
510
|
+
`still matches. Nothing is rendered in its place.`
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
// Never the generic block loop. A section whose type this build does not
|
|
514
|
+
// register would otherwise spill its seeded region blocks onto the page
|
|
515
|
+
// as loose top-level content, in an order the developer never chose.
|
|
516
|
+
// The designer still gets a selectable, deletable band.
|
|
517
|
+
return isDesigner ? (
|
|
518
|
+
<SectionWrapper
|
|
519
|
+
section={effectiveSection}
|
|
520
|
+
placeholderId={placeholderId}
|
|
521
|
+
isDesigner={isDesigner}
|
|
522
|
+
isSelected={isSelected}
|
|
523
|
+
onSelect={onSelect}
|
|
524
|
+
onMoveUp={onMoveUp}
|
|
525
|
+
onMoveDown={onMoveDown}
|
|
526
|
+
onDuplicate={onDuplicate}
|
|
527
|
+
onToggleVisibility={onToggleVisibility}
|
|
528
|
+
onDelete={onDelete}
|
|
529
|
+
onRename={onRename}
|
|
530
|
+
>
|
|
531
|
+
<DesignerPlaceholder
|
|
532
|
+
label={`${effectiveSection.label || effectiveSection.type} — this build does not register this section`}
|
|
533
|
+
/>
|
|
534
|
+
</SectionWrapper>
|
|
535
|
+
) : null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ONE RESOLVED BLOCK PER AUTHORED BLOCK, and the top-level count must not
|
|
539
|
+
// change. `findRegionChild` matches by `properties.regionName` over
|
|
540
|
+
// `section.blocks`, so a pass that spliced siblings in would slide every
|
|
541
|
+
// later region onto the wrong block. `resolveIteratorsRecursively` and
|
|
542
|
+
// `resolveBlockBindings` are both block-in/block-out — a repeat is expanded
|
|
543
|
+
// INSIDE the iterator block, never beside it — so `.map` is the whole
|
|
544
|
+
// guarantee. Written as a plain `.map` rather than `useMemo` because this
|
|
545
|
+
// sits after a conditional return and a hook here would be conditional.
|
|
546
|
+
//
|
|
547
|
+
// BINDINGS RUN UNCONDITIONALLY, where the generic path below gates them on
|
|
548
|
+
// `shouldApplyGenericBindings`. That gate asks "is this section type
|
|
549
|
+
// literally `custom`, or is there a page-context source, or a page
|
|
550
|
+
// context?" — and a `custom:vendor:name` type is not the string `custom`,
|
|
551
|
+
// so reusing the gate would silently drop bindings for a custom section
|
|
552
|
+
// whose data comes from a collection or static source. The gate exists to
|
|
553
|
+
// avoid resolving where there is nothing to resolve against, and
|
|
554
|
+
// `resolveBlockBindings` already answers that itself: with no binding root
|
|
555
|
+
// it walks the tree and returns it unchanged. Unconditional is therefore
|
|
556
|
+
// the reading that keeps a merchant's binding working wherever it worked
|
|
557
|
+
// before the area was rewritten, which is the regression this fixes.
|
|
558
|
+
const resolvedSection = {
|
|
559
|
+
...effectiveSection,
|
|
560
|
+
blocks: effectiveSection.blocks.map((block) =>
|
|
561
|
+
resolveBlockBindings({
|
|
562
|
+
block: resolveIteratorsRecursively(block),
|
|
563
|
+
sectionDataSource: effectiveSection.dataSource,
|
|
564
|
+
pageContext,
|
|
565
|
+
isDesigner,
|
|
566
|
+
currency: storefrontCurrency
|
|
567
|
+
})
|
|
568
|
+
)
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<SectionWrapper
|
|
573
|
+
// The wrapper reads the section's id, type, label and styles and never
|
|
574
|
+
// its children, so it keeps the object it always had; only the host,
|
|
575
|
+
// which is what `ThemeRegion`/`ThemeSlot` read their children out of,
|
|
576
|
+
// is handed the resolved one.
|
|
577
|
+
section={effectiveSection}
|
|
578
|
+
placeholderId={placeholderId}
|
|
579
|
+
isDesigner={isDesigner}
|
|
580
|
+
isSelected={isSelected}
|
|
581
|
+
onSelect={onSelect}
|
|
582
|
+
onMoveUp={onMoveUp}
|
|
583
|
+
onMoveDown={onMoveDown}
|
|
584
|
+
onDuplicate={onDuplicate}
|
|
585
|
+
onToggleVisibility={onToggleVisibility}
|
|
586
|
+
onDelete={onDelete}
|
|
587
|
+
onRename={onRename}
|
|
588
|
+
>
|
|
589
|
+
<CustomSectionHost
|
|
590
|
+
type={effectiveSection.type}
|
|
591
|
+
label={effectiveSection.label || effectiveSection.type}
|
|
592
|
+
definition={sectionDefinition}
|
|
593
|
+
section={resolvedSection}
|
|
594
|
+
placeholderId={placeholderId}
|
|
595
|
+
isDesigner={Boolean(isDesigner)}
|
|
596
|
+
selectedBlockId={selectedBlockId}
|
|
597
|
+
currentBreakpoint={currentBreakpoint}
|
|
598
|
+
/>
|
|
599
|
+
</SectionWrapper>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
459
603
|
const hasTabBlocks = effectiveSection.blocks.some(
|
|
460
604
|
(block) => block.type === 'tab'
|
|
461
605
|
);
|
|
@@ -515,7 +659,22 @@ export default function ThemeSection({
|
|
|
515
659
|
);
|
|
516
660
|
}
|
|
517
661
|
|
|
518
|
-
|
|
662
|
+
/*
|
|
663
|
+
* A FUNCTION DECLARATION, NOT A `const` ARROW, AND THAT IS LOAD-BEARING.
|
|
664
|
+
*
|
|
665
|
+
* The developer-authored-section branch above needs this resolver, and that
|
|
666
|
+
* branch has to stay ABOVE the `hasTabBlocks` hatch: the hatch claims any
|
|
667
|
+
* section holding a `tab` block, and a custom section's regions may
|
|
668
|
+
* legitimately hold one. Moving the custom return down here to reach the
|
|
669
|
+
* resolver would hand a brand's own product page to TabsSection; moving this
|
|
670
|
+
* body up would relocate ~450 lines of related/upsell normalisation into the
|
|
671
|
+
* middle of the branch chain. A declaration is hoisted to the top of the
|
|
672
|
+
* component body, so the branch can call it where it stands and neither
|
|
673
|
+
* order has to change. Everything it closes over — `effectiveSection`,
|
|
674
|
+
* `pageContext`, `isDesigner`, `currentBreakpoint` — is initialised long
|
|
675
|
+
* before either call site, so there is no temporal-dead-zone read.
|
|
676
|
+
*/
|
|
677
|
+
function resolveIteratorsRecursively(block: Block): Block {
|
|
519
678
|
const normalizedType = String(effectiveSection.type || '').toLowerCase();
|
|
520
679
|
const normalizedVariant = String(
|
|
521
680
|
effectiveSection.properties?.dataSourceVariant || ''
|
|
@@ -965,7 +1124,7 @@ export default function ThemeSection({
|
|
|
965
1124
|
...resolvedBlock,
|
|
966
1125
|
blocks: resolvedBlock.blocks.map(resolveIteratorsRecursively)
|
|
967
1126
|
};
|
|
968
|
-
}
|
|
1127
|
+
}
|
|
969
1128
|
|
|
970
1129
|
return (
|
|
971
1130
|
<ThemeSectionDataProvider items={sectionDataItems}>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import {
|
|
3
|
+
PART_STYLE_STATES,
|
|
4
|
+
buildPartSelectors,
|
|
5
|
+
buildRootStateSelectors,
|
|
6
|
+
parseRootStateKey,
|
|
7
|
+
splitPartStyles
|
|
8
|
+
} from '../part-styles';
|
|
9
|
+
import type { Section } from '../../theme-section';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `docked` — the third page-level style state, beside `scrolled` and
|
|
13
|
+
* `overlay-open`.
|
|
14
|
+
*
|
|
15
|
+
* It exists for the header with two appearances: over the home page's hero it
|
|
16
|
+
* is transparent with light content, and everywhere else it sits above the
|
|
17
|
+
* page, solid, with dark content. Which one applies is a fact about the ROUTE,
|
|
18
|
+
* and a route is the one thing a composition cannot ask about — so the brand
|
|
19
|
+
* stamps `data-chrome-mode` on `body` and the composition styles against it.
|
|
20
|
+
*
|
|
21
|
+
* The thing worth pinning is not that the state works. It is the `body` in its
|
|
22
|
+
* selector.
|
|
23
|
+
*
|
|
24
|
+
* ON AN INNER PAGE BOTH STATES MATCH AT ONCE. The shopper scrolls a docked
|
|
25
|
+
* header, so `[data-scroll-state~="scrolled"]` and `body[data-chrome-mode~=
|
|
26
|
+
* "docked"]` are both true of the same band, and both rules set the same
|
|
27
|
+
* property. Every other ancestor-matched state weighs (0,2,0) against an
|
|
28
|
+
* element selector; the `body` makes this one (0,2,1), so the mode wins on
|
|
29
|
+
* specificity rather than on whichever rule the generator happened to write
|
|
30
|
+
* last. That is the right answer and not merely a chosen one: `scrolled`
|
|
31
|
+
* describes how far the page has slid under a bar that floats over it, and once
|
|
32
|
+
* the bar is docked the question has no meaning.
|
|
33
|
+
*
|
|
34
|
+
* WHY THERE IS NO `getComputedStyle` TEST BELOW, and why adding one would make
|
|
35
|
+
* this file worse: jsdom has no cascade worth the name. Its `getComputedStyle`
|
|
36
|
+
* resolves competing rules by SOURCE ORDER ALONE and ignores specificity
|
|
37
|
+
* entirely — measured here, an `#id` rule loses to a later `div` rule. A
|
|
38
|
+
* cascade test would therefore assert the opposite of what a browser does, pass
|
|
39
|
+
* today for the wrong reason, and keep passing after someone deleted the very
|
|
40
|
+
* qualifier it was written to protect. So the weight is asserted directly.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const DOCKED = 'body[data-chrome-mode~="docked"]';
|
|
44
|
+
const SCROLLED = '[data-scroll-state~="scrolled"]';
|
|
45
|
+
const SECTION = '[data-section-id="sec-1"]';
|
|
46
|
+
|
|
47
|
+
const section = (
|
|
48
|
+
id: string,
|
|
49
|
+
styles: Record<string, unknown>,
|
|
50
|
+
blocks: unknown[] = []
|
|
51
|
+
) =>
|
|
52
|
+
({
|
|
53
|
+
id,
|
|
54
|
+
type: 'custom',
|
|
55
|
+
properties: {},
|
|
56
|
+
styles,
|
|
57
|
+
order: 0,
|
|
58
|
+
hidden: false,
|
|
59
|
+
blocks
|
|
60
|
+
}) as unknown as Section;
|
|
61
|
+
|
|
62
|
+
const block = (id: string, styles: Record<string, unknown>, type = 'group') => ({
|
|
63
|
+
id,
|
|
64
|
+
type,
|
|
65
|
+
label: 'Block',
|
|
66
|
+
properties: {},
|
|
67
|
+
styles,
|
|
68
|
+
order: 0,
|
|
69
|
+
hidden: false,
|
|
70
|
+
blocks: []
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Weighs the two selectors this file is about, and nothing else.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately naive: it counts attribute selectors and leading type selectors,
|
|
77
|
+
* which is all either selector contains. A general specificity parser would be
|
|
78
|
+
* a second implementation to get wrong, and the honest alternative — asking the
|
|
79
|
+
* DOM — does not exist here (see the note above).
|
|
80
|
+
*/
|
|
81
|
+
const weigh = (selector: string) => ({
|
|
82
|
+
attributes: (selector.match(/\[/g) || []).length,
|
|
83
|
+
types: (selector.match(/(^|[\s>+~])[a-z][a-z0-9-]*/g) || []).length
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
document.body.removeAttribute('data-chrome-mode');
|
|
88
|
+
document.body.innerHTML = '';
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('the `body` qualifier is load-bearing — do not tidy it away', () => {
|
|
92
|
+
it('keeps `body` in the docked selector, which is the only thing that outranks scrolled', () => {
|
|
93
|
+
const [selector] = buildRootStateSelectors(SECTION, 'docked');
|
|
94
|
+
|
|
95
|
+
// Asserted as literal text, because the qualifier reads like a redundant
|
|
96
|
+
// ancestor to anyone who has not read why it is there.
|
|
97
|
+
expect(selector).toBe(`${DOCKED} ${SECTION}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('outweighs the scrolled selector, which it ties with on attributes alone', () => {
|
|
101
|
+
const [docked] = buildRootStateSelectors(SECTION, 'docked');
|
|
102
|
+
const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
|
|
103
|
+
|
|
104
|
+
// Same attribute count: this is the tie, and it is why order would decide.
|
|
105
|
+
expect(weigh(docked).attributes).toBe(weigh(scrolled).attributes);
|
|
106
|
+
// The type selector is the entire margin of victory.
|
|
107
|
+
expect(weigh(docked).types).toBeGreaterThan(weigh(scrolled).types);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('matches the same band as scrolled on an inner page, so the tie is real', () => {
|
|
111
|
+
// Not hypothetical: a docked header is scrolled the moment the shopper
|
|
112
|
+
// moves, and both rules then target one element and one property.
|
|
113
|
+
document.body.setAttribute('data-chrome-mode', 'docked');
|
|
114
|
+
document.body.innerHTML =
|
|
115
|
+
'<div data-chrome-slot="header" data-scroll-state="scrolled">' +
|
|
116
|
+
'<div data-section-id="sec-1"></div>' +
|
|
117
|
+
'</div>';
|
|
118
|
+
|
|
119
|
+
const [docked] = buildRootStateSelectors(SECTION, 'docked');
|
|
120
|
+
const [scrolled] = buildRootStateSelectors(SECTION, 'scrolled');
|
|
121
|
+
|
|
122
|
+
expect(document.querySelectorAll(docked)).toHaveLength(1);
|
|
123
|
+
expect(document.querySelectorAll(scrolled)).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('matches nothing when the brand stamps nothing, so overlay stays the default', () => {
|
|
127
|
+
// Every composition published before this state existed carries no
|
|
128
|
+
// attribute, and has to render exactly as it did.
|
|
129
|
+
document.body.innerHTML = '<div data-section-id="sec-1"></div>';
|
|
130
|
+
|
|
131
|
+
const [docked] = buildRootStateSelectors(SECTION, 'docked');
|
|
132
|
+
|
|
133
|
+
expect(document.querySelectorAll(docked)).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('matches whole tokens only, so `undocked` is not docked', () => {
|
|
137
|
+
document.body.setAttribute('data-chrome-mode', 'undocked');
|
|
138
|
+
document.body.innerHTML = '<div data-section-id="sec-1"></div>';
|
|
139
|
+
|
|
140
|
+
const [docked] = buildRootStateSelectors(SECTION, 'docked');
|
|
141
|
+
|
|
142
|
+
expect(document.querySelectorAll(docked)).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('the generated CSS', () => {
|
|
147
|
+
it('emits both states for one band, with docked written FIRST', () => {
|
|
148
|
+
const css = generateThemeCSS([
|
|
149
|
+
section('sec-1', {
|
|
150
|
+
'background-color': 'transparent',
|
|
151
|
+
// Declared before `scrolled` on purpose: source order favours the
|
|
152
|
+
// scrolled rule, so if this pair ever came out the wrong way round in a
|
|
153
|
+
// browser, order is not what would be saving it.
|
|
154
|
+
':docked/background-color': '#ffffff',
|
|
155
|
+
':scrolled/background-color': '#f5f5f5'
|
|
156
|
+
})
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
expect(css).toContain(
|
|
160
|
+
`${DOCKED} ${SECTION} {\n background-color: rgba(255, 255, 255, 1);\n}`
|
|
161
|
+
);
|
|
162
|
+
expect(css).toContain(
|
|
163
|
+
`${SCROLLED} ${SECTION} {\n background-color: rgba(245, 245, 245, 1);\n}`
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(css.indexOf(`${DOCKED} ${SECTION}`)).toBeLessThan(
|
|
167
|
+
css.indexOf(`${SCROLLED} ${SECTION}`)
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('emits a docked rule for a block wrapper too', () => {
|
|
172
|
+
const css = generateThemeCSS([
|
|
173
|
+
section('sec-1', {}, [block('bar', { ':docked/color': '#4a0e26' })])
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(css).toContain(
|
|
177
|
+
`${DOCKED} [data-block-id="bar"] {\n color: #4a0e26;\n}`
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('never emits the raw key as a declaration', () => {
|
|
182
|
+
const css = generateThemeCSS([
|
|
183
|
+
section('sec-1', { ':docked/background-color': '#ffffff' })
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
expect(css).not.toContain(':docked/background-color:');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('docked as a part state', () => {
|
|
191
|
+
it('conditions a part on the page without losing the ownership guard', () => {
|
|
192
|
+
const [selector] = buildPartSelectors('[data-section-id="sec-1"]', {
|
|
193
|
+
part: 'bar',
|
|
194
|
+
state: 'docked',
|
|
195
|
+
styles: {}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// The whole compound, guard included, is substituted into the ancestor
|
|
199
|
+
// pattern — so a docked rule can no more reach into a nested block than the
|
|
200
|
+
// stateless one can.
|
|
201
|
+
//
|
|
202
|
+
// Asserted as text rather than by querying the DOM on purpose: the guard
|
|
203
|
+
// uses `:where()`, and the nwsapi build in this workspace's node_modules
|
|
204
|
+
// cannot parse it (it is the cause of the three known-red suites here). A
|
|
205
|
+
// DOM assertion would fail for a reason that has nothing to do with docked.
|
|
206
|
+
expect(selector).toBe(
|
|
207
|
+
`${DOCKED} [data-section-id="sec-1"] [data-part="bar"]` +
|
|
208
|
+
':not(:where([data-section-id="sec-1"] [data-block-id] *))'
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('parses `bar/docked/color` as a part key and buckets it', () => {
|
|
213
|
+
const { root, parts } = splitPartStyles({
|
|
214
|
+
color: '#ffffff',
|
|
215
|
+
'bar/docked/color': '#4a0e26'
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(root).toEqual({ color: '#ffffff' });
|
|
219
|
+
expect(parts).toEqual([
|
|
220
|
+
{ part: 'bar', state: 'docked', styles: { color: '#4a0e26' } }
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('emits the part rule through the generator', () => {
|
|
225
|
+
const css = generateThemeCSS([
|
|
226
|
+
section('sec-1', { 'bar/docked/color': '#4a0e26' })
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
expect(css).toContain(
|
|
230
|
+
`${DOCKED} [data-section-id="sec-1"] [data-part="bar"]`
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('the vocabulary', () => {
|
|
236
|
+
it('lists docked in PART_STYLE_STATES, so a brand may declare it', () => {
|
|
237
|
+
// `defineCustomSection` validates declared part states against this list
|
|
238
|
+
// and drops anything absent, so a state missing from it is a control that
|
|
239
|
+
// silently does nothing.
|
|
240
|
+
expect(PART_STYLE_STATES).toContain('docked');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('accepts `:docked/<prop>` as a root state key on any element', () => {
|
|
244
|
+
expect(parseRootStateKey(':docked/background-color')).toEqual({
|
|
245
|
+
state: 'docked',
|
|
246
|
+
property: 'background-color'
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -258,6 +258,34 @@ describe('a section that arranges its children keeps arranging them', () => {
|
|
|
258
258
|
expect(ruleFor(css, BAND_SELECTOR)).toContain('display: flex');
|
|
259
259
|
expect(ruleFor(css, CONTENT_SELECTOR)).toContain('max-width: 1024px');
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
it('moves nothing on a developer-authored section either', () => {
|
|
263
|
+
// The half of the split that is NOT the max-width half. A brand component
|
|
264
|
+
// draws its own root, so moving `display`/`flex-direction` onto the content
|
|
265
|
+
// wrapper would make that root a flex ITEM and silently re-lay-out markup
|
|
266
|
+
// the developer wrote by hand — while the Max Width token beside it still
|
|
267
|
+
// has to be resolved here, because the component has never heard of it.
|
|
268
|
+
const css = generateThemeCSS(
|
|
269
|
+
section(
|
|
270
|
+
{
|
|
271
|
+
'content-width': '1024px',
|
|
272
|
+
'max-width': 'narrow',
|
|
273
|
+
display: 'flex',
|
|
274
|
+
'flex-direction': 'column'
|
|
275
|
+
},
|
|
276
|
+
{ type: 'custom:acme:hero' }
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const band = ruleFor(css, BAND_SELECTOR);
|
|
281
|
+
expect(band).toContain('display: flex');
|
|
282
|
+
expect(band).toContain('flex-direction: column');
|
|
283
|
+
expect(band).toContain('max-width: 56rem');
|
|
284
|
+
|
|
285
|
+
const content = ruleFor(css, CONTENT_SELECTOR);
|
|
286
|
+
expect(content).toContain('max-width: 1024px');
|
|
287
|
+
expect(content).not.toContain('display: flex');
|
|
288
|
+
});
|
|
261
289
|
});
|
|
262
290
|
|
|
263
291
|
describe('the setting is additive', () => {
|
|
@@ -135,6 +135,59 @@ describe('a custom-renderer section keeps resolving its own max-width', () => {
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A DEVELOPER-AUTHORED SECTION SPLITS THE ONE PREDICATE IN TWO.
|
|
140
|
+
*
|
|
141
|
+
* "Owns the box its children sit in" and "translates the max-width token
|
|
142
|
+
* itself" were the same question for all 18 built-in renderers, so one boolean
|
|
143
|
+
* answered both. A brand component answers them differently: it draws a real
|
|
144
|
+
* container, so the child-layout keys stay on the band — but it has never heard
|
|
145
|
+
* of `narrow`, so nothing downstream would turn the token into a length.
|
|
146
|
+
*/
|
|
147
|
+
describe('a developer-authored section cannot translate the token itself', () => {
|
|
148
|
+
const brandSection = (styles: Record<string, unknown>) =>
|
|
149
|
+
section(styles, { type: 'custom:acme:hero' });
|
|
150
|
+
|
|
151
|
+
it('resolves the token to a real length rather than emitting a dropped declaration', () => {
|
|
152
|
+
// Left unresolved this emits `max-width: narrow`, the browser drops the
|
|
153
|
+
// declaration, and the section's Max Width control does nothing at all —
|
|
154
|
+
// the exact failure `resolveSectionMaxWidthTokens` exists to fix,
|
|
155
|
+
// reintroduced on the newest surface.
|
|
156
|
+
const css = generateThemeCSS(brandSection({ 'max-width': 'narrow' }));
|
|
157
|
+
|
|
158
|
+
expect(css).toContain('max-width: 56rem');
|
|
159
|
+
expect(css).not.toContain('max-width: narrow');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('centres it, because nothing else will', () => {
|
|
163
|
+
// A built-in renderer pairs the token with `mx-auto` on the container it
|
|
164
|
+
// owns. A brand component pairs it with nothing, so the auto margins have
|
|
165
|
+
// to come from here or a constrained band sits against the left edge.
|
|
166
|
+
const css = generateThemeCSS(brandSection({ 'max-width': 'normal' }));
|
|
167
|
+
|
|
168
|
+
expect(css).toContain('max-width: 80rem');
|
|
169
|
+
expect(css).toContain('margin-left: auto');
|
|
170
|
+
expect(css).toContain('margin-right: auto');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('resolves per breakpoint, inside the media query', () => {
|
|
174
|
+
const css = generateThemeCSS(
|
|
175
|
+
brandSection({ 'max-width': { desktop: 'narrow', mobile: 'none' } })
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(css).toContain('max-width: 56rem');
|
|
179
|
+
expect(css).toContain('max-width: none');
|
|
180
|
+
expect(css).not.toContain('max-width: narrow');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('still leaves a real CSS length the author typed alone', () => {
|
|
184
|
+
const css = generateThemeCSS(brandSection({ 'max-width': '640px' }));
|
|
185
|
+
|
|
186
|
+
expect(css).toContain('max-width: 640px');
|
|
187
|
+
expect(css).not.toContain('margin-left: auto');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
138
191
|
describe('the scale is one table, not two', () => {
|
|
139
192
|
// The class and the length are the same measurement: Tailwind's max-w-4xl is
|
|
140
193
|
// 56rem and max-w-7xl is 80rem. A test would not catch them drifting if they
|
package/src/utils/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
stripHidingStyles
|
|
19
19
|
} from './checkout-safety';
|
|
20
20
|
import { hasBindingToken } from './iterator-utils';
|
|
21
|
+
import { isCustomThemeType } from '../custom/types';
|
|
21
22
|
import {
|
|
22
23
|
BREAKPOINT_FALLBACKS,
|
|
23
24
|
MOBILE_MEDIA_QUERY,
|
|
@@ -707,6 +708,14 @@ interface Block {
|
|
|
707
708
|
|
|
708
709
|
interface Section {
|
|
709
710
|
id: string;
|
|
711
|
+
/**
|
|
712
|
+
* Read to decide whether the section owns the box its children sit in: a
|
|
713
|
+
* registered renderer, the tab hatch, or a developer-authored
|
|
714
|
+
* `custom:<vendor>:<name>` type. Optional because published JSON written by
|
|
715
|
+
* an older editor may omit it, and `hasCustomSectionRenderer` already treats
|
|
716
|
+
* a missing type as "generic".
|
|
717
|
+
*/
|
|
718
|
+
type?: string;
|
|
710
719
|
styles?: ResponsiveStyles;
|
|
711
720
|
properties?: Record<string, unknown>;
|
|
712
721
|
blocks?: Block[];
|
|
@@ -1467,7 +1476,26 @@ export const generateThemeCSS = (
|
|
|
1467
1476
|
const { root: sectionRoot } = splitPartStyles(sectionStyles);
|
|
1468
1477
|
|
|
1469
1478
|
if (Object.keys(sectionRoot).length > 0) {
|
|
1470
|
-
|
|
1479
|
+
// ONE PREDICATE, TWO QUESTIONS, AND A DEVELOPER-AUTHORED SECTION
|
|
1480
|
+
// ANSWERS THEM DIFFERENTLY.
|
|
1481
|
+
//
|
|
1482
|
+
// "Owns the box its children sit in" decides whether the child-layout
|
|
1483
|
+
// keys stay on the band. True for a custom section: its component draws
|
|
1484
|
+
// a real container, so moving `display`/`flex-direction`/`gap` onto the
|
|
1485
|
+
// content wrapper would make the developer's own root a flex item and
|
|
1486
|
+
// silently re-lay-out the authored markup.
|
|
1487
|
+
//
|
|
1488
|
+
// "Translates the max-width token itself" is the opposite. Each of the
|
|
1489
|
+
// 18 built-in renderers turns `narrow`/`normal` into a real length on
|
|
1490
|
+
// the container it owns. A brand component has never heard of the
|
|
1491
|
+
// token, so leaving it unresolved would emit `max-width: narrow`, the
|
|
1492
|
+
// browser would drop the declaration, and the section's Max Width
|
|
1493
|
+
// control would do nothing — with the auto-centring never firing
|
|
1494
|
+
// either. That is the exact failure `resolveSectionMaxWidthTokens`
|
|
1495
|
+
// exists to fix, and it would be reintroduced on the newest surface.
|
|
1496
|
+
const ownsContentBox = hasCustomSectionRenderer(section);
|
|
1497
|
+
const resolvesOwnMaxWidth =
|
|
1498
|
+
ownsContentBox && !isCustomThemeType(section.type);
|
|
1471
1499
|
|
|
1472
1500
|
// The content width leaves the band before anything else is decided
|
|
1473
1501
|
// about it: it is not a declaration a section ever carries, and on the
|
|
@@ -1475,13 +1503,13 @@ export const generateThemeCSS = (
|
|
|
1475
1503
|
// utils/section-content-width.ts for why that move exists.
|
|
1476
1504
|
const { section: band, content } = splitSectionContentWidth(
|
|
1477
1505
|
sectionRoot,
|
|
1478
|
-
{ moveLayout: !
|
|
1506
|
+
{ moveLayout: !ownsContentBox }
|
|
1479
1507
|
);
|
|
1480
1508
|
|
|
1481
1509
|
if (Object.keys(band).length > 0) {
|
|
1482
1510
|
css += generateElementCSS(
|
|
1483
1511
|
sectionSelector,
|
|
1484
|
-
|
|
1512
|
+
resolvesOwnMaxWidth ? band : resolveSectionMaxWidthTokens(band),
|
|
1485
1513
|
targetBreakpoint
|
|
1486
1514
|
);
|
|
1487
1515
|
}
|
package/src/utils/part-styles.ts
CHANGED
|
@@ -81,7 +81,23 @@ const STATE_SELECTORS: Record<string, string[]> = {
|
|
|
81
81
|
// hero, opaque while its menu panel is open. The panel cannot express it —
|
|
82
82
|
// the header is not inside the panel — and the header cannot know it without
|
|
83
83
|
// the page saying so.
|
|
84
|
-
'overlay-open': ['[data-overlay-state~="open"] &']
|
|
84
|
+
'overlay-open': ['[data-overlay-state~="open"] &'],
|
|
85
|
+
/**
|
|
86
|
+
* The third page-level state: the chrome is DOCKED above the page rather than
|
|
87
|
+
* sitting over it. `body` carries `data-chrome-mode` (see
|
|
88
|
+
* chrome/chrome-mode.ts), stamped by the brand, because only the brand knows
|
|
89
|
+
* which of its routes carry a hero.
|
|
90
|
+
*
|
|
91
|
+
* THE `body` QUALIFIER IS A DELIBERATE SPECIFICITY BUMP, not tidiness. Every
|
|
92
|
+
* other ancestor-matched state weighs (0,2,0) against an element selector,
|
|
93
|
+
* which ties with `scrolled` — and on an inner page BOTH match at once, so a
|
|
94
|
+
* tie would be settled by whichever declaration the generator happened to
|
|
95
|
+
* emit last. Adding the element name makes this (0,2,1), so the mode wins.
|
|
96
|
+
* That is the correct answer rather than an arbitrary one: `scrolled`
|
|
97
|
+
* describes how far the page has moved under a bar that floats over it, and
|
|
98
|
+
* once the bar is docked that question no longer has a meaning.
|
|
99
|
+
*/
|
|
100
|
+
docked: ['body[data-chrome-mode~="docked"] &']
|
|
85
101
|
};
|
|
86
102
|
|
|
87
103
|
export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);
|