@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
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
// Type-only: the CSS generator imports the binding-token guard from here, and
|
|
2
|
+
// a value import of the block module would close that loop at runtime.
|
|
3
|
+
import type { Block } from '../theme-block';
|
|
2
4
|
import { devWarn } from './dev-warn';
|
|
5
|
+
import { isVisibleForItem, readRowVisibility } from './row-visibility';
|
|
3
6
|
|
|
4
7
|
interface BindingContext {
|
|
5
8
|
sectionDataSource?: any;
|
|
6
9
|
pageContext?: Record<string, unknown> | null;
|
|
7
10
|
isDesigner?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Locale for the money a binding resolves to. Bound prices used to be
|
|
13
|
+
* formatted as tr-TR whatever the storefront's locale was, so a brand
|
|
14
|
+
* running in en-GB got Turkish grouping in every repeated row.
|
|
15
|
+
*/
|
|
16
|
+
locale?: string;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
interface IteratorOptions extends BindingContext {
|
|
11
20
|
iteratorBlock: Block;
|
|
12
21
|
forceIteratorCount?: number;
|
|
13
22
|
forceIteratorOffset?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Rows supplied by the caller instead of looked up on the section's data
|
|
25
|
+
* source. Functional blocks fetch their own list (basket, orders, stores),
|
|
26
|
+
* so they have the items in hand and no data-source path to point at.
|
|
27
|
+
*/
|
|
28
|
+
items?: Record<string, unknown>[];
|
|
14
29
|
}
|
|
15
30
|
|
|
16
31
|
const PAGE_CONTEXT_DATA_SOURCE_TYPE = 'page-context';
|
|
@@ -26,6 +41,97 @@ const DEFAULT_BINDING_TARGETS: Record<string, string> = {
|
|
|
26
41
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
42
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Binding tokens: `{{item.badgeColor}}`.
|
|
46
|
+
*
|
|
47
|
+
* A binding used to be a bare path (`item.name`) that replaced the whole
|
|
48
|
+
* value, and a bare path still does exactly that — image URLs, booleans and
|
|
49
|
+
* numbers all depend on getting the RAW value back rather than its string
|
|
50
|
+
* form. A token is the additive spelling: several of them can sit inside one
|
|
51
|
+
* value next to literal text, which is what lets a single text block say
|
|
52
|
+
* "Ürün Kodu {{item.base_code}}.{{item.color}}" instead of three siblings
|
|
53
|
+
* fighting over the spacing, and what lets a style value carry a colour that
|
|
54
|
+
* comes from the row.
|
|
55
|
+
*
|
|
56
|
+
* `{{path}}` on its own is the bare path spelled differently, so it is
|
|
57
|
+
* normalized back to the bare path and keeps the raw-value contract.
|
|
58
|
+
*/
|
|
59
|
+
const BINDING_TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
60
|
+
const SINGLE_BINDING_TOKEN_PATTERN = /^\{\{\s*([^{}]+?)\s*\}\}$/;
|
|
61
|
+
const CONTAINS_BINDING_TOKEN = /\{\{\s*[^{}]+?\s*\}\}/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True for a value that still carries an unresolved binding token. Exported
|
|
65
|
+
* because the CSS generator has to leave such a value out of the stylesheet:
|
|
66
|
+
* a token is an instruction, not a CSS value.
|
|
67
|
+
*/
|
|
68
|
+
export const hasBindingToken = (value: unknown): boolean =>
|
|
69
|
+
typeof value === 'string' && CONTAINS_BINDING_TOKEN.test(value);
|
|
70
|
+
|
|
71
|
+
const readBindingTokens = (value: string): string[] =>
|
|
72
|
+
Array.from(value.matchAll(BINDING_TOKEN_PATTERN), (match) =>
|
|
73
|
+
String(match[1]).trim()
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/** `{{path}}` alone means the same as the bare `path` the editor writes today. */
|
|
77
|
+
const normalizeBindingExpression = (binding: string): string => {
|
|
78
|
+
const single = binding.match(SINGLE_BINDING_TOKEN_PATTERN);
|
|
79
|
+
return single ? single[1].trim() : binding;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `item.colour` names a field of the row; a bare `item` names the row itself.
|
|
84
|
+
* The bare form is the only way to address an entry of a list of plain values
|
|
85
|
+
* (`tags: ['new','sale']`) — a repeat over one produces a clone per entry, and
|
|
86
|
+
* without this the entry has no spelling, so every clone prints the authored
|
|
87
|
+
* fallback. The empty path reads back as the row (see `getValueAtPath`).
|
|
88
|
+
*/
|
|
89
|
+
const stripItemPrefix = (path: string): string =>
|
|
90
|
+
path === 'item' ? '' : path.replace(/^item\./, '');
|
|
91
|
+
|
|
92
|
+
/** A single token addresses the repeated row when it reads `item` or `item.*`. */
|
|
93
|
+
const isRowScopedToken = (token: string): boolean =>
|
|
94
|
+
token === 'item' || token.startsWith('item.');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether a binding addresses the repeated row rather than the section's data
|
|
98
|
+
* source. For a bare path this is the `item` test the tokens already use;
|
|
99
|
+
* a token expression is row-scoped as soon as one of its tokens is.
|
|
100
|
+
*/
|
|
101
|
+
const isRowScopedBinding = (binding: string): boolean => {
|
|
102
|
+
if (isRowScopedToken(binding)) return true;
|
|
103
|
+
if (!hasBindingToken(binding)) return false;
|
|
104
|
+
|
|
105
|
+
return readBindingTokens(binding).some(isRowScopedToken);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Replaces every token with its resolved text and keeps the literal text
|
|
110
|
+
* around it. Returns undefined when nothing resolved, so the block keeps the
|
|
111
|
+
* value the designer authored as a fallback — which is what an unresolved bare
|
|
112
|
+
* path does today.
|
|
113
|
+
*/
|
|
114
|
+
const interpolateBindingTemplate = (
|
|
115
|
+
template: string,
|
|
116
|
+
resolveToken: (path: string) => unknown,
|
|
117
|
+
toText: (value: unknown) => string | undefined
|
|
118
|
+
): string | undefined => {
|
|
119
|
+
let resolvedCount = 0;
|
|
120
|
+
|
|
121
|
+
const text = template.replace(
|
|
122
|
+
BINDING_TOKEN_PATTERN,
|
|
123
|
+
(_match, rawPath: string) => {
|
|
124
|
+
const asText = toText(resolveToken(String(rawPath).trim()));
|
|
125
|
+
if (asText === undefined) return '';
|
|
126
|
+
|
|
127
|
+
resolvedCount += 1;
|
|
128
|
+
return asText;
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return resolvedCount > 0 ? text : undefined;
|
|
133
|
+
};
|
|
134
|
+
|
|
29
135
|
const getValueAtPath = (source: unknown, path: string): unknown => {
|
|
30
136
|
if (!path.trim()) return source;
|
|
31
137
|
|
|
@@ -239,10 +345,70 @@ const escapeHtml = (value: string): string =>
|
|
|
239
345
|
.replace(/"/g, '"')
|
|
240
346
|
.replace(/'/g, ''');
|
|
241
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Which items a repeat actually draws, and the count it falls back to when
|
|
350
|
+
* there are none.
|
|
351
|
+
*
|
|
352
|
+
* Exported because the product row scope has to hand clone N the row clone N
|
|
353
|
+
* was built from: with the count properties set, clone N is NOT items[N]. Two
|
|
354
|
+
* copies of this arithmetic drift the moment one of them learns a new
|
|
355
|
+
* property, and the symptom — every card showing the neighbouring product's
|
|
356
|
+
* price — is the kind nobody reads as an off-by-offset.
|
|
357
|
+
*/
|
|
358
|
+
export const sliceIteratorItems = (
|
|
359
|
+
iteratorBlock: Block,
|
|
360
|
+
items: Record<string, unknown>[],
|
|
361
|
+
overrides: {
|
|
362
|
+
forceIteratorCount?: number;
|
|
363
|
+
forceIteratorOffset?: number;
|
|
364
|
+
} = {}
|
|
365
|
+
): { items: Record<string, unknown>[]; count: number; offset: number } => {
|
|
366
|
+
const { forceIteratorCount, forceIteratorOffset } = overrides;
|
|
367
|
+
|
|
368
|
+
const useIteratorCount =
|
|
369
|
+
forceIteratorCount !== undefined
|
|
370
|
+
? true
|
|
371
|
+
: iteratorBlock.properties?.useIteratorCount === true ||
|
|
372
|
+
iteratorBlock.properties?.useIteratorCount === 'true';
|
|
373
|
+
const count =
|
|
374
|
+
forceIteratorCount !== undefined
|
|
375
|
+
? Math.max(1, Math.floor(forceIteratorCount))
|
|
376
|
+
: parsePositiveNumber(iteratorBlock.properties?.iteratorCount, 1);
|
|
377
|
+
const offset =
|
|
378
|
+
forceIteratorOffset !== undefined
|
|
379
|
+
? Math.max(0, Math.floor(forceIteratorOffset))
|
|
380
|
+
: parsePositiveNumber(iteratorBlock.properties?.iteratorOffset, 0);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
items:
|
|
384
|
+
items.length > 0
|
|
385
|
+
? useIteratorCount
|
|
386
|
+
? items.slice(offset, offset + count)
|
|
387
|
+
: items
|
|
388
|
+
: [],
|
|
389
|
+
count,
|
|
390
|
+
offset
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The storefront's locale, set once per render pass by the section that owns
|
|
396
|
+
* the bindings. A module-level value rather than a parameter because the
|
|
397
|
+
* formatting sits deep inside a recursive value walk, and threading it
|
|
398
|
+
* through every call would touch a dozen signatures for one string.
|
|
399
|
+
*/
|
|
400
|
+
let bindingLocale = 'tr-TR';
|
|
401
|
+
|
|
402
|
+
export const setBindingLocale = (locale?: string) => {
|
|
403
|
+
if (typeof locale === 'string' && locale.trim()) {
|
|
404
|
+
bindingLocale = locale.trim();
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
242
408
|
const formatDisplayPrice = (value: unknown): string => {
|
|
243
409
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
244
410
|
const hasDecimals = Math.abs(value % 1) > 0.00001;
|
|
245
|
-
return value.toLocaleString(
|
|
411
|
+
return value.toLocaleString(bindingLocale, {
|
|
246
412
|
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
247
413
|
maximumFractionDigits: 2
|
|
248
414
|
});
|
|
@@ -255,7 +421,7 @@ const formatDisplayPrice = (value: unknown): string => {
|
|
|
255
421
|
|
|
256
422
|
const parsed = parsePriceValue(raw);
|
|
257
423
|
const hasDecimals = Math.abs(parsed % 1) > 0.00001;
|
|
258
|
-
return parsed.toLocaleString(
|
|
424
|
+
return parsed.toLocaleString(bindingLocale, {
|
|
259
425
|
minimumFractionDigits: hasDecimals ? 2 : 0,
|
|
260
426
|
maximumFractionDigits: 2
|
|
261
427
|
});
|
|
@@ -403,20 +569,41 @@ export const resolveBlockBindings = ({
|
|
|
403
569
|
: sourceBlock.properties
|
|
404
570
|
};
|
|
405
571
|
|
|
406
|
-
const bindingPath =
|
|
572
|
+
const bindingPath = normalizeBindingExpression(
|
|
407
573
|
typeof nextBlock.properties?.dataBinding === 'string'
|
|
408
574
|
? nextBlock.properties.dataBinding.trim()
|
|
409
|
-
: ''
|
|
575
|
+
: ''
|
|
576
|
+
);
|
|
410
577
|
|
|
411
578
|
let resolvedBlock = nextBlock;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
579
|
+
// A row-scoped binding is left alone here exactly as before: the iterator
|
|
580
|
+
// already resolved it against the row, and re-resolving it against the
|
|
581
|
+
// section would blank the value back out.
|
|
582
|
+
if (bindingRoot && bindingPath && !isRowScopedBinding(bindingPath)) {
|
|
583
|
+
if (hasBindingToken(bindingPath)) {
|
|
584
|
+
const target = getItemBindingTarget(nextBlock);
|
|
585
|
+
const interpolated = interpolateBindingTemplate(
|
|
586
|
+
bindingPath,
|
|
587
|
+
(path) => getValueAtPath(bindingRoot, path),
|
|
588
|
+
(value) => normalizeScalarValue(value, target, nextBlock.type)
|
|
419
589
|
);
|
|
590
|
+
|
|
591
|
+
if (interpolated !== undefined) {
|
|
592
|
+
resolvedBlock = applyResolvedBinding(
|
|
593
|
+
nextBlock,
|
|
594
|
+
interpolated,
|
|
595
|
+
bindingPath
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
const resolvedValue = getValueAtPath(bindingRoot, bindingPath);
|
|
600
|
+
if (resolvedValue !== undefined) {
|
|
601
|
+
resolvedBlock = applyResolvedBinding(
|
|
602
|
+
nextBlock,
|
|
603
|
+
resolvedValue,
|
|
604
|
+
bindingPath
|
|
605
|
+
);
|
|
606
|
+
}
|
|
420
607
|
}
|
|
421
608
|
}
|
|
422
609
|
|
|
@@ -447,8 +634,20 @@ const getIteratorItems = ({
|
|
|
447
634
|
sectionDataSource,
|
|
448
635
|
dataPath,
|
|
449
636
|
isDesigner = false,
|
|
450
|
-
pageContext
|
|
451
|
-
|
|
637
|
+
pageContext,
|
|
638
|
+
scopedItems
|
|
639
|
+
}: BindingContext & {
|
|
640
|
+
dataPath?: string;
|
|
641
|
+
scopedItems?: Record<string, unknown>[] | null;
|
|
642
|
+
}): Record<string, unknown>[] => {
|
|
643
|
+
// A nested repeat draws the list the row it lives in handed it. Nothing else
|
|
644
|
+
// is redirected: a top-level iterator has no row, and a nested one pointing
|
|
645
|
+
// at a section path is handed nothing and keeps resolving exactly where it
|
|
646
|
+
// did before.
|
|
647
|
+
if (scopedItems) {
|
|
648
|
+
return scopedItems;
|
|
649
|
+
}
|
|
650
|
+
|
|
452
651
|
const bindingRoot = getSectionBindingRoot({
|
|
453
652
|
sectionDataSource,
|
|
454
653
|
pageContext,
|
|
@@ -488,10 +687,279 @@ const getIteratorItems = ({
|
|
|
488
687
|
return [];
|
|
489
688
|
};
|
|
490
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Per-row appearance.
|
|
692
|
+
*
|
|
693
|
+
* Every clone is pinned to the template's style identity, so the generated
|
|
694
|
+
* stylesheet writes one rule that serves every row. That is deliberate — it is
|
|
695
|
+
* what keeps a repeated card cheap and what makes a style edit apply to the
|
|
696
|
+
* whole list. But a badge whose colour comes from the product itself cannot be
|
|
697
|
+
* built that way: the value differs per row.
|
|
698
|
+
*
|
|
699
|
+
* A style value may therefore carry a binding (`{{item.badgeColor}}`). Only the
|
|
700
|
+
* properties that actually carry one are resolved per clone; every other
|
|
701
|
+
* property is left on the shared rule, untouched and byte-identical. A bound
|
|
702
|
+
* property that does not resolve is dropped rather than emitted, so it stays
|
|
703
|
+
* unset instead of becoming `background-color: undefined`.
|
|
704
|
+
*/
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* What a resolved style value is allowed to contain.
|
|
708
|
+
*
|
|
709
|
+
* This value comes from merchant data — an attribute a shop owner typed into
|
|
710
|
+
* the catalogue — and ends up inside a CSS declaration, so it is untrusted.
|
|
711
|
+
* Only what a colour, a length, a number or a simple function needs is kept:
|
|
712
|
+
* letters, digits, space and `# % , . / + - _ ( )`. Everything else is
|
|
713
|
+
* rejected, which is what stops:
|
|
714
|
+
* `;` `}` `{` closing this declaration/rule and opening another one,
|
|
715
|
+
* `<` `>` the `</style` sequence that ends the raw-text element,
|
|
716
|
+
* `"` `'` `\` string context and CSS escapes (`\3c` re-creates `<`),
|
|
717
|
+
* `:` `!` `@` a second property, an `!important` hijack, an at-rule,
|
|
718
|
+
* `*` the `/*` comment that would swallow the rest of the rule.
|
|
719
|
+
* Asset-fetching functions are rejected on top of that: their characters are
|
|
720
|
+
* all legal, but they would let catalogue data pull a remote request (a
|
|
721
|
+
* tracking pixel) into every card on the page.
|
|
722
|
+
*/
|
|
723
|
+
const SAFE_BOUND_CSS_VALUE = /^[A-Za-z0-9 #%,./+\-_()]+$/;
|
|
724
|
+
const FETCHING_CSS_FUNCTION =
|
|
725
|
+
/(?:url|image-set|image|cross-fade|element|expression|attr)\s*\(/i;
|
|
726
|
+
const MAX_BOUND_CSS_VALUE_LENGTH = 120;
|
|
727
|
+
|
|
728
|
+
const sanitizeBoundCssValue = (value: string): string | undefined => {
|
|
729
|
+
const trimmed = value.trim();
|
|
730
|
+
|
|
731
|
+
if (!trimmed || trimmed.length > MAX_BOUND_CSS_VALUE_LENGTH) {
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!SAFE_BOUND_CSS_VALUE.test(trimmed)) return undefined;
|
|
736
|
+
if (FETCHING_CSS_FUNCTION.test(trimmed)) return undefined;
|
|
737
|
+
|
|
738
|
+
return trimmed;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
/** Only text and finite numbers make a CSS value; anything else is "no value". */
|
|
742
|
+
const toCssText = (value: unknown): string | undefined => {
|
|
743
|
+
if (typeof value === 'string') return value;
|
|
744
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
745
|
+
return undefined;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const resolveBoundStyleValue = (
|
|
749
|
+
value: string,
|
|
750
|
+
itemData: unknown,
|
|
751
|
+
styleKey: string
|
|
752
|
+
): string | undefined => {
|
|
753
|
+
let hasUnresolvedToken = false;
|
|
754
|
+
|
|
755
|
+
const text = value.replace(
|
|
756
|
+
BINDING_TOKEN_PATTERN,
|
|
757
|
+
(_match, rawPath: string) => {
|
|
758
|
+
const path = String(rawPath).trim();
|
|
759
|
+
// Only `item.*` addresses the row, the same rule a text binding follows.
|
|
760
|
+
// Stripping the prefix and reading the row regardless would let
|
|
761
|
+
// `{{brandColor}}` — a site-level spelling — be answered by whatever
|
|
762
|
+
// catalogue field happens to share the name, so a colour meant to be
|
|
763
|
+
// the same everywhere would change per product.
|
|
764
|
+
const resolved = isRowScopedToken(path)
|
|
765
|
+
? getValueAtPath(itemData, stripItemPrefix(path))
|
|
766
|
+
: undefined;
|
|
767
|
+
const asText = toCssText(resolved);
|
|
768
|
+
|
|
769
|
+
if (asText === undefined) {
|
|
770
|
+
hasUnresolvedToken = true;
|
|
771
|
+
return '';
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return asText;
|
|
775
|
+
}
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
// Half a CSS value ("rgb(255, , 0)") is worse than none: it can change what
|
|
779
|
+
// the shorthand around it means. All or nothing.
|
|
780
|
+
if (hasUnresolvedToken) return undefined;
|
|
781
|
+
|
|
782
|
+
const safe = sanitizeBoundCssValue(text);
|
|
783
|
+
if (safe === undefined) {
|
|
784
|
+
devWarn(
|
|
785
|
+
'iterator',
|
|
786
|
+
`bound style "${styleKey}" resolved to a value CSS cannot safely carry; leaving the property unset`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return safe;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Whether this row is the one that answers a style value: it carries a token
|
|
795
|
+
* and at least one of its tokens addresses the row. A value spelled entirely
|
|
796
|
+
* in site-level tokens is left exactly as authored — not this repeat's to
|
|
797
|
+
* resolve, and the CSS layer already leaves an unresolved token unset.
|
|
798
|
+
*/
|
|
799
|
+
const isRowBoundStyleValue = (value: unknown): value is string =>
|
|
800
|
+
hasBindingToken(value) && isRowScopedBinding(value as string);
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Resolves the bound values in one block's styles against a row. Returns the
|
|
804
|
+
* SAME object when nothing is bound, so a composition without style bindings
|
|
805
|
+
* is not merely equal but identical — no new object, no changed CSS.
|
|
806
|
+
*/
|
|
807
|
+
const resolveStyleBindingsForRow = (
|
|
808
|
+
styles: unknown,
|
|
809
|
+
itemData: unknown
|
|
810
|
+
): unknown => {
|
|
811
|
+
if (!isRecord(styles)) return styles;
|
|
812
|
+
|
|
813
|
+
let changed = false;
|
|
814
|
+
const nextStyles: Record<string, unknown> = {};
|
|
815
|
+
|
|
816
|
+
Object.entries(styles).forEach(([key, value]) => {
|
|
817
|
+
if (typeof value === 'string') {
|
|
818
|
+
if (!isRowBoundStyleValue(value)) {
|
|
819
|
+
nextStyles[key] = value;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
changed = true;
|
|
824
|
+
const resolved = resolveBoundStyleValue(value, itemData, key);
|
|
825
|
+
if (resolved !== undefined) {
|
|
826
|
+
nextStyles[key] = resolved;
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Responsive shape: one value per breakpoint, each bindable on its own.
|
|
832
|
+
if (isRecord(value)) {
|
|
833
|
+
const isBoundHere = Object.values(value).some(isRowBoundStyleValue);
|
|
834
|
+
|
|
835
|
+
if (!isBoundHere) {
|
|
836
|
+
nextStyles[key] = value;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
changed = true;
|
|
841
|
+
let hasUnresolvedBreakpoint = false;
|
|
842
|
+
const nextValue: Record<string, unknown> = {};
|
|
843
|
+
|
|
844
|
+
Object.entries(value).forEach(([breakpoint, breakpointValue]) => {
|
|
845
|
+
if (!hasBindingToken(breakpointValue)) {
|
|
846
|
+
nextValue[breakpoint] = breakpointValue;
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const resolved = isRowBoundStyleValue(breakpointValue)
|
|
851
|
+
? resolveBoundStyleValue(breakpointValue, itemData, key)
|
|
852
|
+
: undefined;
|
|
853
|
+
|
|
854
|
+
if (resolved === undefined) {
|
|
855
|
+
hasUnresolvedBreakpoint = true;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
nextValue[breakpoint] = resolved;
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// A breakpoint the row does not answer is unset AT EVERY breakpoint, so
|
|
863
|
+
// the property falls back to the shared rule. Keeping the breakpoints
|
|
864
|
+
// that did resolve would be worse than useless: a missing breakpoint is
|
|
865
|
+
// served the desktop value, so a mobile binding that resolved to nothing
|
|
866
|
+
// would be painted with the desktop row's colour rather than left alone.
|
|
867
|
+
if (!hasUnresolvedBreakpoint) {
|
|
868
|
+
nextStyles[key] = nextValue;
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
nextStyles[key] = value;
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
return changed ? nextStyles : styles;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The list a nested iterator draws. Written onto the nested iterator's
|
|
881
|
+
* properties while its parent row is being built, and read back when that
|
|
882
|
+
* iterator expands — so the tree still has exactly one expansion point and no
|
|
883
|
+
* caller has to learn a new argument.
|
|
884
|
+
*
|
|
885
|
+
* The LIST, not the row it came from: whatever is written here rides along in
|
|
886
|
+
* the rendered tree until that iterator expands and is serialized to the
|
|
887
|
+
* client with it, so handing over the whole row would ship every field the API
|
|
888
|
+
* returned — cost prices, stock notes, internal codes — for a strip that draws
|
|
889
|
+
* three colours. A path that resolves to no list is not written at all.
|
|
890
|
+
*/
|
|
891
|
+
const SCOPED_ITEMS_PROPERTY = 'iteratorScopedItems';
|
|
892
|
+
|
|
893
|
+
const readIteratorDataPath = (block: Block): string => {
|
|
894
|
+
const direct =
|
|
895
|
+
typeof block.iteratorDataPath === 'string' ? block.iteratorDataPath : '';
|
|
896
|
+
if (direct.trim()) return direct.trim();
|
|
897
|
+
|
|
898
|
+
const fromProperties =
|
|
899
|
+
typeof block.properties?.iteratorDataPath === 'string'
|
|
900
|
+
? block.properties.iteratorDataPath
|
|
901
|
+
: '';
|
|
902
|
+
|
|
903
|
+
return fromProperties.trim();
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const readScopedItems = (
|
|
907
|
+
properties: unknown
|
|
908
|
+
): Record<string, unknown>[] | null => {
|
|
909
|
+
if (!isRecord(properties)) return null;
|
|
910
|
+
|
|
911
|
+
const scopedItems = properties[SCOPED_ITEMS_PROPERTY];
|
|
912
|
+
return Array.isArray(scopedItems)
|
|
913
|
+
? (scopedItems as Record<string, unknown>[])
|
|
914
|
+
: null;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
/** Returns the same object when there is no handed-over list to forget. */
|
|
918
|
+
const forgetScopedItems = (properties: unknown): unknown => {
|
|
919
|
+
if (!isRecord(properties) || !(SCOPED_ITEMS_PROPERTY in properties)) {
|
|
920
|
+
return properties;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const { [SCOPED_ITEMS_PROPERTY]: _consumedItems, ...rest } = properties;
|
|
924
|
+
return rest;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* A row with nothing behind it: the canvas draws these so a designer has
|
|
929
|
+
* something to arrange before any data exists.
|
|
930
|
+
*
|
|
931
|
+
* The test is "an object with no keys", not "no keys", because an entry of a
|
|
932
|
+
* list of plain values has none to count — `Object.keys(38)` is empty, and
|
|
933
|
+
* reading a real size as a blank canvas row prints the authored fallback.
|
|
934
|
+
*/
|
|
935
|
+
const isPlaceholderRow = (itemData: unknown): boolean => {
|
|
936
|
+
if (itemData === null || itemData === undefined) return true;
|
|
937
|
+
if (typeof itemData !== 'object') return false;
|
|
938
|
+
return Object.keys(itemData).length === 0;
|
|
939
|
+
};
|
|
940
|
+
|
|
491
941
|
const replaceBlockValues = (
|
|
492
942
|
blockToReplace: Block,
|
|
493
|
-
itemData:
|
|
494
|
-
itemIndex: number
|
|
943
|
+
itemData: unknown,
|
|
944
|
+
itemIndex: number,
|
|
945
|
+
/**
|
|
946
|
+
* True once the walk has stepped inside a repeat of its own. Everything
|
|
947
|
+
* below that point is drawn once per entry of THAT repeat, so this row is
|
|
948
|
+
* not the one its appearance is resolved against.
|
|
949
|
+
*
|
|
950
|
+
* Nothing about this row is read below that point — not its styles, not its
|
|
951
|
+
* text, not its visibility rule.
|
|
952
|
+
*
|
|
953
|
+
* A style token is consumed the moment it resolves, so the outer row's
|
|
954
|
+
* reading of `{{item.hex}}` would be final. A text binding looks safer,
|
|
955
|
+
* because the block keeps its `dataBinding` and the owning repeat resolves it
|
|
956
|
+
* again — but only when the inner entry ANSWERS the path. When it does not,
|
|
957
|
+
* the outer row's value has nothing to overwrite it and stays: a swatch bound
|
|
958
|
+
* to `item.internal_note` prints the product's warehouse note in every
|
|
959
|
+
* swatch. A visibility rule is worse still: `hidden` is only ever set, never
|
|
960
|
+
* taken back, so one outer row decides every entry at once.
|
|
961
|
+
*/
|
|
962
|
+
insideNestedRepeat = false
|
|
495
963
|
): Block => {
|
|
496
964
|
const nextBlock: Block = {
|
|
497
965
|
...blockToReplace,
|
|
@@ -500,31 +968,144 @@ const replaceBlockValues = (
|
|
|
500
968
|
: blockToReplace.properties
|
|
501
969
|
};
|
|
502
970
|
|
|
503
|
-
const isPlaceholderMode =
|
|
504
|
-
|
|
971
|
+
const isPlaceholderMode = isPlaceholderRow(itemData);
|
|
972
|
+
// The children of a repeat belong to that repeat, not to this row. Read off
|
|
973
|
+
// this block rather than the parent so the rule holds at any depth: a repeat
|
|
974
|
+
// inside a repeat inside a repeat resolves its own template and no other.
|
|
975
|
+
const childInsideNestedRepeat =
|
|
976
|
+
insideNestedRepeat || nextBlock.isIterator === true;
|
|
977
|
+
|
|
978
|
+
// A badge that exists on three products out of forty, a campaign label on
|
|
979
|
+
// two: the row is the same for every item, so the element has to be able to
|
|
980
|
+
// remove itself. Written onto `hidden`, which the storefront already
|
|
981
|
+
// respects.
|
|
982
|
+
//
|
|
983
|
+
// Never in placeholder mode: with no data every field reads empty, so the
|
|
984
|
+
// rule would hide the element on the canvas and leave the designer with
|
|
985
|
+
// nothing to arrange.
|
|
986
|
+
if (!isPlaceholderMode) {
|
|
987
|
+
// All of this is this row's business only while the walk is still inside
|
|
988
|
+
// the repeat that owns this row.
|
|
989
|
+
if (!insideNestedRepeat) {
|
|
990
|
+
const visibility = readRowVisibility(nextBlock.properties);
|
|
991
|
+
if (visibility && !isVisibleForItem(visibility, itemData)) {
|
|
992
|
+
nextBlock.hidden = true;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Only the bound properties are replaced; the rest of the block's styles
|
|
996
|
+
// stay on the shared rule, so a card with one bound colour keeps the
|
|
997
|
+
// whole stylesheet it had.
|
|
998
|
+
const resolvedStyles = resolveStyleBindingsForRow(
|
|
999
|
+
nextBlock.styles,
|
|
1000
|
+
itemData
|
|
1001
|
+
);
|
|
1002
|
+
if (resolvedStyles !== nextBlock.styles) {
|
|
1003
|
+
nextBlock.styles = resolvedStyles;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// A repeat inside a row means "the row's list": a colour-swatch strip
|
|
1007
|
+
// iterates `item.colors` of THIS product. Resolved against the section's
|
|
1008
|
+
// data source that path is nothing, which is why such a strip renders a
|
|
1009
|
+
// single placeholder today. The list is handed to the nested iterator
|
|
1010
|
+
// here and read back when it expands.
|
|
1011
|
+
const nestedPath =
|
|
1012
|
+
nextBlock.isIterator === true
|
|
1013
|
+
? normalizeBindingExpression(readIteratorDataPath(nextBlock))
|
|
1014
|
+
: '';
|
|
1015
|
+
if (nestedPath && isRowScopedBinding(nestedPath)) {
|
|
1016
|
+
const nestedItems = getValueAtPath(
|
|
1017
|
+
itemData,
|
|
1018
|
+
stripItemPrefix(nestedPath)
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
// A defined-but-non-array answer means the path names the wrong shape
|
|
1022
|
+
// (`item.colour`, a string, where `item.colours` was meant). Because
|
|
1023
|
+
// the handover makes such a repeat draw nothing at all, staying quiet
|
|
1024
|
+
// would leave the author with an empty strip and no clue why — while
|
|
1025
|
+
// the section-scoped spelling of the same typo has always been
|
|
1026
|
+
// reported. An absent field stays silent: it is ambiguous with data
|
|
1027
|
+
// that has not landed yet.
|
|
1028
|
+
if (nestedItems !== undefined && !Array.isArray(nestedItems)) {
|
|
1029
|
+
devWarn(
|
|
1030
|
+
'iterator',
|
|
1031
|
+
`iteratorDataPath "${nestedPath}" resolved to a non-array value on the row; rendering no items`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Written even when the row answers with nothing. An empty handover is
|
|
1036
|
+
// the row saying "this product has no colours", which is a different
|
|
1037
|
+
// statement from a repeat that was never given a list and still owes
|
|
1038
|
+
// the canvas a placeholder row — and the difference is what keeps an
|
|
1039
|
+
// empty swatch from appearing next to products that do have colours.
|
|
1040
|
+
nextBlock.properties = {
|
|
1041
|
+
...(nextBlock.properties || {}),
|
|
1042
|
+
[SCOPED_ITEMS_PROPERTY]: Array.isArray(nestedItems) ? nestedItems : []
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const bindingPath = normalizeBindingExpression(
|
|
505
1049
|
typeof nextBlock.properties?.dataBinding === 'string'
|
|
506
1050
|
? nextBlock.properties.dataBinding.trim()
|
|
507
|
-
: ''
|
|
1051
|
+
: ''
|
|
1052
|
+
);
|
|
508
1053
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
1054
|
+
// `!insideNestedRepeat` for the same reason the styles carry it: the repeat
|
|
1055
|
+
// that owns a binding is the one that reads it. Leaving the binding
|
|
1056
|
+
// unresolved here costs nothing — the owning repeat resolves it against its
|
|
1057
|
+
// own entry — while resolving it writes a value the inner entry may have no
|
|
1058
|
+
// way to replace.
|
|
1059
|
+
if (bindingPath && !isPlaceholderMode && !insideNestedRepeat) {
|
|
1060
|
+
if (hasBindingToken(bindingPath)) {
|
|
1061
|
+
const target = getItemBindingTarget(nextBlock);
|
|
1062
|
+
const interpolated = interpolateBindingTemplate(
|
|
1063
|
+
bindingPath,
|
|
1064
|
+
(path) => getValueAtPath(itemData, stripItemPrefix(path)),
|
|
1065
|
+
(value) => normalizeScalarValue(value, target, nextBlock.type)
|
|
1066
|
+
);
|
|
512
1067
|
|
|
513
|
-
|
|
514
|
-
if (
|
|
515
|
-
nextBlock.type === 'text' &&
|
|
516
|
-
bindingPath.startsWith('item.') &&
|
|
517
|
-
isPriceBinding(bindingPath)
|
|
518
|
-
) {
|
|
519
|
-
nextBlock.value = buildProductPriceHtml(itemData);
|
|
520
|
-
} else {
|
|
1068
|
+
if (interpolated !== undefined) {
|
|
521
1069
|
return {
|
|
522
|
-
...applyResolvedBinding(nextBlock,
|
|
1070
|
+
...applyResolvedBinding(nextBlock, interpolated, bindingPath),
|
|
523
1071
|
blocks: nextBlock.blocks?.map((childBlock) =>
|
|
524
|
-
replaceBlockValues(
|
|
1072
|
+
replaceBlockValues(
|
|
1073
|
+
childBlock,
|
|
1074
|
+
itemData,
|
|
1075
|
+
itemIndex,
|
|
1076
|
+
childInsideNestedRepeat
|
|
1077
|
+
)
|
|
525
1078
|
)
|
|
526
1079
|
};
|
|
527
1080
|
}
|
|
1081
|
+
} else {
|
|
1082
|
+
const itemBindingPath = stripItemPrefix(bindingPath);
|
|
1083
|
+
const resolvedValue = getValueAtPath(itemData, itemBindingPath);
|
|
1084
|
+
|
|
1085
|
+
if (resolvedValue !== undefined) {
|
|
1086
|
+
if (
|
|
1087
|
+
nextBlock.type === 'text' &&
|
|
1088
|
+
bindingPath.startsWith('item.') &&
|
|
1089
|
+
isPriceBinding(bindingPath) &&
|
|
1090
|
+
// A price is read off the whole row (list price, currency, discount),
|
|
1091
|
+
// so a row that is a plain value cannot answer one.
|
|
1092
|
+
isRecord(itemData)
|
|
1093
|
+
) {
|
|
1094
|
+
nextBlock.value = buildProductPriceHtml(itemData);
|
|
1095
|
+
} else {
|
|
1096
|
+
return {
|
|
1097
|
+
...applyResolvedBinding(nextBlock, resolvedValue, bindingPath),
|
|
1098
|
+
blocks: nextBlock.blocks?.map((childBlock) =>
|
|
1099
|
+
replaceBlockValues(
|
|
1100
|
+
childBlock,
|
|
1101
|
+
itemData,
|
|
1102
|
+
itemIndex,
|
|
1103
|
+
childInsideNestedRepeat
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
528
1109
|
}
|
|
529
1110
|
} else if (isPlaceholderMode && nextBlock.type === 'image') {
|
|
530
1111
|
const placeholderImages = [
|
|
@@ -547,7 +1128,12 @@ const replaceBlockValues = (
|
|
|
547
1128
|
|
|
548
1129
|
if (nextBlock.blocks && nextBlock.blocks.length > 0) {
|
|
549
1130
|
nextBlock.blocks = nextBlock.blocks.map((childBlock) =>
|
|
550
|
-
replaceBlockValues(
|
|
1131
|
+
replaceBlockValues(
|
|
1132
|
+
childBlock,
|
|
1133
|
+
itemData,
|
|
1134
|
+
itemIndex,
|
|
1135
|
+
childInsideNestedRepeat
|
|
1136
|
+
)
|
|
551
1137
|
);
|
|
552
1138
|
}
|
|
553
1139
|
|
|
@@ -560,7 +1146,8 @@ export const buildIteratorBlock = ({
|
|
|
560
1146
|
pageContext,
|
|
561
1147
|
isDesigner = false,
|
|
562
1148
|
forceIteratorCount,
|
|
563
|
-
forceIteratorOffset
|
|
1149
|
+
forceIteratorOffset,
|
|
1150
|
+
items: providedItems
|
|
564
1151
|
}: IteratorOptions): Block => {
|
|
565
1152
|
if (
|
|
566
1153
|
!iteratorBlock.isIterator ||
|
|
@@ -574,43 +1161,59 @@ export const buildIteratorBlock = ({
|
|
|
574
1161
|
const dataPath =
|
|
575
1162
|
iteratorBlock.iteratorDataPath ||
|
|
576
1163
|
iteratorBlock.properties?.iteratorDataPath;
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const iteratorOffset =
|
|
594
|
-
forceIteratorOffset !== undefined
|
|
595
|
-
? Math.max(0, Math.floor(forceIteratorOffset))
|
|
596
|
-
: parsePositiveNumber(iteratorBlock.properties?.iteratorOffset, 0);
|
|
1164
|
+
// Only a repeat that resolves its own list can have been handed one; a
|
|
1165
|
+
// caller that brought its items (a functional block's basket rows, orders,
|
|
1166
|
+
// stores) speaks for itself.
|
|
1167
|
+
const scopedItems =
|
|
1168
|
+
providedItems === undefined
|
|
1169
|
+
? readScopedItems(iteratorBlock.properties)
|
|
1170
|
+
: null;
|
|
1171
|
+
const items =
|
|
1172
|
+
providedItems ??
|
|
1173
|
+
getIteratorItems({
|
|
1174
|
+
sectionDataSource,
|
|
1175
|
+
pageContext,
|
|
1176
|
+
dataPath,
|
|
1177
|
+
isDesigner,
|
|
1178
|
+
scopedItems
|
|
1179
|
+
});
|
|
597
1180
|
|
|
598
|
-
const displayItems =
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
: [];
|
|
1181
|
+
const { count: iteratorCount, items: displayItems } = sliceIteratorItems(
|
|
1182
|
+
iteratorBlock,
|
|
1183
|
+
items,
|
|
1184
|
+
{ forceIteratorCount, forceIteratorOffset }
|
|
1185
|
+
);
|
|
604
1186
|
|
|
1187
|
+
// The count floor draws a row where there is no data, which is what the
|
|
1188
|
+
// canvas and a page whose fetch has not landed both need. A repeat handed
|
|
1189
|
+
// its row's list is neither: the row has already answered, and an empty
|
|
1190
|
+
// answer means this product has no colours — one empty swatch next to
|
|
1191
|
+
// products that do have colours reads as a colour the shopper can pick.
|
|
605
1192
|
const actualCount =
|
|
606
|
-
displayItems.length > 0
|
|
1193
|
+
displayItems.length > 0
|
|
1194
|
+
? displayItems.length
|
|
1195
|
+
: scopedItems
|
|
1196
|
+
? 0
|
|
1197
|
+
: Math.max(iteratorCount, 1);
|
|
607
1198
|
|
|
608
1199
|
const clonedBlocks = Array.from({ length: actualCount }, (_, index) => {
|
|
609
|
-
|
|
1200
|
+
// `??`, not `||`: an entry of a list of plain values can be falsy (0, '')
|
|
1201
|
+
// and is still the row, while a missing entry is the placeholder padding
|
|
1202
|
+
// the count floor asked for.
|
|
1203
|
+
const item = displayItems[index] ?? {};
|
|
610
1204
|
|
|
611
1205
|
const templateCopy: Block = {
|
|
612
1206
|
...template,
|
|
613
1207
|
id: `${template.id}-clone-${index}`,
|
|
1208
|
+
// The clone needs its own id (React keys, selection), but the generated
|
|
1209
|
+
// stylesheet only knows the template's id and the DOM writes
|
|
1210
|
+
// `styleSourceId || id`. Without this, every style authored on a
|
|
1211
|
+
// repeated item is emitted and then matches nothing. It stays pinned
|
|
1212
|
+
// even when the row varies its appearance: giving a clone its own style
|
|
1213
|
+
// identity would drop the shared rule for EVERY property, so a bound
|
|
1214
|
+
// property is resolved onto the clone's own style set instead and only
|
|
1215
|
+
// that property stops being shared.
|
|
1216
|
+
styleSourceId: template.styleSourceId || template.id,
|
|
614
1217
|
properties: template.properties
|
|
615
1218
|
? { ...template.properties }
|
|
616
1219
|
: template.properties,
|
|
@@ -622,8 +1225,18 @@ export const buildIteratorBlock = ({
|
|
|
622
1225
|
return replaceBlockValues(templateCopy, item, index);
|
|
623
1226
|
});
|
|
624
1227
|
|
|
1228
|
+
const nextProperties = forgetScopedItems(iteratorBlock.properties);
|
|
1229
|
+
|
|
625
1230
|
return {
|
|
626
1231
|
...iteratorBlock,
|
|
1232
|
+
// The list a nested iterator was handed has now been consumed, so it is
|
|
1233
|
+
// dropped again rather than riding along in the rendered tree. Written
|
|
1234
|
+
// only when there was something to drop: writing it unconditionally gives
|
|
1235
|
+
// an iterator that never had properties a `properties: undefined` key, and
|
|
1236
|
+
// an object that gained a key is no longer the object it was.
|
|
1237
|
+
...(nextProperties !== iteratorBlock.properties
|
|
1238
|
+
? { properties: nextProperties }
|
|
1239
|
+
: {}),
|
|
627
1240
|
blocks: clonedBlocks
|
|
628
1241
|
};
|
|
629
1242
|
};
|