@akinon/pz-theme 2.0.78-rc.2 → 2.0.78
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 +6 -41
- 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/CHANGELOG.md
CHANGED
|
@@ -1,52 +1,17 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.78
|
|
3
|
+
## 2.0.78
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.78-rc.2
|
|
9
|
-
- @akinon/theme-contract@2.0.78-rc.2
|
|
7
|
+
- 18d2b061: ZERO-4880: Developer-authored custom sections
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
A brand writes a whole area — a header, a footer — as one section and places its children in JSX with `<ThemeRegion name="search" />`. Each region is seeded as a real composition block of a declared type, so the merchant gets that block's own panel, its shared controls and its part groups with no descriptor written by hand. The section root gets the section root panel, and `lockRootTransforms` keeps the five properties that trap a fixed descendant off it.
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- @akinon/next@2.0.78-rc.1
|
|
16
|
-
- @akinon/theme-contract@2.0.78-rc.1
|
|
17
|
-
|
|
18
|
-
## 2.0.78-rc.0
|
|
19
|
-
|
|
20
|
-
### Patch Changes
|
|
11
|
+
New: `defineCustomSection`, `registerCustomSections`, `ThemeRegion`, and the section catalog, which rides the existing `PLACEHOLDERS_AVAILABLE` frame as a sibling field with its own byte budget. The CSS generator now treats a `custom:` section as owning its own content box while still resolving its max-width token, which a brand component cannot do for itself.
|
|
21
12
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
- Updated dependencies [51ea06888]
|
|
25
|
-
- Updated dependencies
|
|
26
|
-
- Updated dependencies [2fea4143]
|
|
27
|
-
- Updated dependencies [b55acb768]
|
|
28
|
-
- Updated dependencies [760258c1c]
|
|
29
|
-
- Updated dependencies [143be2b9d]
|
|
30
|
-
- Updated dependencies [7889b08fe]
|
|
31
|
-
- Updated dependencies [9f8cd3bc5]
|
|
32
|
-
- Updated dependencies [bfafa3f49]
|
|
33
|
-
- Updated dependencies [57d7eb305]
|
|
34
|
-
- Updated dependencies [d99a6a7d5]
|
|
35
|
-
- Updated dependencies [9db81a714]
|
|
36
|
-
- Updated dependencies [591e345e1]
|
|
37
|
-
- Updated dependencies [4de5303c5]
|
|
38
|
-
- Updated dependencies [95b139dc1]
|
|
39
|
-
- Updated dependencies [1d00f2d06]
|
|
40
|
-
- Updated dependencies [4ac7b2a1e]
|
|
41
|
-
- Updated dependencies [e9598c71]
|
|
42
|
-
- Updated dependencies [4998a9631]
|
|
43
|
-
- Updated dependencies [804d2bd6c]
|
|
44
|
-
- Updated dependencies [3909d3224]
|
|
45
|
-
- Updated dependencies [6a3d8a631]
|
|
46
|
-
- Updated dependencies [e18836b20]
|
|
47
|
-
- Updated dependencies [b1111d7b]
|
|
48
|
-
- @akinon/next@2.0.78-rc.0
|
|
49
|
-
- @akinon/theme-contract@2.0.78-rc.0
|
|
13
|
+
- @akinon/next@2.0.78
|
|
14
|
+
- @akinon/theme-contract@2.0.78
|
|
50
15
|
|
|
51
16
|
## 2.0.77
|
|
52
17
|
|
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.78
|
|
4
|
+
"version": "2.0.78",
|
|
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.78
|
|
11
|
+
"@akinon/next": "2.0.78",
|
|
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.78
|
|
16
|
+
"@akinon/theme-contract": "2.0.78",
|
|
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.78
|
|
23
|
+
"@akinon/next": "2.0.78",
|
|
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",
|
|
@@ -46,6 +46,7 @@ const load = (): Loaded => {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const Promo = () => null;
|
|
49
|
+
const Header = () => null;
|
|
49
50
|
|
|
50
51
|
describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
51
52
|
const postMessage = jest.fn();
|
|
@@ -124,6 +125,75 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
124
125
|
]);
|
|
125
126
|
});
|
|
126
127
|
|
|
128
|
+
it('carries the section catalog as a SIBLING field, not inside the block one', () => {
|
|
129
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
130
|
+
true;
|
|
131
|
+
const { registry, register } = load();
|
|
132
|
+
|
|
133
|
+
registry.registerPlaceholder('header');
|
|
134
|
+
register.registerCustomSections('zsazsazsu', { header: Header });
|
|
135
|
+
jest.advanceTimersByTime(100);
|
|
136
|
+
|
|
137
|
+
const [payload] = frames();
|
|
138
|
+
expect(payload.customSections.vendor).toBe('zsazsazsu');
|
|
139
|
+
expect(
|
|
140
|
+
payload.customSections.sections.map((entry) => entry.type)
|
|
141
|
+
).toEqual(['custom:zsazsazsu:header']);
|
|
142
|
+
// Beside `customBlocks` and never nested inside it: the editor validates
|
|
143
|
+
// the block catalog element by element and drops the whole thing on an
|
|
144
|
+
// unexpected shape, so a `sections` key inside it would make every block in
|
|
145
|
+
// a build vanish from the palette of any editor older than this change.
|
|
146
|
+
// A build that ships only sections therefore posts no block field at all.
|
|
147
|
+
expect('customBlocks' in payload).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('carries both catalogs in one frame when a build ships both', () => {
|
|
151
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
152
|
+
true;
|
|
153
|
+
const { registry, register } = load();
|
|
154
|
+
|
|
155
|
+
registry.registerPlaceholder('header');
|
|
156
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
157
|
+
register.registerCustomSections('acme', { header: Header });
|
|
158
|
+
jest.advanceTimersByTime(100);
|
|
159
|
+
|
|
160
|
+
// One frame, two payloads. Each announce replaces what the editor holds,
|
|
161
|
+
// so a build shipping both must never have to send them in two frames.
|
|
162
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
163
|
+
const [payload] = frames();
|
|
164
|
+
expect(payload.customBlocks.blocks).toHaveLength(1);
|
|
165
|
+
expect(payload.customSections.sections).toHaveLength(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('gives a section-only build its second frame after a slow handshake', () => {
|
|
169
|
+
const { registry, register } = load();
|
|
170
|
+
|
|
171
|
+
register.registerCustomSections('zsazsazsu', { header: Header });
|
|
172
|
+
registry.registerPlaceholder('header');
|
|
173
|
+
jest.advanceTimersByTime(100);
|
|
174
|
+
expect('customSections' in frames()[0]).toBe(false);
|
|
175
|
+
|
|
176
|
+
// The re-announce latch has to consult BOTH catalogs. Checking only the
|
|
177
|
+
// block one would leave a brand that ships nothing but sections with an
|
|
178
|
+
// empty section band on exactly the loads where the cookie handshake lost
|
|
179
|
+
// the 100ms debounce — intermittently, and invisibly.
|
|
180
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
181
|
+
true;
|
|
182
|
+
window.dispatchEvent(
|
|
183
|
+
new MessageEvent('message', {
|
|
184
|
+
origin: EDITOR_ORIGIN,
|
|
185
|
+
source: window.parent as Window,
|
|
186
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
jest.advanceTimersByTime(100);
|
|
190
|
+
|
|
191
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
192
|
+
expect(
|
|
193
|
+
frames()[1].customSections.sections.map((entry) => entry.type)
|
|
194
|
+
).toEqual(['custom:zsazsazsu:header']);
|
|
195
|
+
});
|
|
196
|
+
|
|
127
197
|
it('omits the catalog key when the build registers no custom block', () => {
|
|
128
198
|
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
129
199
|
true;
|
|
@@ -134,6 +204,9 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
134
204
|
|
|
135
205
|
const [payload] = frames();
|
|
136
206
|
expect(payload.placeholders).toEqual(['header']);
|
|
207
|
+
// Neither field, for a build that registers neither. The section payload
|
|
208
|
+
// is omitted on exactly the same terms as the block one.
|
|
209
|
+
expect('customSections' in payload).toBe(false);
|
|
137
210
|
// Absent, not empty — a build with no custom blocks has to be
|
|
138
211
|
// indistinguishable from one whose catalog is not this frame's business.
|
|
139
212
|
expect('customBlocks' in payload).toBe(false);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHROME_MODE_ATTRIBUTE,
|
|
3
|
+
CHROME_MODE_DOCKED,
|
|
4
|
+
setChromeMode
|
|
5
|
+
} from '../chrome-mode';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The writer for the `docked` style state.
|
|
9
|
+
*
|
|
10
|
+
* pz-theme owns the vocabulary — the attribute name, the token and the selector
|
|
11
|
+
* that reads them (utils/part-styles.ts). The BRAND owns the signal, because
|
|
12
|
+
* only the brand knows which of its routes carry a hero. This function is the
|
|
13
|
+
* seam between the two, and the reason it exists at all is that the alternative
|
|
14
|
+
* is every brand hand-typing `data-chrome-mode` into `document.body`: a typo
|
|
15
|
+
* there produces no error, just a header that never changes appearance.
|
|
16
|
+
*
|
|
17
|
+
* Two behaviours carry real weight and are pinned hardest below.
|
|
18
|
+
*
|
|
19
|
+
* IT WRITES TO `body`, NOT THE SLOT. That is not a stylistic difference from
|
|
20
|
+
* `data-scroll-state`. It is what earns the selector its specificity bump over
|
|
21
|
+
* `scrolled` (both match on an inner page), and it is what lets a hero written
|
|
22
|
+
* to sit under a transparent bar answer to the mode even though it is nowhere
|
|
23
|
+
* near the chrome.
|
|
24
|
+
*
|
|
25
|
+
* ITS CLEANUP RESTORES RATHER THAN CLEARS — a SNAPSHOT, not a stack. Each
|
|
26
|
+
* cleanup replays the value its own call displaced, which unwinds correctly
|
|
27
|
+
* when calls nest (mount, re-stamp, unmount) and does NOT compose when two
|
|
28
|
+
* owners overlap. Both halves are pinned below, the second as a
|
|
29
|
+
* characterisation test, because the constraint it implies — one owner for the
|
|
30
|
+
* signal — is invisible in the signature and its symptom only appears while
|
|
31
|
+
* navigating.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const mode = () => document.body.getAttribute(CHROME_MODE_ATTRIBUTE);
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('stamping', () => {
|
|
41
|
+
it('writes the docked token onto body', () => {
|
|
42
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
43
|
+
|
|
44
|
+
expect(mode()).toBe('docked');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('writes to body rather than to the chrome slot', () => {
|
|
48
|
+
// The selector is `body[data-chrome-mode~="docked"] &`. Stamping the slot
|
|
49
|
+
// instead would match nothing at all, silently.
|
|
50
|
+
document.body.innerHTML = '<div data-chrome-slot="header"></div>';
|
|
51
|
+
|
|
52
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
53
|
+
|
|
54
|
+
expect(mode()).toBe('docked');
|
|
55
|
+
expect(
|
|
56
|
+
document
|
|
57
|
+
.querySelector('[data-chrome-slot="header"]')
|
|
58
|
+
?.getAttribute(CHROME_MODE_ATTRIBUTE)
|
|
59
|
+
).toBeNull();
|
|
60
|
+
|
|
61
|
+
document.body.innerHTML = '';
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('leaves nothing behind when cleared with null, so overlay is the default', () => {
|
|
65
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
66
|
+
setChromeMode(null);
|
|
67
|
+
|
|
68
|
+
// Absent, not `data-chrome-mode=""`: the token match `~="docked"` would
|
|
69
|
+
// fail on an empty value either way, but an empty attribute is a puzzle in
|
|
70
|
+
// devtools for anyone debugging why a header looks wrong.
|
|
71
|
+
expect(mode()).toBeNull();
|
|
72
|
+
expect(document.body.hasAttribute(CHROME_MODE_ATTRIBUTE)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is idempotent', () => {
|
|
76
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
77
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
78
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
79
|
+
|
|
80
|
+
expect(mode()).toBe('docked');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('the cleanup', () => {
|
|
85
|
+
it('restores the value that was there before, not a blank', () => {
|
|
86
|
+
const restoreFirst = setChromeMode(CHROME_MODE_DOCKED);
|
|
87
|
+
const restoreSecond = setChromeMode(null);
|
|
88
|
+
|
|
89
|
+
expect(mode()).toBeNull();
|
|
90
|
+
|
|
91
|
+
restoreSecond();
|
|
92
|
+
|
|
93
|
+
expect(mode()).toBe('docked');
|
|
94
|
+
|
|
95
|
+
restoreFirst();
|
|
96
|
+
|
|
97
|
+
expect(mode()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('unwinds correctly when cleanups nest, which is the mount/unmount case', () => {
|
|
101
|
+
// One owner mounting, re-stamping, and unmounting: cleanups run last-in
|
|
102
|
+
// first-out, and each finds exactly the value its own call displaced.
|
|
103
|
+
const restoreOuter = setChromeMode(CHROME_MODE_DOCKED);
|
|
104
|
+
const restoreInner = setChromeMode(null);
|
|
105
|
+
|
|
106
|
+
restoreInner();
|
|
107
|
+
expect(mode()).toBe('docked');
|
|
108
|
+
|
|
109
|
+
restoreOuter();
|
|
110
|
+
expect(mode()).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('restores its OWN snapshot, so the signal must have a single owner', () => {
|
|
114
|
+
// A characterisation test, not an endorsement. Each cleanup replays the
|
|
115
|
+
// value observed at ITS call, so cleanups running out of order do not
|
|
116
|
+
// compose: here the outgoing caller's cleanup wipes a mode the incoming
|
|
117
|
+
// caller set after it.
|
|
118
|
+
//
|
|
119
|
+
// That is fine for the way this is meant to be used — one brand header
|
|
120
|
+
// owning the signal and re-stamping as the route changes — and it is the
|
|
121
|
+
// reason that constraint is not optional. Two components both stamping
|
|
122
|
+
// during a route transition can land here, and the symptom is a transparent
|
|
123
|
+
// bar over an inner page's content: reachable only by navigating, so no
|
|
124
|
+
// component test would show it.
|
|
125
|
+
//
|
|
126
|
+
// If a second owner ever becomes real, this needs a counted stack rather
|
|
127
|
+
// than a snapshot, and this test is where that decision should be recorded.
|
|
128
|
+
const restoreOutgoing = setChromeMode(null);
|
|
129
|
+
setChromeMode(CHROME_MODE_DOCKED);
|
|
130
|
+
|
|
131
|
+
restoreOutgoing();
|
|
132
|
+
|
|
133
|
+
expect(mode()).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('can be called more than once without drifting', () => {
|
|
137
|
+
const restore = setChromeMode(CHROME_MODE_DOCKED);
|
|
138
|
+
|
|
139
|
+
restore();
|
|
140
|
+
restore();
|
|
141
|
+
|
|
142
|
+
expect(mode()).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('on the server', () => {
|
|
147
|
+
it('is a safe no-op and still returns a callable cleanup', () => {
|
|
148
|
+
// The chrome mode is stamped from a brand's header, which renders on the
|
|
149
|
+
// server first. Touching `document` there would throw during SSR and take
|
|
150
|
+
// the whole page down, so the guard is not a nicety.
|
|
151
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
|
152
|
+
|
|
153
|
+
Object.defineProperty(globalThis, 'document', {
|
|
154
|
+
value: undefined,
|
|
155
|
+
configurable: true
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
expect(typeof document).toBe('undefined');
|
|
160
|
+
|
|
161
|
+
const restore = setChromeMode(CHROME_MODE_DOCKED);
|
|
162
|
+
|
|
163
|
+
expect(typeof restore).toBe('function');
|
|
164
|
+
// The caller runs this in a cleanup it never guards itself.
|
|
165
|
+
expect(() => restore()).not.toThrow();
|
|
166
|
+
} finally {
|
|
167
|
+
if (descriptor) {
|
|
168
|
+
Object.defineProperty(globalThis, 'document', descriptor);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
expect(typeof document).toBe('object');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the chrome sits OVER the page or in the flow above it.
|
|
3
|
+
*
|
|
4
|
+
* The third page-level style state, beside `scrolled` (chrome/scroll-state.tsx)
|
|
5
|
+
* and `overlay-open` (hooks/use-overlay.ts). All three exist for the same
|
|
6
|
+
* reason: a composition cannot see the page it is on, so the page has to say.
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT IS FOR. Almost every storefront header has two appearances — over the
|
|
9
|
+
* home page's hero it is transparent with light content, and on a product or
|
|
10
|
+
* category page it sits above the body, solid, with dark content. Which one
|
|
11
|
+
* applies is a fact about the ROUTE, and a route is the one thing a composition
|
|
12
|
+
* has no way to ask about. Before this, a brand had to choose: keep the header
|
|
13
|
+
* in code and lose every merchant control, or compose it and lose the second
|
|
14
|
+
* appearance.
|
|
15
|
+
*
|
|
16
|
+
* WHO STAMPS IT. The brand, from its own header, exactly as the mega menu
|
|
17
|
+
* stamps `data-overlay-state`. pz-theme supplies the vocabulary and the
|
|
18
|
+
* selector; only the brand knows which of its routes carry a hero. `setChromeMode`
|
|
19
|
+
* below is the one writer, so no brand hand-types the attribute.
|
|
20
|
+
*
|
|
21
|
+
* DEFAULT IS OVERLAY, and that is deliberate: the attribute is absent until
|
|
22
|
+
* something declares otherwise, so every composition that existed before this
|
|
23
|
+
* state renders exactly as it did.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The attribute the renderer's `docked` selector matches on. */
|
|
27
|
+
export const CHROME_MODE_ATTRIBUTE = 'data-chrome-mode';
|
|
28
|
+
|
|
29
|
+
/** The style-state name this signal answers to (see utils/part-styles.ts). */
|
|
30
|
+
export const CHROME_MODE_DOCKED = 'docked';
|
|
31
|
+
|
|
32
|
+
export type ChromeMode = 'docked' | null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stamps or clears the mode on `<body>`.
|
|
36
|
+
*
|
|
37
|
+
* The BODY rather than the chrome slot, unlike `data-scroll-state`, and the
|
|
38
|
+
* difference is load-bearing in two ways. It is what lets the state outrank
|
|
39
|
+
* `scrolled` on specificity — see the selector in `utils/part-styles.ts` — and
|
|
40
|
+
* it is what lets a block anywhere on the page answer to it, not only one
|
|
41
|
+
* inside the chrome: a docked header changes the space the page starts in, and
|
|
42
|
+
* a hero that was written to sit under a transparent bar needs to know.
|
|
43
|
+
*
|
|
44
|
+
* Idempotent, and a no-op on the server. Returns a cleanup that restores what
|
|
45
|
+
* was there before, so two headers on one page (a route transition rendering
|
|
46
|
+
* both) cannot leave the attribute stuck.
|
|
47
|
+
*/
|
|
48
|
+
export const setChromeMode = (mode: ChromeMode): (() => void) => {
|
|
49
|
+
if (typeof document === 'undefined') return () => undefined;
|
|
50
|
+
|
|
51
|
+
const { body } = document;
|
|
52
|
+
const previous = body.getAttribute(CHROME_MODE_ATTRIBUTE);
|
|
53
|
+
|
|
54
|
+
if (mode === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
|
|
55
|
+
else body.setAttribute(CHROME_MODE_ATTRIBUTE, mode);
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
if (previous === null) body.removeAttribute(CHROME_MODE_ATTRIBUTE);
|
|
59
|
+
else body.setAttribute(CHROME_MODE_ATTRIBUTE, previous);
|
|
60
|
+
};
|
|
61
|
+
};
|