@akinon/pz-theme 2.0.101-rc.0 → 2.0.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -33
- package/package.json +4 -4
- package/src/blocks/__tests__/force-open.test.tsx +45 -13
- package/src/blocks/input-block.tsx +22 -0
- package/src/chrome/__tests__/scroll-state.test.tsx +26 -3
- package/src/chrome/chrome-mode.ts +38 -4
- package/src/components/__tests__/part-state-preview-scope.test.tsx +25 -0
- package/src/components/__tests__/root-state-pin.test.tsx +219 -0
- package/src/components/part-state-preview.tsx +85 -27
- package/src/hooks/use-overlay.ts +38 -3
- package/src/placeholder-registry.ts +22 -0
- package/src/state-pin-capabilities.ts +28 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +3 -2
- package/src/utils/__tests__/root-state-values.test.ts +158 -0
- package/src/utils/__tests__/scrolled-state-styles.test.ts +29 -4
- package/src/utils/index.ts +60 -12
- package/src/utils/part-styles.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.101
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.101-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.101-rc.0
|
|
3
|
+
## 2.0.101
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aa1754c0: ZERO-5018: Hold any state still for the designer, and apply every value one saves — a state can now be pinned on the element a root rule lands on (a block wrapper or a section band, not just a named part inside one), `overlay-open` and `docked` gained the page-level levers they have always been advertised with and never had, `opacity` authored in a state reaches the stylesheet instead of being dropped as a helper key, a state rule routed to a renderer's own root carries `!important` so it beats the inline styles that element already has, an unknown state name is refused rather than written into the element's own rule as an invalid declaration, part and state keys no longer leak into React inline styles, and the input block marks its control as its root so its border, background and focus ring reach the control rather than the transparent wrapper around it. The announce tells the editor which of these pins this build can perform, so an editor serving a storefront on an older theme keeps exactly the behaviour it has today.
|
|
8
|
+
- @akinon/next@2.0.101
|
|
9
|
+
- @akinon/theme-contract@2.0.101
|
|
36
10
|
|
|
37
11
|
## 2.0.100
|
|
38
12
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
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.101
|
|
4
|
+
"version": "2.0.101",
|
|
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.101
|
|
11
|
+
"@akinon/next": "2.0.101",
|
|
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.101
|
|
16
|
+
"@akinon/theme-contract": "2.0.101",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.101
|
|
24
|
+
"@akinon/next": "2.0.101",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -80,10 +80,35 @@ const blockProps = {
|
|
|
80
80
|
currentBreakpoint: 'desktop'
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The pin listener checks its origin like every other inbound listener in the
|
|
85
|
+
* package, so a bare same-window dispatch is refused. A localhost origin on a
|
|
86
|
+
* port that is not jsdom's own is trusted outside production without any
|
|
87
|
+
* allowlist — the same allowance local editor + storefront development runs on.
|
|
88
|
+
*/
|
|
89
|
+
const EDITOR_ORIGIN = 'http://localhost:4400';
|
|
90
|
+
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
Object.defineProperty(window, 'parent', {
|
|
93
|
+
configurable: true,
|
|
94
|
+
value: { postMessage: () => undefined }
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
|
|
104
|
+
});
|
|
105
|
+
|
|
83
106
|
const pin = (blockId: string, partName: string, state: string | null) =>
|
|
84
107
|
act(() => {
|
|
85
108
|
window.dispatchEvent(
|
|
86
109
|
new MessageEvent('message', {
|
|
110
|
+
origin: EDITOR_ORIGIN,
|
|
111
|
+
source: window.parent as Window,
|
|
87
112
|
data: {
|
|
88
113
|
type: 'PREVIEW_PART_STATE',
|
|
89
114
|
data: { blockId, partName, state }
|
|
@@ -243,16 +268,17 @@ describe('SearchBoxBlock under force-open', () => {
|
|
|
243
268
|
expect(document.querySelector('[data-search-backdrop]')).toBeNull();
|
|
244
269
|
expect(document.querySelector('[data-part="body"]')).toBeNull();
|
|
245
270
|
// And with no backdrop the panel makes no claim about the page behind it.
|
|
246
|
-
expect(
|
|
247
|
-
|
|
248
|
-
)
|
|
271
|
+
expect(document.querySelector('[data-search-overlay]')).not.toHaveAttribute(
|
|
272
|
+
'aria-modal'
|
|
273
|
+
);
|
|
249
274
|
});
|
|
250
275
|
|
|
251
276
|
it('moves focus into the field on open and hands it back on close', () => {
|
|
252
277
|
renderBox(false, {});
|
|
253
278
|
|
|
254
|
-
const trigger = document.querySelector(
|
|
255
|
-
|
|
279
|
+
const trigger = document.querySelector(
|
|
280
|
+
'[data-part="trigger"]'
|
|
281
|
+
) as HTMLElement;
|
|
256
282
|
trigger.focus();
|
|
257
283
|
fireEvent.click(trigger);
|
|
258
284
|
|
|
@@ -270,8 +296,9 @@ describe('SearchBoxBlock under force-open', () => {
|
|
|
270
296
|
it('leaves focus on the icon when the composition turned it off', () => {
|
|
271
297
|
renderBox(false, {}, { autoFocus: false });
|
|
272
298
|
|
|
273
|
-
const trigger = document.querySelector(
|
|
274
|
-
|
|
299
|
+
const trigger = document.querySelector(
|
|
300
|
+
'[data-part="trigger"]'
|
|
301
|
+
) as HTMLElement;
|
|
275
302
|
trigger.focus();
|
|
276
303
|
fireEvent.click(trigger);
|
|
277
304
|
|
|
@@ -363,7 +390,9 @@ describe('MegaMenuBlock under force-open', () => {
|
|
|
363
390
|
try {
|
|
364
391
|
renderMenu(true, { forceOpen: true });
|
|
365
392
|
|
|
366
|
-
fireEvent.mouseLeave(
|
|
393
|
+
fireEvent.mouseLeave(
|
|
394
|
+
document.querySelector('[data-mega-menu]') as Element
|
|
395
|
+
);
|
|
367
396
|
act(() => {
|
|
368
397
|
jest.advanceTimersByTime(2000);
|
|
369
398
|
});
|
|
@@ -450,7 +479,10 @@ describe('NavMenuBlock under force-open', () => {
|
|
|
450
479
|
});
|
|
451
480
|
|
|
452
481
|
describe('HotspotBlock under force-open', () => {
|
|
453
|
-
const renderHotspot = (
|
|
482
|
+
const renderHotspot = (
|
|
483
|
+
isDesigner: boolean,
|
|
484
|
+
styles: Record<string, unknown>
|
|
485
|
+
) =>
|
|
454
486
|
render(
|
|
455
487
|
<HotspotBlock
|
|
456
488
|
block={mkBlock('hotspot', { title: 'Hot Title' }, styles)}
|
|
@@ -468,9 +500,7 @@ describe('HotspotBlock under force-open', () => {
|
|
|
468
500
|
it('leaves the popup mounted when the close button is clicked', () => {
|
|
469
501
|
const { container } = renderHotspot(true, { forceOpen: true });
|
|
470
502
|
|
|
471
|
-
fireEvent.click(
|
|
472
|
-
container.querySelector('.text-gray-400') as Element
|
|
473
|
-
);
|
|
503
|
+
fireEvent.click(container.querySelector('.text-gray-400') as Element);
|
|
474
504
|
|
|
475
505
|
expect(screen.getByText('Hot Title')).toBeInTheDocument();
|
|
476
506
|
});
|
|
@@ -553,7 +583,9 @@ describe('PanelBlock under force-open', () => {
|
|
|
553
583
|
it('leaves the panel mounted when the close button is clicked', () => {
|
|
554
584
|
renderPanel(true, { forceOpen: true });
|
|
555
585
|
|
|
556
|
-
fireEvent.click(
|
|
586
|
+
fireEvent.click(
|
|
587
|
+
document.querySelector('[data-size-guide-close]') as Element
|
|
588
|
+
);
|
|
557
589
|
|
|
558
590
|
expect(
|
|
559
591
|
document.querySelector('[data-part="modal"][data-state="open"]')
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { useLocalization } from '@akinon/next/hooks';
|
|
5
5
|
import { getResponsiveValue, resolveThemeCssVariables } from '../utils';
|
|
6
|
+
import {
|
|
7
|
+
isPartStyleKey,
|
|
8
|
+
isRootStateKey,
|
|
9
|
+
partAttrs
|
|
10
|
+
} from '../utils/part-styles';
|
|
6
11
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
12
|
import { useThemeSettingsContext } from '../theme-settings-context';
|
|
8
13
|
|
|
@@ -68,6 +73,13 @@ const InputBlock = ({
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
Object.keys(block.styles).forEach((key) => {
|
|
76
|
+
// Routing, not a property: `:focus/border-color` names this control in a
|
|
77
|
+
// state and is turned into a real rule by the generated stylesheet. Left
|
|
78
|
+
// in, it becomes the React style prop `:focus/borderColor` — a warning
|
|
79
|
+
// and dead weight. This loop builds its own object rather than calling
|
|
80
|
+
// `getCSSStyles`, so it has to say so itself.
|
|
81
|
+
if (isPartStyleKey(key) || isRootStateKey(key)) return;
|
|
82
|
+
|
|
71
83
|
const styleValue = getResponsiveValue(
|
|
72
84
|
block.styles[key],
|
|
73
85
|
currentBreakpoint
|
|
@@ -114,6 +126,16 @@ const InputBlock = ({
|
|
|
114
126
|
disabled={disabled}
|
|
115
127
|
style={inputStyles}
|
|
116
128
|
onChange={handleChange}
|
|
129
|
+
/*
|
|
130
|
+
* The block's REAL root, and the reason this type is in
|
|
131
|
+
* INNER_ROOT_BLOCK_TYPES: everything a designer sets on an Input — the
|
|
132
|
+
* border, the radius, the background, the focus ring — belongs to this
|
|
133
|
+
* control, not to the transparent div the designer wrapper draws around
|
|
134
|
+
* it. Marking it routes the generated root rule here instead of onto the
|
|
135
|
+
* wrapper, which is also what lets a state at the root match anything at
|
|
136
|
+
* all: a div is never focused and never disabled.
|
|
137
|
+
*/
|
|
138
|
+
{...partAttrs('root')}
|
|
117
139
|
/>
|
|
118
140
|
);
|
|
119
141
|
};
|
|
@@ -157,9 +157,7 @@ describe('the chrome slot scroll signal', () => {
|
|
|
157
157
|
|
|
158
158
|
unmount();
|
|
159
159
|
expect(observers[0].disconnected).toBe(true);
|
|
160
|
-
expect(
|
|
161
|
-
document.querySelector('[data-scroll-state-sentinel]')
|
|
162
|
-
).toBeNull();
|
|
160
|
+
expect(document.querySelector('[data-scroll-state-sentinel]')).toBeNull();
|
|
163
161
|
});
|
|
164
162
|
|
|
165
163
|
it('keeps the observer while one of several slots unmounts', () => {
|
|
@@ -188,11 +186,36 @@ describe('the chrome slot scroll signal', () => {
|
|
|
188
186
|
});
|
|
189
187
|
});
|
|
190
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The pin listener checks its origin like every other inbound listener in the
|
|
191
|
+
* package, so a bare same-window dispatch is refused. A localhost origin on a
|
|
192
|
+
* port that is not jsdom's own is trusted outside production without any
|
|
193
|
+
* allowlist — the same allowance local editor + storefront development runs on.
|
|
194
|
+
*/
|
|
195
|
+
const EDITOR_ORIGIN = 'http://localhost:4400';
|
|
196
|
+
|
|
197
|
+
beforeAll(() => {
|
|
198
|
+
Object.defineProperty(window, 'parent', {
|
|
199
|
+
configurable: true,
|
|
200
|
+
value: { postMessage: () => undefined }
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
|
|
210
|
+
});
|
|
211
|
+
|
|
191
212
|
describe('previewing the scroll state in the designer', () => {
|
|
192
213
|
const previewState = (state: string | null) =>
|
|
193
214
|
fireEvent(
|
|
194
215
|
window,
|
|
195
216
|
new MessageEvent('message', {
|
|
217
|
+
origin: EDITOR_ORIGIN,
|
|
218
|
+
source: window.parent as Window,
|
|
196
219
|
data: { type: 'PREVIEW_PART_STATE', data: { state } }
|
|
197
220
|
})
|
|
198
221
|
);
|
|
@@ -51,11 +51,45 @@ export const setChromeMode = (mode: ChromeMode): (() => void) => {
|
|
|
51
51
|
const { body } = document;
|
|
52
52
|
const previous = body.getAttribute(CHROME_MODE_ATTRIBUTE);
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
observedMode = mode;
|
|
55
|
+
writeChromeMode(pinnedMode ?? observedMode);
|
|
56
56
|
|
|
57
57
|
return () => {
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
observedMode = previous === CHROME_MODE_DOCKED ? CHROME_MODE_DOCKED : null;
|
|
59
|
+
writeChromeMode(pinnedMode ?? observedMode);
|
|
60
60
|
};
|
|
61
61
|
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the brand asked for, and what the designer asked to SEE instead.
|
|
65
|
+
*
|
|
66
|
+
* Kept apart because they answer different questions and change on different
|
|
67
|
+
* clocks. `setChromeMode` above is the brand's setter and its cleanup restores
|
|
68
|
+
* the mode as it stood when it was called — correct for a route transition,
|
|
69
|
+
* wrong for a pin, because the brand is free to change routes while a pin is
|
|
70
|
+
* held and the restore would silently write the pin away.
|
|
71
|
+
*/
|
|
72
|
+
let observedMode: ChromeMode = null;
|
|
73
|
+
let pinnedMode: ChromeMode = null;
|
|
74
|
+
|
|
75
|
+
const writeChromeMode = (mode: ChromeMode) => {
|
|
76
|
+
if (typeof document === 'undefined') return;
|
|
77
|
+
|
|
78
|
+
if (mode === null) document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
|
|
79
|
+
else document.body.setAttribute(CHROME_MODE_ATTRIBUTE, mode);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The designer's override, released with `null`.
|
|
84
|
+
*
|
|
85
|
+
* Which mode a route gets is the one thing a composition cannot ask about —
|
|
86
|
+
* only the brand knows which of its routes carry a hero — so this is also the
|
|
87
|
+
* only way a designer can ever SEE the docked half of the header they are
|
|
88
|
+
* styling. Mirrors `pinScrollState`: a layer over what the brand set, never a
|
|
89
|
+
* capture of it.
|
|
90
|
+
*/
|
|
91
|
+
export const pinChromeMode = (mode: ChromeMode): void => {
|
|
92
|
+
if (pinnedMode === mode) return;
|
|
93
|
+
pinnedMode = mode;
|
|
94
|
+
writeChromeMode(pinnedMode ?? observedMode);
|
|
95
|
+
};
|
|
@@ -67,10 +67,35 @@ const accordion = (id: string, inner?: unknown) => ({
|
|
|
67
67
|
]
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The pin listener checks its origin like every other inbound listener in the
|
|
72
|
+
* package, so a bare same-window dispatch is refused. A localhost origin on a
|
|
73
|
+
* port that is not jsdom's own is trusted outside production without any
|
|
74
|
+
* allowlist — the same allowance local editor + storefront development runs on.
|
|
75
|
+
*/
|
|
76
|
+
const EDITOR_ORIGIN = 'http://localhost:4400';
|
|
77
|
+
|
|
78
|
+
beforeAll(() => {
|
|
79
|
+
Object.defineProperty(window, 'parent', {
|
|
80
|
+
configurable: true,
|
|
81
|
+
value: { postMessage: () => undefined }
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS = EDITOR_ORIGIN;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
delete process.env.NEXT_PUBLIC_THEME_EDITOR_ORIGINS;
|
|
91
|
+
});
|
|
92
|
+
|
|
70
93
|
const pin = (blockId: string, partName: string, state: string | null) =>
|
|
71
94
|
act(() => {
|
|
72
95
|
window.dispatchEvent(
|
|
73
96
|
new MessageEvent('message', {
|
|
97
|
+
origin: EDITOR_ORIGIN,
|
|
98
|
+
source: window.parent as Window,
|
|
74
99
|
data: {
|
|
75
100
|
type: 'PREVIEW_PART_STATE',
|
|
76
101
|
data: { blockId, partName, state }
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render } from '@testing-library/react';
|
|
3
|
+
import { CHROME_MODE_ATTRIBUTE, setChromeMode } from '../../chrome/chrome-mode';
|
|
4
|
+
import { OVERLAY_STATE_ATTRIBUTE } from '../../hooks/use-overlay';
|
|
5
|
+
import ThemeBlock from '../../theme-block';
|
|
6
|
+
import { PartStatePreview } from '../part-state-preview';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pinning a state on a ROOT, and on the page.
|
|
10
|
+
*
|
|
11
|
+
* The editor may now offer a state chip on any element that can really be in
|
|
12
|
+
* that state — a card that lights up under the pointer, a link that shows its
|
|
13
|
+
* focus ring — and a chip is only honest if the canvas can hold the state
|
|
14
|
+
* still while the colour is picked. Three of those pins did not exist:
|
|
15
|
+
*
|
|
16
|
+
* - a root address stamped nothing at all, because the handler demanded a
|
|
17
|
+
* part name and then searched the block's DESCENDANTS for it;
|
|
18
|
+
* - `overlay-open` and `docked` stamped the element while their selectors
|
|
19
|
+
* read `body`, so both chips have been decoration since they shipped.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const EDITOR_ORIGIN = 'http://localhost:4400';
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
Object.defineProperty(window, 'parent', {
|
|
26
|
+
configurable: true,
|
|
27
|
+
value: { postMessage: () => undefined }
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const pin = (blockId: string, partName: string, state: string | null): void => {
|
|
32
|
+
act(() => {
|
|
33
|
+
window.dispatchEvent(
|
|
34
|
+
new MessageEvent('message', {
|
|
35
|
+
origin: EDITOR_ORIGIN,
|
|
36
|
+
source: window.parent as Window,
|
|
37
|
+
data: {
|
|
38
|
+
type: 'PREVIEW_PART_STATE',
|
|
39
|
+
data: { blockId, partName, state }
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const text = (id: string) => ({
|
|
47
|
+
id,
|
|
48
|
+
type: 'text',
|
|
49
|
+
label: 'Text',
|
|
50
|
+
value: '<p>copy</p>',
|
|
51
|
+
properties: {},
|
|
52
|
+
styles: {},
|
|
53
|
+
order: 0,
|
|
54
|
+
hidden: false
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const group = (id: string, children: unknown[]) => ({
|
|
58
|
+
id,
|
|
59
|
+
type: 'group',
|
|
60
|
+
label: 'Group',
|
|
61
|
+
properties: {},
|
|
62
|
+
styles: {},
|
|
63
|
+
order: 0,
|
|
64
|
+
hidden: false,
|
|
65
|
+
blocks: children
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const stateOf = (root: HTMLElement, id: string): string | null =>
|
|
69
|
+
root
|
|
70
|
+
.querySelector<HTMLElement>(`[data-block-id="${id}"]`)
|
|
71
|
+
?.getAttribute('data-state') ?? null;
|
|
72
|
+
|
|
73
|
+
const renderCanvas = (block: unknown) =>
|
|
74
|
+
render(
|
|
75
|
+
<>
|
|
76
|
+
<PartStatePreview isDesigner />
|
|
77
|
+
<ThemeBlock block={block as never} isDesigner />
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
document.body.removeAttribute(OVERLAY_STATE_ATTRIBUTE);
|
|
83
|
+
document.body.removeAttribute(CHROME_MODE_ATTRIBUTE);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('pinning a state on the element the root rule lands on', () => {
|
|
87
|
+
it('stamps the block wrapper when no part is named', () => {
|
|
88
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
89
|
+
|
|
90
|
+
pin('grp', '', 'hover');
|
|
91
|
+
|
|
92
|
+
expect(stateOf(container, 'grp')).toBe('hover');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('releases it again', () => {
|
|
96
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
97
|
+
|
|
98
|
+
pin('grp', '', 'hover');
|
|
99
|
+
pin('grp', '', null);
|
|
100
|
+
|
|
101
|
+
expect(stateOf(container, 'grp')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('swaps one state for another without leaving the first behind', () => {
|
|
105
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
106
|
+
|
|
107
|
+
pin('grp', '', 'hover');
|
|
108
|
+
pin('grp', '', 'focus');
|
|
109
|
+
|
|
110
|
+
expect(stateOf(container, 'grp')).toBe('focus');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The wrapper is the element the rule names, and a block inside it marks a
|
|
114
|
+
// wrapper of its own. Stamping both would light up rules that belong to the
|
|
115
|
+
// nested block.
|
|
116
|
+
it('stamps only the addressed block, not the ones inside it', () => {
|
|
117
|
+
const { container } = renderCanvas(
|
|
118
|
+
group('outer', [group('inner', [text('inner-copy')])])
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
pin('outer', '', 'hover');
|
|
122
|
+
|
|
123
|
+
expect(stateOf(container, 'outer')).toBe('hover');
|
|
124
|
+
expect(stateOf(container, 'inner')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does nothing for an address the canvas cannot resolve', () => {
|
|
128
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
129
|
+
|
|
130
|
+
pin('gone', '', 'hover');
|
|
131
|
+
|
|
132
|
+
expect(container.querySelectorAll('[data-state]')).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The two page states. Neither is a fact about the element: both are matched
|
|
138
|
+
* through an attribute on `body`, so stamping the element lights nothing up.
|
|
139
|
+
*/
|
|
140
|
+
describe('pinning a state that belongs to the page', () => {
|
|
141
|
+
it('holds the overlay state on the body', () => {
|
|
142
|
+
renderCanvas(group('grp', [text('grp-copy')]));
|
|
143
|
+
|
|
144
|
+
pin('', '', 'overlay-open');
|
|
145
|
+
|
|
146
|
+
expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBe('open');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('holds the docked mode on the body', () => {
|
|
150
|
+
renderCanvas(group('grp', [text('grp-copy')]));
|
|
151
|
+
|
|
152
|
+
pin('', '', 'docked');
|
|
153
|
+
|
|
154
|
+
expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('releases one page state when another is asked for', () => {
|
|
158
|
+
renderCanvas(group('grp', [text('grp-copy')]));
|
|
159
|
+
|
|
160
|
+
pin('', '', 'overlay-open');
|
|
161
|
+
pin('', '', 'docked');
|
|
162
|
+
|
|
163
|
+
expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBeNull();
|
|
164
|
+
expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('releases a page state when an element state is asked for', () => {
|
|
168
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
169
|
+
|
|
170
|
+
pin('', '', 'docked');
|
|
171
|
+
pin('grp', '', 'hover');
|
|
172
|
+
|
|
173
|
+
expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBeNull();
|
|
174
|
+
expect(stateOf(container, 'grp')).toBe('hover');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The brand owns the mode; the pin only layers over it. Restoring what the
|
|
178
|
+
// pin captured would write away a route change made while it was held.
|
|
179
|
+
it('gives the brand its mode back when the pin is released', () => {
|
|
180
|
+
renderCanvas(group('grp', [text('grp-copy')]));
|
|
181
|
+
setChromeMode('docked');
|
|
182
|
+
|
|
183
|
+
pin('', '', 'overlay-open');
|
|
184
|
+
pin('', '', null);
|
|
185
|
+
|
|
186
|
+
expect(document.body.getAttribute(CHROME_MODE_ATTRIBUTE)).toBe('docked');
|
|
187
|
+
setChromeMode(null);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('leaves the page clean when the canvas unmounts', () => {
|
|
191
|
+
const view = renderCanvas(group('grp', [text('grp-copy')]));
|
|
192
|
+
|
|
193
|
+
pin('', '', 'overlay-open');
|
|
194
|
+
view.unmount();
|
|
195
|
+
|
|
196
|
+
expect(document.body.getAttribute(OVERLAY_STATE_ATTRIBUTE)).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('an untrusted frame', () => {
|
|
201
|
+
it('cannot pin anything', () => {
|
|
202
|
+
const { container } = renderCanvas(group('grp', [text('grp-copy')]));
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
window.dispatchEvent(
|
|
206
|
+
new MessageEvent('message', {
|
|
207
|
+
origin: 'http://evil.example',
|
|
208
|
+
source: window.parent as Window,
|
|
209
|
+
data: {
|
|
210
|
+
type: 'PREVIEW_PART_STATE',
|
|
211
|
+
data: { blockId: 'grp', partName: '', state: 'hover' }
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(stateOf(container, 'grp')).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
|
|
5
|
+
import { CHROME_MODE_DOCKED, pinChromeMode } from '../chrome/chrome-mode';
|
|
5
6
|
import { SCROLL_STYLE_STATE, pinScrollState } from '../chrome/scroll-state';
|
|
7
|
+
import { isTrustedEditorEvent } from '../editor-bridge';
|
|
8
|
+
import { OVERLAY_PIN_STATE, pinOverlayState } from '../hooks/use-overlay';
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
11
|
* Pins a part's state open while the designer styles it.
|
|
@@ -43,34 +46,92 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
|
|
|
43
46
|
});
|
|
44
47
|
};
|
|
45
48
|
|
|
49
|
+
const releasePagePins = () => {
|
|
50
|
+
pinScrollState(null);
|
|
51
|
+
pinOverlayState(false);
|
|
52
|
+
pinChromeMode(null);
|
|
53
|
+
};
|
|
54
|
+
|
|
46
55
|
const handleMessage = (event: MessageEvent) => {
|
|
56
|
+
// The only designer listener in this package that did not check. Same
|
|
57
|
+
// guard as every other inbound message: a wrong origin, or a protocol the
|
|
58
|
+
// other side has moved past, is refused rather than acted on.
|
|
59
|
+
if (!isTrustedEditorEvent(event)) return;
|
|
47
60
|
if (event.data?.type !== 'PREVIEW_PART_STATE') return;
|
|
48
61
|
|
|
49
62
|
const { blockId, partName, state } = event.data.data || {};
|
|
50
63
|
clear();
|
|
51
64
|
|
|
52
|
-
//
|
|
53
|
-
// through `data-scroll-state` on the chrome slot
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
65
|
+
// A PAGE state is not a fact about the element. `scrolled` is matched
|
|
66
|
+
// through `data-scroll-state` on the chrome slot; `overlay-open` and
|
|
67
|
+
// `docked` through attributes on `body`. Stamping any of the three onto
|
|
68
|
+
// the element lights nothing up — which is exactly how two of the three
|
|
69
|
+
// chips the editor has been showing came to be decoration. Each now has a
|
|
70
|
+
// lever of its own, and every message releases the ones it is not asking
|
|
71
|
+
// for, so the canvas is never left frozen in a state the designer has
|
|
72
|
+
// moved on from.
|
|
73
|
+
//
|
|
74
|
+
// These branches stay ABOVE the address check, as `scrolled` always has:
|
|
75
|
+
// a page state needs no element, which is also why a root chip appeared
|
|
76
|
+
// to work for `scrolled` while sending an empty address.
|
|
58
77
|
if (state === SCROLL_STYLE_STATE) {
|
|
78
|
+
pinOverlayState(false);
|
|
79
|
+
pinChromeMode(null);
|
|
59
80
|
pinScrollState('scrolled');
|
|
60
81
|
return;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
if (state === OVERLAY_PIN_STATE) {
|
|
85
|
+
pinScrollState(null);
|
|
86
|
+
pinChromeMode(null);
|
|
87
|
+
pinOverlayState(true);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
64
90
|
|
|
65
|
-
if (
|
|
91
|
+
if (state === CHROME_MODE_DOCKED) {
|
|
92
|
+
pinScrollState(null);
|
|
93
|
+
pinOverlayState(false);
|
|
94
|
+
pinChromeMode(CHROME_MODE_DOCKED);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
releasePagePins();
|
|
99
|
+
|
|
100
|
+
if (!blockId || !state) return;
|
|
101
|
+
|
|
102
|
+
const id = CSS.escape(String(blockId));
|
|
103
|
+
// A block wrapper or a section band: the generated root-state rule names
|
|
104
|
+
// one element by id either way, and a section owns parts too. Resolving
|
|
105
|
+
// both here is what lets the editor address a section without the message
|
|
106
|
+
// growing a field — the payload already carries everything it needs.
|
|
107
|
+
const host = document.querySelector<HTMLElement>(
|
|
108
|
+
`[data-block-id="${id}"], [data-section-id="${id}"]`
|
|
69
109
|
);
|
|
70
|
-
if (!
|
|
110
|
+
if (!host) return;
|
|
111
|
+
|
|
112
|
+
const pinOn = (element: HTMLElement) => {
|
|
113
|
+
const existing = element.getAttribute('data-state') || '';
|
|
114
|
+
// The pinned token is remembered, not the whole value: the renderer
|
|
115
|
+
// is free to change the element's real state while it is pinned.
|
|
116
|
+
element.setAttribute(PREVIEW_ATTRIBUTE, String(state));
|
|
117
|
+
element.setAttribute(
|
|
118
|
+
'data-state',
|
|
119
|
+
existing ? `${existing} ${state}` : String(state)
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// No part named: the ROOT was addressed, and a root-state rule lands on
|
|
124
|
+
// this element itself. A block whose renderer draws its own root is
|
|
125
|
+
// addressed as the part "root" instead — that is where the generator
|
|
126
|
+
// routes the rule — and the branch below already reaches exactly that
|
|
127
|
+
// element, ownership guard included.
|
|
128
|
+
if (!partName) {
|
|
129
|
+
pinOn(host);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
71
132
|
|
|
72
133
|
Array.from(
|
|
73
|
-
|
|
134
|
+
host.querySelectorAll<HTMLElement>(
|
|
74
135
|
`[data-part="${CSS.escape(String(partName))}"]`
|
|
75
136
|
)
|
|
76
137
|
)
|
|
@@ -78,27 +139,24 @@ export const PartStatePreview = ({ isDesigner }: { isDesigner: boolean }) => {
|
|
|
78
139
|
// search also finds the identically-named part of a block nested
|
|
79
140
|
// inside this one. A part belongs to its closest `[data-block-id]`
|
|
80
141
|
// ancestor — the same rule a click already uses to decide which part
|
|
81
|
-
// it selected (hooks/use-designer-features).
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
.filter((element) =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
element.setAttribute(PREVIEW_ATTRIBUTE, String(state));
|
|
90
|
-
element.setAttribute(
|
|
91
|
-
'data-state',
|
|
92
|
-
existing ? `${existing} ${state}` : String(state)
|
|
93
|
-
);
|
|
94
|
-
});
|
|
142
|
+
// it selected (hooks/use-designer-features). A SECTION's own part is
|
|
143
|
+
// one with no block between it and the band; both readings are the
|
|
144
|
+
// guard the generator already emits.
|
|
145
|
+
.filter((element) => {
|
|
146
|
+
const owner = element.closest('[data-block-id]');
|
|
147
|
+
return owner === host || !(owner && host.contains(owner));
|
|
148
|
+
})
|
|
149
|
+
.forEach(pinOn);
|
|
95
150
|
};
|
|
96
151
|
|
|
97
152
|
window.addEventListener('message', handleMessage);
|
|
98
153
|
return () => {
|
|
99
154
|
window.removeEventListener('message', handleMessage);
|
|
100
155
|
clear();
|
|
101
|
-
|
|
156
|
+
// All three, not just the scroll pin: a module-level attribute that
|
|
157
|
+
// outlived the editor session would follow the user into the storefront
|
|
158
|
+
// they browse in that same tab.
|
|
159
|
+
releasePagePins();
|
|
102
160
|
};
|
|
103
161
|
}, [isDesigner]);
|
|
104
162
|
|
package/src/hooks/use-overlay.ts
CHANGED
|
@@ -67,19 +67,53 @@ const stackListeners = new Set<() => void>();
|
|
|
67
67
|
export const OVERLAY_STATE_ATTRIBUTE = 'data-overlay-state';
|
|
68
68
|
export const OVERLAY_STYLE_STATE = 'open';
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The STYLE-STATE name this signal answers to, which is NOT the attribute value.
|
|
72
|
+
*
|
|
73
|
+
* `data-overlay-state="open"` is what the selector matches; `overlay-open` is
|
|
74
|
+
* what the style key says and what the editor puts on the wire. They have
|
|
75
|
+
* always been two different strings, and a pin that compared against
|
|
76
|
+
* OVERLAY_STYLE_STATE would look right and never fire once.
|
|
77
|
+
*/
|
|
78
|
+
export const OVERLAY_PIN_STATE = 'overlay-open';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The designer's override of the overlay state.
|
|
82
|
+
*
|
|
83
|
+
* A layer OVER the stack rather than a fake push, exactly as
|
|
84
|
+
* `chrome/scroll-state.tsx` layers a pinned state over its observer. A fake
|
|
85
|
+
* push would be popped by the next real overlay to close, and a pin a shopper
|
|
86
|
+
* interaction can cancel is worse than no pin at all.
|
|
87
|
+
*
|
|
88
|
+
* It has to exist because the canvas can never reach this state by itself: the
|
|
89
|
+
* push effect is gated on `active = open && !isDesigner`, so a panel
|
|
90
|
+
* force-opened for styling pushes nothing. A designer asking to see "while a
|
|
91
|
+
* panel is open" is therefore the only way this attribute is ever set in the
|
|
92
|
+
* editor.
|
|
93
|
+
*/
|
|
94
|
+
let pinnedOverlay = false;
|
|
95
|
+
|
|
70
96
|
const syncOverlayStateAttribute = () => {
|
|
71
97
|
if (typeof document === 'undefined') return;
|
|
72
98
|
|
|
73
99
|
// Driven off the stack rather than off each open/close call: two overlays
|
|
74
100
|
// closing in either order must leave exactly one attribute state behind, and
|
|
75
|
-
// the stack is already the thing that knows whether anything is left.
|
|
76
|
-
|
|
101
|
+
// the stack is already the thing that knows whether anything is left. The
|
|
102
|
+
// designer pin rides on top of it and never disturbs it, so releasing the pin
|
|
103
|
+
// restores whatever the page was really doing.
|
|
104
|
+
if (pinnedOverlay || overlayStack.length > 0) {
|
|
77
105
|
document.body.setAttribute(OVERLAY_STATE_ATTRIBUTE, OVERLAY_STYLE_STATE);
|
|
78
106
|
} else {
|
|
79
107
|
document.body.removeAttribute(OVERLAY_STATE_ATTRIBUTE);
|
|
80
108
|
}
|
|
81
109
|
};
|
|
82
110
|
|
|
111
|
+
export const pinOverlayState = (pinned: boolean): void => {
|
|
112
|
+
if (pinnedOverlay === pinned) return;
|
|
113
|
+
pinnedOverlay = pinned;
|
|
114
|
+
syncOverlayStateAttribute();
|
|
115
|
+
};
|
|
116
|
+
|
|
83
117
|
const notifyStack = () => {
|
|
84
118
|
syncOverlayStateAttribute();
|
|
85
119
|
stackListeners.forEach((listener) => listener());
|
|
@@ -606,7 +640,8 @@ export function useOverlay<T extends HTMLElement = HTMLElement>(
|
|
|
606
640
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
607
641
|
}, [active, closeOnEscape, isTopmost]);
|
|
608
642
|
|
|
609
|
-
const ariaModal =
|
|
643
|
+
const ariaModal =
|
|
644
|
+
active && modal && claimsPage ? ('true' as const) : undefined;
|
|
610
645
|
|
|
611
646
|
const overlayProps = useMemo(
|
|
612
647
|
() => ({ ref: panelRef, 'aria-modal': ariaModal }),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
|
|
2
2
|
import { getCustomSectionCatalog } from './custom/section-catalog';
|
|
3
|
+
import {
|
|
4
|
+
STATE_PIN_CAPABILITIES,
|
|
5
|
+
type StatePinCapability
|
|
6
|
+
} from './state-pin-capabilities';
|
|
3
7
|
import type { CustomSectionCatalog } from './custom/section-types';
|
|
4
8
|
import type { CustomCatalog } from './custom/types';
|
|
5
9
|
import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
|
|
@@ -52,6 +56,22 @@ interface PlaceholdersAvailablePayload {
|
|
|
52
56
|
* with it.
|
|
53
57
|
*/
|
|
54
58
|
customSections?: CustomSectionCatalog;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Which kinds of state pin THIS build can perform, so the editor can offer
|
|
62
|
+
* exactly the state chips this storefront is able to hold still.
|
|
63
|
+
*
|
|
64
|
+
* Additive, and deliberately a list of capability NAMES rather than a version
|
|
65
|
+
* number: the editor must not have to know which release added what, and one
|
|
66
|
+
* Omnitron serves channels whose storefronts pin their own theme version.
|
|
67
|
+
*
|
|
68
|
+
* A build that does not send it is read as the baseline every shipped version
|
|
69
|
+
* can do — pinning the scroll state — so a storefront that has not upgraded
|
|
70
|
+
* keeps exactly the chip row it has today rather than losing it to a
|
|
71
|
+
* handshake it has never heard of. `scroll` is therefore not listed here:
|
|
72
|
+
* listing it would make an old storefront look LESS capable than it is.
|
|
73
|
+
*/
|
|
74
|
+
statePins?: StatePinCapability[];
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
const isFramed = (): boolean =>
|
|
@@ -103,6 +123,8 @@ const flush = () => {
|
|
|
103
123
|
|
|
104
124
|
const customSections = getCustomSectionCatalog(getCatalogVendor());
|
|
105
125
|
if (customSections) data.customSections = customSections;
|
|
126
|
+
|
|
127
|
+
data.statePins = STATE_PIN_CAPABILITIES;
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
postToEditor({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this build can hold still for a designer, announced to the editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor offers a state chip only where it can also SHOW the state, so it
|
|
5
|
+
* has to be told what the storefront in front of it is able to pin — one
|
|
6
|
+
* Omnitron serves channels whose storefronts pin their own theme version, and
|
|
7
|
+
* "the repo has it" is not a fact the editor may assume.
|
|
8
|
+
*
|
|
9
|
+
* - `element` a state is stamped on the addressed element, a block wrapper
|
|
10
|
+
* and a section band included.
|
|
11
|
+
* - `overlay` `overlay-open` is held on the page.
|
|
12
|
+
* - `chrome-mode` `docked` is held on the page.
|
|
13
|
+
*
|
|
14
|
+
* `scroll` is absent on purpose: every version that ever shipped could do it
|
|
15
|
+
* and the editor assumes it, so listing it would make an old storefront look
|
|
16
|
+
* less capable than it is.
|
|
17
|
+
*
|
|
18
|
+
* Its own module rather than a constant beside the component that implements
|
|
19
|
+
* it: the placeholder registry announces this, and it must not have to pull a
|
|
20
|
+
* client component in to say one list of strings.
|
|
21
|
+
*/
|
|
22
|
+
export type StatePinCapability = 'element' | 'overlay' | 'chrome-mode';
|
|
23
|
+
|
|
24
|
+
export const STATE_PIN_CAPABILITIES: readonly StatePinCapability[] = [
|
|
25
|
+
'element',
|
|
26
|
+
'overlay',
|
|
27
|
+
'chrome-mode'
|
|
28
|
+
];
|
|
@@ -37,7 +37,7 @@ const blockOf = (overrides: Partial<Block>): Block =>
|
|
|
37
37
|
styles: {},
|
|
38
38
|
blocks: [],
|
|
39
39
|
...overrides
|
|
40
|
-
}
|
|
40
|
+
} as Block);
|
|
41
41
|
|
|
42
42
|
const sectionWith = (block: Partial<Block>) =>
|
|
43
43
|
[
|
|
@@ -75,9 +75,10 @@ describe('a renderer that draws its own root', () => {
|
|
|
75
75
|
expect(css).not.toContain('[data-part="root"]');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it('covers the
|
|
78
|
+
it('covers exactly the renderers that mark their root', () => {
|
|
79
79
|
expect([...INNER_ROOT_BLOCK_TYPES].sort()).toEqual([
|
|
80
80
|
'button',
|
|
81
|
+
'input',
|
|
81
82
|
'link',
|
|
82
83
|
'text'
|
|
83
84
|
]);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { generateThemeCSS, getCSSStyles } from '../index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The values a root state saves, and where they are allowed to end up.
|
|
5
|
+
*
|
|
6
|
+
* A root state used to be reachable on a chrome band and nowhere else, so three
|
|
7
|
+
* holes in this path stayed dormant. Offering states on every element opens all
|
|
8
|
+
* three at once: an authored value that produces no declaration, a routing key
|
|
9
|
+
* turned into a React style prop, and an unknown state name written into the
|
|
10
|
+
* element's own rule as junk.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const block = (id: string, styles: Record<string, unknown>) => ({
|
|
14
|
+
id,
|
|
15
|
+
type: 'group',
|
|
16
|
+
label: 'Group',
|
|
17
|
+
properties: {},
|
|
18
|
+
styles,
|
|
19
|
+
order: 0,
|
|
20
|
+
hidden: false,
|
|
21
|
+
blocks: []
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const section = (
|
|
25
|
+
id: string,
|
|
26
|
+
styles: Record<string, unknown>,
|
|
27
|
+
blocks: unknown[] = []
|
|
28
|
+
) => ({
|
|
29
|
+
id,
|
|
30
|
+
type: 'custom',
|
|
31
|
+
label: 'Section',
|
|
32
|
+
properties: {},
|
|
33
|
+
styles,
|
|
34
|
+
order: 0,
|
|
35
|
+
blocks
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const css = (styles: Record<string, unknown>) =>
|
|
39
|
+
generateThemeCSS([section('sec', {}, [block('blk', styles)]) as never]);
|
|
40
|
+
|
|
41
|
+
describe('a value authored in a state', () => {
|
|
42
|
+
/**
|
|
43
|
+
* `opacity` is a HELPER key on a block root — it feeds the hover/alpha maths
|
|
44
|
+
* rather than being emitted. In a STATE bucket it is a real CSS property, as
|
|
45
|
+
* it has always been on a part, and the panel offers a slider for it.
|
|
46
|
+
*/
|
|
47
|
+
it('emits opacity, which the panel has always offered and the sheet dropped', () => {
|
|
48
|
+
expect(css({ ':hover/opacity': '50' })).toContain('opacity');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits an ordinary property in the same bucket', () => {
|
|
52
|
+
expect(css({ ':hover/background-color': '#00ff00' })).toContain(
|
|
53
|
+
'[data-block-id="blk"]:hover'
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The alternative selector IS the pin hook: the canvas writes the token onto
|
|
58
|
+
// the element and the same rule applies, which is what lets the author see
|
|
59
|
+
// what they are picking.
|
|
60
|
+
it('matches the pinned token beside the real pseudo-class', () => {
|
|
61
|
+
expect(css({ ':hover/background-color': '#00ff00' })).toContain(
|
|
62
|
+
'[data-block-id="blk"][data-state~="hover"]'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('the full CSS surface in a state', () => {
|
|
68
|
+
/**
|
|
69
|
+
* The editor offers every CSS property inside a transient state, on the
|
|
70
|
+
* grounds that nothing written into a hover outlives the hover. That is only
|
|
71
|
+
* true if the generator writes them — a property it dropped would be a
|
|
72
|
+
* control that saves and does nothing.
|
|
73
|
+
*/
|
|
74
|
+
it.each([
|
|
75
|
+
'clip-path',
|
|
76
|
+
'backdrop-filter',
|
|
77
|
+
'mix-blend-mode',
|
|
78
|
+
'letter-spacing',
|
|
79
|
+
'z-index'
|
|
80
|
+
])('emits %s authored in a state', (property) => {
|
|
81
|
+
const sheet = css({ [`:hover/${property}`]: 'inherit' });
|
|
82
|
+
|
|
83
|
+
expect(sheet).toContain('[data-block-id="blk"]:hover');
|
|
84
|
+
expect(sheet).toContain(`${property}: inherit`);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('a state name this build does not know', () => {
|
|
89
|
+
it('is refused rather than written into the element rule', () => {
|
|
90
|
+
// `:visited` is the deliberate absence: a link really has it, but the
|
|
91
|
+
// preview cannot put an element into it, so it is refused rather than
|
|
92
|
+
// offered against a canvas that can never show it.
|
|
93
|
+
const sheet = css({
|
|
94
|
+
':visited/color': '#123456',
|
|
95
|
+
color: '#000000'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(sheet).not.toContain('visited');
|
|
99
|
+
// The rest of the element's rule is untouched.
|
|
100
|
+
expect(sheet).toContain('color: #000000');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('is refused for a section band too', () => {
|
|
104
|
+
const sheet = generateThemeCSS([
|
|
105
|
+
section('sec', { ':visited/color': '#123456' }) as never
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
expect(sheet).not.toContain(':visited/color');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('a routing key never becomes an inline style', () => {
|
|
113
|
+
/**
|
|
114
|
+
* `panel/color` means "the panel, in the base state" and `:hover/color` means
|
|
115
|
+
* "this element, while hovered". Both become real rules against selectors an
|
|
116
|
+
* inline object cannot express; passed through they are React style props
|
|
117
|
+
* named `panel/color` and `:hover/color` — a dev warning and dead weight, and
|
|
118
|
+
* on the inner-root path a token-valued one would be inlined onto the exact
|
|
119
|
+
* element the generated rule targets.
|
|
120
|
+
*/
|
|
121
|
+
it('drops root-state and part keys, and keeps the real ones', () => {
|
|
122
|
+
expect(
|
|
123
|
+
getCSSStyles({
|
|
124
|
+
':hover/background-color': '#00ff00',
|
|
125
|
+
'panel/color': '#ff0000',
|
|
126
|
+
'background-color': '#0000ff'
|
|
127
|
+
})
|
|
128
|
+
// Composited through the background-opacity maths, as any real property
|
|
129
|
+
// on this path is — the point here is that it is the ONLY key left.
|
|
130
|
+
).toEqual({ backgroundColor: 'rgba(0, 0, 255, 1)' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('drops an unknown state key as well, since it is still routing', () => {
|
|
134
|
+
expect(getCSSStyles({ ':visited/color': '#123456' })).toEqual({});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The focus a CONTAINER has.
|
|
140
|
+
*
|
|
141
|
+
* A wrapper is not focusable, so `focus` never matches it, and the input's own
|
|
142
|
+
* `focus` paints the control rather than the box around it. This is the one
|
|
143
|
+
* state a container can genuinely be in that no tag gives it — and the
|
|
144
|
+
* commonest form pattern there is.
|
|
145
|
+
*/
|
|
146
|
+
describe('a container whose contents are focused', () => {
|
|
147
|
+
it('emits the real pseudo-class', () => {
|
|
148
|
+
expect(css({ ':focus-within/border-color': '#00ff00' })).toContain(
|
|
149
|
+
'[data-block-id="blk"]:focus-within'
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('matches the pinned token too, so the canvas can hold it', () => {
|
|
154
|
+
expect(css({ ':focus-within/border-color': '#00ff00' })).toContain(
|
|
155
|
+
'[data-block-id="blk"][data-state~="focus-within"]'
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -23,7 +23,11 @@ import type { Section } from '../../theme-section';
|
|
|
23
23
|
|
|
24
24
|
const SLOT = '[data-scroll-state~="scrolled"]';
|
|
25
25
|
|
|
26
|
-
const section = (
|
|
26
|
+
const section = (
|
|
27
|
+
id: string,
|
|
28
|
+
styles: Record<string, unknown>,
|
|
29
|
+
blocks: unknown[] = []
|
|
30
|
+
) =>
|
|
27
31
|
({
|
|
28
32
|
id,
|
|
29
33
|
type: 'custom',
|
|
@@ -32,9 +36,13 @@ const section = (id: string, styles: Record<string, unknown>, blocks: unknown[]
|
|
|
32
36
|
order: 0,
|
|
33
37
|
hidden: false,
|
|
34
38
|
blocks
|
|
35
|
-
}
|
|
39
|
+
} as unknown as Section);
|
|
36
40
|
|
|
37
|
-
const block = (
|
|
41
|
+
const block = (
|
|
42
|
+
id: string,
|
|
43
|
+
styles: Record<string, unknown>,
|
|
44
|
+
type = 'group'
|
|
45
|
+
) => ({
|
|
38
46
|
id,
|
|
39
47
|
type,
|
|
40
48
|
label: 'Block',
|
|
@@ -177,13 +185,30 @@ describe('the block and section root rules', () => {
|
|
|
177
185
|
// or it would style the transparent designer wrapper the block sits in.
|
|
178
186
|
// Ownership guard included: a routed root rule is a descendant selector,
|
|
179
187
|
// and a nested block marks a root of its own.
|
|
188
|
+
//
|
|
189
|
+
// `!important` on this path only, exactly as a part rule carries it and for
|
|
190
|
+
// the identical reason: the routed element is the one the renderer inlines
|
|
191
|
+
// its own styles onto, and inline outranks any stylesheet. A stock button
|
|
192
|
+
// is seeded a background colour that way, so without this a state colour
|
|
193
|
+
// would be written, saved, shown in the panel and never seen.
|
|
180
194
|
expect(css).toContain(
|
|
181
195
|
`${SLOT} [data-block-id="blk-1"] [data-part="root"]` +
|
|
182
196
|
`:not(:where([data-block-id="blk-1"] [data-block-id] *))` +
|
|
183
|
-
` {\n color: #111111;\n}`
|
|
197
|
+
` {\n color: #111111 !important;\n}`
|
|
184
198
|
);
|
|
185
199
|
});
|
|
186
200
|
|
|
201
|
+
it('leaves the wrapper path unforced, so custom CSS can still win', () => {
|
|
202
|
+
const css = generateThemeCSS([
|
|
203
|
+
section('sec-1', {}, [block('blk-1', { ':scrolled/color': '#111111' })])
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
expect(css).toContain(
|
|
207
|
+
`${SLOT} [data-block-id="blk-1"] {\n color: #111111;\n}`
|
|
208
|
+
);
|
|
209
|
+
expect(css).not.toContain('color: #111111 !important');
|
|
210
|
+
});
|
|
211
|
+
|
|
187
212
|
it('never emits the raw key as a declaration', () => {
|
|
188
213
|
const css = generateThemeCSS([
|
|
189
214
|
section('sec-1', { ':scrolled/background-color': '#ffffff' })
|
package/src/utils/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
buildInnerRootSelectors,
|
|
10
10
|
buildPartSelectors,
|
|
11
11
|
buildRootStateSelectors,
|
|
12
|
+
isPartStyleKey,
|
|
13
|
+
isRootStateKey,
|
|
12
14
|
splitPartStyles
|
|
13
15
|
} from './part-styles';
|
|
14
16
|
import { buildCustomCss, buildPartCustomCss } from './custom-css';
|
|
@@ -172,8 +174,7 @@ export const colorToRgba = (color: string, alpha: number): string | null => {
|
|
|
172
174
|
const g = Number(commaRgb[2]);
|
|
173
175
|
const b = Number(commaRgb[3]);
|
|
174
176
|
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
|
|
175
|
-
const authored =
|
|
176
|
-
commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
|
|
177
|
+
const authored = commaRgb[4] !== undefined ? Number(commaRgb[4]) : 1;
|
|
177
178
|
const effective = Number.isNaN(authored) ? alpha : authored * alpha;
|
|
178
179
|
return `rgba(${r}, ${g}, ${b}, ${effective})`;
|
|
179
180
|
}
|
|
@@ -545,7 +546,12 @@ const resolveSectionMaxWidthTokens = <T extends Record<string, unknown>>(
|
|
|
545
546
|
export const INNER_ROOT_BLOCK_TYPES: ReadonlySet<string> = new Set([
|
|
546
547
|
'text',
|
|
547
548
|
'link',
|
|
548
|
-
'button'
|
|
549
|
+
'button',
|
|
550
|
+
// The block's root IS a form control. Until this, its styles landed twice —
|
|
551
|
+
// on the wrapper through the sheet and on the control through the renderer's
|
|
552
|
+
// own inline styles — and no state at its root could match anything, because
|
|
553
|
+
// the element the rule reached was a div.
|
|
554
|
+
'input'
|
|
549
555
|
]);
|
|
550
556
|
|
|
551
557
|
/**
|
|
@@ -668,6 +674,21 @@ export const getCSSStyles = (
|
|
|
668
674
|
: null;
|
|
669
675
|
|
|
670
676
|
Object.keys(styles).forEach((key) => {
|
|
677
|
+
// Part and root-state keys carry ROUTING, not a property: `panel/color`
|
|
678
|
+
// means "the panel, in the base state" and `:hover/background-color` means
|
|
679
|
+
// "this element, while hovered". Both are turned into real rules by
|
|
680
|
+
// `generatePartCSS` / `generateRootStateCSS` against selectors an inline
|
|
681
|
+
// object cannot express. Passed through, they become React style props
|
|
682
|
+
// named `panel/color` and `:hover/backgroundColor` — a dev warning, dead
|
|
683
|
+
// weight, and on the inner-root path a token-valued one would be inlined
|
|
684
|
+
// onto the exact element the generated rule targets.
|
|
685
|
+
//
|
|
686
|
+
// Harmless until now only because a root-state key could be authored on a
|
|
687
|
+
// chrome band and nowhere else. It is checked here rather than in each of
|
|
688
|
+
// the renderers that inline this output, because this is where they all
|
|
689
|
+
// converge.
|
|
690
|
+
if (isPartStyleKey(key) || isRootStateKey(key)) return;
|
|
691
|
+
|
|
671
692
|
const value = getResponsiveValue(styles[key], breakpoint);
|
|
672
693
|
if (value === undefined || value === null) return;
|
|
673
694
|
|
|
@@ -1439,13 +1460,22 @@ export const generatePartCSS = (
|
|
|
1439
1460
|
/**
|
|
1440
1461
|
* Rules for a block or section's own root in a state.
|
|
1441
1462
|
*
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1463
|
+
* `!important` ONLY on the inner-root path, and the split is the whole point.
|
|
1464
|
+
* A block wrapper and a section band carry no renderer inline styles, so the
|
|
1465
|
+
* only rule a state there has to beat is the plain root rule emitted a few
|
|
1466
|
+
* lines earlier — which it already beats on specificity, a state selector
|
|
1467
|
+
* being strictly longer. Forcing it would buy nothing and would cost the
|
|
1468
|
+
* custom-CSS hatch its `!important`, the designer's documented way to win an
|
|
1469
|
+
* argument with a generated rule.
|
|
1470
|
+
*
|
|
1471
|
+
* The INNER root is the opposite case and always was. That element carries
|
|
1472
|
+
* `getInnerRootInlineStyles()` — every token-carrying value, plus `color` once
|
|
1473
|
+
* text-opacity has been composited into it — and inline outranks any
|
|
1474
|
+
* stylesheet. `mergeButtonStylesWithThemeDefaults` seeds a background colour
|
|
1475
|
+
* onto every button the editor creates, so without this a hover background on
|
|
1476
|
+
* a stock button was authored against an inline style it could never beat. It
|
|
1477
|
+
* is the same trade `generatePartCSS` has made since it was written, for the
|
|
1478
|
+
* identical reason, and the same replacement is used so the two cannot drift.
|
|
1449
1479
|
*/
|
|
1450
1480
|
const generateRootStateCSS = (
|
|
1451
1481
|
elementSelector: string,
|
|
@@ -1471,11 +1501,29 @@ const generateRootStateCSS = (
|
|
|
1471
1501
|
: buildRootStateSelectors(elementSelector, bucket.state);
|
|
1472
1502
|
if (selectors.length === 0) return;
|
|
1473
1503
|
|
|
1474
|
-
|
|
1504
|
+
const rules = generateElementCSS(
|
|
1475
1505
|
selectors.join(',\n'),
|
|
1476
1506
|
bucket.styles as ResponsiveStyles,
|
|
1477
|
-
targetBreakpoint
|
|
1507
|
+
targetBreakpoint,
|
|
1508
|
+
// `opacity` is a HELPER key on a block ROOT — it feeds the hover/alpha
|
|
1509
|
+
// maths rather than being emitted — so without this flag every
|
|
1510
|
+
// `:<state>/opacity` the editor offers is dropped on the floor. A state
|
|
1511
|
+
// BUCKET has no such helper: there it is a real CSS property, exactly as
|
|
1512
|
+
// it is on a part, which is why `generatePartCSS` has always passed this.
|
|
1513
|
+
{ treatHelperKeysAsCss: true }
|
|
1478
1514
|
);
|
|
1515
|
+
|
|
1516
|
+
if (!rules) return;
|
|
1517
|
+
|
|
1518
|
+
css += targetsInnerRoot
|
|
1519
|
+
? rules.replace(
|
|
1520
|
+
/^(\s*)([a-z-]+):\s*([^;\n]+);/gm,
|
|
1521
|
+
(line, indent, property, value) =>
|
|
1522
|
+
value.includes('!important')
|
|
1523
|
+
? line
|
|
1524
|
+
: `${indent}${property}: ${value} !important;`
|
|
1525
|
+
)
|
|
1526
|
+
: rules;
|
|
1479
1527
|
});
|
|
1480
1528
|
|
|
1481
1529
|
return css;
|
package/src/utils/part-styles.ts
CHANGED
|
@@ -39,6 +39,22 @@
|
|
|
39
39
|
const STATE_SELECTORS: Record<string, string[]> = {
|
|
40
40
|
hover: [':hover', '[data-state~="hover"]'],
|
|
41
41
|
focus: [':focus-visible', ':focus', '[data-state~="focus"]'],
|
|
42
|
+
/**
|
|
43
|
+
* The focus a CONTAINER has: something inside it is focused.
|
|
44
|
+
*
|
|
45
|
+
* The one state a wrapper can genuinely be in that no tag gives it, and the
|
|
46
|
+
* commonest form control pattern there is — a field whose box lights up while
|
|
47
|
+
* the input inside it has the caret. Without it a Group holding an Input, an
|
|
48
|
+
* auth field and a card wrapping a link were all unstyleable on focus: the
|
|
49
|
+
* wrapper is not focusable, so `focus` never matches it, and the input's own
|
|
50
|
+
* `focus` paints the control rather than the box around it.
|
|
51
|
+
*
|
|
52
|
+
* It was left out once on the grounds that the renderer had no entry for it,
|
|
53
|
+
* which was true and is a circle. The pin token is what makes it offerable
|
|
54
|
+
* rather than merely emittable: `:focus-within` cannot be held while the
|
|
55
|
+
* designer reaches for a colour picker, exactly as `:hover` cannot.
|
|
56
|
+
*/
|
|
57
|
+
'focus-within': [':focus-within', '[data-state~="focus-within"]'],
|
|
42
58
|
active: [':active', '[data-state~="active"]'],
|
|
43
59
|
// Native controls, ARIA and our own data-state all mean the same thing here,
|
|
44
60
|
// so each is covered rather than forcing renderers into one convention.
|
|
@@ -213,6 +229,18 @@ export const parsePartStyleKey = (key: string): ParsedPartKey | null => {
|
|
|
213
229
|
export const isPartStyleKey = (key: string): boolean =>
|
|
214
230
|
parsePartStyleKey(key) !== null;
|
|
215
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Whether the key addresses this element IN A STATE, known state or not.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately the marker rather than `parseRootStateKey`, because the callers
|
|
236
|
+
* are asking "is this routing?" and not "can I render it?". An unknown state
|
|
237
|
+
* name is still routing — it is simply routing nobody can honour — and the one
|
|
238
|
+
* thing it must never become is a declaration or a React style prop. No real
|
|
239
|
+
* CSS property begins with the marker, so nothing legitimate answers yes.
|
|
240
|
+
*/
|
|
241
|
+
export const isRootStateKey = (key: string): boolean =>
|
|
242
|
+
Boolean(key) && key[0] === ROOT_STATE_MARKER;
|
|
243
|
+
|
|
216
244
|
/** Builds the style key a descriptor writes into. */
|
|
217
245
|
export const buildPartStyleKey = (
|
|
218
246
|
part: string,
|
|
@@ -337,6 +365,15 @@ export const splitPartStyles = (
|
|
|
337
365
|
return;
|
|
338
366
|
}
|
|
339
367
|
|
|
368
|
+
// A key that LOOKS like a root state and is not one: an unknown state name,
|
|
369
|
+
// reaching here from a brand catalog this build does not know, from an
|
|
370
|
+
// editor that has shipped ahead of this storefront, or from a hand-edited
|
|
371
|
+
// theme. Left on the root bucket it is emitted as the declaration
|
|
372
|
+
// `:focus-within/color: #123456` inside the element's BASE rule — invalid,
|
|
373
|
+
// silently dropped by the browser, invisible to everyone. Dropped here it
|
|
374
|
+
// is simply not written.
|
|
375
|
+
if (isRootStateKey(key)) return;
|
|
376
|
+
|
|
340
377
|
const parsed = parsePartStyleKey(key);
|
|
341
378
|
|
|
342
379
|
if (!parsed) {
|