@akinon/pz-theme 2.0.84-rc.0 → 2.0.84
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 +9 -33
- package/package.json +4 -4
- package/src/blocks/__tests__/video-block.test.tsx +42 -0
- package/src/blocks/video-block.tsx +27 -4
- package/src/overlays/__tests__/modal-chrome-styles.test.tsx +168 -0
- package/src/overlays/__tests__/modal-close-text.test.tsx +118 -0
- package/src/overlays/__tests__/modal-preview-registers-region.test.tsx +117 -0
- package/src/overlays/modal-chrome-css.ts +95 -0
- package/src/overlays/modal-preview-host.tsx +12 -0
- package/src/overlays/modal-registry.ts +39 -1
- package/src/overlays/overlay-surface.tsx +45 -1
- package/src/overlays/theme-modal-mount.tsx +17 -0
- package/src/utils/__tests__/inner-root-ownership.test.tsx +334 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +6 -1
- package/src/utils/__tests__/part-ownership-guard.test.tsx +6 -0
- package/src/utils/__tests__/scrolled-state-styles.test.ts +5 -1
- package/src/utils/index.ts +31 -8
- package/src/utils/part-styles.ts +22 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
|
|
5
5
|
import ThemePlaceholderWrapper from '../theme-placeholder-wrapper';
|
|
6
|
+
import { buildModalChromeCss } from './modal-chrome-css';
|
|
6
7
|
import {
|
|
7
8
|
CentredModalSurface,
|
|
8
9
|
LayeredOverlaySurface,
|
|
@@ -120,6 +121,13 @@ export const ModalPreviewHost = ({
|
|
|
120
121
|
*/
|
|
121
122
|
const presentation = resolveModalPresentation(modal, breakpoint);
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The designer's chrome styles. Always pinned to the editor's chosen
|
|
126
|
+
* breakpoint: this host only ever runs inside the canvas, where the iframe's
|
|
127
|
+
* own width answers a different question than the viewport buttons do.
|
|
128
|
+
*/
|
|
129
|
+
const chromeCss = buildModalChromeCss(modal, breakpoint);
|
|
130
|
+
|
|
123
131
|
// Every dismissal is inert: the designer has to be able to click the close
|
|
124
132
|
// button and the backdrop they are colouring without the surface vanishing.
|
|
125
133
|
const noop = () => undefined;
|
|
@@ -132,6 +140,8 @@ export const ModalPreviewHost = ({
|
|
|
132
140
|
right={chrome.closeRight}
|
|
133
141
|
size={chrome.closeSize}
|
|
134
142
|
elevate
|
|
143
|
+
text={chrome.closeText}
|
|
144
|
+
textSide={chrome.closeTextSide}
|
|
135
145
|
/>
|
|
136
146
|
) : null;
|
|
137
147
|
|
|
@@ -154,6 +164,7 @@ export const ModalPreviewHost = ({
|
|
|
154
164
|
panelPadding={chrome.padding}
|
|
155
165
|
panelStretch={chrome.stretchContent}
|
|
156
166
|
topInset={chrome.topInset}
|
|
167
|
+
chromeCss={chromeCss}
|
|
157
168
|
header={header}
|
|
158
169
|
>
|
|
159
170
|
{composition(modal.slug)}
|
|
@@ -179,6 +190,7 @@ export const ModalPreviewHost = ({
|
|
|
179
190
|
sheetHeight={chrome.sheetHeight}
|
|
180
191
|
showSheetHandle={chrome.showSheetHandle}
|
|
181
192
|
topInset={chrome.topInset}
|
|
193
|
+
chromeCss={chromeCss}
|
|
182
194
|
header={header}
|
|
183
195
|
>
|
|
184
196
|
{composition(modal.slug)}
|
|
@@ -140,6 +140,18 @@ export interface ModalChrome {
|
|
|
140
140
|
* turns on.
|
|
141
141
|
*/
|
|
142
142
|
stretchContent?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Visible text beside the built-in close button, and which side of the icon
|
|
145
|
+
* it sits on.
|
|
146
|
+
*
|
|
147
|
+
* `closeLabel` stays the ACCESSIBLE name — it is what a screen reader says,
|
|
148
|
+
* and it has to keep saying it whether or not sighted text exists. This is
|
|
149
|
+
* markup rather than a style, which is why no CSS control could supply it:
|
|
150
|
+
* absent, the button renders the icon alone, exactly as every modal
|
|
151
|
+
* published before this does.
|
|
152
|
+
*/
|
|
153
|
+
closeText?: string;
|
|
154
|
+
closeTextSide?: 'before' | 'after';
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
/**
|
|
@@ -181,6 +193,19 @@ export interface ThemeModal {
|
|
|
181
193
|
Record<'tablet' | 'mobile', ModalPresentation>
|
|
182
194
|
>;
|
|
183
195
|
chrome: ModalChrome;
|
|
196
|
+
/**
|
|
197
|
+
* The designer's own styles for the modal's CHROME — the panel, the backdrop,
|
|
198
|
+
* the close button, its text and a sheet's grab bar.
|
|
199
|
+
*
|
|
200
|
+
* Same shape as a block's `styles`, so the editor drives it with the control
|
|
201
|
+
* vocabulary it already has: `styles[cssKey][breakpoint]`. The key grammar is
|
|
202
|
+
* the one thing to keep straight — a BARE css key targets the panel, and a
|
|
203
|
+
* `part/property` (or `part/state/property`) key targets a named chrome part.
|
|
204
|
+
* `buildModalChromeCss` in `modal-chrome-css.ts` is what turns this into CSS,
|
|
205
|
+
* and it emits `!important` because the elements it dresses carry the
|
|
206
|
+
* renderer's own inline defaults.
|
|
207
|
+
*/
|
|
208
|
+
styles?: Record<string, unknown>;
|
|
184
209
|
/**
|
|
185
210
|
* Chrome values that differ at a narrower breakpoint.
|
|
186
211
|
*
|
|
@@ -572,7 +597,12 @@ const readChrome = (value: unknown, name: string): ModalChrome => {
|
|
|
572
597
|
closeRight: asChromeLength(raw.closeRight, 200),
|
|
573
598
|
// A tap target below ~24px is hard to hit and above ~64px is not an icon.
|
|
574
599
|
closeSize: asChromeLength(raw.closeSize, 64),
|
|
575
|
-
stretchContent: asToggle(raw.stretchContent, false)
|
|
600
|
+
stretchContent: asToggle(raw.stretchContent, false),
|
|
601
|
+
// Absence-preserving, per the doctrine above: a modal that never authored
|
|
602
|
+
// a close text must not come out of here claiming an empty one, and the
|
|
603
|
+
// side only means something once the text exists.
|
|
604
|
+
closeText: asString(raw.closeText) || undefined,
|
|
605
|
+
closeTextSide: raw.closeTextSide === 'before' ? 'before' : undefined
|
|
576
606
|
};
|
|
577
607
|
};
|
|
578
608
|
|
|
@@ -620,6 +650,14 @@ export const readThemeModal = (value: unknown): ThemeModal | null => {
|
|
|
620
650
|
visibilityRules:
|
|
621
651
|
raw.visibilityRules && typeof raw.visibilityRules === 'object'
|
|
622
652
|
? (raw.visibilityRules as VisibilityRules)
|
|
653
|
+
: undefined,
|
|
654
|
+
// The one gate every chrome style passes through. Left out, the editor
|
|
655
|
+
// saves, the panel looks alive and nothing renders — in the canvas OR in
|
|
656
|
+
// production. `undefined` rather than `{}` so "never authored" stays
|
|
657
|
+
// distinguishable from "authored empty".
|
|
658
|
+
styles:
|
|
659
|
+
raw.styles && typeof raw.styles === 'object' && !Array.isArray(raw.styles)
|
|
660
|
+
? (raw.styles as Record<string, unknown>)
|
|
623
661
|
: undefined
|
|
624
662
|
};
|
|
625
663
|
};
|
|
@@ -312,6 +312,17 @@ export interface OverlayCloseButtonProps {
|
|
|
312
312
|
* hosts ask.
|
|
313
313
|
*/
|
|
314
314
|
elevate?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* Visible text beside the glyph, and which side of it that text sits on.
|
|
317
|
+
*
|
|
318
|
+
* `label` stays the accessible name whether or not this is set — a screen
|
|
319
|
+
* reader must still be told what the button does, and the two can legitimately
|
|
320
|
+
* differ ("Close" vs. "Skip for now"). Absent leaves the button's markup and
|
|
321
|
+
* style object byte-identical to what it has always rendered, which is what
|
|
322
|
+
* `panel-legacy-golden.json` pins for the legacy size-guide panel.
|
|
323
|
+
*/
|
|
324
|
+
text?: string;
|
|
325
|
+
textSide?: 'before' | 'after';
|
|
315
326
|
/** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
|
|
316
327
|
markerAttrs?: Record<string, string>;
|
|
317
328
|
}
|
|
@@ -336,6 +347,8 @@ export const OverlayCloseButton = ({
|
|
|
336
347
|
right,
|
|
337
348
|
size,
|
|
338
349
|
elevate,
|
|
350
|
+
text,
|
|
351
|
+
textSide,
|
|
339
352
|
markerAttrs
|
|
340
353
|
}: OverlayCloseButtonProps) => (
|
|
341
354
|
<button
|
|
@@ -365,7 +378,20 @@ export const OverlayCloseButton = ({
|
|
|
365
378
|
: { padding: '8px', margin: '-8px', lineHeight: 0 }),
|
|
366
379
|
// Only has to beat static content, which is what the composition's
|
|
367
380
|
// blocks are; the panel's own layer sits at 1000 above the page.
|
|
368
|
-
...(elevate ? { zIndex: 2 } : null)
|
|
381
|
+
...(elevate ? { zIndex: 2 } : null),
|
|
382
|
+
// Nothing at all without a text: the glyph-only button is pinned byte
|
|
383
|
+
// for byte by the legacy golden fixture.
|
|
384
|
+
...(text
|
|
385
|
+
? {
|
|
386
|
+
display: 'inline-flex',
|
|
387
|
+
alignItems: 'center',
|
|
388
|
+
gap: '8px',
|
|
389
|
+
flexDirection:
|
|
390
|
+
textSide === 'before'
|
|
391
|
+
? ('row-reverse' as const)
|
|
392
|
+
: ('row' as const)
|
|
393
|
+
}
|
|
394
|
+
: null)
|
|
369
395
|
}}
|
|
370
396
|
>
|
|
371
397
|
<svg
|
|
@@ -379,6 +405,7 @@ export const OverlayCloseButton = ({
|
|
|
379
405
|
>
|
|
380
406
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
381
407
|
</svg>
|
|
408
|
+
{text ? <span {...partAttrs('close-text')}>{text}</span> : null}
|
|
382
409
|
</button>
|
|
383
410
|
);
|
|
384
411
|
|
|
@@ -440,6 +467,15 @@ export interface OverlaySurfaceProps extends OverlayGeometryInput {
|
|
|
440
467
|
* which is what every published modal does.
|
|
441
468
|
*/
|
|
442
469
|
topInset?: number | string;
|
|
470
|
+
/**
|
|
471
|
+
* The designer's chrome styles for THIS modal, already compiled to CSS by
|
|
472
|
+
* `buildModalChromeCss`. Rendered as a `<style>` inside the surface, next to
|
|
473
|
+
* the stretch rules, so it travels with the panel it dresses.
|
|
474
|
+
*
|
|
475
|
+
* A prop the panel block never passes, deliberately: an empty string must
|
|
476
|
+
* render no tag at all, or `panel-legacy-golden.json` moves.
|
|
477
|
+
*/
|
|
478
|
+
chromeCss?: string;
|
|
443
479
|
/** Rendered inside the panel, above the authored content. */
|
|
444
480
|
header?: React.ReactNode;
|
|
445
481
|
children: React.ReactNode;
|
|
@@ -492,6 +528,7 @@ export const CentredModalSurface = ({
|
|
|
492
528
|
panelPadding,
|
|
493
529
|
panelStretch,
|
|
494
530
|
topInset,
|
|
531
|
+
chromeCss,
|
|
495
532
|
header,
|
|
496
533
|
children,
|
|
497
534
|
markers
|
|
@@ -552,6 +589,9 @@ export const CentredModalSurface = ({
|
|
|
552
589
|
}}
|
|
553
590
|
>
|
|
554
591
|
{panelStretch && <PanelStretchStyles />}
|
|
592
|
+
{chromeCss ? (
|
|
593
|
+
<style dangerouslySetInnerHTML={{ __html: chromeCss }} />
|
|
594
|
+
) : null}
|
|
555
595
|
{header}
|
|
556
596
|
{children}
|
|
557
597
|
</div>
|
|
@@ -584,6 +624,7 @@ export const LayeredOverlaySurface = ({
|
|
|
584
624
|
panelStretch,
|
|
585
625
|
showSheetHandle,
|
|
586
626
|
topInset,
|
|
627
|
+
chromeCss,
|
|
587
628
|
header,
|
|
588
629
|
children,
|
|
589
630
|
markers
|
|
@@ -637,6 +678,9 @@ export const LayeredOverlaySurface = ({
|
|
|
637
678
|
}}
|
|
638
679
|
>
|
|
639
680
|
{panelStretch && <PanelStretchStyles />}
|
|
681
|
+
{chromeCss ? (
|
|
682
|
+
<style dangerouslySetInnerHTML={{ __html: chromeCss }} />
|
|
683
|
+
) : null}
|
|
640
684
|
{presentation === 'sheet' && showSheetHandle !== false && (
|
|
641
685
|
<OverlaySheetHandle markerAttrs={markers?.handle} />
|
|
642
686
|
)}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveModalChrome,
|
|
13
13
|
resolveModalPresentation
|
|
14
14
|
} from './modal-registry';
|
|
15
|
+
import { buildModalChromeCss } from './modal-chrome-css';
|
|
15
16
|
import {
|
|
16
17
|
CentredModalSurface,
|
|
17
18
|
LayeredOverlaySurface,
|
|
@@ -121,6 +122,18 @@ const ThemeModalMount = ({
|
|
|
121
122
|
const presentation = resolveModalPresentation(modal, breakpoint);
|
|
122
123
|
const isCentred = presentation === 'modal';
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The designer's chrome styles, compiled here so both surfaces get the same
|
|
127
|
+
* string. In the canvas it is FORCED to one breakpoint — the iframe's width
|
|
128
|
+
* is not the device's, so real media queries would answer the wrong
|
|
129
|
+
* question; on a storefront the queries are emitted, which also spares the
|
|
130
|
+
* server render a flash while `useOverlayBreakpoint` starts at `desktop`.
|
|
131
|
+
*/
|
|
132
|
+
const chromeCss = buildModalChromeCss(
|
|
133
|
+
modal,
|
|
134
|
+
isDesigner ? breakpoint : undefined
|
|
135
|
+
);
|
|
136
|
+
|
|
124
137
|
/**
|
|
125
138
|
* In the canvas the overlay is fully inert: it never steals focus into the
|
|
126
139
|
* page the designer is styling, never freezes it, never claims `aria-modal`
|
|
@@ -151,6 +164,8 @@ const ThemeModalMount = ({
|
|
|
151
164
|
right={chrome.closeRight}
|
|
152
165
|
size={chrome.closeSize}
|
|
153
166
|
elevate
|
|
167
|
+
text={chrome.closeText}
|
|
168
|
+
textSide={chrome.closeTextSide}
|
|
154
169
|
/>
|
|
155
170
|
) : null;
|
|
156
171
|
|
|
@@ -169,6 +184,7 @@ const ThemeModalMount = ({
|
|
|
169
184
|
panelPadding={chrome.padding}
|
|
170
185
|
panelStretch={chrome.stretchContent}
|
|
171
186
|
topInset={chrome.topInset}
|
|
187
|
+
chromeCss={chromeCss}
|
|
172
188
|
header={header}
|
|
173
189
|
>
|
|
174
190
|
{children}
|
|
@@ -191,6 +207,7 @@ const ThemeModalMount = ({
|
|
|
191
207
|
topInset={chrome.topInset}
|
|
192
208
|
panelPadding={chrome.padding}
|
|
193
209
|
panelStretch={chrome.stretchContent}
|
|
210
|
+
chromeCss={chromeCss}
|
|
194
211
|
header={header}
|
|
195
212
|
>
|
|
196
213
|
{children}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import ThemeBlock from '../../theme-block';
|
|
4
|
+
import { generateThemeCSS } from '../index';
|
|
5
|
+
import { buildInnerRootSelectors, buildPartSelectors } from '../part-styles';
|
|
6
|
+
import { buildIteratorBlock } from '../iterator-utils';
|
|
7
|
+
import type { Section } from '../../theme-section';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The routed root rule, confined to the block that owns the root.
|
|
11
|
+
*
|
|
12
|
+
* Three renderers draw their own root and mark it `data-part="root"` (text,
|
|
13
|
+
* link, button — see INNER_ROOT_BLOCK_TYPES), and the sheet routes the block's
|
|
14
|
+
* rule to that element. The routed selector is a DESCENDANT one, so on a card
|
|
15
|
+
* built the way every collection grid is built — a link wrapping a label — the
|
|
16
|
+
* link's rule also matched the label's root.
|
|
17
|
+
*
|
|
18
|
+
* It was the worst possible part name to leak. `root` is not an internal a
|
|
19
|
+
* designer occasionally reaches for: those three renderers mark one every time,
|
|
20
|
+
* and so do several functional blocks that are not in the routed set at all
|
|
21
|
+
* (auth-error, auth-step-title, basket-summary, checkout-steps,
|
|
22
|
+
* checkout-summary, order-success), so the leak fired on the most ordinary
|
|
23
|
+
* composition there is and its victims were not limited to the routed types.
|
|
24
|
+
* The keys a card carries are layout keys, which is what made it loud: the
|
|
25
|
+
* label inherited the card's `aspect-ratio: 1/1` and `flex: 1 1 0`, went square
|
|
26
|
+
* at the card's full width, and left the image beside it a 4px sliver — plus a
|
|
27
|
+
* second border and radius drawn inside the first.
|
|
28
|
+
*
|
|
29
|
+
* The fix is the guard that already confines every other part rule.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const text = (id: string, styles: Record<string, unknown> = {}) => ({
|
|
33
|
+
id,
|
|
34
|
+
type: 'text',
|
|
35
|
+
label: 'Label',
|
|
36
|
+
value: '<p>SOLID</p>',
|
|
37
|
+
properties: {},
|
|
38
|
+
styles,
|
|
39
|
+
order: 0,
|
|
40
|
+
hidden: false
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const group = (id: string, children: unknown[]) => ({
|
|
44
|
+
id,
|
|
45
|
+
type: 'group',
|
|
46
|
+
label: 'Group',
|
|
47
|
+
properties: {},
|
|
48
|
+
styles: {},
|
|
49
|
+
order: 0,
|
|
50
|
+
hidden: false,
|
|
51
|
+
blocks: children
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const link = (
|
|
55
|
+
id: string,
|
|
56
|
+
styles: Record<string, unknown> = {},
|
|
57
|
+
children: unknown[] = []
|
|
58
|
+
) => ({
|
|
59
|
+
id,
|
|
60
|
+
type: 'link',
|
|
61
|
+
label: 'Card',
|
|
62
|
+
value: '',
|
|
63
|
+
properties: { href: '/collections' },
|
|
64
|
+
styles,
|
|
65
|
+
order: 0,
|
|
66
|
+
hidden: false,
|
|
67
|
+
blocks: children
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const button = (id: string, styles: Record<string, unknown> = {}) => ({
|
|
71
|
+
id,
|
|
72
|
+
type: 'button',
|
|
73
|
+
label: 'CTA',
|
|
74
|
+
value: 'Shop',
|
|
75
|
+
// `tag: 'button'` so the renderer draws a <button>; its default is an <a>,
|
|
76
|
+
// which inside the card's own <a> is markup no theme can produce.
|
|
77
|
+
properties: { text: 'Shop', tag: 'button' },
|
|
78
|
+
styles,
|
|
79
|
+
order: 0,
|
|
80
|
+
hidden: false
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const image = (id: string) => ({
|
|
84
|
+
id,
|
|
85
|
+
type: 'image',
|
|
86
|
+
label: 'Image',
|
|
87
|
+
value: '',
|
|
88
|
+
properties: { src: '/a.png' },
|
|
89
|
+
styles: { flex: { desktop: '1 1 0' } },
|
|
90
|
+
order: 0,
|
|
91
|
+
hidden: false
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** The card that broke: a link, a label inside a group, an image beside it. */
|
|
95
|
+
const card = (cardStyles: Record<string, unknown>, extra: unknown[] = []) =>
|
|
96
|
+
link('card', cardStyles, [
|
|
97
|
+
image('media'),
|
|
98
|
+
group('caption', [text('label')]),
|
|
99
|
+
...extra
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const sectionOf = (root: unknown): Section[] =>
|
|
103
|
+
[
|
|
104
|
+
{
|
|
105
|
+
id: 'sec-1',
|
|
106
|
+
type: 'custom',
|
|
107
|
+
name: 'Section',
|
|
108
|
+
label: 'Section',
|
|
109
|
+
order: 0,
|
|
110
|
+
hidden: false,
|
|
111
|
+
properties: {},
|
|
112
|
+
styles: {},
|
|
113
|
+
blocks: [root]
|
|
114
|
+
}
|
|
115
|
+
] as unknown as Section[];
|
|
116
|
+
|
|
117
|
+
const renderRoot = (root: unknown) =>
|
|
118
|
+
render(
|
|
119
|
+
<ThemeBlock
|
|
120
|
+
block={root as never}
|
|
121
|
+
placeholderId="p"
|
|
122
|
+
sectionId="sec-1"
|
|
123
|
+
isDesigner={false}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Every selector the generated stylesheet aims at a `root` part.
|
|
129
|
+
*
|
|
130
|
+
* At-rule preludes are dropped with the chunk they open, so a rule that only
|
|
131
|
+
* exists inside `@media` is not seen here. That is why the breakpoint case
|
|
132
|
+
* below reads the TARGETED output, which `generateElementCSS` emits unwrapped.
|
|
133
|
+
*/
|
|
134
|
+
const rootSelectors = (css: string): string[] =>
|
|
135
|
+
css
|
|
136
|
+
.split('}')
|
|
137
|
+
.map((chunk) => chunk.slice(0, chunk.indexOf('{')))
|
|
138
|
+
.filter((selector) => selector.includes('[data-part="root"]'))
|
|
139
|
+
.flatMap((selector) => selector.split(',').map((one) => one.trim()))
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
|
|
142
|
+
/** Which blocks' roots those selectors actually reach in the rendered card. */
|
|
143
|
+
const ownersOf = (root: HTMLElement, selectors: string[]): string[] => {
|
|
144
|
+
const owners = new Set<string>();
|
|
145
|
+
|
|
146
|
+
selectors.forEach((selector) => {
|
|
147
|
+
root.querySelectorAll<HTMLElement>(selector).forEach((element) => {
|
|
148
|
+
owners.add(
|
|
149
|
+
element.closest('[data-block-id]')?.getAttribute('data-block-id') || '?'
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return Array.from(owners).sort();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const boxStyles = {
|
|
158
|
+
'aspect-ratio': { desktop: '1/1' },
|
|
159
|
+
flex: { desktop: '1 1 0' },
|
|
160
|
+
'border-radius': { desktop: '20px' },
|
|
161
|
+
'border-width': { desktop: '1px' }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
describe('a card whose link carries the box', () => {
|
|
165
|
+
it('paints the link root and not the label root', () => {
|
|
166
|
+
const css = generateThemeCSS(sectionOf(card(boxStyles)));
|
|
167
|
+
const { container } = renderRoot(card({}));
|
|
168
|
+
|
|
169
|
+
// Premise: both blocks really do mark a root, one inside the other.
|
|
170
|
+
expect(container.querySelectorAll('[data-part="root"]').length).toBe(2);
|
|
171
|
+
expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('leaves the label reachable by its own rule', () => {
|
|
175
|
+
// The other way this could go wrong: a guard that also deletes the style
|
|
176
|
+
// the nested block itself declares.
|
|
177
|
+
const styledLabel = generateThemeCSS(
|
|
178
|
+
sectionOf(
|
|
179
|
+
link('card', boxStyles, [
|
|
180
|
+
image('media'),
|
|
181
|
+
group('caption', [text('label', { 'font-size': { desktop: '18px' } })])
|
|
182
|
+
])
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
const { container } = renderRoot(card({}));
|
|
186
|
+
|
|
187
|
+
expect(ownersOf(container, rootSelectors(styledLabel))).toEqual([
|
|
188
|
+
'card',
|
|
189
|
+
'label'
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('confines a hover rule to the link root as well', () => {
|
|
194
|
+
const css = generateThemeCSS(
|
|
195
|
+
sectionOf(card({ ':hover/border-color': { desktop: '#000000' } }))
|
|
196
|
+
);
|
|
197
|
+
const { container } = renderRoot(card({}));
|
|
198
|
+
|
|
199
|
+
// The canvas pins a state by stamping it, which is why every state also
|
|
200
|
+
// matches `[data-state~="…"]` — and it is the only one of the emitted
|
|
201
|
+
// expressions a test can put on an element. Stamped on BOTH roots, so the
|
|
202
|
+
// rule gets its chance to leak.
|
|
203
|
+
container
|
|
204
|
+
.querySelectorAll<HTMLElement>('[data-part="root"]')
|
|
205
|
+
.forEach((element) => element.setAttribute('data-state', 'hover'));
|
|
206
|
+
|
|
207
|
+
const stamped = rootSelectors(css).filter((selector) =>
|
|
208
|
+
selector.includes('[data-state~="hover"]')
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(stamped.length).toBeGreaterThan(0);
|
|
212
|
+
expect(ownersOf(container, stamped)).toEqual(['card']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('holds on every breakpoint', () => {
|
|
216
|
+
['desktop', 'mobile', 'tablet'].forEach((breakpoint) => {
|
|
217
|
+
const css = generateThemeCSS(
|
|
218
|
+
sectionOf(
|
|
219
|
+
card({
|
|
220
|
+
'aspect-ratio': { desktop: '1/1', tablet: '3/4', mobile: '4/5' }
|
|
221
|
+
})
|
|
222
|
+
),
|
|
223
|
+
breakpoint
|
|
224
|
+
);
|
|
225
|
+
const { container, unmount } = renderRoot(card({}));
|
|
226
|
+
const selectors = rootSelectors(css);
|
|
227
|
+
|
|
228
|
+
// Targeted output is emitted unwrapped, so the value resolved for THIS
|
|
229
|
+
// breakpoint is the one carried by the selectors read here.
|
|
230
|
+
expect(selectors.length).toBeGreaterThan(0);
|
|
231
|
+
expect(ownersOf(container, selectors)).toEqual(['card']);
|
|
232
|
+
unmount();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('the other shapes that nest a root inside a root', () => {
|
|
238
|
+
/**
|
|
239
|
+
* Only a link nests another block's root among the three routed types: a text
|
|
240
|
+
* renders its copy and a button renders its own label, neither takes children
|
|
241
|
+
* (see button-block.tsx, which draws `{content}`). So a link is the outer
|
|
242
|
+
* block in every shape below — and `<a>` inside `<a>` is not markup a theme
|
|
243
|
+
* can produce, which leaves a button, a text, and the functional blocks named
|
|
244
|
+
* at the top of this file as the roots a card can contain.
|
|
245
|
+
*/
|
|
246
|
+
it('a button inside a link', () => {
|
|
247
|
+
const withCta = (styles: Record<string, unknown>) =>
|
|
248
|
+
card(styles, [button('cta')]);
|
|
249
|
+
const css = generateThemeCSS(sectionOf(withCta(boxStyles)));
|
|
250
|
+
const { container } = renderRoot(withCta({}));
|
|
251
|
+
|
|
252
|
+
expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a repeated card, whose clones share one style identity', () => {
|
|
256
|
+
const expanded = buildIteratorBlock({
|
|
257
|
+
iteratorBlock: {
|
|
258
|
+
id: 'it-1',
|
|
259
|
+
type: 'group',
|
|
260
|
+
label: 'Repeat',
|
|
261
|
+
isIterator: true,
|
|
262
|
+
blocks: [card(boxStyles)]
|
|
263
|
+
} as never,
|
|
264
|
+
items: [{ a: 1 }, { a: 2 }, { a: 3 }]
|
|
265
|
+
});
|
|
266
|
+
const composition = group('grid', (expanded.blocks || []) as unknown[]);
|
|
267
|
+
|
|
268
|
+
const css = generateThemeCSS(sectionOf(composition));
|
|
269
|
+
const { container } = renderRoot(composition);
|
|
270
|
+
const [guarded] = buildInnerRootSelectors('[data-block-id="card"]');
|
|
271
|
+
|
|
272
|
+
// One authored rule, three rows: it has to keep matching every clone's own
|
|
273
|
+
// root, and none of their labels.
|
|
274
|
+
expect(container.querySelectorAll(guarded)).toHaveLength(3);
|
|
275
|
+
expect(ownersOf(container, rootSelectors(css))).toEqual(['card']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('a block whose style identity is not its id', () => {
|
|
279
|
+
const shape = (styles: Record<string, unknown>) => ({
|
|
280
|
+
...link('instance', styles, [group('caption', [text('label')])]),
|
|
281
|
+
styleSourceId: 'template'
|
|
282
|
+
});
|
|
283
|
+
const css = generateThemeCSS(sectionOf(shape(boxStyles)));
|
|
284
|
+
const { container } = renderRoot(shape({}));
|
|
285
|
+
|
|
286
|
+
// Rule and guard are keyed on the same identity, or the guard would exclude
|
|
287
|
+
// the element the rule is aimed at.
|
|
288
|
+
expect(css).toContain('[data-block-id="template"] [data-part="root"]');
|
|
289
|
+
expect(ownersOf(container, rootSelectors(css))).toEqual(['template']);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('a block type that opts into the routing explicitly', () => {
|
|
293
|
+
// `stylesTargetRoot` overrides the type list, so the flag path has to reach
|
|
294
|
+
// the guarded builder too.
|
|
295
|
+
const css = generateThemeCSS(
|
|
296
|
+
sectionOf({
|
|
297
|
+
...group('opted-in', [text('label')]),
|
|
298
|
+
styles: boxStyles,
|
|
299
|
+
stylesTargetRoot: true
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(css).toContain(
|
|
304
|
+
'[data-block-id="opted-in"] [data-part="root"]' +
|
|
305
|
+
':not(:where([data-block-id="opted-in"] [data-block-id] *))'
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('the selector builder', () => {
|
|
311
|
+
it('delegates to the part builder, so root cannot drift from every part', () => {
|
|
312
|
+
// Asserted against the part builder itself rather than a copy of its
|
|
313
|
+
// output: the point of the fix is that `root` is not special-cased.
|
|
314
|
+
['[data-block-id="c"]', '[data-block-id="a\\"] , * {}"]'].forEach(
|
|
315
|
+
(blockSelector) => {
|
|
316
|
+
expect(buildInnerRootSelectors(blockSelector)).toEqual(
|
|
317
|
+
buildPartSelectors(blockSelector, {
|
|
318
|
+
part: 'root',
|
|
319
|
+
state: null,
|
|
320
|
+
styles: {}
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(buildInnerRootSelectors(blockSelector, 'hover')).toEqual(
|
|
325
|
+
buildPartSelectors(blockSelector, {
|
|
326
|
+
part: 'root',
|
|
327
|
+
state: 'hover',
|
|
328
|
+
styles: {}
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -54,7 +54,12 @@ describe('a renderer that draws its own root', () => {
|
|
|
54
54
|
sectionWith({ id: 'txt', type: 'text', styles: { padding: '24px' } })
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// Guarded, so the rule cannot also paint the root a nested block marks —
|
|
58
|
+
// that containment is covered in inner-root-ownership.test.tsx.
|
|
59
|
+
expect(css).toContain(
|
|
60
|
+
'[data-block-id="txt"] [data-part="root"]' +
|
|
61
|
+
':not(:where([data-block-id="txt"] [data-block-id] *)) {'
|
|
62
|
+
);
|
|
58
63
|
// The wrapper must NOT also carry it: that second application is the bug.
|
|
59
64
|
expect(css).not.toContain('[data-block-id="txt"] {');
|
|
60
65
|
});
|
|
@@ -283,6 +283,12 @@ describe('a composition that names no part', () => {
|
|
|
283
283
|
it('is generated without a guard anywhere in it', () => {
|
|
284
284
|
// The feature is off until a designer types a part key; the stylesheet a
|
|
285
285
|
// published composition already ships must come out exactly as before.
|
|
286
|
+
//
|
|
287
|
+
// Read the scope literally: it is a composition of blocks whose styles land
|
|
288
|
+
// on the WRAPPER. A text, link or button root rule is routed to
|
|
289
|
+
// `data-part="root"` and therefore carries the guard whether or not a part
|
|
290
|
+
// key was typed — see inner-root-ownership.test.tsx. Accordions are used
|
|
291
|
+
// here precisely because they are not one of those three.
|
|
286
292
|
const css = generateThemeCSS(
|
|
287
293
|
sectionOf(
|
|
288
294
|
accordion(
|
|
@@ -174,8 +174,12 @@ describe('the block and section root rules', () => {
|
|
|
174
174
|
|
|
175
175
|
// The state has to land on the same element the stateless root rule does,
|
|
176
176
|
// or it would style the transparent designer wrapper the block sits in.
|
|
177
|
+
// Ownership guard included: a routed root rule is a descendant selector,
|
|
178
|
+
// and a nested block marks a root of its own.
|
|
177
179
|
expect(css).toContain(
|
|
178
|
-
`${SLOT} [data-block-id="blk-1"] [data-part="root"]
|
|
180
|
+
`${SLOT} [data-block-id="blk-1"] [data-part="root"]` +
|
|
181
|
+
`:not(:where([data-block-id="blk-1"] [data-block-id] *))` +
|
|
182
|
+
` {\n color: #111111;\n}`
|
|
179
183
|
);
|
|
180
184
|
});
|
|
181
185
|
|