@akinon/pz-theme 2.0.37-rc.0 → 2.0.37
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 +7 -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,923 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import NextLink from 'next/link';
|
|
5
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
6
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
+
import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
|
|
8
|
+
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
9
|
+
import { BlockRendererProps } from './block-renderer-registry';
|
|
10
|
+
import {
|
|
11
|
+
buildListingHref,
|
|
12
|
+
listingPlaceholderStyle,
|
|
13
|
+
readToggle,
|
|
14
|
+
useListingContext
|
|
15
|
+
} from './listing-context';
|
|
16
|
+
|
|
17
|
+
const linkStyle = (isCurrent: boolean): React.CSSProperties => ({
|
|
18
|
+
display: 'inline-flex',
|
|
19
|
+
alignItems: 'center',
|
|
20
|
+
justifyContent: 'center',
|
|
21
|
+
minWidth: '32px',
|
|
22
|
+
padding: '4px 8px',
|
|
23
|
+
color: 'inherit',
|
|
24
|
+
textDecoration: 'none',
|
|
25
|
+
border: '1px solid currentColor',
|
|
26
|
+
borderRadius: 'inherit',
|
|
27
|
+
opacity: isCurrent ? 1 : 0.7,
|
|
28
|
+
fontWeight: isCurrent ? 600 : 400
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
type AppendTrigger = 'button' | 'scroll';
|
|
32
|
+
const APPEND_TRIGGERS: AppendTrigger[] = ['button', 'scroll'];
|
|
33
|
+
|
|
34
|
+
type StatusPosition = 'before' | 'after';
|
|
35
|
+
const STATUS_POSITIONS: StatusPosition[] = ['before', 'after'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A brand can type any selector into "List Container", so every lookup is
|
|
39
|
+
* wrapped: an unfinished selector throws a SyntaxError that would otherwise
|
|
40
|
+
* take the whole listing down while they are still typing it.
|
|
41
|
+
*/
|
|
42
|
+
const safeQuery = (scope: ParentNode, selector: string): Element | null => {
|
|
43
|
+
try {
|
|
44
|
+
return scope.querySelector(selector);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const safeQueryAll = (scope: ParentNode, selector: string): Element[] => {
|
|
51
|
+
try {
|
|
52
|
+
return Array.from(scope.querySelectorAll(selector));
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The list this pagination belongs to: the nearest ancestor that contains a
|
|
60
|
+
* match wins, so a page carrying two listings appends into the one the block
|
|
61
|
+
* actually sits under rather than into whichever grid happens to be first in
|
|
62
|
+
* the document.
|
|
63
|
+
*/
|
|
64
|
+
const findAppendTarget = (
|
|
65
|
+
from: Element | null,
|
|
66
|
+
selector: string
|
|
67
|
+
): Element | null => {
|
|
68
|
+
let scope: Element | null = from ? from.parentElement : null;
|
|
69
|
+
|
|
70
|
+
while (scope) {
|
|
71
|
+
const match = safeQuery(scope, selector);
|
|
72
|
+
if (match) return match;
|
|
73
|
+
scope = scope.parentElement;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return safeQuery(document, selector);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Which of the page's lists the target is, so the fetched page is read the same way. */
|
|
80
|
+
const indexOfTarget = (target: Element, selector: string): number => {
|
|
81
|
+
const index = safeQueryAll(document, selector).indexOf(target);
|
|
82
|
+
return index < 0 ? 0 : index;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What may cross from a fetched page into the live document.
|
|
87
|
+
*
|
|
88
|
+
* An allowlist, not a skip list. A skip list only ever excludes what somebody
|
|
89
|
+
* thought of, and it is checked one level deep while `importNode(node, true)`
|
|
90
|
+
* copies the entire subtree: a `<script>` two levels down inside a card rode
|
|
91
|
+
* straight through. That script is cloned out of a `DOMParser` document, where
|
|
92
|
+
* scripting is off and it therefore never started, so it RUNS the moment it is
|
|
93
|
+
* inserted here. A nested `<style>` is the same problem the top level always
|
|
94
|
+
* had — a lottie, an announcement bar or a recommendation strip inside a card
|
|
95
|
+
* emits one, and rules re-declared after the live ones outrank them and undo
|
|
96
|
+
* the shopper's density choice.
|
|
97
|
+
*
|
|
98
|
+
* Everything below is inert: it draws, it does not execute and it does not
|
|
99
|
+
* pull in a document of its own. What is absent is as much the point — script,
|
|
100
|
+
* style, link, template, iframe, object, embed, noscript, slot, and anything a
|
|
101
|
+
* brand invented, since a custom element runs a constructor on upgrade.
|
|
102
|
+
*/
|
|
103
|
+
const GRAFT_ALLOWED_TAGS = new Set([
|
|
104
|
+
// Flow and text.
|
|
105
|
+
'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
|
|
106
|
+
'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'dd', 'del',
|
|
107
|
+
'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer',
|
|
108
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'ins',
|
|
109
|
+
'kbd', 'li', 'main', 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
|
|
110
|
+
'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
|
|
111
|
+
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
|
|
112
|
+
'ul', 'var', 'wbr',
|
|
113
|
+
// Media. The card's image is the whole point of grafting the row at all.
|
|
114
|
+
'audio', 'canvas', 'img', 'picture', 'source', 'track', 'video',
|
|
115
|
+
// Controls a card can draw. They arrive inert whatever happens — React owns
|
|
116
|
+
// none of this DOM — but dropping them would leave an appended card looking
|
|
117
|
+
// unlike the one directly above it.
|
|
118
|
+
'button', 'fieldset', 'form', 'input', 'label', 'legend', 'meter',
|
|
119
|
+
'optgroup', 'option', 'output', 'progress', 'select', 'textarea',
|
|
120
|
+
// SVG: icons, badges, ratings. Lower-cased on both sides, so `clipPath` and
|
|
121
|
+
// `linearGradient` are compared the same way an HTML tag name is.
|
|
122
|
+
'circle', 'clippath', 'defs', 'desc', 'ellipse', 'feblend', 'fecolormatrix',
|
|
123
|
+
'fecomposite', 'fedropshadow', 'feflood', 'fegaussianblur', 'femerge',
|
|
124
|
+
'femergenode', 'feoffset', 'filter', 'g', 'image', 'line', 'lineargradient',
|
|
125
|
+
'marker', 'mask', 'path', 'pattern', 'polygon', 'polyline',
|
|
126
|
+
'radialgradient', 'rect', 'stop', 'svg', 'symbol', 'text', 'textpath',
|
|
127
|
+
'title', 'tspan', 'use'
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const SCRIPT_SCHEME = /^(?:javascript|vbscript):/;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Browsers strip whitespace and control characters while parsing a url, so a
|
|
134
|
+
* tab wedged into "javascript:" still resolves to a live handler; the value
|
|
135
|
+
* is normalised the same way before it is judged.
|
|
136
|
+
*/
|
|
137
|
+
const isScriptUrl = (value: string): boolean =>
|
|
138
|
+
SCRIPT_SCHEME.test(value.replace(/[\u0000-\u0020]/g, '').toLowerCase());
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Attributes carrying text a shopper reads rather than something the browser
|
|
142
|
+
* resolves.
|
|
143
|
+
*
|
|
144
|
+
* "JavaScript: The Good Parts" is a product a bookshop really sells, and it
|
|
145
|
+
* normalises to a script scheme: judging EVERY attribute took the `alt` off
|
|
146
|
+
* that card — the only description a screen reader has for it — and took it on
|
|
147
|
+
* the appended page only, so the same product read differently above and below
|
|
148
|
+
* the fold. Nothing named here is ever fetched, navigated or executed, so no
|
|
149
|
+
* scheme can act from inside one. Everything not named here is still judged,
|
|
150
|
+
* which keeps this deny-by-default: an attribute nobody thought of is scrubbed,
|
|
151
|
+
* and that is the property that caught the script url in the first place.
|
|
152
|
+
*/
|
|
153
|
+
const TEXT_ONLY_ATTRIBUTES = new Set(['alt', 'title', 'placeholder']);
|
|
154
|
+
|
|
155
|
+
const isTextOnlyAttribute = (name: string): boolean =>
|
|
156
|
+
TEXT_ONLY_ATTRIBUTES.has(name) || name.startsWith('aria-');
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Strips what executes out of a subtree while it is still in the inert parsed
|
|
160
|
+
* document, so the clone that reaches the live DOM never carried it.
|
|
161
|
+
*/
|
|
162
|
+
const sanitizeGraft = (element: Element) => {
|
|
163
|
+
Array.from(element.attributes).forEach((attribute) => {
|
|
164
|
+
const name = attribute.name.toLowerCase();
|
|
165
|
+
// `on*` is a handler; `is` upgrades a plain tag into a custom element and
|
|
166
|
+
// runs its constructor; a `javascript:` url is one click or one load from
|
|
167
|
+
// the same thing.
|
|
168
|
+
if (
|
|
169
|
+
name.startsWith('on') ||
|
|
170
|
+
name === 'is' ||
|
|
171
|
+
(!isTextOnlyAttribute(name) && isScriptUrl(attribute.value))
|
|
172
|
+
) {
|
|
173
|
+
element.removeAttribute(attribute.name);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
Array.from(element.children).forEach((child) => {
|
|
178
|
+
if (GRAFT_ALLOWED_TAGS.has(child.tagName.toLowerCase())) {
|
|
179
|
+
sanitizeGraft(child);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
child.remove();
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reads the rows of page N out of that page's own server response.
|
|
189
|
+
*
|
|
190
|
+
* Returns null when the response carried no list at all (a redirect, an error
|
|
191
|
+
* page, a login wall) — that is a failure the shopper has to be told about.
|
|
192
|
+
* An empty array means the page rendered but held no rows, which is the end of
|
|
193
|
+
* the list, not an error.
|
|
194
|
+
*/
|
|
195
|
+
const extractAppendNodes = (
|
|
196
|
+
html: string,
|
|
197
|
+
selector: string,
|
|
198
|
+
targetIndex: number
|
|
199
|
+
): Element[] | null => {
|
|
200
|
+
const parsed = new DOMParser().parseFromString(html, 'text/html');
|
|
201
|
+
const matches = safeQueryAll(parsed, selector);
|
|
202
|
+
const source = matches[targetIndex] || matches[0];
|
|
203
|
+
|
|
204
|
+
if (!source) return null;
|
|
205
|
+
|
|
206
|
+
return Array.from(source.children)
|
|
207
|
+
.filter((node) => GRAFT_ALLOWED_TAGS.has(node.tagName.toLowerCase()))
|
|
208
|
+
.map((node) => {
|
|
209
|
+
sanitizeGraft(node);
|
|
210
|
+
return node;
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* How many of the grafted cells are products.
|
|
216
|
+
*
|
|
217
|
+
* A grid cell is not always one: a merchandising tile is a first-class child
|
|
218
|
+
* of the grid and marks itself as such. Counting NODES put "showing 5 of 6"
|
|
219
|
+
* under four cards and let {to} run past {total} — precisely what the readout
|
|
220
|
+
* was built to stop doing. A list that emits no tiles counts every node, which
|
|
221
|
+
* is what it always did.
|
|
222
|
+
*/
|
|
223
|
+
const countProducts = (nodes: Element[]): number =>
|
|
224
|
+
nodes.filter((node) => !node.hasAttribute('data-product-grid-insert')).length;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Next patches `history.replaceState`, so this also re-syncs `useSearchParams`
|
|
228
|
+
* for every block on the page without triggering a navigation.
|
|
229
|
+
*/
|
|
230
|
+
const replaceListingUrl = (href: string) => {
|
|
231
|
+
try {
|
|
232
|
+
window.history.replaceState(null, '', href);
|
|
233
|
+
} catch {
|
|
234
|
+
// A blocked history call only costs the deep link, never the products.
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const fillTemplate = (
|
|
239
|
+
template: string,
|
|
240
|
+
values: Record<string, string>
|
|
241
|
+
): string =>
|
|
242
|
+
template.replace(
|
|
243
|
+
/\{(shown|total|from|to|page|pages)\}/g,
|
|
244
|
+
(match, key: string) => (key in values ? values[key] : match)
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Functional listing block: pagination. Page counts are commerce-driven (the
|
|
249
|
+
* `pagination` payload of the category API); the editor owns the mode, how
|
|
250
|
+
* many page links are shown, which affordances appear and the labels.
|
|
251
|
+
*
|
|
252
|
+
* APPEND ('loadmore' + Append In Place, off by default). The first page is
|
|
253
|
+
* still whatever the server rendered — a crawler and a cold visit see products
|
|
254
|
+
* with no javascript at all, and the "load more" anchor is still a real link
|
|
255
|
+
* to page N+1, so the whole listing stays reachable. Append is layered on top
|
|
256
|
+
* of that: the click is intercepted only once a target list is actually found.
|
|
257
|
+
*
|
|
258
|
+
* The next page is taken from ITS OWN SERVER RESPONSE and its rows are moved
|
|
259
|
+
* into the live list, rather than fetching JSON and re-rendering cards here.
|
|
260
|
+
* That is the only way the appended cards can be *identical* to the first
|
|
261
|
+
* page's: they were produced by the same grid block, the same properties, the
|
|
262
|
+
* same money format and — the part a JSON path cannot reach at all — the same
|
|
263
|
+
* designer-authored row layout, iterator bindings and item scope. Grafting is
|
|
264
|
+
* kept strictly additive (nothing existing is touched) and every grafted node
|
|
265
|
+
* is tracked so a later navigation can take it back out; React never removes
|
|
266
|
+
* DOM it did not create, so leaving them would strand stale products in a
|
|
267
|
+
* freshly sorted list.
|
|
268
|
+
*
|
|
269
|
+
* Additive means visually additive too, not just "appended to the end of the
|
|
270
|
+
* children". A grid pins a merchandising tile by a cell rule, and page two's
|
|
271
|
+
* tiles carry page two's key, so no rule the live page wrote can move them on
|
|
272
|
+
* top of a card the shopper already saw. What comes back is markup off the
|
|
273
|
+
* wire either way, so it is scrubbed against an allowlist before it is cloned.
|
|
274
|
+
*
|
|
275
|
+
* URL: each appended page is written with `replaceState`, never `pushState`.
|
|
276
|
+
* pushState would stack one history entry per append, so "back" would walk the
|
|
277
|
+
* shopper through every batch instead of leaving the listing; replaceState
|
|
278
|
+
* keeps a single entry. A click inside the list re-writes it to the page the
|
|
279
|
+
* clicked row came from, so following a product and coming back lands on what
|
|
280
|
+
* the shopper was looking at rather than on page 1. The cost, deliberately
|
|
281
|
+
* accepted: the restored URL renders that one page rather than re-fetching the
|
|
282
|
+
* whole appended run.
|
|
283
|
+
*
|
|
284
|
+
* Parts: list, page, prev, next, first, last, ellipsis, load-more, status,
|
|
285
|
+
* end, error, retry.
|
|
286
|
+
* Properties: mode, maxLinks, prevLabel, nextLabel, loadMoreLabel,
|
|
287
|
+
* showPrevNext, showFirstLast, firstLabel, lastLabel, showEllipsis,
|
|
288
|
+
* ellipsisLabel, navLabel, queryParam, append, appendTrigger, appendTarget,
|
|
289
|
+
* prefetchOffset, loadingLabel, errorLabel, retryLabel, endLabel, showStatus,
|
|
290
|
+
* statusTemplate, statusPosition, groupThousands.
|
|
291
|
+
*/
|
|
292
|
+
const ListingPaginationBlock = ({
|
|
293
|
+
block,
|
|
294
|
+
placeholderId,
|
|
295
|
+
sectionId,
|
|
296
|
+
selectedBlockId = null,
|
|
297
|
+
currentBreakpoint = 'desktop',
|
|
298
|
+
isDesigner = false
|
|
299
|
+
}: BlockRendererProps) => {
|
|
300
|
+
const listing = useListingContext();
|
|
301
|
+
const pathname = usePathname();
|
|
302
|
+
const searchParams = useSearchParams();
|
|
303
|
+
const { locale } = useLocalization();
|
|
304
|
+
|
|
305
|
+
const mode = String(
|
|
306
|
+
getResponsiveValue(block.properties?.mode, currentBreakpoint, 'pages')
|
|
307
|
+
);
|
|
308
|
+
const maxLinksRaw = Math.round(
|
|
309
|
+
Number(getResponsiveValue(block.properties?.maxLinks, currentBreakpoint, 5))
|
|
310
|
+
);
|
|
311
|
+
const maxLinks = Number.isFinite(maxLinksRaw)
|
|
312
|
+
? Math.min(9, Math.max(3, maxLinksRaw))
|
|
313
|
+
: 5;
|
|
314
|
+
const prevLabel = String(
|
|
315
|
+
getResponsiveValue(block.properties?.prevLabel, currentBreakpoint, 'Previous')
|
|
316
|
+
);
|
|
317
|
+
const nextLabel = String(
|
|
318
|
+
getResponsiveValue(block.properties?.nextLabel, currentBreakpoint, 'Next')
|
|
319
|
+
);
|
|
320
|
+
const loadMoreLabel = String(
|
|
321
|
+
getResponsiveValue(
|
|
322
|
+
block.properties?.loadMoreLabel,
|
|
323
|
+
currentBreakpoint,
|
|
324
|
+
'Load more'
|
|
325
|
+
)
|
|
326
|
+
);
|
|
327
|
+
const showPrevNext = readToggle(
|
|
328
|
+
block.properties?.showPrevNext,
|
|
329
|
+
currentBreakpoint,
|
|
330
|
+
true
|
|
331
|
+
);
|
|
332
|
+
const showFirstLast = readToggle(
|
|
333
|
+
block.properties?.showFirstLast,
|
|
334
|
+
currentBreakpoint,
|
|
335
|
+
false
|
|
336
|
+
);
|
|
337
|
+
const firstLabel = String(
|
|
338
|
+
getResponsiveValue(block.properties?.firstLabel, currentBreakpoint, 'First')
|
|
339
|
+
);
|
|
340
|
+
const lastLabel = String(
|
|
341
|
+
getResponsiveValue(block.properties?.lastLabel, currentBreakpoint, 'Last')
|
|
342
|
+
);
|
|
343
|
+
const showEllipsis = readToggle(
|
|
344
|
+
block.properties?.showEllipsis,
|
|
345
|
+
currentBreakpoint,
|
|
346
|
+
false
|
|
347
|
+
);
|
|
348
|
+
const ellipsisLabel = String(
|
|
349
|
+
getResponsiveValue(block.properties?.ellipsisLabel, currentBreakpoint, '…')
|
|
350
|
+
);
|
|
351
|
+
const navLabel = String(
|
|
352
|
+
getResponsiveValue(block.properties?.navLabel, currentBreakpoint, 'Pagination')
|
|
353
|
+
);
|
|
354
|
+
// Commerce normally paginates on `page`, but a brand running a custom
|
|
355
|
+
// listing route can move it; the filter blocks reset the same key.
|
|
356
|
+
const queryParam = String(
|
|
357
|
+
getResponsiveValue(block.properties?.queryParam, currentBreakpoint, 'page')
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const append = readToggle(block.properties?.append, currentBreakpoint, false);
|
|
361
|
+
const appendTrigger = resolveOption<AppendTrigger>(
|
|
362
|
+
block.properties?.appendTrigger,
|
|
363
|
+
currentBreakpoint,
|
|
364
|
+
'button',
|
|
365
|
+
APPEND_TRIGGERS
|
|
366
|
+
);
|
|
367
|
+
const appendTarget = String(
|
|
368
|
+
getResponsiveValue(
|
|
369
|
+
block.properties?.appendTarget,
|
|
370
|
+
currentBreakpoint,
|
|
371
|
+
'[data-product-grid]'
|
|
372
|
+
)
|
|
373
|
+
);
|
|
374
|
+
const prefetchOffsetRaw = Math.round(
|
|
375
|
+
Number(
|
|
376
|
+
getResponsiveValue(block.properties?.prefetchOffset, currentBreakpoint, 200)
|
|
377
|
+
)
|
|
378
|
+
);
|
|
379
|
+
const prefetchOffset = Number.isFinite(prefetchOffsetRaw)
|
|
380
|
+
? Math.min(2000, Math.max(0, prefetchOffsetRaw))
|
|
381
|
+
: 200;
|
|
382
|
+
const loadingLabel = String(
|
|
383
|
+
getResponsiveValue(block.properties?.loadingLabel, currentBreakpoint, 'Loading…')
|
|
384
|
+
);
|
|
385
|
+
const errorLabel = String(
|
|
386
|
+
getResponsiveValue(
|
|
387
|
+
block.properties?.errorLabel,
|
|
388
|
+
currentBreakpoint,
|
|
389
|
+
'Could not load more products.'
|
|
390
|
+
)
|
|
391
|
+
);
|
|
392
|
+
const retryLabel = String(
|
|
393
|
+
getResponsiveValue(block.properties?.retryLabel, currentBreakpoint, 'Try again')
|
|
394
|
+
);
|
|
395
|
+
const endLabel = String(
|
|
396
|
+
getResponsiveValue(block.properties?.endLabel, currentBreakpoint, '')
|
|
397
|
+
);
|
|
398
|
+
const showStatus = readToggle(
|
|
399
|
+
block.properties?.showStatus,
|
|
400
|
+
currentBreakpoint,
|
|
401
|
+
false
|
|
402
|
+
);
|
|
403
|
+
const statusTemplate = String(
|
|
404
|
+
getResponsiveValue(
|
|
405
|
+
block.properties?.statusTemplate,
|
|
406
|
+
currentBreakpoint,
|
|
407
|
+
'Showing {shown} of {total}'
|
|
408
|
+
)
|
|
409
|
+
);
|
|
410
|
+
const statusPosition = resolveOption<StatusPosition>(
|
|
411
|
+
block.properties?.statusPosition,
|
|
412
|
+
currentBreakpoint,
|
|
413
|
+
'before',
|
|
414
|
+
STATUS_POSITIONS
|
|
415
|
+
);
|
|
416
|
+
const groupThousands = readToggle(
|
|
417
|
+
block.properties?.groupThousands,
|
|
418
|
+
currentBreakpoint,
|
|
419
|
+
false
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const navRef = React.useRef<HTMLElement | null>(null);
|
|
423
|
+
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
|
|
424
|
+
/** Every node this block grafted, so a later navigation can take them back. */
|
|
425
|
+
const appendedNodesRef = React.useRef<Element[]>([]);
|
|
426
|
+
/** Synchronous, unlike state: the sentinel must not queue a second request. */
|
|
427
|
+
const inFlightRef = React.useRef(false);
|
|
428
|
+
const abortRef = React.useRef<AbortController | null>(null);
|
|
429
|
+
const [appendedPage, setAppendedPage] = React.useState(0);
|
|
430
|
+
const [appendedCount, setAppendedCount] = React.useState(0);
|
|
431
|
+
const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'error'>(
|
|
432
|
+
'idle'
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const currentPage = listing?.pagination.currentPage ?? 1;
|
|
436
|
+
const totalPages = listing?.pagination.totalPages ?? 1;
|
|
437
|
+
const pageSize = listing?.pagination.pageSize ?? 0;
|
|
438
|
+
const totalCount = listing?.pagination.totalCount ?? 0;
|
|
439
|
+
const pageProducts = listing?.products.length ?? 0;
|
|
440
|
+
const products = listing?.products;
|
|
441
|
+
// 0 means nothing has been appended, so the block is still on the page the
|
|
442
|
+
// server rendered.
|
|
443
|
+
const loadedPage = appendedPage > 0 ? appendedPage : currentPage;
|
|
444
|
+
const appendEnabled = append && mode === 'loadmore';
|
|
445
|
+
|
|
446
|
+
const hrefForPage = React.useCallback(
|
|
447
|
+
(page: number) => {
|
|
448
|
+
const params = new URLSearchParams(
|
|
449
|
+
searchParams ? searchParams.toString() : ''
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
if (page <= 1) {
|
|
453
|
+
params.delete(queryParam);
|
|
454
|
+
} else {
|
|
455
|
+
params.set(queryParam, String(page));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return buildListingHref(pathname || listing?.path || '', params);
|
|
459
|
+
},
|
|
460
|
+
[searchParams, queryParam, pathname, listing?.path]
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const loadNextPage = React.useCallback(async () => {
|
|
464
|
+
// Fetching inside the canvas would drop real product markup into the page
|
|
465
|
+
// the author is composing, where it cannot be selected or styled.
|
|
466
|
+
if (isDesigner || inFlightRef.current) return;
|
|
467
|
+
if (loadedPage >= totalPages) return;
|
|
468
|
+
|
|
469
|
+
const target = findAppendTarget(navRef.current, appendTarget);
|
|
470
|
+
// No list to append into: the anchor is left to navigate instead.
|
|
471
|
+
if (!target) return;
|
|
472
|
+
|
|
473
|
+
const nextPage = loadedPage + 1;
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
|
|
476
|
+
inFlightRef.current = true;
|
|
477
|
+
abortRef.current = controller;
|
|
478
|
+
setLoadState('loading');
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const response = await fetch(hrefForPage(nextPage), {
|
|
482
|
+
signal: controller.signal,
|
|
483
|
+
credentials: 'same-origin',
|
|
484
|
+
headers: { Accept: 'text/html' }
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
488
|
+
|
|
489
|
+
const nodes = extractAppendNodes(
|
|
490
|
+
await response.text(),
|
|
491
|
+
appendTarget,
|
|
492
|
+
indexOfTarget(target, appendTarget)
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
if (!nodes) throw new Error('No list in the response');
|
|
496
|
+
|
|
497
|
+
const fragment = document.createDocumentFragment();
|
|
498
|
+
nodes.forEach((node) => {
|
|
499
|
+
const clone = document.importNode(node, true);
|
|
500
|
+
// Which page a row came from, so a click on it can restore that page.
|
|
501
|
+
clone.setAttribute('data-listing-appended', String(nextPage));
|
|
502
|
+
appendedNodesRef.current.push(clone);
|
|
503
|
+
fragment.appendChild(clone);
|
|
504
|
+
});
|
|
505
|
+
target.appendChild(fragment);
|
|
506
|
+
|
|
507
|
+
setAppendedPage(nextPage);
|
|
508
|
+
setAppendedCount((count) => count + countProducts(nodes));
|
|
509
|
+
setLoadState('idle');
|
|
510
|
+
replaceListingUrl(hrefForPage(nextPage));
|
|
511
|
+
} catch {
|
|
512
|
+
// An abort is this block unmounting or a navigation, not a failure the
|
|
513
|
+
// shopper should be shown.
|
|
514
|
+
if (!controller.signal.aborted) setLoadState('error');
|
|
515
|
+
} finally {
|
|
516
|
+
inFlightRef.current = false;
|
|
517
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
518
|
+
}
|
|
519
|
+
}, [isDesigner, loadedPage, totalPages, appendTarget, hrefForPage]);
|
|
520
|
+
|
|
521
|
+
React.useEffect(() => {
|
|
522
|
+
// A sort, a filter or a page link re-renders the list from the server.
|
|
523
|
+
// React only owns the children it created, so the rows grafted from the
|
|
524
|
+
// previous URL would survive that render as stale products.
|
|
525
|
+
if (appendedNodesRef.current.length > 0) {
|
|
526
|
+
appendedNodesRef.current.forEach((node) => node.remove());
|
|
527
|
+
appendedNodesRef.current = [];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (abortRef.current) abortRef.current.abort();
|
|
531
|
+
inFlightRef.current = false;
|
|
532
|
+
setAppendedPage(0);
|
|
533
|
+
setAppendedCount(0);
|
|
534
|
+
setLoadState('idle');
|
|
535
|
+
}, [products]);
|
|
536
|
+
|
|
537
|
+
React.useEffect(
|
|
538
|
+
() => () => {
|
|
539
|
+
if (abortRef.current) abortRef.current.abort();
|
|
540
|
+
appendedNodesRef.current.forEach((node) => node.remove());
|
|
541
|
+
appendedNodesRef.current = [];
|
|
542
|
+
},
|
|
543
|
+
[]
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
React.useEffect(() => {
|
|
547
|
+
if (!appendEnabled || isDesigner) return undefined;
|
|
548
|
+
if (appendTrigger !== 'scroll') return undefined;
|
|
549
|
+
// The end of the list: an observer that stays armed here fires forever
|
|
550
|
+
// against a request that can never be made.
|
|
551
|
+
if (loadedPage >= totalPages) return undefined;
|
|
552
|
+
// 'loading' keeps a second request from being queued behind the first;
|
|
553
|
+
// 'error' keeps a failed page from being hammered on every scroll frame —
|
|
554
|
+
// the shopper re-arms it through the retry button.
|
|
555
|
+
if (loadState !== 'idle') return undefined;
|
|
556
|
+
if (typeof IntersectionObserver === 'undefined') return undefined;
|
|
557
|
+
|
|
558
|
+
const sentinel = sentinelRef.current;
|
|
559
|
+
if (!sentinel) return undefined;
|
|
560
|
+
|
|
561
|
+
const observer = new IntersectionObserver(
|
|
562
|
+
(entries) => {
|
|
563
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
564
|
+
if (inFlightRef.current) return;
|
|
565
|
+
// Re-armed by this effect once the page number moves; leaving it
|
|
566
|
+
// connected would re-fire while the request is still in flight.
|
|
567
|
+
observer.disconnect();
|
|
568
|
+
loadNextPage();
|
|
569
|
+
},
|
|
570
|
+
{ rootMargin: `${prefetchOffset}px 0px` }
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
observer.observe(sentinel);
|
|
574
|
+
return () => observer.disconnect();
|
|
575
|
+
}, [
|
|
576
|
+
appendEnabled,
|
|
577
|
+
isDesigner,
|
|
578
|
+
appendTrigger,
|
|
579
|
+
loadedPage,
|
|
580
|
+
totalPages,
|
|
581
|
+
loadState,
|
|
582
|
+
prefetchOffset,
|
|
583
|
+
loadNextPage
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
React.useEffect(() => {
|
|
587
|
+
if (!appendEnabled || isDesigner) return undefined;
|
|
588
|
+
|
|
589
|
+
const target = findAppendTarget(navRef.current, appendTarget);
|
|
590
|
+
if (!target) return undefined;
|
|
591
|
+
|
|
592
|
+
// Capture phase, so the page is recorded before a client-side Link
|
|
593
|
+
// navigation takes over.
|
|
594
|
+
const handleClick = (event: Event) => {
|
|
595
|
+
if (appendedNodesRef.current.length === 0) return;
|
|
596
|
+
|
|
597
|
+
const origin = event.target as Element | null;
|
|
598
|
+
const link = origin && origin.closest ? origin.closest('a[href]') : null;
|
|
599
|
+
if (!link || !target.contains(link)) return;
|
|
600
|
+
|
|
601
|
+
const batch = link.closest('[data-listing-appended]');
|
|
602
|
+
const page = batch
|
|
603
|
+
? Number(batch.getAttribute('data-listing-appended'))
|
|
604
|
+
: currentPage;
|
|
605
|
+
|
|
606
|
+
if (!Number.isFinite(page) || page < 1) return;
|
|
607
|
+
replaceListingUrl(hrefForPage(page));
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
target.addEventListener('click', handleClick, true);
|
|
611
|
+
return () => target.removeEventListener('click', handleClick, true);
|
|
612
|
+
}, [appendEnabled, isDesigner, appendTarget, hrefForPage, currentPage]);
|
|
613
|
+
|
|
614
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
615
|
+
// page they are editing, so links are inert there.
|
|
616
|
+
const guard = (event: React.MouseEvent) => {
|
|
617
|
+
if (isDesigner) event.preventDefault();
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const handleLoadMore = (event: React.MouseEvent) => {
|
|
621
|
+
if (isDesigner) {
|
|
622
|
+
event.preventDefault();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!appendEnabled || event.defaultPrevented) return;
|
|
627
|
+
// A modified click is the shopper asking for a new tab: the href is theirs.
|
|
628
|
+
if (
|
|
629
|
+
event.metaKey ||
|
|
630
|
+
event.ctrlKey ||
|
|
631
|
+
event.shiftKey ||
|
|
632
|
+
event.altKey ||
|
|
633
|
+
event.button !== 0
|
|
634
|
+
) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Only once there is somewhere to append; otherwise the link navigates,
|
|
639
|
+
// which is what it does with no javascript at all.
|
|
640
|
+
if (!findAppendTarget(navRef.current, appendTarget)) return;
|
|
641
|
+
|
|
642
|
+
event.preventDefault();
|
|
643
|
+
loadNextPage();
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const formatCount = (value: number): string => {
|
|
647
|
+
if (!groupThousands) return String(value);
|
|
648
|
+
|
|
649
|
+
// Explicit locale: an implicit one resolves to the Node default on the
|
|
650
|
+
// server and the browser's on the client, so the grouped number would
|
|
651
|
+
// differ between SSR and hydration.
|
|
652
|
+
try {
|
|
653
|
+
return new Intl.NumberFormat(locale || 'tr-TR').format(value);
|
|
654
|
+
} catch {
|
|
655
|
+
return String(value);
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// How many rows are on screen right now: what the server rendered for this
|
|
660
|
+
// page plus whatever has been appended to it. Counting what is there rather
|
|
661
|
+
// than multiplying the page size keeps the readout honest for a shopper who
|
|
662
|
+
// deep-linked into the middle of the listing.
|
|
663
|
+
const shownCount = pageProducts + appendedCount;
|
|
664
|
+
const firstIndex = pageSize > 0 ? (currentPage - 1) * pageSize + 1 : 1;
|
|
665
|
+
const statusText = fillTemplate(statusTemplate, {
|
|
666
|
+
shown: formatCount(shownCount),
|
|
667
|
+
total: formatCount(totalCount),
|
|
668
|
+
from: formatCount(shownCount > 0 ? firstIndex : 0),
|
|
669
|
+
to: formatCount(shownCount > 0 ? firstIndex + shownCount - 1 : 0),
|
|
670
|
+
page: formatCount(loadedPage),
|
|
671
|
+
pages: formatCount(totalPages)
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const statusNode = showStatus ? (
|
|
675
|
+
<p
|
|
676
|
+
data-listing-pagination-status=""
|
|
677
|
+
data-listing-shown={shownCount}
|
|
678
|
+
data-listing-total={totalCount}
|
|
679
|
+
{...partAttrs('status', { empty: shownCount === 0 })}
|
|
680
|
+
// The count changes on append without any navigation, so a screen reader
|
|
681
|
+
// is told rather than left on the number it read at page load.
|
|
682
|
+
aria-live="polite"
|
|
683
|
+
style={{ margin: 0 }}
|
|
684
|
+
>
|
|
685
|
+
{statusText}
|
|
686
|
+
</p>
|
|
687
|
+
) : null;
|
|
688
|
+
|
|
689
|
+
// With no status the block returns exactly the element it always has; the
|
|
690
|
+
// fragment is only introduced once a designer asks for the readout.
|
|
691
|
+
const withStatus = (node: React.ReactNode): React.ReactElement => {
|
|
692
|
+
if (!statusNode) return node as React.ReactElement;
|
|
693
|
+
|
|
694
|
+
return (
|
|
695
|
+
<>
|
|
696
|
+
{statusPosition === 'after' ? null : statusNode}
|
|
697
|
+
{node}
|
|
698
|
+
{statusPosition === 'after' ? statusNode : null}
|
|
699
|
+
</>
|
|
700
|
+
);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
if (!listing || totalPages <= 1) {
|
|
704
|
+
if (listing && statusNode) return statusNode;
|
|
705
|
+
|
|
706
|
+
return isDesigner ? (
|
|
707
|
+
<div data-listing-pagination="" style={listingPlaceholderStyle}>
|
|
708
|
+
Pagination (single page)
|
|
709
|
+
</div>
|
|
710
|
+
) : null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (mode === 'loadmore') {
|
|
714
|
+
if (loadedPage >= totalPages) {
|
|
715
|
+
const endNode =
|
|
716
|
+
appendEnabled && endLabel ? (
|
|
717
|
+
<p
|
|
718
|
+
data-listing-load-end=""
|
|
719
|
+
{...partAttrs('end')}
|
|
720
|
+
style={{ margin: 0 }}
|
|
721
|
+
>
|
|
722
|
+
{endLabel}
|
|
723
|
+
</p>
|
|
724
|
+
) : null;
|
|
725
|
+
|
|
726
|
+
if (!statusNode && !endNode) {
|
|
727
|
+
return isDesigner ? (
|
|
728
|
+
<div data-listing-pagination="" style={listingPlaceholderStyle}>
|
|
729
|
+
Load more (last page)
|
|
730
|
+
</div>
|
|
731
|
+
) : null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return withStatus(endNode);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return withStatus(
|
|
738
|
+
<nav
|
|
739
|
+
ref={navRef}
|
|
740
|
+
data-listing-pagination=""
|
|
741
|
+
data-pagination-mode="loadmore"
|
|
742
|
+
{...partAttrs('list')}
|
|
743
|
+
>
|
|
744
|
+
<NextLink
|
|
745
|
+
href={hrefForPage(loadedPage + 1)}
|
|
746
|
+
rel="next"
|
|
747
|
+
onClick={handleLoadMore}
|
|
748
|
+
data-listing-load-more=""
|
|
749
|
+
{...partAttrs(
|
|
750
|
+
'load-more',
|
|
751
|
+
appendEnabled ? { loading: loadState === 'loading' } : undefined
|
|
752
|
+
)}
|
|
753
|
+
style={linkStyle(false)}
|
|
754
|
+
aria-busy={
|
|
755
|
+
appendEnabled && loadState === 'loading' ? true : undefined
|
|
756
|
+
}
|
|
757
|
+
>
|
|
758
|
+
{appendEnabled && loadState === 'loading' ? loadingLabel : loadMoreLabel}
|
|
759
|
+
</NextLink>
|
|
760
|
+
|
|
761
|
+
{appendEnabled && loadState === 'error' && (
|
|
762
|
+
<>
|
|
763
|
+
<span data-listing-load-error="" {...partAttrs('error')} role="alert">
|
|
764
|
+
{errorLabel}
|
|
765
|
+
</span>
|
|
766
|
+
{/* A failed page is a dead end without this: the sentinel has
|
|
767
|
+
stood down and the anchor's own click is intercepted. */}
|
|
768
|
+
<button
|
|
769
|
+
type="button"
|
|
770
|
+
data-listing-load-retry=""
|
|
771
|
+
{...partAttrs('retry')}
|
|
772
|
+
onClick={() => loadNextPage()}
|
|
773
|
+
style={{ ...linkStyle(false), background: 'none', cursor: 'pointer' }}
|
|
774
|
+
>
|
|
775
|
+
{retryLabel}
|
|
776
|
+
</button>
|
|
777
|
+
</>
|
|
778
|
+
)}
|
|
779
|
+
|
|
780
|
+
{appendEnabled && appendTrigger === 'scroll' && (
|
|
781
|
+
<div
|
|
782
|
+
ref={sentinelRef}
|
|
783
|
+
data-listing-load-sentinel=""
|
|
784
|
+
aria-hidden="true"
|
|
785
|
+
style={{ width: '1px', height: '1px' }}
|
|
786
|
+
/>
|
|
787
|
+
)}
|
|
788
|
+
</nav>
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const half = Math.floor(maxLinks / 2);
|
|
793
|
+
const end = Math.min(totalPages, Math.max(currentPage + half, maxLinks));
|
|
794
|
+
const start = Math.max(1, Math.min(currentPage - half, end - maxLinks + 1));
|
|
795
|
+
const pages: number[] = [];
|
|
796
|
+
for (let page = start; page <= end; page += 1) {
|
|
797
|
+
pages.push(page);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// A designer who marked a child as the page-link layout gets that layout
|
|
801
|
+
// repeated per page; otherwise the built-in links render unchanged. Prev,
|
|
802
|
+
// next and the edge affordances stay built in either way.
|
|
803
|
+
const pageItems = pages.map((page) => ({
|
|
804
|
+
page,
|
|
805
|
+
name: String(page),
|
|
806
|
+
label: String(page),
|
|
807
|
+
url: hrefForPage(page),
|
|
808
|
+
current: page === currentPage
|
|
809
|
+
}));
|
|
810
|
+
const customPages = hasItemTemplate(block, currentBreakpoint) ? (
|
|
811
|
+
<ItemTemplateList
|
|
812
|
+
block={block}
|
|
813
|
+
items={pageItems as unknown as Record<string, unknown>[]}
|
|
814
|
+
placeholderId={placeholderId}
|
|
815
|
+
sectionId={sectionId}
|
|
816
|
+
isDesigner={isDesigner}
|
|
817
|
+
selectedBlockId={selectedBlockId}
|
|
818
|
+
currentBreakpoint={currentBreakpoint}
|
|
819
|
+
/>
|
|
820
|
+
) : null;
|
|
821
|
+
|
|
822
|
+
return withStatus(
|
|
823
|
+
<nav
|
|
824
|
+
data-listing-pagination=""
|
|
825
|
+
data-pagination-mode="pages"
|
|
826
|
+
aria-label={navLabel}
|
|
827
|
+
{...partAttrs('list')}
|
|
828
|
+
style={{
|
|
829
|
+
display: 'flex',
|
|
830
|
+
alignItems: 'center',
|
|
831
|
+
gap: 'var(--listing-pagination-gap, 8px)',
|
|
832
|
+
flexWrap: 'wrap'
|
|
833
|
+
}}
|
|
834
|
+
>
|
|
835
|
+
{showPrevNext && currentPage > 1 && (
|
|
836
|
+
<NextLink
|
|
837
|
+
href={hrefForPage(currentPage - 1)}
|
|
838
|
+
rel="prev"
|
|
839
|
+
onClick={guard}
|
|
840
|
+
data-pagination-prev=""
|
|
841
|
+
{...partAttrs('prev')}
|
|
842
|
+
style={linkStyle(false)}
|
|
843
|
+
>
|
|
844
|
+
{prevLabel}
|
|
845
|
+
</NextLink>
|
|
846
|
+
)}
|
|
847
|
+
|
|
848
|
+
{showFirstLast && start > 1 && (
|
|
849
|
+
<NextLink
|
|
850
|
+
href={hrefForPage(1)}
|
|
851
|
+
onClick={guard}
|
|
852
|
+
data-pagination-first=""
|
|
853
|
+
{...partAttrs('first')}
|
|
854
|
+
style={linkStyle(false)}
|
|
855
|
+
>
|
|
856
|
+
{firstLabel}
|
|
857
|
+
</NextLink>
|
|
858
|
+
)}
|
|
859
|
+
|
|
860
|
+
{showEllipsis && start > 1 && (
|
|
861
|
+
<span
|
|
862
|
+
data-pagination-ellipsis="start"
|
|
863
|
+
aria-hidden="true"
|
|
864
|
+
{...partAttrs('ellipsis')}
|
|
865
|
+
>
|
|
866
|
+
{ellipsisLabel}
|
|
867
|
+
</span>
|
|
868
|
+
)}
|
|
869
|
+
|
|
870
|
+
{customPages ??
|
|
871
|
+
pages.map((page) => (
|
|
872
|
+
<NextLink
|
|
873
|
+
key={page}
|
|
874
|
+
href={hrefForPage(page)}
|
|
875
|
+
onClick={guard}
|
|
876
|
+
data-pagination-page={page}
|
|
877
|
+
aria-current={page === currentPage ? 'page' : undefined}
|
|
878
|
+
{...partAttrs('page', { selected: page === currentPage })}
|
|
879
|
+
style={linkStyle(page === currentPage)}
|
|
880
|
+
>
|
|
881
|
+
{page}
|
|
882
|
+
</NextLink>
|
|
883
|
+
))}
|
|
884
|
+
|
|
885
|
+
{showEllipsis && end < totalPages && (
|
|
886
|
+
<span
|
|
887
|
+
data-pagination-ellipsis="end"
|
|
888
|
+
aria-hidden="true"
|
|
889
|
+
{...partAttrs('ellipsis')}
|
|
890
|
+
>
|
|
891
|
+
{ellipsisLabel}
|
|
892
|
+
</span>
|
|
893
|
+
)}
|
|
894
|
+
|
|
895
|
+
{showFirstLast && end < totalPages && (
|
|
896
|
+
<NextLink
|
|
897
|
+
href={hrefForPage(totalPages)}
|
|
898
|
+
onClick={guard}
|
|
899
|
+
data-pagination-last=""
|
|
900
|
+
{...partAttrs('last')}
|
|
901
|
+
style={linkStyle(false)}
|
|
902
|
+
>
|
|
903
|
+
{lastLabel}
|
|
904
|
+
</NextLink>
|
|
905
|
+
)}
|
|
906
|
+
|
|
907
|
+
{showPrevNext && currentPage < totalPages && (
|
|
908
|
+
<NextLink
|
|
909
|
+
href={hrefForPage(currentPage + 1)}
|
|
910
|
+
rel="next"
|
|
911
|
+
onClick={guard}
|
|
912
|
+
data-pagination-next=""
|
|
913
|
+
{...partAttrs('next')}
|
|
914
|
+
style={linkStyle(false)}
|
|
915
|
+
>
|
|
916
|
+
{nextLabel}
|
|
917
|
+
</NextLink>
|
|
918
|
+
)}
|
|
919
|
+
</nav>
|
|
920
|
+
);
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
export default ListingPaginationBlock;
|