@akinon/pz-theme 2.0.54-rc.0 → 2.0.55-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 +15 -31
- package/package.json +4 -4
- package/src/chrome/__tests__/scroll-state.test.tsx +227 -0
- package/src/chrome/chrome-slot.tsx +5 -0
- package/src/chrome/scroll-state.tsx +162 -0
- package/src/components/part-state-preview.tsx +16 -0
- package/src/index.ts +8 -0
- package/src/utils/__tests__/scrolled-state-styles.test.ts +206 -0
- package/src/utils/checkout-safety.ts +7 -2
- package/src/utils/index.ts +59 -11
- package/src/utils/part-styles.ts +140 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,36 +1,20 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
- Updated dependencies [57d7eb30]
|
|
19
|
-
- Updated dependencies [d99a6a7d5]
|
|
20
|
-
- Updated dependencies [9db81a71]
|
|
21
|
-
- Updated dependencies [591e345e1]
|
|
22
|
-
- Updated dependencies [4de5303c5]
|
|
23
|
-
- Updated dependencies [95b139dc1]
|
|
24
|
-
- Updated dependencies [1d00f2d0]
|
|
25
|
-
- Updated dependencies [4ac7b2a1]
|
|
26
|
-
- Updated dependencies [e9598c71]
|
|
27
|
-
- Updated dependencies [4998a963]
|
|
28
|
-
- Updated dependencies [804d2bd6]
|
|
29
|
-
- Updated dependencies [3909d3224]
|
|
30
|
-
- Updated dependencies [6a3d8a63]
|
|
31
|
-
- Updated dependencies [e18836b2]
|
|
32
|
-
- @akinon/next@2.0.54-rc.0
|
|
33
|
-
- @akinon/theme-contract@2.0.54-rc.0
|
|
3
|
+
## 2.0.55-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd75]
|
|
8
|
+
- @akinon/next@2.0.55-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.55-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.54
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 2de849f: ZERO-4749: Publish a scroll state on chrome slots and add a `scrolled` style state
|
|
16
|
+
- @akinon/next@2.0.54
|
|
17
|
+
- @akinon/theme-contract@2.0.54
|
|
34
18
|
|
|
35
19
|
## 2.0.53
|
|
36
20
|
|
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.
|
|
4
|
+
"version": "2.0.55-beta.0",
|
|
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.
|
|
11
|
+
"@akinon/next": "2.0.55-beta.0",
|
|
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.
|
|
16
|
+
"@akinon/theme-contract": "2.0.55-beta.0",
|
|
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.
|
|
23
|
+
"@akinon/next": "2.0.55-beta.0",
|
|
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,227 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChromeScrollState,
|
|
6
|
+
SCROLL_STATE_ATTRIBUTE,
|
|
7
|
+
SCROLL_STATE_THRESHOLD,
|
|
8
|
+
pinScrollState
|
|
9
|
+
} from '../scroll-state';
|
|
10
|
+
import { PartStatePreview } from '../../components/part-state-preview';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The scroll signal a composition styles against.
|
|
14
|
+
*
|
|
15
|
+
* Two properties matter more than the attribute itself. The first is that the
|
|
16
|
+
* storefront pays nothing for it: this has to be an IntersectionObserver, not a
|
|
17
|
+
* scroll handler running on the main thread for every frame of every scroll on
|
|
18
|
+
* every page that mounts a slot. The second is that the designer can see the
|
|
19
|
+
* state, which for a page-level fact means pinning the page rather than
|
|
20
|
+
* stamping an element — the mechanism every other state uses does not reach it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type ObserverRecord = {
|
|
24
|
+
callback: IntersectionObserverCallback;
|
|
25
|
+
observed: Element[];
|
|
26
|
+
disconnected: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const observers: ObserverRecord[] = [];
|
|
30
|
+
|
|
31
|
+
class RecordingIntersectionObserver {
|
|
32
|
+
private readonly record: ObserverRecord;
|
|
33
|
+
|
|
34
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
35
|
+
this.record = { callback, observed: [], disconnected: false };
|
|
36
|
+
observers.push(this.record);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
observe(target: Element) {
|
|
40
|
+
this.record.observed.push(target);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
unobserve() {}
|
|
44
|
+
|
|
45
|
+
disconnect() {
|
|
46
|
+
this.record.disconnected = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
takeRecords() {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drives the newest observer as the browser would when the sentinel crosses. */
|
|
55
|
+
const crossViewportEdge = (isIntersecting: boolean) => {
|
|
56
|
+
const record = observers[observers.length - 1];
|
|
57
|
+
record.callback(
|
|
58
|
+
[{ isIntersecting } as IntersectionObserverEntry],
|
|
59
|
+
null as unknown as IntersectionObserver
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const slotState = (slug = 'header') =>
|
|
64
|
+
document
|
|
65
|
+
.querySelector(`div[data-chrome-slot="${slug}"]`)
|
|
66
|
+
?.getAttribute(SCROLL_STATE_ATTRIBUTE);
|
|
67
|
+
|
|
68
|
+
const renderSlot = (slug = 'header') =>
|
|
69
|
+
render(
|
|
70
|
+
<div data-chrome-slot={slug}>
|
|
71
|
+
<ChromeScrollState />
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const originalObserver = window.IntersectionObserver;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
observers.length = 0;
|
|
79
|
+
window.IntersectionObserver =
|
|
80
|
+
RecordingIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
pinScrollState(null);
|
|
85
|
+
window.IntersectionObserver = originalObserver;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('the chrome slot scroll signal', () => {
|
|
89
|
+
it('stamps the state on every slot and follows the sentinel', () => {
|
|
90
|
+
renderSlot();
|
|
91
|
+
|
|
92
|
+
// Present from the first paint: a slot whose attribute only appeared on the
|
|
93
|
+
// first crossing would leave `[data-scroll-state~="scrolled"]` unmatchable
|
|
94
|
+
// and the authored default unexplained.
|
|
95
|
+
expect(slotState()).toBe('top');
|
|
96
|
+
|
|
97
|
+
crossViewportEdge(false);
|
|
98
|
+
expect(slotState()).toBe('scrolled');
|
|
99
|
+
|
|
100
|
+
crossViewportEdge(true);
|
|
101
|
+
expect(slotState()).toBe('top');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('observes a zero-height sentinel on the body, at the threshold', () => {
|
|
105
|
+
renderSlot();
|
|
106
|
+
|
|
107
|
+
const sentinel = document.querySelector<HTMLElement>(
|
|
108
|
+
'[data-scroll-state-sentinel]'
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(sentinel).not.toBeNull();
|
|
112
|
+
// On the body rather than in the slot: the element that wants this state is
|
|
113
|
+
// usually fixed or sticky, and a sentinel inside it would travel with it and
|
|
114
|
+
// never leave the viewport.
|
|
115
|
+
expect(sentinel?.parentElement).toBe(document.body);
|
|
116
|
+
expect(sentinel?.style.height).toBe('0px');
|
|
117
|
+
expect(sentinel?.style.top).toBe(`${SCROLL_STATE_THRESHOLD}px`);
|
|
118
|
+
expect(observers[0].observed).toEqual([sentinel]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('adds no scroll listener anywhere on the storefront path', () => {
|
|
122
|
+
const onWindow = jest.spyOn(window, 'addEventListener');
|
|
123
|
+
const onDocument = jest.spyOn(document, 'addEventListener');
|
|
124
|
+
|
|
125
|
+
renderSlot();
|
|
126
|
+
crossViewportEdge(false);
|
|
127
|
+
|
|
128
|
+
const listened = [...onWindow.mock.calls, ...onDocument.mock.calls].map(
|
|
129
|
+
([type]) => type
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(listened).not.toContain('scroll');
|
|
133
|
+
|
|
134
|
+
onWindow.mockRestore();
|
|
135
|
+
onDocument.mockRestore();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('serves every slot from one observer and takes it down with the last', () => {
|
|
139
|
+
const { unmount } = render(
|
|
140
|
+
<>
|
|
141
|
+
<div data-chrome-slot="header">
|
|
142
|
+
<ChromeScrollState />
|
|
143
|
+
</div>
|
|
144
|
+
<div data-chrome-slot="footer">
|
|
145
|
+
<ChromeScrollState />
|
|
146
|
+
</div>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Both slots read the same scroll position, so a second observer would be
|
|
151
|
+
// a second answer to a question that has one.
|
|
152
|
+
expect(observers).toHaveLength(1);
|
|
153
|
+
|
|
154
|
+
crossViewportEdge(false);
|
|
155
|
+
expect(slotState('header')).toBe('scrolled');
|
|
156
|
+
expect(slotState('footer')).toBe('scrolled');
|
|
157
|
+
|
|
158
|
+
unmount();
|
|
159
|
+
expect(observers[0].disconnected).toBe(true);
|
|
160
|
+
expect(
|
|
161
|
+
document.querySelector('[data-scroll-state-sentinel]')
|
|
162
|
+
).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('keeps the observer while one of several slots unmounts', () => {
|
|
166
|
+
const { unmount } = renderSlot('footer');
|
|
167
|
+
renderSlot('header');
|
|
168
|
+
|
|
169
|
+
unmount();
|
|
170
|
+
|
|
171
|
+
expect(observers[0].disconnected).toBe(false);
|
|
172
|
+
crossViewportEdge(false);
|
|
173
|
+
expect(slotState('header')).toBe('scrolled');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('previewing the scroll state in the designer', () => {
|
|
178
|
+
const previewState = (state: string | null) =>
|
|
179
|
+
fireEvent(
|
|
180
|
+
window,
|
|
181
|
+
new MessageEvent('message', {
|
|
182
|
+
data: { type: 'PREVIEW_PART_STATE', data: { state } }
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it('pins the page rather than the part, and releases it again', () => {
|
|
187
|
+
render(
|
|
188
|
+
<>
|
|
189
|
+
<PartStatePreview isDesigner />
|
|
190
|
+
<div data-chrome-slot="header">
|
|
191
|
+
<ChromeScrollState />
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
expect(slotState()).toBe('top');
|
|
197
|
+
|
|
198
|
+
// The canvas is at the top of the page and the designer cannot both hold it
|
|
199
|
+
// scrolled and reach the colour picker — the pin is the whole point.
|
|
200
|
+
previewState('scrolled');
|
|
201
|
+
expect(slotState()).toBe('scrolled');
|
|
202
|
+
|
|
203
|
+
// Moving to any other state releases it: a canvas left frozen mid-page
|
|
204
|
+
// disagrees with the storefront for every style typed after it.
|
|
205
|
+
previewState('hover');
|
|
206
|
+
expect(slotState()).toBe('top');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('lets the observer win again once the pin is released', () => {
|
|
210
|
+
render(
|
|
211
|
+
<>
|
|
212
|
+
<PartStatePreview isDesigner />
|
|
213
|
+
<div data-chrome-slot="header">
|
|
214
|
+
<ChromeScrollState />
|
|
215
|
+
</div>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
crossViewportEdge(false);
|
|
220
|
+
previewState('scrolled');
|
|
221
|
+
previewState(null);
|
|
222
|
+
|
|
223
|
+
// Released to what the sentinel actually reports, not to 'top': the
|
|
224
|
+
// designer may have scrolled the canvas while the pin was held.
|
|
225
|
+
expect(slotState()).toBe('scrolled');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -4,6 +4,7 @@ import { warnAboutUncomposedSlot } from './composition-state';
|
|
|
4
4
|
import { getSlugCompositionState } from './composition-source';
|
|
5
5
|
import { getThemeConfig } from './theme-config-source';
|
|
6
6
|
import { ChromeFallback } from './chrome-fallback';
|
|
7
|
+
import { ChromeScrollState } from './scroll-state';
|
|
7
8
|
import {
|
|
8
9
|
resolveSlotSticky,
|
|
9
10
|
slotStickyStyle,
|
|
@@ -116,6 +117,9 @@ export default async function ChromeSlot({
|
|
|
116
117
|
) : emptyZoneBand ? (
|
|
117
118
|
<ChromeFallback slug={slug} emptyZone initiallyOverridden />
|
|
118
119
|
) : null}
|
|
120
|
+
{/* Publishes `data-scroll-state` on the wrapper above, so a composition
|
|
121
|
+
can style a header differently once the page is scrolled past it. */}
|
|
122
|
+
<ChromeScrollState />
|
|
119
123
|
{pinned && <StickySlotOverflow />}
|
|
120
124
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
121
125
|
</div>
|
|
@@ -139,6 +143,7 @@ export default async function ChromeSlot({
|
|
|
139
143
|
// live site keeps rendering nothing.
|
|
140
144
|
<ChromeFallback slug={slug} emptyZone />
|
|
141
145
|
) : null}
|
|
146
|
+
<ChromeScrollState />
|
|
142
147
|
{pinned && <StickySlotOverflow />}
|
|
143
148
|
{/* Empty until the designer overrides this area from the editor */}
|
|
144
149
|
<ThemePlaceholder slug={slug} pageContext={pageContext} />
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "The page has scrolled away from the top", published as a styling signal.
|
|
7
|
+
*
|
|
8
|
+
* A header that floats transparently over a hero has to turn opaque once the
|
|
9
|
+
* shopper scrolls past it, and no rung of the composition could say that: a
|
|
10
|
+
* style value is static per breakpoint, and the only scroll listener this
|
|
11
|
+
* package ships repositions the designer's own toolbar. The signal is published
|
|
12
|
+
* as an attribute on the slot — `data-scroll-state="top" | "scrolled"` — so the
|
|
13
|
+
* CSS generator can match it through an ancestor selector and no block renderer
|
|
14
|
+
* has to know the state exists at all (see utils/part-styles.ts).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately an IntersectionObserver over a sentinel rather than a scroll
|
|
17
|
+
* handler. A scroll handler runs on the main thread for every frame of every
|
|
18
|
+
* scroll, on every page that mounts a slot, whether or not the composition
|
|
19
|
+
* styles the state — the observer costs nothing until the sentinel crosses the
|
|
20
|
+
* viewport edge. Pinned by test.
|
|
21
|
+
*
|
|
22
|
+
* The sentinel lives on `document.body`, not inside the slot. The element that
|
|
23
|
+
* wants this state is almost always the one that is `position: fixed` or
|
|
24
|
+
* `sticky`, and a sentinel inside it would travel with it and never leave the
|
|
25
|
+
* viewport. Absolutely positioned at the threshold instead, "no longer
|
|
26
|
+
* intersecting" reads as exactly `scrollY > threshold` — the same reading brand
|
|
27
|
+
* sticky-header hooks take from `window.scrollY`.
|
|
28
|
+
*
|
|
29
|
+
* One observer serves the whole document: every slot on the page reaches the
|
|
30
|
+
* same conclusion about the same scroll position. Slots register, and the last
|
|
31
|
+
* one to unmount takes the sentinel down with it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The attribute the renderer's `scrolled` selector matches on. */
|
|
35
|
+
export const SCROLL_STATE_ATTRIBUTE = 'data-scroll-state';
|
|
36
|
+
|
|
37
|
+
/** The style-state name this signal answers to (see utils/part-styles.ts). */
|
|
38
|
+
export const SCROLL_STYLE_STATE = 'scrolled';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How far the page scrolls before it counts as scrolled, in px. Matches the
|
|
42
|
+
* threshold brand sticky-header hooks already use, so a composition that
|
|
43
|
+
* replaces one flips at the same place the hand-written header did.
|
|
44
|
+
*/
|
|
45
|
+
export const SCROLL_STATE_THRESHOLD = 100;
|
|
46
|
+
|
|
47
|
+
export type ScrollState = 'top' | 'scrolled';
|
|
48
|
+
|
|
49
|
+
/** Every chrome slot carries the state; the selector is half of the contract. */
|
|
50
|
+
const CHROME_SLOT_SELECTOR = 'div[data-chrome-slot]';
|
|
51
|
+
|
|
52
|
+
let sentinel: HTMLElement | null = null;
|
|
53
|
+
let observer: IntersectionObserver | null = null;
|
|
54
|
+
let registrations = 0;
|
|
55
|
+
let observedState: ScrollState = 'top';
|
|
56
|
+
let pinnedState: ScrollState | null = null;
|
|
57
|
+
|
|
58
|
+
const stampSlots = () => {
|
|
59
|
+
if (typeof document === 'undefined') return;
|
|
60
|
+
|
|
61
|
+
const state = pinnedState ?? observedState;
|
|
62
|
+
|
|
63
|
+
document
|
|
64
|
+
.querySelectorAll<HTMLElement>(CHROME_SLOT_SELECTOR)
|
|
65
|
+
.forEach((slot) => slot.setAttribute(SCROLL_STATE_ATTRIBUTE, state));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const createSentinel = (): HTMLElement => {
|
|
69
|
+
const element = document.createElement('div');
|
|
70
|
+
|
|
71
|
+
element.setAttribute('data-scroll-state-sentinel', '');
|
|
72
|
+
element.setAttribute('aria-hidden', 'true');
|
|
73
|
+
// Zero-sized and out of the pointer's way: it exists to be observed, never to
|
|
74
|
+
// be seen, hit or to take part in layout. `top` carries the threshold, which
|
|
75
|
+
// is what makes the crossing happen at the scroll position that flips state
|
|
76
|
+
// rather than at zero.
|
|
77
|
+
element.style.position = 'absolute';
|
|
78
|
+
element.style.top = `${SCROLL_STATE_THRESHOLD}px`;
|
|
79
|
+
element.style.left = '0px';
|
|
80
|
+
element.style.width = '0px';
|
|
81
|
+
element.style.height = '0px';
|
|
82
|
+
element.style.pointerEvents = 'none';
|
|
83
|
+
|
|
84
|
+
return element;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const start = () => {
|
|
88
|
+
if (observer || typeof document === 'undefined') return;
|
|
89
|
+
// A browser without IntersectionObserver is not worth a scroll handler
|
|
90
|
+
// fallback: the state simply stays at its authored default, which is the
|
|
91
|
+
// unscrolled look the page loads with.
|
|
92
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
93
|
+
|
|
94
|
+
sentinel = createSentinel();
|
|
95
|
+
document.body.appendChild(sentinel);
|
|
96
|
+
|
|
97
|
+
observer = new IntersectionObserver((entries) => {
|
|
98
|
+
const entry = entries[entries.length - 1];
|
|
99
|
+
if (!entry) return;
|
|
100
|
+
|
|
101
|
+
observedState = entry.isIntersecting ? 'top' : 'scrolled';
|
|
102
|
+
stampSlots();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
observer.observe(sentinel);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const stop = () => {
|
|
109
|
+
observer?.disconnect();
|
|
110
|
+
observer = null;
|
|
111
|
+
sentinel?.remove();
|
|
112
|
+
sentinel = null;
|
|
113
|
+
observedState = 'top';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Registers one slot with the shared observer and returns the release its
|
|
118
|
+
* effect has to run.
|
|
119
|
+
*/
|
|
120
|
+
export const observeScrollState = (): (() => void) => {
|
|
121
|
+
registrations += 1;
|
|
122
|
+
start();
|
|
123
|
+
// A slot that mounted after the last crossing — a client route change, a late
|
|
124
|
+
// hydration — would otherwise carry no attribute until the next one.
|
|
125
|
+
stampSlots();
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
registrations -= 1;
|
|
129
|
+
|
|
130
|
+
if (registrations <= 0) {
|
|
131
|
+
registrations = 0;
|
|
132
|
+
stop();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Holds the page in a scroll state while the designer styles it.
|
|
139
|
+
*
|
|
140
|
+
* Every other style state is a fact about an element, so the canvas pins it by
|
|
141
|
+
* writing `data-state` onto that element. This one is a fact about the page and
|
|
142
|
+
* is matched through an ancestor, so there is nothing on the element to write —
|
|
143
|
+
* and a designer cannot hold the canvas scrolled and reach the colour picker at
|
|
144
|
+
* the same time any more than they can hold a hover. Pinning here overrides
|
|
145
|
+
* what the observer sees until it is released with `null`.
|
|
146
|
+
*/
|
|
147
|
+
export const pinScrollState = (state: ScrollState | null): void => {
|
|
148
|
+
pinnedState = state;
|
|
149
|
+
stampSlots();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Mounted by every ChromeSlot. Renders nothing: the slot element it stamps is
|
|
154
|
+
* the server-rendered wrapper it sits inside.
|
|
155
|
+
*/
|
|
156
|
+
export const ChromeScrollState = () => {
|
|
157
|
+
useEffect(() => observeScrollState(), []);
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export default ChromeScrollState;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
|
|
5
|
+
import { SCROLL_STYLE_STATE, pinScrollState } from '../chrome/scroll-state';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Pins a part's state open while the designer styles it.
|
|
7
9
|
*
|
|
@@ -47,6 +49,19 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
|
|
|
47
49
|
const { blockId, partName, state } = event.data.data || {};
|
|
48
50
|
clear();
|
|
49
51
|
|
|
52
|
+
// One state is not a fact about the element: `scrolled` is matched
|
|
53
|
+
// through `data-scroll-state` on the chrome slot, so writing it onto the
|
|
54
|
+
// part would light nothing up and the designer would type a colour into
|
|
55
|
+
// a canvas that never changes. The page is pinned instead — and released
|
|
56
|
+
// on every other message, so the canvas never stays frozen in a scrolled
|
|
57
|
+
// state after the designer has moved on to a different one.
|
|
58
|
+
if (state === SCROLL_STYLE_STATE) {
|
|
59
|
+
pinScrollState('scrolled');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pinScrollState(null);
|
|
64
|
+
|
|
50
65
|
if (!blockId || !partName || !state) return;
|
|
51
66
|
|
|
52
67
|
const block = document.querySelector<HTMLElement>(
|
|
@@ -83,6 +98,7 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
|
|
|
83
98
|
return () => {
|
|
84
99
|
window.removeEventListener('message', handleMessage);
|
|
85
100
|
clear();
|
|
101
|
+
pinScrollState(null);
|
|
86
102
|
};
|
|
87
103
|
}, [isDesigner]);
|
|
88
104
|
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,14 @@ export { default as ChromeSlot } from './chrome/chrome-slot';
|
|
|
28
28
|
export { default as ComposableRegion } from './chrome/composable-region';
|
|
29
29
|
export { ChromeFallback } from './chrome/chrome-fallback';
|
|
30
30
|
export { CodeOwnedRegion } from './chrome/code-owned-region';
|
|
31
|
+
// Brand CSS following the same signal a composition's `scrolled` state follows
|
|
32
|
+
// needs the attribute name itself, not a second copy of the string.
|
|
33
|
+
export {
|
|
34
|
+
ChromeScrollState,
|
|
35
|
+
SCROLL_STATE_ATTRIBUTE,
|
|
36
|
+
SCROLL_STATE_THRESHOLD,
|
|
37
|
+
SCROLL_STYLE_STATE
|
|
38
|
+
} from './chrome/scroll-state';
|
|
31
39
|
export { PreviewAuthStateReporter } from './chrome/preview-auth-state';
|
|
32
40
|
export { LiveBasketPageContext } from './chrome/live-basket-page-context';
|
|
33
41
|
export {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { generateThemeCSS } from '../index';
|
|
2
|
+
import { stripHidingStyles } from '../checkout-safety';
|
|
3
|
+
import {
|
|
4
|
+
buildPartSelectors,
|
|
5
|
+
buildRootStateKey,
|
|
6
|
+
parseRootStateKey,
|
|
7
|
+
splitPartStyles
|
|
8
|
+
} from '../part-styles';
|
|
9
|
+
import type { Section } from '../../theme-section';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `scrolled` as a style state, at all three rungs it has to reach.
|
|
13
|
+
*
|
|
14
|
+
* Every other state is a fact about an element and matches on the element, so
|
|
15
|
+
* it can be expressed as a selector SUFFIX. This one is a fact about the page,
|
|
16
|
+
* published on the chrome slot, so its selector is ancestor-conditioned — which
|
|
17
|
+
* is why the part-selector builder had to learn a placeholder, and why the
|
|
18
|
+
* root path (block wrapper, section band) needed a key grammar at all: the
|
|
19
|
+
* first thing a designer wants from a scroll state is a whole header band
|
|
20
|
+
* changing colour, and a band is a section, not a part inside one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SLOT = '[data-scroll-state~="scrolled"]';
|
|
24
|
+
|
|
25
|
+
const section = (id: string, styles: Record<string, unknown>, blocks: unknown[] = []) =>
|
|
26
|
+
({
|
|
27
|
+
id,
|
|
28
|
+
type: 'custom',
|
|
29
|
+
properties: {},
|
|
30
|
+
styles,
|
|
31
|
+
order: 0,
|
|
32
|
+
hidden: false,
|
|
33
|
+
blocks
|
|
34
|
+
}) as unknown as Section;
|
|
35
|
+
|
|
36
|
+
const block = (id: string, styles: Record<string, unknown>, type = 'group') => ({
|
|
37
|
+
id,
|
|
38
|
+
type,
|
|
39
|
+
label: 'Block',
|
|
40
|
+
properties: {},
|
|
41
|
+
styles,
|
|
42
|
+
order: 0,
|
|
43
|
+
hidden: false,
|
|
44
|
+
blocks: []
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('the part rule', () => {
|
|
48
|
+
it('conditions the part on the slot without losing the ownership guard', () => {
|
|
49
|
+
const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
|
|
50
|
+
part: 'logo',
|
|
51
|
+
state: 'scrolled',
|
|
52
|
+
styles: {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(selector).toBe(
|
|
56
|
+
`${SLOT} [data-block-id="blk-1"] [data-part="logo"]` +
|
|
57
|
+
':not(:where([data-block-id="blk-1"] [data-block-id] *))'
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still refuses to reach a nested block that names the same part', () => {
|
|
62
|
+
const [selector] = buildPartSelectors('[data-block-id="outer"]', {
|
|
63
|
+
part: 'logo',
|
|
64
|
+
state: 'scrolled',
|
|
65
|
+
styles: {}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
document.body.innerHTML =
|
|
69
|
+
'<div data-chrome-slot="header" data-scroll-state="scrolled">' +
|
|
70
|
+
'<div data-block-id="outer">' +
|
|
71
|
+
'<img id="own" data-part="logo" />' +
|
|
72
|
+
'<div data-block-id="inner"><img id="nested" data-part="logo" /></div>' +
|
|
73
|
+
'</div>' +
|
|
74
|
+
'</div>';
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
Array.from(document.querySelectorAll(selector)).map((el) => el.id)
|
|
78
|
+
).toEqual(['own']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('matches nothing while the page is at the top', () => {
|
|
82
|
+
const [selector] = buildPartSelectors('[data-block-id="blk-1"]', {
|
|
83
|
+
part: 'logo',
|
|
84
|
+
state: 'scrolled',
|
|
85
|
+
styles: {}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
document.body.innerHTML =
|
|
89
|
+
'<div data-chrome-slot="header" data-scroll-state="top">' +
|
|
90
|
+
'<div data-block-id="blk-1"><img data-part="logo" /></div>' +
|
|
91
|
+
'</div>';
|
|
92
|
+
|
|
93
|
+
expect(document.querySelectorAll(selector)).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('the root-state key grammar', () => {
|
|
98
|
+
it('reads `:state/property` and refuses anything else', () => {
|
|
99
|
+
expect(parseRootStateKey(':scrolled/background-color')).toEqual({
|
|
100
|
+
state: 'scrolled',
|
|
101
|
+
property: 'background-color'
|
|
102
|
+
});
|
|
103
|
+
expect(parseRootStateKey(buildRootStateKey('hover', 'color'))).toEqual({
|
|
104
|
+
state: 'hover',
|
|
105
|
+
property: 'color'
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// A bare property, a part key and an unknown state all stay where they are.
|
|
109
|
+
expect(parseRootStateKey('background-color')).toBeNull();
|
|
110
|
+
expect(parseRootStateKey('logo/background-color')).toBeNull();
|
|
111
|
+
expect(parseRootStateKey(':wobble/background-color')).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('cannot be confused with a part that happens to be named after a state', () => {
|
|
115
|
+
const { root, parts, rootStates } = splitPartStyles({
|
|
116
|
+
'background-color': '#fff',
|
|
117
|
+
'scrolled/background-color': '#eee',
|
|
118
|
+
':scrolled/background-color': '#000'
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(root).toEqual({ 'background-color': '#fff' });
|
|
122
|
+
expect(parts).toEqual([
|
|
123
|
+
{ part: 'scrolled', state: null, styles: { 'background-color': '#eee' } }
|
|
124
|
+
]);
|
|
125
|
+
expect(rootStates).toEqual([
|
|
126
|
+
{ state: 'scrolled', styles: { 'background-color': '#000' } }
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('the block and section root rules', () => {
|
|
132
|
+
it('emits a scrolled rule for a section band, after its plain rule', () => {
|
|
133
|
+
const css = generateThemeCSS([
|
|
134
|
+
section('sec-1', {
|
|
135
|
+
'background-color': 'transparent',
|
|
136
|
+
':scrolled/background-color': '#ffffff'
|
|
137
|
+
})
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(css).toContain(
|
|
141
|
+
'[data-section-id="sec-1"] {\n background-color: transparent;\n}'
|
|
142
|
+
);
|
|
143
|
+
// Through the same background-opacity path a plain root colour takes, so a
|
|
144
|
+
// state value is composed exactly like the value it overrides.
|
|
145
|
+
expect(css).toContain(
|
|
146
|
+
`${SLOT} [data-section-id="sec-1"] {\n background-color: rgba(255, 255, 255, 1);\n}`
|
|
147
|
+
);
|
|
148
|
+
// Order is the tie-breaker the whole cascade in this file relies on; the
|
|
149
|
+
// state also carries more specificity, but it must never be written first.
|
|
150
|
+
expect(css.indexOf('[data-section-id="sec-1"] {')).toBeLessThan(
|
|
151
|
+
css.indexOf(`${SLOT} [data-section-id="sec-1"] {`)
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('emits a scrolled rule for a block wrapper', () => {
|
|
156
|
+
const css = generateThemeCSS([
|
|
157
|
+
section('sec-1', {}, [block('blk-1', { ':scrolled/color': '#111111' })])
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
expect(css).toContain(
|
|
161
|
+
`${SLOT} [data-block-id="blk-1"] {\n color: #111111;\n}`
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('follows a block that routes its root styles to its real root part', () => {
|
|
166
|
+
const css = generateThemeCSS([
|
|
167
|
+
section('sec-1', {}, [
|
|
168
|
+
{
|
|
169
|
+
...block('blk-1', { ':scrolled/color': '#111111' }),
|
|
170
|
+
stylesTargetRoot: true
|
|
171
|
+
}
|
|
172
|
+
])
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// The state has to land on the same element the stateless root rule does,
|
|
176
|
+
// or it would style the transparent designer wrapper the block sits in.
|
|
177
|
+
expect(css).toContain(
|
|
178
|
+
`${SLOT} [data-block-id="blk-1"] [data-part="root"] {\n color: #111111;\n}`
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('never emits the raw key as a declaration', () => {
|
|
183
|
+
const css = generateThemeCSS([
|
|
184
|
+
section('sec-1', { ':scrolled/background-color': '#ffffff' })
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
expect(css).not.toContain(':scrolled/background-color:');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('checkout safety', () => {
|
|
192
|
+
it('strips a hiding declaration written into a root state', () => {
|
|
193
|
+
// Without the root-state key being read for its property, `display: none`
|
|
194
|
+
// would arrive under a name nothing recognises and sail through the one
|
|
195
|
+
// filter that stands between a composition and an unorderable checkout.
|
|
196
|
+
expect(
|
|
197
|
+
stripHidingStyles(
|
|
198
|
+
{
|
|
199
|
+
':scrolled/display': 'none',
|
|
200
|
+
':scrolled/background-color': '#ffffff'
|
|
201
|
+
},
|
|
202
|
+
'root-only'
|
|
203
|
+
)
|
|
204
|
+
).toEqual({ ':scrolled/background-color': '#ffffff' });
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* subtracts the values that end ordering.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { parsePartStyleKey } from './part-styles';
|
|
31
|
+
import { parsePartStyleKey, parseRootStateKey } from './part-styles';
|
|
32
32
|
|
|
33
33
|
export const CHECKOUT_FLOW_OUTLET_TYPE = 'checkout-flow-outlet';
|
|
34
34
|
|
|
@@ -142,6 +142,11 @@ export const stripHidingStyles = (
|
|
|
142
142
|
|
|
143
143
|
Object.entries(styles || {}).forEach(([key, value]) => {
|
|
144
144
|
const parsed = parsePartStyleKey(key);
|
|
145
|
+
// A root-state key (`:scrolled/display`) addresses the element's own root,
|
|
146
|
+
// so it is guarded exactly as a bare root key is. Reading the property out
|
|
147
|
+
// of it is what makes that true: left as the whole key, `display: none`
|
|
148
|
+
// would arrive here under a name nothing recognises and sail through.
|
|
149
|
+
const rootState = parsed ? null : parseRootStateKey(key);
|
|
145
150
|
|
|
146
151
|
const guarded = parsed
|
|
147
152
|
? partScope === 'critical-parts' &&
|
|
@@ -153,7 +158,7 @@ export const stripHidingStyles = (
|
|
|
153
158
|
return;
|
|
154
159
|
}
|
|
155
160
|
|
|
156
|
-
const property = parsed
|
|
161
|
+
const property = (parsed ?? rootState)?.property ?? key;
|
|
157
162
|
const kept = stripHidingFromValue(property, value);
|
|
158
163
|
|
|
159
164
|
if (kept !== undefined) safe[key] = kept;
|
package/src/utils/index.ts
CHANGED
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
neutralizeStyleTagBreakout,
|
|
6
6
|
escapeCssAttributeValue
|
|
7
7
|
} from './css-safety';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildPartSelectors,
|
|
10
|
+
buildRootStateSelectors,
|
|
11
|
+
splitPartStyles
|
|
12
|
+
} from './part-styles';
|
|
9
13
|
import { buildCustomCss, buildPartCustomCss } from './custom-css';
|
|
10
14
|
import {
|
|
11
15
|
CHECKOUT_FLOW_OUTLET_TYPE,
|
|
@@ -1278,6 +1282,41 @@ const generatePartCSS = (
|
|
|
1278
1282
|
return css;
|
|
1279
1283
|
};
|
|
1280
1284
|
|
|
1285
|
+
/**
|
|
1286
|
+
* Rules for a block or section's own root in a state.
|
|
1287
|
+
*
|
|
1288
|
+
* Not `!important`, unlike the part rules above. Those need it because the
|
|
1289
|
+
* element they target carries the renderer's own inline styles; a block wrapper
|
|
1290
|
+
* or a section band does not, and the only rule this has to beat is the plain
|
|
1291
|
+
* root rule emitted a few lines earlier — which it already does on specificity,
|
|
1292
|
+
* a state selector being strictly longer than the stateless one. Forcing it
|
|
1293
|
+
* would buy nothing and cost the custom-CSS hatch its `!important`, which is
|
|
1294
|
+
* the designer's documented way to win an argument with a generated rule.
|
|
1295
|
+
*/
|
|
1296
|
+
const generateRootStateCSS = (
|
|
1297
|
+
elementSelector: string,
|
|
1298
|
+
styles: Record<string, unknown>,
|
|
1299
|
+
targetBreakpoint?: string
|
|
1300
|
+
): string => {
|
|
1301
|
+
const { rootStates } = splitPartStyles(styles);
|
|
1302
|
+
if (rootStates.length === 0) return '';
|
|
1303
|
+
|
|
1304
|
+
let css = '';
|
|
1305
|
+
|
|
1306
|
+
rootStates.forEach((bucket) => {
|
|
1307
|
+
const selectors = buildRootStateSelectors(elementSelector, bucket.state);
|
|
1308
|
+
if (selectors.length === 0) return;
|
|
1309
|
+
|
|
1310
|
+
css += generateElementCSS(
|
|
1311
|
+
selectors.join(',\n'),
|
|
1312
|
+
bucket.styles as ResponsiveStyles,
|
|
1313
|
+
targetBreakpoint
|
|
1314
|
+
);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
return css;
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1281
1320
|
const collectBlockCSS = (
|
|
1282
1321
|
blocks: Block[],
|
|
1283
1322
|
targetBreakpoint?: string
|
|
@@ -1308,21 +1347,24 @@ const collectBlockCSS = (
|
|
|
1308
1347
|
// block wrapper (a padding meant for the dropdown would otherwise
|
|
1309
1348
|
// reposition the whole block).
|
|
1310
1349
|
const { root } = splitPartStyles(blockStyles);
|
|
1350
|
+
// `data-block-id` sits on the designer wrapper, not on the element the
|
|
1351
|
+
// renderer actually draws — so a padding or border set on a functional
|
|
1352
|
+
// block stopped at a transparent div and never reached the control.
|
|
1353
|
+
// A block that names its real root with `data-part="root"` gets its
|
|
1354
|
+
// own styles routed there instead. Blocks that do not are untouched,
|
|
1355
|
+
// which is what keeps published compositions rendering as before.
|
|
1356
|
+
const rootSelector = block.stylesTargetRoot
|
|
1357
|
+
? `${selector} [data-part="root"]`
|
|
1358
|
+
: selector;
|
|
1311
1359
|
|
|
1312
1360
|
if (Object.keys(root).length > 0) {
|
|
1313
|
-
// `data-block-id` sits on the designer wrapper, not on the element the
|
|
1314
|
-
// renderer actually draws — so a padding or border set on a functional
|
|
1315
|
-
// block stopped at a transparent div and never reached the control.
|
|
1316
|
-
// A block that names its real root with `data-part="root"` gets its
|
|
1317
|
-
// own styles routed there instead. Blocks that do not are untouched,
|
|
1318
|
-
// which is what keeps published compositions rendering as before.
|
|
1319
|
-
const rootSelector = block.stylesTargetRoot
|
|
1320
|
-
? `${selector} [data-part="root"]`
|
|
1321
|
-
: selector;
|
|
1322
|
-
|
|
1323
1361
|
css += generateElementCSS(rootSelector, root, targetBreakpoint);
|
|
1324
1362
|
}
|
|
1325
1363
|
|
|
1364
|
+
// After the plain root rule, and against the same element: a state has to
|
|
1365
|
+
// be able to override what the stateless value painted.
|
|
1366
|
+
css += generateRootStateCSS(rootSelector, blockStyles, targetBreakpoint);
|
|
1367
|
+
|
|
1326
1368
|
css += generatePartCSS(selector, blockStyles, targetBreakpoint);
|
|
1327
1369
|
}
|
|
1328
1370
|
|
|
@@ -1393,6 +1435,12 @@ export const generateThemeCSS = (
|
|
|
1393
1435
|
);
|
|
1394
1436
|
}
|
|
1395
1437
|
|
|
1438
|
+
css += generateRootStateCSS(
|
|
1439
|
+
sectionSelector,
|
|
1440
|
+
sectionStyles,
|
|
1441
|
+
targetBreakpoint
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1396
1444
|
css += generatePartCSS(
|
|
1397
1445
|
sectionSelector,
|
|
1398
1446
|
sectionStyles,
|
package/src/utils/part-styles.ts
CHANGED
|
@@ -24,12 +24,17 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* State segment -> the selector
|
|
27
|
+
* State segment -> the selector expressions that express it.
|
|
28
28
|
*
|
|
29
29
|
* Every state also matches `[data-state~="…"]`, including the ones that are
|
|
30
30
|
* really pseudo-classes. That is what lets the editor pin a state open: a
|
|
31
31
|
* designer cannot hover an element while typing a hover colour, so the canvas
|
|
32
32
|
* writes the state onto the element and the same rule applies.
|
|
33
|
+
*
|
|
34
|
+
* An expression is normally a SUFFIX appended to the styled element's selector.
|
|
35
|
+
* One containing `&` is a pattern instead, and `&` is where that selector goes
|
|
36
|
+
* — the only way to express a state that is matched on an ANCESTOR rather than
|
|
37
|
+
* on the element itself.
|
|
33
38
|
*/
|
|
34
39
|
const STATE_SELECTORS: Record<string, string[]> = {
|
|
35
40
|
hover: [':hover', '[data-state~="hover"]'],
|
|
@@ -63,14 +68,38 @@ const STATE_SELECTORS: Record<string, string[]> = {
|
|
|
63
68
|
// "unavailable" never satisfies the "available" selector.
|
|
64
69
|
available: ['[data-state~="available"]'],
|
|
65
70
|
unavailable: ['[data-state~="unavailable"]'],
|
|
66
|
-
unknown: ['[data-state~="unknown"]']
|
|
71
|
+
unknown: ['[data-state~="unknown"]'],
|
|
72
|
+
// The odd one out: every state above is a fact about the ELEMENT and matches
|
|
73
|
+
// on the element itself. This one is a fact about the PAGE — the chrome slot
|
|
74
|
+
// carries `data-scroll-state` (see chrome/scroll-state.tsx) — so it is
|
|
75
|
+
// matched through an ancestor and no renderer has to stamp anything for it to
|
|
76
|
+
// apply. That is what lets a header become opaque on scroll, which nothing in
|
|
77
|
+
// a composition could previously say.
|
|
78
|
+
scrolled: ['[data-scroll-state~="scrolled"] &']
|
|
67
79
|
};
|
|
68
80
|
|
|
69
81
|
export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);
|
|
70
82
|
|
|
83
|
+
/** Marks where the styled element's selector goes in an ancestor-matched state. */
|
|
84
|
+
const STATE_ELEMENT_PLACEHOLDER = '&';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Places the styled element's selector into a state expression.
|
|
88
|
+
*
|
|
89
|
+
* A suffix is appended, which is what every element-matched state needs. A
|
|
90
|
+
* pattern is substituted into, which is what an ancestor-matched state needs.
|
|
91
|
+
*/
|
|
92
|
+
const applyStateExpression = (element: string, expression: string): string =>
|
|
93
|
+
expression.includes(STATE_ELEMENT_PLACEHOLDER)
|
|
94
|
+
? expression.split(STATE_ELEMENT_PLACEHOLDER).join(element)
|
|
95
|
+
: `${element}${expression}`;
|
|
96
|
+
|
|
71
97
|
/** Keys are `part/prop` or `part/state/prop`; `/` never appears in a CSS property. */
|
|
72
98
|
const PART_KEY_DELIMITER = '/';
|
|
73
99
|
|
|
100
|
+
/** Leads a root-state key (`:scrolled/background-color`); see below. */
|
|
101
|
+
const ROOT_STATE_MARKER = ':';
|
|
102
|
+
|
|
74
103
|
export type ParsedPartKey = {
|
|
75
104
|
part: string;
|
|
76
105
|
state: string | null;
|
|
@@ -79,6 +108,11 @@ export type ParsedPartKey = {
|
|
|
79
108
|
|
|
80
109
|
export const parsePartStyleKey = (key: string): ParsedPartKey | null => {
|
|
81
110
|
if (!key || key.indexOf(PART_KEY_DELIMITER) === -1) return null;
|
|
111
|
+
// A root-state key has the same two segments as a part key, so without this
|
|
112
|
+
// it parses as a part literally named ":scrolled" — which is not merely wrong
|
|
113
|
+
// output: `stripHidingStyles` reads part keys as belonging to a nested part
|
|
114
|
+
// and would wave a `display: none` on the checkout wrapper straight through.
|
|
115
|
+
if (key[0] === ROOT_STATE_MARKER) return null;
|
|
82
116
|
|
|
83
117
|
const segments = key.split(PART_KEY_DELIMITER).filter(Boolean);
|
|
84
118
|
|
|
@@ -108,6 +142,47 @@ export const buildPartStyleKey = (
|
|
|
108
142
|
? [part, state, property].join(PART_KEY_DELIMITER)
|
|
109
143
|
: [part, property].join(PART_KEY_DELIMITER);
|
|
110
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The same states, on the element's OWN root rather than on a part inside it.
|
|
147
|
+
*
|
|
148
|
+
* A part key could already say "this dropdown, while scrolled", but a block or
|
|
149
|
+
* section root could not say anything of the kind: its keys are bare CSS
|
|
150
|
+
* properties with nowhere to put a state. That leaves the most obvious thing a
|
|
151
|
+
* designer wants from a scroll state — a whole header band changing its
|
|
152
|
+
* background and text colour — unreachable, since the band is the section, not
|
|
153
|
+
* a part inside it.
|
|
154
|
+
*
|
|
155
|
+
* Root-state keys are `:<state>/<property>`. The leading marker is what keeps
|
|
156
|
+
* them unambiguous: a bare `scrolled/background` would be indistinguishable
|
|
157
|
+
* from a part literally named `scrolled`, and `:` cannot appear in a part name
|
|
158
|
+
* (`sanitizePartName` strips it), so nothing that parses as one can ever parse
|
|
159
|
+
* as the other.
|
|
160
|
+
*/
|
|
161
|
+
export type ParsedRootStateKey = {
|
|
162
|
+
state: string;
|
|
163
|
+
property: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const parseRootStateKey = (key: string): ParsedRootStateKey | null => {
|
|
167
|
+
if (!key || key[0] !== ROOT_STATE_MARKER) return null;
|
|
168
|
+
|
|
169
|
+
const segments = key
|
|
170
|
+
.slice(ROOT_STATE_MARKER.length)
|
|
171
|
+
.split(PART_KEY_DELIMITER)
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
|
|
174
|
+
if (segments.length !== 2) return null;
|
|
175
|
+
|
|
176
|
+
const [state, property] = segments;
|
|
177
|
+
if (!STATE_SELECTORS[state]) return null;
|
|
178
|
+
|
|
179
|
+
return { state, property };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Builds the style key a root-state descriptor writes into. */
|
|
183
|
+
export const buildRootStateKey = (state: string, property: string): string =>
|
|
184
|
+
`${ROOT_STATE_MARKER}${state}${PART_KEY_DELIMITER}${property}`;
|
|
185
|
+
|
|
111
186
|
/**
|
|
112
187
|
* `data-part` plus the states the renderer wants stylable. Renderers spread
|
|
113
188
|
* this onto the element instead of hand-writing the attributes, so the
|
|
@@ -142,18 +217,45 @@ export type PartStyleBucket = {
|
|
|
142
217
|
styles: Record<string, unknown>;
|
|
143
218
|
};
|
|
144
219
|
|
|
220
|
+
/** A root/state bucket of styles, ready for the CSS generator. */
|
|
221
|
+
export type RootStateBucket = {
|
|
222
|
+
state: string;
|
|
223
|
+
styles: Record<string, unknown>;
|
|
224
|
+
};
|
|
225
|
+
|
|
145
226
|
/**
|
|
146
|
-
* Splits a block's styles into the root bucket
|
|
147
|
-
*
|
|
227
|
+
* Splits a block's styles into the root bucket, one bucket per part/state pair
|
|
228
|
+
* and one per root state, preserving insertion order so generated CSS is stable
|
|
148
229
|
* (a moving stylesheet would defeat caching and make diffs unreadable).
|
|
149
230
|
*/
|
|
150
231
|
export const splitPartStyles = (
|
|
151
232
|
styles: Record<string, unknown> | undefined | null
|
|
152
|
-
): {
|
|
233
|
+
): {
|
|
234
|
+
root: Record<string, unknown>;
|
|
235
|
+
parts: PartStyleBucket[];
|
|
236
|
+
rootStates: RootStateBucket[];
|
|
237
|
+
} => {
|
|
153
238
|
const root: Record<string, unknown> = {};
|
|
154
239
|
const buckets = new Map<string, PartStyleBucket>();
|
|
240
|
+
const rootStateBuckets = new Map<string, RootStateBucket>();
|
|
155
241
|
|
|
156
242
|
Object.entries(styles || {}).forEach(([key, value]) => {
|
|
243
|
+
// Before the part parse: a root-state key is not a part key, and left to
|
|
244
|
+
// the root bucket it would be emitted as the invalid declaration
|
|
245
|
+
// `:scrolled/background-color:`.
|
|
246
|
+
const rootState = parseRootStateKey(key);
|
|
247
|
+
|
|
248
|
+
if (rootState) {
|
|
249
|
+
const bucket = rootStateBuckets.get(rootState.state) ?? {
|
|
250
|
+
state: rootState.state,
|
|
251
|
+
styles: {}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
bucket.styles[rootState.property] = value;
|
|
255
|
+
rootStateBuckets.set(rootState.state, bucket);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
157
259
|
const parsed = parsePartStyleKey(key);
|
|
158
260
|
|
|
159
261
|
if (!parsed) {
|
|
@@ -172,7 +274,11 @@ export const splitPartStyles = (
|
|
|
172
274
|
buckets.set(bucketKey, bucket);
|
|
173
275
|
});
|
|
174
276
|
|
|
175
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
root,
|
|
279
|
+
parts: Array.from(buckets.values()),
|
|
280
|
+
rootStates: Array.from(rootStateBuckets.values())
|
|
281
|
+
};
|
|
176
282
|
};
|
|
177
283
|
|
|
178
284
|
/** Only `[A-Za-z0-9_-]` survives, so a part name cannot break out of the selector. */
|
|
@@ -226,10 +332,35 @@ export const buildPartSelectors = (
|
|
|
226
332
|
|
|
227
333
|
if (!bucket.state) return [`${base}${owned}`];
|
|
228
334
|
|
|
229
|
-
const
|
|
230
|
-
if (!
|
|
335
|
+
const expressions = STATE_SELECTORS[bucket.state];
|
|
336
|
+
if (!expressions) return [];
|
|
337
|
+
|
|
338
|
+
return expressions.map((expression) =>
|
|
339
|
+
expression.includes(STATE_ELEMENT_PLACEHOLDER)
|
|
340
|
+
? // An ancestor-matched state substitutes the whole compound, guard
|
|
341
|
+
// included, so the rule can no more leak into a nested block than the
|
|
342
|
+
// stateless one can.
|
|
343
|
+
applyStateExpression(`${base}${owned}`, expression)
|
|
344
|
+
: `${base}${expression}${owned}`
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Selectors for a block or section's OWN root in a state.
|
|
350
|
+
*
|
|
351
|
+
* No ownership guard here, and none is needed: the selector already names one
|
|
352
|
+
* element by id, so there is nothing for it to leak into.
|
|
353
|
+
*/
|
|
354
|
+
export const buildRootStateSelectors = (
|
|
355
|
+
elementSelector: string,
|
|
356
|
+
state: string
|
|
357
|
+
): string[] => {
|
|
358
|
+
const expressions = STATE_SELECTORS[state];
|
|
359
|
+
if (!expressions) return [];
|
|
231
360
|
|
|
232
|
-
return
|
|
361
|
+
return expressions.map((expression) =>
|
|
362
|
+
applyStateExpression(elementSelector, expression)
|
|
363
|
+
);
|
|
233
364
|
};
|
|
234
365
|
|
|
235
366
|
/**
|