@akinon/pz-theme 2.0.36-beta.0 → 2.0.36
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 +5 -4
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- package/src/utils/visibility-rules.ts +346 -3
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createPageContextDiscovery } from './page-context-discovery';
|
|
2
|
+
import { evaluateVisibilityRules, VisibilityRuleContext } from './visibility-rules';
|
|
3
|
+
|
|
4
|
+
const makeContext = (
|
|
5
|
+
pageContext: Record<string, unknown>
|
|
6
|
+
): VisibilityRuleContext => ({
|
|
7
|
+
authState: 'guest',
|
|
8
|
+
breakpoint: 'desktop',
|
|
9
|
+
locale: 'tr',
|
|
10
|
+
currency: 'TRY',
|
|
11
|
+
pathname: '/',
|
|
12
|
+
searchParams: null,
|
|
13
|
+
pageContext
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The picker writes a discovered path verbatim into a rule, so a path the
|
|
18
|
+
* evaluator cannot read is a block that disappears on the storefront while the
|
|
19
|
+
* canvas — which skips data conditions — still shows it. Every entry offered
|
|
20
|
+
* has to answer `exists` on the very page it was discovered from.
|
|
21
|
+
*/
|
|
22
|
+
const expectEveryDiscoveredPathToResolve = (
|
|
23
|
+
pageContext: Record<string, unknown>
|
|
24
|
+
) => {
|
|
25
|
+
const discovery = createPageContextDiscovery(pageContext);
|
|
26
|
+
const unresolvable = discovery.entries
|
|
27
|
+
.map(entry => entry.path)
|
|
28
|
+
.filter(
|
|
29
|
+
path =>
|
|
30
|
+
!evaluateVisibilityRules(
|
|
31
|
+
{ enabled: true, dataConditions: [{ path, operator: 'exists' }] },
|
|
32
|
+
makeContext(pageContext)
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(unresolvable).toEqual([]);
|
|
37
|
+
return discovery;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('createPageContextDiscovery', () => {
|
|
41
|
+
const pdpContext = {
|
|
42
|
+
product: {
|
|
43
|
+
sku: 'TS-1',
|
|
44
|
+
stock: 3,
|
|
45
|
+
inStock: true,
|
|
46
|
+
attributes: { gender: 'Women', category: 'tops' },
|
|
47
|
+
tags: ['sale', 'new-season'],
|
|
48
|
+
images: [
|
|
49
|
+
{ url: 'https://cdn.example.com/a.jpg', alt: 'front' },
|
|
50
|
+
{ url: 'https://cdn.example.com/b.jpg', alt: 'back' }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
listing: {
|
|
54
|
+
totalCount: 2,
|
|
55
|
+
products: [
|
|
56
|
+
{ sku: 'A', price: { value: 10, currency: 'TRY' } },
|
|
57
|
+
{ sku: 'B', price: { value: 20, currency: 'TRY' } }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
it('offers only paths the rule evaluator can read', () => {
|
|
63
|
+
const discovery = expectEveryDiscoveredPathToResolve(pdpContext);
|
|
64
|
+
|
|
65
|
+
// The array-element paths are the majority of the interesting fields on a
|
|
66
|
+
// real page; they must be offered, not quietly dropped.
|
|
67
|
+
const paths = discovery.entries.map(entry => entry.path);
|
|
68
|
+
expect(paths).toContain('product.images[].url');
|
|
69
|
+
expect(paths).toContain('listing.products[].price.value');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps the array root alongside its element paths', () => {
|
|
73
|
+
const paths = createPageContextDiscovery(pdpContext).entries.map(
|
|
74
|
+
entry => entry.path
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(paths).toContain('product.tags');
|
|
78
|
+
expect(paths).toContain('product.images');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('drops merchant keys that cannot round-trip through a path', () => {
|
|
82
|
+
// Merchant payloads are untrusted: an attribute literally named
|
|
83
|
+
// "size.chart" would be offered as product.attributes.size.chart and read
|
|
84
|
+
// back as two nested keys that do not exist.
|
|
85
|
+
const context = {
|
|
86
|
+
product: {
|
|
87
|
+
attributes: {
|
|
88
|
+
'size.chart': 'https://cdn.example.com/size.pdf',
|
|
89
|
+
'legacy[0]': 'x',
|
|
90
|
+
'': 'y',
|
|
91
|
+
' ': 'z',
|
|
92
|
+
gender: 'Women'
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const discovery = expectEveryDiscoveredPathToResolve(context);
|
|
98
|
+
const paths = discovery.entries.map(entry => entry.path);
|
|
99
|
+
|
|
100
|
+
expect(paths).toEqual(['product.attributes.gender']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('drops prototype keys instead of offering a path into the prototype chain', () => {
|
|
104
|
+
const context = {
|
|
105
|
+
product: {
|
|
106
|
+
constructor: 'own-key-but-unreadable',
|
|
107
|
+
__proto__: 'also-unreadable',
|
|
108
|
+
sku: 'TS-1'
|
|
109
|
+
} as Record<string, unknown>
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const paths = createPageContextDiscovery(context).entries.map(
|
|
113
|
+
entry => entry.path
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(paths).toEqual(['product.sku']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('drops a root key that cannot round-trip', () => {
|
|
120
|
+
const discovery = createPageContextDiscovery({
|
|
121
|
+
'weird.root': { sku: 'A' },
|
|
122
|
+
product: { sku: 'B' }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(discovery.roots).toEqual(['product']);
|
|
126
|
+
expect(discovery.entries.map(entry => entry.path)).toEqual(['product.sku']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('drops keys the rule normaliser would trim into a different key', () => {
|
|
130
|
+
// A CSV import routinely produces an attribute named "size " — the rule
|
|
131
|
+
// normaliser trims the whole path, so `product.attributes.size ` is read
|
|
132
|
+
// back as `product.attributes.size` and the block never appears. A leading
|
|
133
|
+
// space on a root breaks the same way, from the other end.
|
|
134
|
+
const context = {
|
|
135
|
+
' product': { sku: 'A' },
|
|
136
|
+
product: { attributes: { 'size ': 'L', ' fit': 'slim', gender: 'Women' } }
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const discovery = expectEveryDiscoveredPathToResolve(context);
|
|
140
|
+
|
|
141
|
+
expect(discovery.roots).toEqual(['product']);
|
|
142
|
+
// The interior key keeps its space and still round-trips, so it stays.
|
|
143
|
+
expect(discovery.entries.map(entry => entry.path)).toEqual([
|
|
144
|
+
'product.attributes. fit',
|
|
145
|
+
'product.attributes.gender'
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('offers a field the page leaves empty as readable, not as broken', () => {
|
|
150
|
+
// `exists` says no for an empty listing or a null description, and that is
|
|
151
|
+
// the right answer — but the path is still one the evaluator can read, and
|
|
152
|
+
// a designer must be able to write `notExists` against it. Unreadable is
|
|
153
|
+
// the third answer: it fails both.
|
|
154
|
+
const sparse = {
|
|
155
|
+
product: { sku: 'TS-1', description: '', badge: null },
|
|
156
|
+
listing: { products: [], totalCount: 0 }
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const paths = createPageContextDiscovery(sparse).entries.map(
|
|
160
|
+
entry => entry.path
|
|
161
|
+
);
|
|
162
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
163
|
+
|
|
164
|
+
const unreadable = paths.filter(path => {
|
|
165
|
+
const answers = (['exists', 'notExists'] as const).map(operator =>
|
|
166
|
+
evaluateVisibilityRules(
|
|
167
|
+
{ enabled: true, dataConditions: [{ path, operator }] },
|
|
168
|
+
makeContext(sparse)
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
return !answers[0] && !answers[1];
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(unreadable).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('survives a self-referencing page context instead of overflowing the stack', () => {
|
|
178
|
+
const product: Record<string, unknown> = { sku: 'TS-1' };
|
|
179
|
+
product.self = product;
|
|
180
|
+
|
|
181
|
+
expect(() => createPageContextDiscovery({ product })).not.toThrow();
|
|
182
|
+
expectEveryDiscoveredPathToResolve({ product });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports nothing for an absent page context', () => {
|
|
186
|
+
expect(createPageContextDiscovery(null)).toEqual({ roots: [], entries: [] });
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -21,6 +21,27 @@ export interface PageContextDiscovery {
|
|
|
21
21
|
const MAX_DISCOVERY_ENTRIES = 250;
|
|
22
22
|
const MAX_SAMPLE_LENGTH = 60;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Keys that cannot survive a round trip through a dotted path.
|
|
26
|
+
*
|
|
27
|
+
* Merchant payloads are untrusted, and an attribute literally named
|
|
28
|
+
* `size.chart` would be published as `product.attributes.size.chart`, read back
|
|
29
|
+
* as two nested keys that do not exist, and hide the block forever — while the
|
|
30
|
+
* canvas, which skips data conditions, keeps showing it. A key with a bracket
|
|
31
|
+
* mis-tokenises the same way, and an empty key resolves to its *parent*, which
|
|
32
|
+
* is worse: the rule quietly answers about the wrong value.
|
|
33
|
+
*
|
|
34
|
+
* `__proto__` / `constructor` / `prototype` are excluded for the same reason
|
|
35
|
+
* from the other end: the evaluator refuses to walk them, so offering them
|
|
36
|
+
* would hand a designer a path that never passes.
|
|
37
|
+
*/
|
|
38
|
+
const isRoundTrippableKey = (key: string): boolean =>
|
|
39
|
+
key.trim() !== '' &&
|
|
40
|
+
!/[.[\]]/.test(key) &&
|
|
41
|
+
key !== '__proto__' &&
|
|
42
|
+
key !== 'constructor' &&
|
|
43
|
+
key !== 'prototype';
|
|
44
|
+
|
|
24
45
|
const getValueType = (value: unknown): DiscoveryValueType => {
|
|
25
46
|
if (value === null) return 'null';
|
|
26
47
|
if (Array.isArray(value)) return 'array';
|
|
@@ -65,7 +86,16 @@ export const createPageContextDiscovery = (
|
|
|
65
86
|
const seen = new Set<string>();
|
|
66
87
|
|
|
67
88
|
const pushEntry = (entry: PageContextDiscoveryEntry) => {
|
|
68
|
-
|
|
89
|
+
// The rule normaliser trims the whole path before reading it, so a path
|
|
90
|
+
// that ends in whitespace — a merchant attribute named "size ", which a
|
|
91
|
+
// CSV import produces routinely — is read back as a different key and the
|
|
92
|
+
// block never appears. Interior keys are untouched by that trim and stay.
|
|
93
|
+
if (
|
|
94
|
+
!entry.path ||
|
|
95
|
+
entry.path.trim() !== entry.path ||
|
|
96
|
+
seen.has(entry.path) ||
|
|
97
|
+
entries.length >= MAX_DISCOVERY_ENTRIES
|
|
98
|
+
) {
|
|
69
99
|
return;
|
|
70
100
|
}
|
|
71
101
|
|
|
@@ -86,6 +116,10 @@ export const createPageContextDiscovery = (
|
|
|
86
116
|
sample: formatSample(value)
|
|
87
117
|
});
|
|
88
118
|
|
|
119
|
+
// `[]` is the one wildcard in this vocabulary and it means "any element":
|
|
120
|
+
// the rule evaluator resolves such a path to every element of the array,
|
|
121
|
+
// and the binding panel reads the same marker to map an iterator child
|
|
122
|
+
// onto `item.*`. The first element only supplies the shape and sample.
|
|
89
123
|
if (value.length > 0) {
|
|
90
124
|
walk(value[0], `${path}[]`);
|
|
91
125
|
}
|
|
@@ -94,6 +128,8 @@ export const createPageContextDiscovery = (
|
|
|
94
128
|
|
|
95
129
|
if (value && typeof value === 'object') {
|
|
96
130
|
Object.entries(value as Record<string, unknown>).forEach(([key, child]) => {
|
|
131
|
+
if (!isRoundTrippableKey(key)) return;
|
|
132
|
+
|
|
97
133
|
const nextPath = path ? `${path}.${key}` : key;
|
|
98
134
|
walk(child, nextPath);
|
|
99
135
|
});
|
|
@@ -108,12 +144,20 @@ export const createPageContextDiscovery = (
|
|
|
108
144
|
});
|
|
109
145
|
};
|
|
110
146
|
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
// A root additionally may not carry leading whitespace: it sits at the front
|
|
148
|
+
// of every path built under it, and the rule normaliser trims there too.
|
|
149
|
+
// Not folded into isRoundTrippableKey because an interior key with edge
|
|
150
|
+
// whitespace does round-trip, and dropping those would cost real fields.
|
|
151
|
+
const roots = Object.keys(input).filter(
|
|
152
|
+
key => isRoundTrippableKey(key) && key === key.trim()
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
roots.forEach(rootKey => {
|
|
156
|
+
walk(input[rootKey], rootKey);
|
|
113
157
|
});
|
|
114
158
|
|
|
115
159
|
return {
|
|
116
|
-
roots
|
|
160
|
+
roots,
|
|
117
161
|
entries
|
|
118
162
|
};
|
|
119
163
|
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Part styling: letting a designer style what is *inside* a functional block.
|
|
3
|
+
*
|
|
4
|
+
* Until now a block's styles produced exactly one rule, on the block root
|
|
5
|
+
* (`[data-block-id="…"]`), and every functional block drew its internals with
|
|
6
|
+
* inline styles. So a brand could move the variant dropdown around and hide
|
|
7
|
+
* it, but could not change its border, height or focus ring — the markup was
|
|
8
|
+
* out of reach and inline styles outrank any stylesheet anyway.
|
|
9
|
+
*
|
|
10
|
+
* A block now names its internals with `data-part` (see `partAttrs`), and a
|
|
11
|
+
* style key can address one:
|
|
12
|
+
*
|
|
13
|
+
* "dropdown/border-color" -> [data-block-id="x"] [data-part="dropdown"]
|
|
14
|
+
* "dropdown/focus/border-color" -> … [data-part="dropdown"]:focus-visible
|
|
15
|
+
* "option/selected/background" -> … [data-part="option"][data-state~="selected"]
|
|
16
|
+
*
|
|
17
|
+
* Values stay per breakpoint exactly like root styles, so nothing about
|
|
18
|
+
* storage, persistence or the responsive model changes — the key does all the
|
|
19
|
+
* work, which is also why existing published compositions are unaffected.
|
|
20
|
+
*
|
|
21
|
+
* Declarations are emitted `!important` on purpose: the element being styled
|
|
22
|
+
* usually carries the renderer's own inline defaults, and an override the
|
|
23
|
+
* designer just typed has to win over the default the block shipped with.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* State segment -> the selector suffixes that express it.
|
|
28
|
+
*
|
|
29
|
+
* Every state also matches `[data-state~="…"]`, including the ones that are
|
|
30
|
+
* really pseudo-classes. That is what lets the editor pin a state open: a
|
|
31
|
+
* designer cannot hover an element while typing a hover colour, so the canvas
|
|
32
|
+
* writes the state onto the element and the same rule applies.
|
|
33
|
+
*/
|
|
34
|
+
const STATE_SELECTORS: Record<string, string[]> = {
|
|
35
|
+
hover: [':hover', '[data-state~="hover"]'],
|
|
36
|
+
focus: [':focus-visible', ':focus', '[data-state~="focus"]'],
|
|
37
|
+
active: [':active', '[data-state~="active"]'],
|
|
38
|
+
// Native controls, ARIA and our own data-state all mean the same thing here,
|
|
39
|
+
// so each is covered rather than forcing renderers into one convention.
|
|
40
|
+
disabled: [':disabled', '[aria-disabled="true"]', '[data-state~="disabled"]'],
|
|
41
|
+
selected: [
|
|
42
|
+
'[data-state~="selected"]',
|
|
43
|
+
'[aria-selected="true"]',
|
|
44
|
+
'[aria-current="true"]'
|
|
45
|
+
],
|
|
46
|
+
checked: [':checked', '[data-state~="checked"]', '[aria-checked="true"]'],
|
|
47
|
+
open: ['[data-state~="open"]', '[aria-expanded="true"]'],
|
|
48
|
+
invalid: ['[aria-invalid="true"]', '[data-state~="invalid"]'],
|
|
49
|
+
loading: ['[data-state~="loading"]'],
|
|
50
|
+
empty: ['[data-state~="empty"]'],
|
|
51
|
+
// Picked but not yet committed. A panel that stages its choices and applies
|
|
52
|
+
// them in one go has to show the shopper which ticks are already filtering
|
|
53
|
+
// the page and which are only waiting on the Apply button — "selected" alone
|
|
54
|
+
// cannot say both.
|
|
55
|
+
staged: ['[data-state~="staged"]'],
|
|
56
|
+
// A commerce answer is not binary. "We could not ask" has to look different
|
|
57
|
+
// from "we asked and it is not there", otherwise a store the shopper could
|
|
58
|
+
// still ring up reads as a dead end. `~=` matches whole tokens, so
|
|
59
|
+
// "unavailable" never satisfies the "available" selector.
|
|
60
|
+
available: ['[data-state~="available"]'],
|
|
61
|
+
unavailable: ['[data-state~="unavailable"]'],
|
|
62
|
+
unknown: ['[data-state~="unknown"]']
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const PART_STYLE_STATES = Object.keys(STATE_SELECTORS);
|
|
66
|
+
|
|
67
|
+
/** Keys are `part/prop` or `part/state/prop`; `/` never appears in a CSS property. */
|
|
68
|
+
const PART_KEY_DELIMITER = '/';
|
|
69
|
+
|
|
70
|
+
export type ParsedPartKey = {
|
|
71
|
+
part: string;
|
|
72
|
+
state: string | null;
|
|
73
|
+
property: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const parsePartStyleKey = (key: string): ParsedPartKey | null => {
|
|
77
|
+
if (!key || key.indexOf(PART_KEY_DELIMITER) === -1) return null;
|
|
78
|
+
|
|
79
|
+
const segments = key.split(PART_KEY_DELIMITER).filter(Boolean);
|
|
80
|
+
|
|
81
|
+
if (segments.length === 2) {
|
|
82
|
+
return { part: segments[0], state: null, property: segments[1] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (segments.length === 3 && STATE_SELECTORS[segments[1]]) {
|
|
86
|
+
return { part: segments[0], state: segments[1], property: segments[2] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Three segments with an unknown middle, or four+: not a part key. Returning
|
|
90
|
+
// null keeps it on the root bucket rather than silently dropping the style.
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const isPartStyleKey = (key: string): boolean =>
|
|
95
|
+
parsePartStyleKey(key) !== null;
|
|
96
|
+
|
|
97
|
+
/** Builds the style key a descriptor writes into. */
|
|
98
|
+
export const buildPartStyleKey = (
|
|
99
|
+
part: string,
|
|
100
|
+
property: string,
|
|
101
|
+
state?: string | null
|
|
102
|
+
): string =>
|
|
103
|
+
state
|
|
104
|
+
? [part, state, property].join(PART_KEY_DELIMITER)
|
|
105
|
+
: [part, property].join(PART_KEY_DELIMITER);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `data-part` plus the states the renderer wants stylable. Renderers spread
|
|
109
|
+
* this onto the element instead of hand-writing the attributes, so the
|
|
110
|
+
* contract stays in one place.
|
|
111
|
+
*
|
|
112
|
+
* <select {...partAttrs('dropdown')} />
|
|
113
|
+
* <a {...partAttrs('option', { selected: isSelected, disabled })} />
|
|
114
|
+
*/
|
|
115
|
+
export const partAttrs = (
|
|
116
|
+
part: string,
|
|
117
|
+
states?: Record<string, boolean | undefined>
|
|
118
|
+
): Record<string, string> => {
|
|
119
|
+
const attrs: Record<string, string> = { 'data-part': part };
|
|
120
|
+
|
|
121
|
+
if (states) {
|
|
122
|
+
const active = Object.entries(states)
|
|
123
|
+
.filter(([, on]) => on === true)
|
|
124
|
+
.map(([name]) => name);
|
|
125
|
+
|
|
126
|
+
if (active.length > 0) {
|
|
127
|
+
attrs['data-state'] = active.join(' ');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return attrs;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A part/state bucket of styles, ready for the CSS generator. */
|
|
135
|
+
export type PartStyleBucket = {
|
|
136
|
+
part: string;
|
|
137
|
+
state: string | null;
|
|
138
|
+
styles: Record<string, unknown>;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Splits a block's styles into the root bucket and one bucket per
|
|
143
|
+
* part/state pair, preserving insertion order so generated CSS is stable
|
|
144
|
+
* (a moving stylesheet would defeat caching and make diffs unreadable).
|
|
145
|
+
*/
|
|
146
|
+
export const splitPartStyles = (
|
|
147
|
+
styles: Record<string, unknown> | undefined | null
|
|
148
|
+
): { root: Record<string, unknown>; parts: PartStyleBucket[] } => {
|
|
149
|
+
const root: Record<string, unknown> = {};
|
|
150
|
+
const buckets = new Map<string, PartStyleBucket>();
|
|
151
|
+
|
|
152
|
+
Object.entries(styles || {}).forEach(([key, value]) => {
|
|
153
|
+
const parsed = parsePartStyleKey(key);
|
|
154
|
+
|
|
155
|
+
if (!parsed) {
|
|
156
|
+
root[key] = value;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const bucketKey = `${parsed.part}::${parsed.state ?? ''}`;
|
|
161
|
+
const bucket = buckets.get(bucketKey) ?? {
|
|
162
|
+
part: parsed.part,
|
|
163
|
+
state: parsed.state,
|
|
164
|
+
styles: {}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
bucket.styles[parsed.property] = value;
|
|
168
|
+
buckets.set(bucketKey, bucket);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { root, parts: Array.from(buckets.values()) };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Only `[A-Za-z0-9_-]` survives, so a part name cannot break out of the selector. */
|
|
175
|
+
const sanitizePartName = (part: string): string =>
|
|
176
|
+
String(part).replace(/[^A-Za-z0-9_-]/g, '');
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Confines a part rule to the block that declares the part.
|
|
180
|
+
*
|
|
181
|
+
* The rule is a descendant selector, and part names are deliberately generic
|
|
182
|
+
* (`header`, `panel`, `content`, `title`, `icon`, `list`, …) so a designer who
|
|
183
|
+
* learned one block's panel already knows the next one's. Those two facts
|
|
184
|
+
* collide as soon as a container block holds another block that named a part
|
|
185
|
+
* the same way: styling an accordion's header also restyled the header of a
|
|
186
|
+
* block sitting in its panel — an effect no panel announced and no panel could
|
|
187
|
+
* undo. Renaming the parts is not the fix; the names are shared on purpose and
|
|
188
|
+
* several of them are already published in live compositions.
|
|
189
|
+
*
|
|
190
|
+
* A part is owned by its *closest* `[data-block-id]` ancestor — the same rule
|
|
191
|
+
* the canvas already applies when deciding which part a click selected (see
|
|
192
|
+
* hooks/use-designer-features.tsx). In CSS that means excluding anything
|
|
193
|
+
* sitting inside a nested block. No renderer draws its own part inside a child
|
|
194
|
+
* block's wrapper, so nothing a block legitimately owns is caught by this.
|
|
195
|
+
*
|
|
196
|
+
* Wrapped in `:where()` because `:not()` otherwise takes the specificity of
|
|
197
|
+
* its argument. `:where()` contributes zero, so the rule keeps exactly the
|
|
198
|
+
* weight it had before and nothing re-orders in the cascade — the only
|
|
199
|
+
* elements that stop matching are the leaked ones. The guard is a single
|
|
200
|
+
* string per bucket and identical across a block's parts, which is what keeps
|
|
201
|
+
* it cheap on a path every composition walks.
|
|
202
|
+
*/
|
|
203
|
+
const buildOwnershipGuard = (blockSelector: string): string =>
|
|
204
|
+
`:not(:where(${blockSelector} [data-block-id] *))`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Selectors for one bucket. A state expands to one selector per accepted
|
|
208
|
+
* expression (`:disabled`, `[aria-disabled="true"]`, …) so a renderer can use
|
|
209
|
+
* whichever is natural for the element it is drawing.
|
|
210
|
+
*/
|
|
211
|
+
export const buildPartSelectors = (
|
|
212
|
+
blockSelector: string,
|
|
213
|
+
bucket: PartStyleBucket
|
|
214
|
+
): string[] => {
|
|
215
|
+
const part = sanitizePartName(bucket.part);
|
|
216
|
+
if (!part) return [];
|
|
217
|
+
|
|
218
|
+
const base = `${blockSelector} [data-part="${part}"]`;
|
|
219
|
+
// Appended last: a state suffix can be an attribute selector, and those
|
|
220
|
+
// have to precede a pseudo-class inside a compound selector.
|
|
221
|
+
const owned = buildOwnershipGuard(blockSelector);
|
|
222
|
+
|
|
223
|
+
if (!bucket.state) return [`${base}${owned}`];
|
|
224
|
+
|
|
225
|
+
const suffixes = STATE_SELECTORS[bucket.state];
|
|
226
|
+
if (!suffixes) return [];
|
|
227
|
+
|
|
228
|
+
return suffixes.map((suffix) => `${base}${suffix}${owned}`);
|
|
229
|
+
};
|