@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.0
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 +15 -31
- package/package.json +4 -4
- package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
- package/src/__tests__/custom-css.test.ts +181 -0
- package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
- package/src/__tests__/facet-filters-block.test.tsx +707 -0
- package/src/__tests__/item-template.test.tsx +184 -0
- package/src/__tests__/listing-controls.test.tsx +961 -0
- package/src/__tests__/part-style-scope.test.tsx +229 -0
- package/src/__tests__/part-styles.test.ts +311 -0
- package/src/__tests__/registry-parity.test.ts +42 -0
- package/src/__tests__/row-visibility.test.ts +672 -0
- package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
- package/src/__tests__/theme-block-html-id.test.tsx +167 -0
- package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
- package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
- package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
- package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
- package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
- package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
- package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
- package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
- package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
- package/src/blocks/__tests__/share-block.test.tsx +392 -0
- package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
- package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
- package/src/blocks/__tests__/variant-selector-golden.json +20 -0
- package/src/blocks/account-menu-block.tsx +264 -0
- package/src/blocks/add-to-cart-block.tsx +417 -0
- package/src/blocks/address-list-block.tsx +348 -0
- package/src/blocks/applied-filters-block.tsx +288 -0
- package/src/blocks/auth-form-block.tsx +295 -0
- package/src/blocks/basket-items-block.tsx +457 -0
- package/src/blocks/basket-summary-block.tsx +404 -0
- package/src/blocks/block-renderer-registry.tsx +94 -0
- package/src/blocks/change-password-form-block.tsx +240 -0
- package/src/blocks/checkout-steps-block.tsx +288 -0
- package/src/blocks/checkout-summary-block.tsx +449 -0
- package/src/blocks/countdown-block.tsx +182 -0
- package/src/blocks/coupon-list-block.tsx +292 -0
- package/src/blocks/facet-filters-block.tsx +1686 -0
- package/src/blocks/faq-accordion-block.tsx +358 -0
- package/src/blocks/favorite-button-block.tsx +252 -0
- package/src/blocks/favourites-grid-block.tsx +510 -0
- package/src/blocks/forgot-password-block.tsx +155 -0
- package/src/blocks/item-template-utils.ts +131 -0
- package/src/blocks/item-template.tsx +164 -0
- package/src/blocks/listing-breadcrumb-block.tsx +175 -0
- package/src/blocks/listing-context.ts +850 -0
- package/src/blocks/listing-pagination-block.tsx +923 -0
- package/src/blocks/listing-result-count-block.tsx +126 -0
- package/src/blocks/listing-title-block.tsx +115 -0
- package/src/blocks/oauth-buttons-block.tsx +205 -0
- package/src/blocks/order-detail-block.tsx +474 -0
- package/src/blocks/order-list-block.tsx +274 -0
- package/src/blocks/order-success-block.tsx +641 -0
- package/src/blocks/order-tracking-block.tsx +533 -0
- package/src/blocks/product-attributes-block.tsx +307 -0
- package/src/blocks/product-block-utils.tsx +639 -0
- package/src/blocks/product-breadcrumb-block.tsx +215 -0
- package/src/blocks/product-description-block.tsx +169 -0
- package/src/blocks/product-gallery-block.tsx +1523 -0
- package/src/blocks/product-grid-block.tsx +966 -0
- package/src/blocks/product-price-block.tsx +373 -0
- package/src/blocks/product-recommendations-block.tsx +1433 -0
- package/src/blocks/product-title-block.tsx +129 -0
- package/src/blocks/profile-form-block.tsx +297 -0
- package/src/blocks/recently-viewed-store.ts +201 -0
- package/src/blocks/register-form-block.tsx +362 -0
- package/src/blocks/share-block.tsx +667 -0
- package/src/blocks/size-guide-block.tsx +313 -0
- package/src/blocks/sort-select-block.tsx +636 -0
- package/src/blocks/stock-status-block.tsx +168 -0
- package/src/blocks/store-locator-block.tsx +1184 -0
- package/src/blocks/variant-selector-block.tsx +1110 -0
- package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Block } from '../theme-block';
|
|
2
|
+
import { getResponsiveValue } from '../utils';
|
|
3
|
+
import type { ThemeProduct } from './product-block-utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The marker and lookup for custom row layouts, kept free of React so a
|
|
7
|
+
* renderer (or a test) can ask "did the designer author their own row?"
|
|
8
|
+
* without pulling the whole block tree in.
|
|
9
|
+
*/
|
|
10
|
+
export const ITEM_TEMPLATE_ROLE = 'item-template';
|
|
11
|
+
|
|
12
|
+
const readRole = (block: Block, currentBreakpoint = 'desktop'): string =>
|
|
13
|
+
String(
|
|
14
|
+
getResponsiveValue(block.properties?.role, currentBreakpoint, '') ?? ''
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/** The child a designer marked as the row layout, if any. */
|
|
18
|
+
export const findItemTemplate = (
|
|
19
|
+
block: Block,
|
|
20
|
+
currentBreakpoint = 'desktop'
|
|
21
|
+
): Block | null =>
|
|
22
|
+
(block.blocks || []).find(
|
|
23
|
+
child => readRole(child, currentBreakpoint) === ITEM_TEMPLATE_ROLE
|
|
24
|
+
) ?? null;
|
|
25
|
+
|
|
26
|
+
export const hasItemTemplate = (
|
|
27
|
+
block: Block,
|
|
28
|
+
currentBreakpoint = 'desktop'
|
|
29
|
+
): boolean => findItemTemplate(block, currentBreakpoint) !== null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The lists whose rows ARE products.
|
|
33
|
+
*
|
|
34
|
+
* Which rows carry a product has to be declared per list, never sniffed off
|
|
35
|
+
* the row: an address row and a coupon row both carry `pk` and a `name`, and a
|
|
36
|
+
* heuristic that accepted them would point a wishlist toggle at an address id.
|
|
37
|
+
* A list not named here keeps behaving exactly as it does today — the blocks
|
|
38
|
+
* inside its rows go on reading the page's product.
|
|
39
|
+
*/
|
|
40
|
+
const PRODUCT_ITEM_LIST_TYPES = new Set([
|
|
41
|
+
'product-grid',
|
|
42
|
+
'favourites-grid',
|
|
43
|
+
'product-recommendations'
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export const publishesProductItems = (blockType?: string): boolean =>
|
|
47
|
+
typeof blockType === 'string' && PRODUCT_ITEM_LIST_TYPES.has(blockType);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where a row keeps the id of the product it shows. `productPk` is read first
|
|
51
|
+
* because a favourites row carries BOTH: `pk` is the favourite entry's own id
|
|
52
|
+
* (what removing it needs) and `productPk` is the product behind it. Reading
|
|
53
|
+
* `pk` there would favourite a wishlist entry id.
|
|
54
|
+
*/
|
|
55
|
+
const PRODUCT_PK_FIELDS = ['productPk', 'pk'];
|
|
56
|
+
|
|
57
|
+
const readProductPk = (
|
|
58
|
+
item: Record<string, unknown>
|
|
59
|
+
): number | undefined => {
|
|
60
|
+
for (const field of PRODUCT_PK_FIELDS) {
|
|
61
|
+
const value = item[field];
|
|
62
|
+
if (value === undefined || value === null || value === '') continue;
|
|
63
|
+
|
|
64
|
+
const numeric = Number(value);
|
|
65
|
+
// Commerce ids are positive integers; a row that key-fell-back to
|
|
66
|
+
// "product-3" must not resolve to a pk at all.
|
|
67
|
+
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A row seen as the ambient product, or null when this list publishes no
|
|
75
|
+
* products or the row is one of the empty placeholders the designer draws
|
|
76
|
+
* against. `pk` is rewritten to the PRODUCT's id so a mutation started from
|
|
77
|
+
* inside the row (wishlist, add to basket) addresses the right thing; the
|
|
78
|
+
* row itself is left untouched, so `item.*` bindings still resolve as before.
|
|
79
|
+
*/
|
|
80
|
+
export const resolveItemProduct = (
|
|
81
|
+
blockType: string | undefined,
|
|
82
|
+
item: Record<string, unknown> | null | undefined
|
|
83
|
+
): ThemeProduct | null => {
|
|
84
|
+
if (!publishesProductItems(blockType)) return null;
|
|
85
|
+
if (!item || Object.keys(item).length === 0) return null;
|
|
86
|
+
|
|
87
|
+
const pk = readProductPk(item);
|
|
88
|
+
|
|
89
|
+
// No id, no scope: a wishlist toggle or an add-to-basket started from this
|
|
90
|
+
// row would have nothing to address, and claiming the scope would only stop
|
|
91
|
+
// the block reading the page product it reads today.
|
|
92
|
+
if (pk === undefined) return null;
|
|
93
|
+
|
|
94
|
+
return { ...item, pk } as ThemeProduct;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a product row shows in the editor when the page has no live data.
|
|
99
|
+
*
|
|
100
|
+
* Without it a designer laying out a card sees "Add to cart (no product)" and
|
|
101
|
+
* "Favourite button (no product)" boxes where the controls belong — different
|
|
102
|
+
* size, different weight — so the card cannot be judged as a card. The sample
|
|
103
|
+
* is designer-only and never reaches a storefront; mutations stay blocked by
|
|
104
|
+
* the `isDesigner` guard each block already has, and the pk is deliberately
|
|
105
|
+
* not a real one so nothing can be addressed with it.
|
|
106
|
+
*/
|
|
107
|
+
export const DESIGNER_SAMPLE_PRODUCT: ThemeProduct = {
|
|
108
|
+
pk: -1,
|
|
109
|
+
name: 'Sample product',
|
|
110
|
+
title: 'Sample product',
|
|
111
|
+
price: 499,
|
|
112
|
+
retailPrice: 799,
|
|
113
|
+
in_stock: true,
|
|
114
|
+
stock: 12,
|
|
115
|
+
// Enough shape for the optional pieces to draw themselves: a size row on a
|
|
116
|
+
// quick-add card, and the price-history caption. Without them a designer
|
|
117
|
+
// switches the feature on and the canvas answers with a hint instead of the
|
|
118
|
+
// element they are trying to place.
|
|
119
|
+
price_extra_field: { previous_price: 549 },
|
|
120
|
+
variants: [
|
|
121
|
+
{
|
|
122
|
+
attribute_key: 'size',
|
|
123
|
+
attribute_name: 'Size',
|
|
124
|
+
options: [
|
|
125
|
+
{ label: 'S', value: 's', in_stock: true, is_selectable: true },
|
|
126
|
+
{ label: 'M', value: 'm', in_stock: true, is_selectable: true, is_selected: true },
|
|
127
|
+
{ label: 'L', value: 'l', in_stock: false, is_selectable: false }
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
} as ThemeProduct;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import ThemeBlock, { Block } from '../theme-block';
|
|
5
|
+
import {
|
|
6
|
+
buildIteratorBlock,
|
|
7
|
+
sliceIteratorItems
|
|
8
|
+
} from '../utils/iterator-utils';
|
|
9
|
+
import {
|
|
10
|
+
DESIGNER_SAMPLE_PRODUCT,
|
|
11
|
+
findItemTemplate,
|
|
12
|
+
ITEM_TEMPLATE_ROLE,
|
|
13
|
+
publishesProductItems,
|
|
14
|
+
resolveItemProduct
|
|
15
|
+
} from './item-template-utils';
|
|
16
|
+
import { ThemeItemScopeProvider } from './product-block-utils';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
findItemTemplate,
|
|
20
|
+
hasItemTemplate,
|
|
21
|
+
ITEM_TEMPLATE_ROLE,
|
|
22
|
+
publishesProductItems,
|
|
23
|
+
resolveItemProduct
|
|
24
|
+
} from './item-template-utils';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom item layouts for functional lists.
|
|
28
|
+
*
|
|
29
|
+
* Every functional block that shows a list — the product grid, basket rows,
|
|
30
|
+
* order rows, address cards, store results — used to draw its row in TSX.
|
|
31
|
+
* A brand could change spacing around it and nothing inside it: no reordering
|
|
32
|
+
* the price above the title, no extra field, no removing an element. The
|
|
33
|
+
* editor's repeat mechanism existed but was offered for group blocks only.
|
|
34
|
+
*
|
|
35
|
+
* A list block can now carry one child marked as its item template. When it is
|
|
36
|
+
* there, the block hands its own data to the same iterator machinery the group
|
|
37
|
+
* block uses, and the designer's layout is repeated once per item with
|
|
38
|
+
* `item.*` bindings resolved. When it is not, the block draws its built-in row
|
|
39
|
+
* exactly as before — which is what keeps every published composition working.
|
|
40
|
+
*
|
|
41
|
+
* A PRODUCT row is also a SCOPE. Blocks like the favourite toggle or
|
|
42
|
+
* add-to-cart used to read the product off the page context, which on a
|
|
43
|
+
* listing is nothing and on a PDP is the wrong product for a recommendation
|
|
44
|
+
* card. Rows of a list that publishes products are wrapped in the item scope,
|
|
45
|
+
* so those blocks act on the row's own product; every other list is left
|
|
46
|
+
* alone, because its rows are not products and its blocks read the page.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
interface ItemTemplateListProps {
|
|
50
|
+
/** The functional block that owns the list. */
|
|
51
|
+
block: Block;
|
|
52
|
+
/** The rows to draw, already normalised by the block. */
|
|
53
|
+
items: Record<string, unknown>[];
|
|
54
|
+
placeholderId: string;
|
|
55
|
+
sectionId: string;
|
|
56
|
+
isDesigner?: boolean;
|
|
57
|
+
isSelected?: boolean;
|
|
58
|
+
selectedBlockId?: string | null;
|
|
59
|
+
currentBreakpoint?: string;
|
|
60
|
+
/**
|
|
61
|
+
* How many rows to draw in the editor when the page has no live data —
|
|
62
|
+
* an empty grid is impossible to design against.
|
|
63
|
+
*/
|
|
64
|
+
designerPlaceholderCount?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Renders the designer's row layout once per item. Returns null when there is
|
|
69
|
+
* no template, so a caller can fall back to its own markup with a plain
|
|
70
|
+
* `?? builtInRows`.
|
|
71
|
+
*/
|
|
72
|
+
export const ItemTemplateList = ({
|
|
73
|
+
block,
|
|
74
|
+
items,
|
|
75
|
+
placeholderId,
|
|
76
|
+
sectionId,
|
|
77
|
+
isDesigner = false,
|
|
78
|
+
selectedBlockId = null,
|
|
79
|
+
currentBreakpoint = 'desktop',
|
|
80
|
+
designerPlaceholderCount = 3
|
|
81
|
+
}: ItemTemplateListProps): React.ReactElement | null => {
|
|
82
|
+
const template = findItemTemplate(block, currentBreakpoint);
|
|
83
|
+
if (!template) return null;
|
|
84
|
+
|
|
85
|
+
const rows =
|
|
86
|
+
items.length > 0
|
|
87
|
+
? items
|
|
88
|
+
: isDesigner
|
|
89
|
+
? Array.from({ length: designerPlaceholderCount }, () => ({}))
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
if (rows.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
// The iterator is given the rows directly: a functional block fetched them
|
|
95
|
+
// itself (RTK, page context), they are not on the section's data source.
|
|
96
|
+
// The iterator repeats `blocks[0]` of whatever it is given, so the template
|
|
97
|
+
// is wrapped rather than passed directly — passing it directly would repeat
|
|
98
|
+
// only its first child and drop the rest of the card.
|
|
99
|
+
const expanded = buildIteratorBlock({
|
|
100
|
+
iteratorBlock: {
|
|
101
|
+
...block,
|
|
102
|
+
id: `${block.id}-items`,
|
|
103
|
+
isIterator: true,
|
|
104
|
+
blocks: [template]
|
|
105
|
+
},
|
|
106
|
+
items: rows,
|
|
107
|
+
isDesigner
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const clones = expanded.blocks || [];
|
|
111
|
+
// Only lists whose rows ARE products open a scope. An attribute row or a
|
|
112
|
+
// gallery thumbnail row must keep falling through to the page's product:
|
|
113
|
+
// those templates live on a PDP, and a scope there — even an empty one —
|
|
114
|
+
// would silence blocks that render correctly today.
|
|
115
|
+
const scopesProducts = publishesProductItems(block.type);
|
|
116
|
+
// The same slice the iterator applied, so clone N is handed the row it
|
|
117
|
+
// was built from rather than row N.
|
|
118
|
+
const scopedRows = scopesProducts
|
|
119
|
+
? sliceIteratorItems(block, rows).items
|
|
120
|
+
: rows;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{clones.map((clone, index) => {
|
|
125
|
+
const key = clone.id || `${template.id}-item-${index}`;
|
|
126
|
+
const row = (
|
|
127
|
+
<ThemeBlock
|
|
128
|
+
// Keyed here so the non-scoped branch is the element it always
|
|
129
|
+
// was: wrapping it in anything, even a fragment, would change the
|
|
130
|
+
// element type at this position and remount every existing row.
|
|
131
|
+
key={key}
|
|
132
|
+
block={clone}
|
|
133
|
+
placeholderId={placeholderId}
|
|
134
|
+
sectionId={sectionId}
|
|
135
|
+
isDesigner={isDesigner}
|
|
136
|
+
isSelected={selectedBlockId === clone.id}
|
|
137
|
+
selectedBlockId={selectedBlockId}
|
|
138
|
+
currentBreakpoint={currentBreakpoint}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!scopesProducts) return row;
|
|
143
|
+
|
|
144
|
+
const scoped = resolveItemProduct(block.type, scopedRows[index]);
|
|
145
|
+
|
|
146
|
+
// Context only — the provider renders no element, so the markup a
|
|
147
|
+
// published row produces is unchanged.
|
|
148
|
+
return (
|
|
149
|
+
<ThemeItemScopeProvider
|
|
150
|
+
key={key}
|
|
151
|
+
product={
|
|
152
|
+
scoped ?? (isDesigner ? DESIGNER_SAMPLE_PRODUCT : null)
|
|
153
|
+
}
|
|
154
|
+
>
|
|
155
|
+
{row}
|
|
156
|
+
</ThemeItemScopeProvider>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</>
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Alias kept for call sites that read better as a marker than a role. */
|
|
164
|
+
export const ITEM_TEMPLATE_MARKER = ITEM_TEMPLATE_ROLE;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
6
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
7
|
+
import {
|
|
8
|
+
listingPlaceholderStyle,
|
|
9
|
+
readToggle,
|
|
10
|
+
useListingContext
|
|
11
|
+
} from './listing-context';
|
|
12
|
+
|
|
13
|
+
interface BreadcrumbEntry {
|
|
14
|
+
label: string;
|
|
15
|
+
url: string;
|
|
16
|
+
/** A stand-in for the crumbs dropped by the max-items cap. */
|
|
17
|
+
collapsed?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Functional listing block: the breadcrumb trail. Trail CONTENT is
|
|
22
|
+
* commerce-driven (the breadcrumb payload resolved server side); the editor
|
|
23
|
+
* owns the separator, the optional home entry, how long the trail may get
|
|
24
|
+
* and styling.
|
|
25
|
+
*
|
|
26
|
+
* Parts: list, item, link, current, separator, ellipsis.
|
|
27
|
+
* Properties: separator, showHome, homeLabel, homeHref, ariaLabel, maxItems,
|
|
28
|
+
* collapseLabel, maxLabelLength, ellipsis.
|
|
29
|
+
*/
|
|
30
|
+
const ListingBreadcrumbBlock = ({
|
|
31
|
+
block,
|
|
32
|
+
currentBreakpoint = 'desktop',
|
|
33
|
+
isDesigner = false
|
|
34
|
+
}: BlockRendererProps) => {
|
|
35
|
+
const listing = useListingContext();
|
|
36
|
+
|
|
37
|
+
const separator = String(
|
|
38
|
+
getResponsiveValue(block.properties?.separator, currentBreakpoint, '/')
|
|
39
|
+
);
|
|
40
|
+
const showHome = readToggle(
|
|
41
|
+
block.properties?.showHome,
|
|
42
|
+
currentBreakpoint,
|
|
43
|
+
true
|
|
44
|
+
);
|
|
45
|
+
const homeLabel = String(
|
|
46
|
+
getResponsiveValue(block.properties?.homeLabel, currentBreakpoint, 'Home')
|
|
47
|
+
);
|
|
48
|
+
const homeHref = String(
|
|
49
|
+
getResponsiveValue(block.properties?.homeHref, currentBreakpoint, '/')
|
|
50
|
+
);
|
|
51
|
+
const ariaLabel = String(
|
|
52
|
+
getResponsiveValue(block.properties?.ariaLabel, currentBreakpoint, 'Breadcrumb')
|
|
53
|
+
);
|
|
54
|
+
const maxItemsRaw = Math.round(
|
|
55
|
+
Number(getResponsiveValue(block.properties?.maxItems, currentBreakpoint, 0))
|
|
56
|
+
);
|
|
57
|
+
// 0 keeps the whole trail, which is what every published composition shows.
|
|
58
|
+
const maxItems =
|
|
59
|
+
Number.isFinite(maxItemsRaw) && maxItemsRaw >= 2 ? maxItemsRaw : 0;
|
|
60
|
+
const collapseLabel = String(
|
|
61
|
+
getResponsiveValue(block.properties?.collapseLabel, currentBreakpoint, '…')
|
|
62
|
+
);
|
|
63
|
+
const maxLabelLengthRaw = Math.round(
|
|
64
|
+
Number(
|
|
65
|
+
getResponsiveValue(block.properties?.maxLabelLength, currentBreakpoint, 0)
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
const maxLabelLength =
|
|
69
|
+
Number.isFinite(maxLabelLengthRaw) && maxLabelLengthRaw > 0
|
|
70
|
+
? maxLabelLengthRaw
|
|
71
|
+
: 0;
|
|
72
|
+
const ellipsis = String(
|
|
73
|
+
getResponsiveValue(block.properties?.ellipsis, currentBreakpoint, '…')
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const crumbs = listing?.breadcrumbs || [];
|
|
77
|
+
|
|
78
|
+
if (crumbs.length === 0) {
|
|
79
|
+
return isDesigner ? (
|
|
80
|
+
<div data-listing-breadcrumb="" style={listingPlaceholderStyle}>
|
|
81
|
+
Breadcrumb (no trail data)
|
|
82
|
+
</div>
|
|
83
|
+
) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fullTrail: BreadcrumbEntry[] = showHome
|
|
87
|
+
? [{ label: homeLabel, url: homeHref }].concat(crumbs)
|
|
88
|
+
: crumbs.slice();
|
|
89
|
+
|
|
90
|
+
// A long trail is folded from the middle: the root and the deepest crumbs
|
|
91
|
+
// are the ones a shopper navigates with.
|
|
92
|
+
const items: BreadcrumbEntry[] =
|
|
93
|
+
maxItems > 0 && fullTrail.length > maxItems
|
|
94
|
+
? [
|
|
95
|
+
fullTrail[0],
|
|
96
|
+
{ label: collapseLabel, url: '', collapsed: true },
|
|
97
|
+
...fullTrail.slice(fullTrail.length - (maxItems - 1))
|
|
98
|
+
]
|
|
99
|
+
: fullTrail;
|
|
100
|
+
|
|
101
|
+
const shorten = (label: string) =>
|
|
102
|
+
maxLabelLength > 0 && label.length > maxLabelLength
|
|
103
|
+
? `${label.slice(0, maxLabelLength).trimEnd()}${ellipsis}`
|
|
104
|
+
: label;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<nav data-listing-breadcrumb="" aria-label={ariaLabel}>
|
|
108
|
+
<ol
|
|
109
|
+
{...partAttrs('list')}
|
|
110
|
+
style={{
|
|
111
|
+
listStyle: 'none',
|
|
112
|
+
margin: 0,
|
|
113
|
+
padding: 0,
|
|
114
|
+
display: 'flex',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
flexWrap: 'wrap',
|
|
117
|
+
gap: 'var(--listing-breadcrumb-gap, 8px)'
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{items.map((item, index) => {
|
|
121
|
+
const isLast = index === items.length - 1;
|
|
122
|
+
const label = item.collapsed ? item.label : shorten(item.label);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<li
|
|
126
|
+
key={`${item.label}-${index}`}
|
|
127
|
+
data-breadcrumb-item={isLast ? 'current' : 'link'}
|
|
128
|
+
{...partAttrs('item', { selected: isLast })}
|
|
129
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
|
130
|
+
>
|
|
131
|
+
{item.collapsed ? (
|
|
132
|
+
<span data-breadcrumb-collapsed="" {...partAttrs('ellipsis')}>
|
|
133
|
+
{label}
|
|
134
|
+
</span>
|
|
135
|
+
) : isLast || !item.url ? (
|
|
136
|
+
<span
|
|
137
|
+
aria-current={isLast ? 'page' : undefined}
|
|
138
|
+
{...partAttrs(isLast ? 'current' : 'link', {
|
|
139
|
+
selected: isLast
|
|
140
|
+
})}
|
|
141
|
+
>
|
|
142
|
+
{label}
|
|
143
|
+
</span>
|
|
144
|
+
) : (
|
|
145
|
+
<NextLink
|
|
146
|
+
href={item.url}
|
|
147
|
+
{...partAttrs('link')}
|
|
148
|
+
// Navigating inside the designer iframe would drop the author
|
|
149
|
+
// out of the page they are editing, so crumbs are inert there.
|
|
150
|
+
onClick={(event) => {
|
|
151
|
+
if (isDesigner) event.preventDefault();
|
|
152
|
+
}}
|
|
153
|
+
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
154
|
+
>
|
|
155
|
+
{label}
|
|
156
|
+
</NextLink>
|
|
157
|
+
)}
|
|
158
|
+
{!isLast && (
|
|
159
|
+
<span
|
|
160
|
+
data-breadcrumb-separator=""
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
{...partAttrs('separator')}
|
|
163
|
+
>
|
|
164
|
+
{separator}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</ol>
|
|
171
|
+
</nav>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export default ListingBreadcrumbBlock;
|