@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +5 -4
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. 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
- if (!entry.path || seen.has(entry.path) || entries.length >= MAX_DISCOVERY_ENTRIES) {
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
- Object.entries(input).forEach(([rootKey, value]) => {
112
- walk(value, rootKey);
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: Object.keys(input),
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
+ };