@akinon/pz-theme 2.0.37-beta.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 +4 -4
- 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,1686 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
6
|
+
import { getResponsiveValue, partAttrs } from '../utils';
|
|
7
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
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
|
+
ListingFacet,
|
|
16
|
+
ListingFacetChoice
|
|
17
|
+
} from './listing-context';
|
|
18
|
+
|
|
19
|
+
type ToggleIconVariant = 'chevron' | 'plus' | 'none';
|
|
20
|
+
|
|
21
|
+
const TOGGLE_ICON_VARIANTS: ToggleIconVariant[] = ['chevron', 'plus', 'none'];
|
|
22
|
+
|
|
23
|
+
type TriggerIconVariant = 'funnel' | 'sliders' | 'none';
|
|
24
|
+
|
|
25
|
+
const TRIGGER_ICON_VARIANTS: TriggerIconVariant[] = [
|
|
26
|
+
'funnel',
|
|
27
|
+
'sliders',
|
|
28
|
+
'none'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const clampNumber = (
|
|
32
|
+
value: unknown,
|
|
33
|
+
fallback: number,
|
|
34
|
+
min: number,
|
|
35
|
+
max: number
|
|
36
|
+
) => {
|
|
37
|
+
const numeric = Math.round(Number(value));
|
|
38
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
39
|
+
return Math.min(max, Math.max(min, numeric));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const OPTION_ORDERS = [
|
|
43
|
+
'default',
|
|
44
|
+
'label-asc',
|
|
45
|
+
'label-desc',
|
|
46
|
+
'count-desc'
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
type OptionOrder = (typeof OPTION_ORDERS)[number];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The affordance next to a collapsible group heading. `chevron` is the shipped
|
|
53
|
+
* look; `plus` is the second most asked for and `none` lets a brand rely on
|
|
54
|
+
* the heading alone.
|
|
55
|
+
*/
|
|
56
|
+
const ToggleIcon = ({
|
|
57
|
+
variant,
|
|
58
|
+
isCollapsed
|
|
59
|
+
}: {
|
|
60
|
+
variant: ToggleIconVariant;
|
|
61
|
+
isCollapsed: boolean;
|
|
62
|
+
}) => {
|
|
63
|
+
if (variant === 'none') return null;
|
|
64
|
+
|
|
65
|
+
if (variant === 'plus') {
|
|
66
|
+
return (
|
|
67
|
+
<svg
|
|
68
|
+
width="14"
|
|
69
|
+
height="14"
|
|
70
|
+
viewBox="0 0 24 24"
|
|
71
|
+
fill="none"
|
|
72
|
+
stroke="currentColor"
|
|
73
|
+
strokeWidth="1.8"
|
|
74
|
+
{...partAttrs('icon', { open: !isCollapsed })}
|
|
75
|
+
>
|
|
76
|
+
<path d="M4 12h16" />
|
|
77
|
+
{isCollapsed && <path d="M12 4v16" />}
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<svg
|
|
84
|
+
width="14"
|
|
85
|
+
height="14"
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="1.8"
|
|
90
|
+
{...partAttrs('icon', { open: !isCollapsed })}
|
|
91
|
+
style={{
|
|
92
|
+
transform: isCollapsed ? 'none' : 'rotate(180deg)',
|
|
93
|
+
transition: 'transform 150ms'
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<path d="M6 9l6 6 6-6" />
|
|
97
|
+
</svg>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The glyph on the panel trigger. Both audited designs pair a mark with the
|
|
103
|
+
* word ("Filtrele", "Filtre & Sıralama"), and the two marks in circulation are
|
|
104
|
+
* the funnel and the slider stack — so both ship, and `none` leaves the word
|
|
105
|
+
* alone.
|
|
106
|
+
*/
|
|
107
|
+
const TriggerIcon = ({ variant }: { variant: TriggerIconVariant }) => {
|
|
108
|
+
if (variant === 'none') return null;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<svg
|
|
112
|
+
width="16"
|
|
113
|
+
height="16"
|
|
114
|
+
viewBox="0 0 24 24"
|
|
115
|
+
fill="none"
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
strokeWidth="1.8"
|
|
118
|
+
strokeLinecap="round"
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
{...partAttrs('trigger-icon')}
|
|
121
|
+
>
|
|
122
|
+
{variant === 'sliders' ? (
|
|
123
|
+
<>
|
|
124
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
125
|
+
<circle cx="9" cy="7" r="2" fill="currentColor" stroke="none" />
|
|
126
|
+
<circle cx="15" cy="12" r="2" fill="currentColor" stroke="none" />
|
|
127
|
+
<circle cx="8" cy="17" r="2" fill="currentColor" stroke="none" />
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<path d="M3 5h18l-7 8v6l-4 2v-8L3 5z" strokeLinejoin="round" />
|
|
131
|
+
)}
|
|
132
|
+
</svg>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const CloseIcon = () => (
|
|
137
|
+
<svg
|
|
138
|
+
width="18"
|
|
139
|
+
height="18"
|
|
140
|
+
viewBox="0 0 24 24"
|
|
141
|
+
fill="none"
|
|
142
|
+
stroke="currentColor"
|
|
143
|
+
strokeWidth="1.8"
|
|
144
|
+
strokeLinecap="round"
|
|
145
|
+
aria-hidden="true"
|
|
146
|
+
>
|
|
147
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
148
|
+
</svg>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Facet keys a designer typed. Free text on purpose: the keys are the brand's
|
|
153
|
+
* own attribute names, so no fixed list could offer the right ones. Commas and
|
|
154
|
+
* newlines both separate, because a designer will use either.
|
|
155
|
+
*/
|
|
156
|
+
const readFacetKeys = (value: unknown, currentBreakpoint: string): string[] =>
|
|
157
|
+
String(getResponsiveValue(value, currentBreakpoint, '') ?? '')
|
|
158
|
+
.split(/[,\n]/)
|
|
159
|
+
.map((key) => key.trim().toLowerCase())
|
|
160
|
+
.filter(Boolean);
|
|
161
|
+
|
|
162
|
+
const orderChoices = (
|
|
163
|
+
choices: ListingFacetChoice[],
|
|
164
|
+
order: OptionOrder,
|
|
165
|
+
locale?: string
|
|
166
|
+
): ListingFacetChoice[] => {
|
|
167
|
+
if (order === 'default') return choices;
|
|
168
|
+
|
|
169
|
+
// Copied before sorting: the array belongs to the memoised listing context
|
|
170
|
+
// and sorting in place would reorder it for every other block too.
|
|
171
|
+
const sorted = choices.slice();
|
|
172
|
+
|
|
173
|
+
if (order === 'count-desc') {
|
|
174
|
+
sorted.sort((a, b) => b.quantity - a.quantity);
|
|
175
|
+
return sorted;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const direction = order === 'label-desc' ? -1 : 1;
|
|
179
|
+
// The locale is explicit: an implicit collation resolves to the Node default
|
|
180
|
+
// on the server and the browser's on the client, which reorders the list
|
|
181
|
+
// between SSR and hydration.
|
|
182
|
+
sorted.sort(
|
|
183
|
+
(a, b) => direction * String(a.label).localeCompare(String(b.label), locale)
|
|
184
|
+
);
|
|
185
|
+
return sorted;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A count template is the designer's own wording — "({count})", "· {count}",
|
|
190
|
+
* "{count} selected". A template that forgot the placeholder still has to show
|
|
191
|
+
* the number, so it is appended rather than dropped.
|
|
192
|
+
*/
|
|
193
|
+
const formatCount = (template: string, count: number): string =>
|
|
194
|
+
template.includes('{count}')
|
|
195
|
+
? template.replace('{count}', String(count))
|
|
196
|
+
: `${template} ${count}`.trim();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Sorted `key=value` pairs, for comparing two querystrings by CONTENT.
|
|
200
|
+
* Staging rebuilds the query by delete+append, so a value the shopper toggled
|
|
201
|
+
* on and back off comes out in a different order than the URL it started from
|
|
202
|
+
* — a raw string compare would report a pending change that is not there.
|
|
203
|
+
*
|
|
204
|
+
* `Array.sort()` with no comparator, never a collator: this is an equality
|
|
205
|
+
* probe, not a list anyone reads, and a locale-aware sort would answer
|
|
206
|
+
* differently on the server than in the browser.
|
|
207
|
+
*/
|
|
208
|
+
const sortedQueryEntries = (query: string): string =>
|
|
209
|
+
Array.from(new URLSearchParams(query).entries())
|
|
210
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
211
|
+
.sort()
|
|
212
|
+
.join('&');
|
|
213
|
+
|
|
214
|
+
const sameQuery = (a: string, b: string): boolean =>
|
|
215
|
+
sortedQueryEntries(a) === sortedQueryEntries(b);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The same comparison with the page cursor left out of both sides. Staging
|
|
219
|
+
* drops the cursor on every toggle because a filter change invalidates it —
|
|
220
|
+
* correct on the way out, but its absence is not itself something to commit.
|
|
221
|
+
* Counted as a difference it lights the Apply button up on a panel that
|
|
222
|
+
* differs in nothing, and committing then throws a shopper on page 4 back to
|
|
223
|
+
* page 1 having changed nothing.
|
|
224
|
+
*/
|
|
225
|
+
const sameFilterQuery = (a: string, b: string, pageKey: string): boolean => {
|
|
226
|
+
const withoutPage = (query: string): string => {
|
|
227
|
+
const params = new URLSearchParams(query);
|
|
228
|
+
// An empty key is "this composition has no cursor to ignore", not a
|
|
229
|
+
// parameter named "" — see `pageQueryParam` below.
|
|
230
|
+
if (pageKey) params.delete(pageKey);
|
|
231
|
+
return params.toString();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return sameQuery(withoutPage(a), withoutPage(b));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** What the property falls back to when a facet has claimed the typed key. */
|
|
238
|
+
const DEFAULT_PAGE_QUERY_PARAM = 'page';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The designer's preferred facet order. Commerce decides the order it returns
|
|
242
|
+
* facets in, and a brand that wanted size above colour had no way to say so
|
|
243
|
+
* short of one block per facet with the styling duplicated on each. Named
|
|
244
|
+
* facets come first in the order given; the rest keep commerce's order behind
|
|
245
|
+
* them, so naming one facet does not reshuffle the others.
|
|
246
|
+
*/
|
|
247
|
+
const orderFacets = (
|
|
248
|
+
facets: ListingFacet[],
|
|
249
|
+
order: string[]
|
|
250
|
+
): ListingFacet[] => {
|
|
251
|
+
if (order.length === 0) return facets;
|
|
252
|
+
|
|
253
|
+
const rankOf = (facet: ListingFacet): number => {
|
|
254
|
+
// Key or display name, the same pair `includeFacets` matches on: a
|
|
255
|
+
// designer reads "Beden" off the page, not "attributes_size".
|
|
256
|
+
const identity = [facet.key, facet.name].map((value) =>
|
|
257
|
+
String(value || '').toLowerCase()
|
|
258
|
+
);
|
|
259
|
+
const rank = order.findIndex((key) => identity.includes(key));
|
|
260
|
+
return rank === -1 ? Number.MAX_SAFE_INTEGER : rank;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return facets
|
|
264
|
+
.map((facet, index) => ({ facet, index, rank: rankOf(facet) }))
|
|
265
|
+
// The original index is the tiebreak, so the unnamed facets keep the order
|
|
266
|
+
// commerce sent rather than whatever the sort happens to do with equal ranks.
|
|
267
|
+
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
|
268
|
+
.map((entry) => entry.facet);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Functional listing block: commerce facets/filters. Facet CONTENT and the
|
|
273
|
+
* querystring keys are commerce-driven (`search_key` per facet, exactly as
|
|
274
|
+
* the category API returns them); the editor owns layout, ordering, wording
|
|
275
|
+
* and styling.
|
|
276
|
+
*
|
|
277
|
+
* A child marked as the item template replaces the option ROW, repeated inside
|
|
278
|
+
* every group, so headings, collapsing and "show more" keep working around it.
|
|
279
|
+
*
|
|
280
|
+
* The `panel` layout renders ONLY a trigger and opens the same groups in a
|
|
281
|
+
* drawer or modal over the page — the arrangement both audited brand designs
|
|
282
|
+
* use, where the toolbar shows a word plus a glyph and the facets live behind
|
|
283
|
+
* it. Every other layout is untouched.
|
|
284
|
+
*
|
|
285
|
+
* Parts: list, group, label, selected-count, icon, option-list, option-search,
|
|
286
|
+
* no-match, option, checkbox, count, show-more, panel, trigger, trigger-icon,
|
|
287
|
+
* trigger-count, backdrop, panel-header, panel-title, close, panel-footer,
|
|
288
|
+
* clear, apply.
|
|
289
|
+
* Properties: layout, collapsible, defaultCollapsed, toggleIcon, showCounts,
|
|
290
|
+
* countTemplate, showCheckbox, optionOrder, hideZeroCounts, maxVisibleOptions,
|
|
291
|
+
* showMoreLabel, showLessLabel, pageQueryParam, triggerLabel, triggerIcon,
|
|
292
|
+
* panelMode, panelSide, panelWidth, showPanelHeader, panelTitle, showClose,
|
|
293
|
+
* closeLabel, showBackdrop, closeOnBackdrop, closeOnEscape, lockScroll,
|
|
294
|
+
* showClear, clearLabel, showApply, applyLabel, applyMode, facetOrder,
|
|
295
|
+
* showOptionSearch, optionSearchLabel, optionSearchMinOptions, noMatchLabel,
|
|
296
|
+
* showSelectedCount, selectedCountTemplate, showTriggerCount,
|
|
297
|
+
* triggerCountTemplate, showDropdownClose, closeOnOutsideClick.
|
|
298
|
+
*/
|
|
299
|
+
const FacetFiltersBlock = ({
|
|
300
|
+
block,
|
|
301
|
+
placeholderId,
|
|
302
|
+
sectionId,
|
|
303
|
+
selectedBlockId = null,
|
|
304
|
+
currentBreakpoint = 'desktop',
|
|
305
|
+
isDesigner = false
|
|
306
|
+
}: BlockRendererProps) => {
|
|
307
|
+
const listing = useListingContext();
|
|
308
|
+
const router = useRouter();
|
|
309
|
+
const pathname = usePathname();
|
|
310
|
+
const searchParams = useSearchParams();
|
|
311
|
+
const { locale } = useLocalization();
|
|
312
|
+
const [collapsedKeys, setCollapsedKeys] = useState<Record<string, boolean>>(
|
|
313
|
+
{}
|
|
314
|
+
);
|
|
315
|
+
const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({});
|
|
316
|
+
const [searchTerms, setSearchTerms] = useState<Record<string, string>>({});
|
|
317
|
+
/**
|
|
318
|
+
* The querystring the shopper has built but not committed, or null when
|
|
319
|
+
* nothing is staged and the URL is the truth. A string rather than a
|
|
320
|
+
* URLSearchParams so React's identity check actually compares the value.
|
|
321
|
+
*/
|
|
322
|
+
const [stagedQuery, setStagedQuery] = useState<string | null>(null);
|
|
323
|
+
// Closed on load, in the designer too: the author opens it from the trigger
|
|
324
|
+
// and the panel is then a real, stylable element on the canvas.
|
|
325
|
+
const [panelOpen, setPanelOpen] = useState(false);
|
|
326
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
327
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
328
|
+
|
|
329
|
+
const layout = String(
|
|
330
|
+
getResponsiveValue(block.properties?.layout, currentBreakpoint, 'sidebar')
|
|
331
|
+
);
|
|
332
|
+
// Which facets this block shows. A brand puts the same block on the page
|
|
333
|
+
// twice — once as the subcategory row across the top, once as the rail —
|
|
334
|
+
// and each instance names the facets it owns. Empty means all of them,
|
|
335
|
+
// which is what every published composition has.
|
|
336
|
+
const includeFacets = readFacetKeys(
|
|
337
|
+
block.properties?.includeFacets,
|
|
338
|
+
currentBreakpoint
|
|
339
|
+
);
|
|
340
|
+
const excludeFacets = readFacetKeys(
|
|
341
|
+
block.properties?.excludeFacets,
|
|
342
|
+
currentBreakpoint
|
|
343
|
+
);
|
|
344
|
+
// Empty means "however commerce sent them", which is what every published
|
|
345
|
+
// composition renders today.
|
|
346
|
+
const facetOrder = readFacetKeys(
|
|
347
|
+
block.properties?.facetOrder,
|
|
348
|
+
currentBreakpoint
|
|
349
|
+
);
|
|
350
|
+
const showAllOption = readToggle(
|
|
351
|
+
block.properties?.showAllOption,
|
|
352
|
+
currentBreakpoint,
|
|
353
|
+
false
|
|
354
|
+
);
|
|
355
|
+
const allLabel = String(
|
|
356
|
+
getResponsiveValue(block.properties?.allLabel, currentBreakpoint, 'All')
|
|
357
|
+
);
|
|
358
|
+
const collapsible = readToggle(
|
|
359
|
+
block.properties?.collapsible,
|
|
360
|
+
currentBreakpoint,
|
|
361
|
+
true
|
|
362
|
+
);
|
|
363
|
+
const defaultCollapsed = readToggle(
|
|
364
|
+
block.properties?.defaultCollapsed,
|
|
365
|
+
currentBreakpoint,
|
|
366
|
+
false
|
|
367
|
+
);
|
|
368
|
+
const showCounts = readToggle(
|
|
369
|
+
block.properties?.showCounts,
|
|
370
|
+
currentBreakpoint,
|
|
371
|
+
true
|
|
372
|
+
);
|
|
373
|
+
const showCheckbox = readToggle(
|
|
374
|
+
block.properties?.showCheckbox,
|
|
375
|
+
currentBreakpoint,
|
|
376
|
+
true
|
|
377
|
+
);
|
|
378
|
+
const hideZeroCounts = readToggle(
|
|
379
|
+
block.properties?.hideZeroCounts,
|
|
380
|
+
currentBreakpoint,
|
|
381
|
+
false
|
|
382
|
+
);
|
|
383
|
+
const countTemplate = String(
|
|
384
|
+
getResponsiveValue(
|
|
385
|
+
block.properties?.countTemplate,
|
|
386
|
+
currentBreakpoint,
|
|
387
|
+
'({count})'
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
const toggleIconRaw = String(
|
|
391
|
+
getResponsiveValue(block.properties?.toggleIcon, currentBreakpoint, 'chevron')
|
|
392
|
+
);
|
|
393
|
+
const toggleIcon = (
|
|
394
|
+
TOGGLE_ICON_VARIANTS.includes(toggleIconRaw as ToggleIconVariant)
|
|
395
|
+
? toggleIconRaw
|
|
396
|
+
: 'chevron'
|
|
397
|
+
) as ToggleIconVariant;
|
|
398
|
+
const optionOrderRaw = String(
|
|
399
|
+
getResponsiveValue(block.properties?.optionOrder, currentBreakpoint, 'default')
|
|
400
|
+
);
|
|
401
|
+
const optionOrder = (
|
|
402
|
+
OPTION_ORDERS.includes(optionOrderRaw as OptionOrder)
|
|
403
|
+
? optionOrderRaw
|
|
404
|
+
: 'default'
|
|
405
|
+
) as OptionOrder;
|
|
406
|
+
const maxVisibleRaw = Math.round(
|
|
407
|
+
Number(
|
|
408
|
+
getResponsiveValue(block.properties?.maxVisibleOptions, currentBreakpoint, 0)
|
|
409
|
+
)
|
|
410
|
+
);
|
|
411
|
+
// 0 means "no cap", which is what every published composition renders today.
|
|
412
|
+
const maxVisibleOptions =
|
|
413
|
+
Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0 ? maxVisibleRaw : 0;
|
|
414
|
+
const showMoreLabel = String(
|
|
415
|
+
getResponsiveValue(
|
|
416
|
+
block.properties?.showMoreLabel,
|
|
417
|
+
currentBreakpoint,
|
|
418
|
+
'Show more'
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
const showLessLabel = String(
|
|
422
|
+
getResponsiveValue(
|
|
423
|
+
block.properties?.showLessLabel,
|
|
424
|
+
currentBreakpoint,
|
|
425
|
+
'Show less'
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
const pageQueryParamRaw = String(
|
|
429
|
+
getResponsiveValue(
|
|
430
|
+
block.properties?.pageQueryParam,
|
|
431
|
+
currentBreakpoint,
|
|
432
|
+
DEFAULT_PAGE_QUERY_PARAM
|
|
433
|
+
)
|
|
434
|
+
);
|
|
435
|
+
// A sixty-option colour or brand facet is unusable without one; off by
|
|
436
|
+
// default because no published composition has ever drawn an input here.
|
|
437
|
+
const showOptionSearch = readToggle(
|
|
438
|
+
block.properties?.showOptionSearch,
|
|
439
|
+
currentBreakpoint,
|
|
440
|
+
false
|
|
441
|
+
);
|
|
442
|
+
const optionSearchLabel = String(
|
|
443
|
+
getResponsiveValue(
|
|
444
|
+
block.properties?.optionSearchLabel,
|
|
445
|
+
currentBreakpoint,
|
|
446
|
+
'Search'
|
|
447
|
+
)
|
|
448
|
+
);
|
|
449
|
+
const optionSearchMinRaw = Math.round(
|
|
450
|
+
Number(
|
|
451
|
+
getResponsiveValue(
|
|
452
|
+
block.properties?.optionSearchMinOptions,
|
|
453
|
+
currentBreakpoint,
|
|
454
|
+
0
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
);
|
|
458
|
+
// 0 means "every group", which is what turning the toggle on plainly implies.
|
|
459
|
+
const optionSearchMinOptions =
|
|
460
|
+
Number.isFinite(optionSearchMinRaw) && optionSearchMinRaw > 0
|
|
461
|
+
? optionSearchMinRaw
|
|
462
|
+
: 0;
|
|
463
|
+
const noMatchLabel = String(
|
|
464
|
+
getResponsiveValue(block.properties?.noMatchLabel, currentBreakpoint, 'No matches')
|
|
465
|
+
);
|
|
466
|
+
const showSelectedCount = readToggle(
|
|
467
|
+
block.properties?.showSelectedCount,
|
|
468
|
+
currentBreakpoint,
|
|
469
|
+
false
|
|
470
|
+
);
|
|
471
|
+
const selectedCountTemplate = String(
|
|
472
|
+
getResponsiveValue(
|
|
473
|
+
block.properties?.selectedCountTemplate,
|
|
474
|
+
currentBreakpoint,
|
|
475
|
+
'({count})'
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
// Dropdown layout only: the floating panel had no way out of its own.
|
|
479
|
+
const showDropdownClose = readToggle(
|
|
480
|
+
block.properties?.showDropdownClose,
|
|
481
|
+
currentBreakpoint,
|
|
482
|
+
false
|
|
483
|
+
);
|
|
484
|
+
const closeOnOutsideClick = readToggle(
|
|
485
|
+
block.properties?.closeOnOutsideClick,
|
|
486
|
+
currentBreakpoint,
|
|
487
|
+
false
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// Panel layout only. Read unconditionally so the hook order never depends on
|
|
491
|
+
// the layout a breakpoint resolved to.
|
|
492
|
+
const triggerLabel = String(
|
|
493
|
+
getResponsiveValue(block.properties?.triggerLabel, currentBreakpoint, 'Filters')
|
|
494
|
+
);
|
|
495
|
+
const triggerIconRaw = String(
|
|
496
|
+
getResponsiveValue(block.properties?.triggerIcon, currentBreakpoint, 'funnel')
|
|
497
|
+
);
|
|
498
|
+
const triggerIcon = (
|
|
499
|
+
TRIGGER_ICON_VARIANTS.includes(triggerIconRaw as TriggerIconVariant)
|
|
500
|
+
? triggerIconRaw
|
|
501
|
+
: 'funnel'
|
|
502
|
+
) as TriggerIconVariant;
|
|
503
|
+
const panelMode = String(
|
|
504
|
+
getResponsiveValue(block.properties?.panelMode, currentBreakpoint, 'drawer')
|
|
505
|
+
);
|
|
506
|
+
const panelSide = String(
|
|
507
|
+
getResponsiveValue(block.properties?.panelSide, currentBreakpoint, 'right')
|
|
508
|
+
);
|
|
509
|
+
const panelWidth = clampNumber(
|
|
510
|
+
getResponsiveValue(block.properties?.panelWidth, currentBreakpoint, 360),
|
|
511
|
+
360,
|
|
512
|
+
240,
|
|
513
|
+
720
|
|
514
|
+
);
|
|
515
|
+
const panelTitle = String(
|
|
516
|
+
getResponsiveValue(block.properties?.panelTitle, currentBreakpoint, 'Filters')
|
|
517
|
+
);
|
|
518
|
+
const closeLabel = String(
|
|
519
|
+
getResponsiveValue(block.properties?.closeLabel, currentBreakpoint, 'Close')
|
|
520
|
+
);
|
|
521
|
+
const clearLabel = String(
|
|
522
|
+
getResponsiveValue(block.properties?.clearLabel, currentBreakpoint, 'Clear all')
|
|
523
|
+
);
|
|
524
|
+
const applyLabel = String(
|
|
525
|
+
getResponsiveValue(block.properties?.applyLabel, currentBreakpoint, 'Apply')
|
|
526
|
+
);
|
|
527
|
+
const showPanelHeader = readToggle(
|
|
528
|
+
block.properties?.showPanelHeader,
|
|
529
|
+
currentBreakpoint,
|
|
530
|
+
true
|
|
531
|
+
);
|
|
532
|
+
const showClose = readToggle(
|
|
533
|
+
block.properties?.showClose,
|
|
534
|
+
currentBreakpoint,
|
|
535
|
+
true
|
|
536
|
+
);
|
|
537
|
+
const showBackdrop = readToggle(
|
|
538
|
+
block.properties?.showBackdrop,
|
|
539
|
+
currentBreakpoint,
|
|
540
|
+
true
|
|
541
|
+
);
|
|
542
|
+
const closeOnBackdrop = readToggle(
|
|
543
|
+
block.properties?.closeOnBackdrop,
|
|
544
|
+
currentBreakpoint,
|
|
545
|
+
true
|
|
546
|
+
);
|
|
547
|
+
const closeOnEscape = readToggle(
|
|
548
|
+
block.properties?.closeOnEscape,
|
|
549
|
+
currentBreakpoint,
|
|
550
|
+
true
|
|
551
|
+
);
|
|
552
|
+
const lockScroll = readToggle(
|
|
553
|
+
block.properties?.lockScroll,
|
|
554
|
+
currentBreakpoint,
|
|
555
|
+
true
|
|
556
|
+
);
|
|
557
|
+
const showClear = readToggle(
|
|
558
|
+
block.properties?.showClear,
|
|
559
|
+
currentBreakpoint,
|
|
560
|
+
false
|
|
561
|
+
);
|
|
562
|
+
const showApply = readToggle(
|
|
563
|
+
block.properties?.showApply,
|
|
564
|
+
currentBreakpoint,
|
|
565
|
+
false
|
|
566
|
+
);
|
|
567
|
+
const showTriggerCount = readToggle(
|
|
568
|
+
block.properties?.showTriggerCount,
|
|
569
|
+
currentBreakpoint,
|
|
570
|
+
false
|
|
571
|
+
);
|
|
572
|
+
const triggerCountTemplate = String(
|
|
573
|
+
getResponsiveValue(
|
|
574
|
+
block.properties?.triggerCountTemplate,
|
|
575
|
+
currentBreakpoint,
|
|
576
|
+
'({count})'
|
|
577
|
+
)
|
|
578
|
+
);
|
|
579
|
+
const applyMode = String(
|
|
580
|
+
getResponsiveValue(block.properties?.applyMode, currentBreakpoint, 'immediate')
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Staging is opt-in, panel-only, and dead without an Apply button.
|
|
585
|
+
*
|
|
586
|
+
* Published compositions apply a choice the moment it is picked and the
|
|
587
|
+
* descriptor promised exactly that, so the URL must keep being written on
|
|
588
|
+
* the tick unless a designer deliberately asks for the other behaviour.
|
|
589
|
+
* `showApply` is part of the gate rather than a separate warning: staging
|
|
590
|
+
* with no way to commit would strand the shopper with a panel that filters
|
|
591
|
+
* nothing.
|
|
592
|
+
*/
|
|
593
|
+
const layoutIsPanel = layout === 'panel';
|
|
594
|
+
const layoutIsDropdown = layout === 'dropdown';
|
|
595
|
+
const stagedApply = layoutIsPanel && showApply && applyMode === 'staged';
|
|
596
|
+
|
|
597
|
+
// Every way out of the panel is a designer toggle, so a composition can
|
|
598
|
+
// legally have all of them off and seal a shopper inside. Only a POINTER
|
|
599
|
+
// route counts here — Escape does not exist on the phone where this layout
|
|
600
|
+
// is used most — and when none survives the header and its close button are
|
|
601
|
+
// forced back on and Escape re-armed. An authored composition must never be
|
|
602
|
+
// a trap, so this cannot be toggled away.
|
|
603
|
+
const hasPointerDismissRoute =
|
|
604
|
+
(showPanelHeader && showClose) ||
|
|
605
|
+
(showBackdrop && closeOnBackdrop) ||
|
|
606
|
+
showApply;
|
|
607
|
+
const panelHeaderVisible = showPanelHeader || !hasPointerDismissRoute;
|
|
608
|
+
const closeVisible = showClose || !hasPointerDismissRoute;
|
|
609
|
+
const escapeClosesPanel = closeOnEscape || !hasPointerDismissRoute;
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Escape, the scroll lock, the focus trap, returning focus to the trigger
|
|
613
|
+
* and the `aria-modal` claim are the shared overlay primitive's, not this
|
|
614
|
+
* block's.
|
|
615
|
+
*
|
|
616
|
+
* They used to be three effects here, and the focus trap among them
|
|
617
|
+
* registered a DOCUMENT-level `focusin` listener that pulled focus back into
|
|
618
|
+
* THIS panel. A listing page routinely has a second panel up at the same
|
|
619
|
+
* time — a store panel, a size picker, a quick view — and two such listeners
|
|
620
|
+
* each read the other's panel as "outside" and pulled against each other
|
|
621
|
+
* until the tab died with a call-stack overflow. The primitive keeps one
|
|
622
|
+
* stack for the page: only the overlay on top traps focus, claims
|
|
623
|
+
* `aria-modal` and answers Escape, and the scroll lock is counted across
|
|
624
|
+
* every block instead of captured per block.
|
|
625
|
+
*
|
|
626
|
+
* `modal` is `showBackdrop` because the panel only actually covers the page
|
|
627
|
+
* when a backdrop is drawn; without one the page underneath stays clickable
|
|
628
|
+
* (see the root's pointer-events), so the claim would be the opposite of
|
|
629
|
+
* what is true and the trap would have nothing to trap against.
|
|
630
|
+
*
|
|
631
|
+
* `manageFocus` is on regardless: a dialog that never receives focus leaves
|
|
632
|
+
* a keyboard or screen-reader shopper parked on the trigger while the panel
|
|
633
|
+
* they just opened is out of reach, and that is as true of the backdropless
|
|
634
|
+
* panel as of the modal one.
|
|
635
|
+
*
|
|
636
|
+
* `isDesigner` keeps all of it inert — on the canvas the panel is held open
|
|
637
|
+
* to be styled, and nothing may steal focus into the iframe, freeze the page
|
|
638
|
+
* being styled, or close the thing the author is working on.
|
|
639
|
+
*/
|
|
640
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
641
|
+
open: panelOpen,
|
|
642
|
+
onClose: () => setPanelOpen(false),
|
|
643
|
+
isDesigner,
|
|
644
|
+
modal: showBackdrop,
|
|
645
|
+
lockScroll,
|
|
646
|
+
closeOnEscape: escapeClosesPanel,
|
|
647
|
+
manageFocus: true,
|
|
648
|
+
returnFocusRef: triggerRef
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const appliedQuery = searchParams ? searchParams.toString() : '';
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
/**
|
|
655
|
+
* Opening the panel, closing it, and a URL written from anywhere else all
|
|
656
|
+
* mean the same thing: the staging slate starts empty. A shopper who
|
|
657
|
+
* backs out of the panel — backdrop, Escape, the close button — must not
|
|
658
|
+
* find their abandoned choice still ticked the next time they open it,
|
|
659
|
+
* and must never have it applied behind their back.
|
|
660
|
+
*/
|
|
661
|
+
setStagedQuery(null);
|
|
662
|
+
}, [panelOpen, appliedQuery]);
|
|
663
|
+
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
// Only the dropdown floats over the page and so can be dismissed by
|
|
666
|
+
// clicking past it; every other layout sits in flow. Skipped in the
|
|
667
|
+
// designer, where a click on the canvas is the author selecting an
|
|
668
|
+
// element, not a shopper dismissing a menu.
|
|
669
|
+
if (!layoutIsDropdown || !closeOnOutsideClick || isDesigner) return undefined;
|
|
670
|
+
|
|
671
|
+
const handlePointerDown = (event: MouseEvent) => {
|
|
672
|
+
const root = rootRef.current;
|
|
673
|
+
if (!root || root.contains(event.target as Node)) return;
|
|
674
|
+
|
|
675
|
+
// Back to every group's default, which for a dropdown is closed. Bailing
|
|
676
|
+
// out when nothing is open keeps an unrelated click from re-rendering.
|
|
677
|
+
setCollapsedKeys((current) =>
|
|
678
|
+
Object.values(current).some((collapsed) => collapsed === false)
|
|
679
|
+
? {}
|
|
680
|
+
: current
|
|
681
|
+
);
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
685
|
+
return () => document.removeEventListener('mousedown', handlePointerDown);
|
|
686
|
+
}, [layoutIsDropdown, closeOnOutsideClick, isDesigner]);
|
|
687
|
+
|
|
688
|
+
const allFacets = listing?.facets || [];
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The page cursor, refused when a facet already owns the key.
|
|
692
|
+
*
|
|
693
|
+
* `pageQueryParam` is free text, and a merchant who types a facet's own
|
|
694
|
+
* `search_key` into it turns every "drop the cursor" into "drop that
|
|
695
|
+
* filter": the key comes off each href this block writes, and — because the
|
|
696
|
+
* staged-versus-applied comparison ignores it on BOTH sides — a genuine
|
|
697
|
+
* choice under that facet compares equal, so Apply commits nothing and just
|
|
698
|
+
* shuts the panel on the shopper.
|
|
699
|
+
*
|
|
700
|
+
* Commerce owns the search keys, so a collision means the property is wrong,
|
|
701
|
+
* not the facet. Falling back to the shipped default keeps a real cursor
|
|
702
|
+
* being dropped on a filter change; an empty key (the default is claimed
|
|
703
|
+
* too) means this listing has no cursor to drop, which is strictly better
|
|
704
|
+
* than erasing a filter.
|
|
705
|
+
*/
|
|
706
|
+
const pageQueryParam = allFacets.some(
|
|
707
|
+
(facet) => facet.searchKey === pageQueryParamRaw
|
|
708
|
+
)
|
|
709
|
+
? allFacets.some((facet) => facet.searchKey === DEFAULT_PAGE_QUERY_PARAM)
|
|
710
|
+
? ''
|
|
711
|
+
: DEFAULT_PAGE_QUERY_PARAM
|
|
712
|
+
: pageQueryParamRaw;
|
|
713
|
+
|
|
714
|
+
/** Drops the page cursor, when this composition has one to drop. */
|
|
715
|
+
const deletePageCursor = (params: URLSearchParams): URLSearchParams => {
|
|
716
|
+
if (pageQueryParam) params.delete(pageQueryParam);
|
|
717
|
+
return params;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const facets = allFacets.filter((facet) => {
|
|
721
|
+
// Both the key and the display name are matched: a designer reads the name
|
|
722
|
+
// off the page ("Beden"), not the internal key ("attributes_size").
|
|
723
|
+
const identity = [facet.key, facet.name].map((value) =>
|
|
724
|
+
String(value || '').toLowerCase()
|
|
725
|
+
);
|
|
726
|
+
const named = (keys: string[]) =>
|
|
727
|
+
keys.some((key) => identity.includes(key));
|
|
728
|
+
|
|
729
|
+
if (includeFacets.length > 0 && !named(includeFacets)) return false;
|
|
730
|
+
|
|
731
|
+
return !named(excludeFacets);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const orderedFacets = orderFacets(facets, facetOrder);
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The staged querystring parsed, or null when nothing is staged. Read from
|
|
738
|
+
* `stagedApply` as well as the state so a stale string can never leak into a
|
|
739
|
+
* composition that applies as you pick.
|
|
740
|
+
*/
|
|
741
|
+
const stagedParams =
|
|
742
|
+
stagedApply && stagedQuery !== null ? new URLSearchParams(stagedQuery) : null;
|
|
743
|
+
|
|
744
|
+
/** Where the next toggle is computed from: the staged query, else the URL. */
|
|
745
|
+
const pendingParams = (): URLSearchParams =>
|
|
746
|
+
new URLSearchParams(stagedQuery !== null ? stagedQuery : appliedQuery);
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* What the shopper's tick shows right now. Applied selection is commerce's
|
|
750
|
+
* answer (`isSelected`) rather than a re-reading of the URL — commerce
|
|
751
|
+
* normalises values and is the one that decided what the page is filtered
|
|
752
|
+
* by. Only while staging does the pending query take over.
|
|
753
|
+
*/
|
|
754
|
+
const isChoicePending = (
|
|
755
|
+
facet: ListingFacet,
|
|
756
|
+
choice: ListingFacetChoice
|
|
757
|
+
): boolean =>
|
|
758
|
+
stagedParams
|
|
759
|
+
? stagedParams.getAll(facet.searchKey).includes(choice.value)
|
|
760
|
+
: choice.isSelected;
|
|
761
|
+
|
|
762
|
+
/** Toggles one choice inside the given params, in place. */
|
|
763
|
+
const toggleChoiceParams = (
|
|
764
|
+
params: URLSearchParams,
|
|
765
|
+
facet: ListingFacet,
|
|
766
|
+
choice: ListingFacetChoice,
|
|
767
|
+
isSelected: boolean
|
|
768
|
+
): URLSearchParams => {
|
|
769
|
+
// Any filter change invalidates the current page cursor.
|
|
770
|
+
deletePageCursor(params);
|
|
771
|
+
|
|
772
|
+
if (facet.widgetType === 'multiselect') {
|
|
773
|
+
const existing = params.getAll(facet.searchKey);
|
|
774
|
+
const next = existing.includes(choice.value)
|
|
775
|
+
? existing.filter((value) => value !== choice.value)
|
|
776
|
+
: existing.concat(choice.value);
|
|
777
|
+
|
|
778
|
+
params.delete(facet.searchKey);
|
|
779
|
+
next.forEach((value) => params.append(facet.searchKey, value));
|
|
780
|
+
} else if (isSelected) {
|
|
781
|
+
params.delete(facet.searchKey);
|
|
782
|
+
} else {
|
|
783
|
+
params.set(facet.searchKey, choice.value);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return params;
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The URL that toggles one choice. Split out from the click handler so a
|
|
791
|
+
* designer-authored option row can bind a link to `item.url` and still
|
|
792
|
+
* filter — a custom row has no way to reach the handler.
|
|
793
|
+
*/
|
|
794
|
+
const hrefForChoice = (
|
|
795
|
+
facet: ListingFacet,
|
|
796
|
+
choice: ListingFacetChoice
|
|
797
|
+
): string =>
|
|
798
|
+
buildListingHref(
|
|
799
|
+
pathname || listing?.path || '',
|
|
800
|
+
toggleChoiceParams(
|
|
801
|
+
new URLSearchParams(appliedQuery),
|
|
802
|
+
facet,
|
|
803
|
+
choice,
|
|
804
|
+
choice.isSelected
|
|
805
|
+
)
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
/** The same page with this facet dropped — what the "All" link points at. */
|
|
809
|
+
const hrefForReset = (facet: ListingFacet): string => {
|
|
810
|
+
const params = deletePageCursor(new URLSearchParams(appliedQuery));
|
|
811
|
+
params.delete(facet.searchKey);
|
|
812
|
+
|
|
813
|
+
return buildListingHref(pathname || listing?.path || '', params);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const handleSelect = (facet: ListingFacet, choice: ListingFacetChoice) => {
|
|
817
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
818
|
+
// page they are editing, so clicks are inert there.
|
|
819
|
+
if (isDesigner) return;
|
|
820
|
+
|
|
821
|
+
if (stagedApply) {
|
|
822
|
+
// On a phone over a slow connection, a fetch per tap is the whole
|
|
823
|
+
// problem the Apply button exists to solve. Nothing reaches the URL
|
|
824
|
+
// until the shopper commits.
|
|
825
|
+
setStagedQuery(
|
|
826
|
+
toggleChoiceParams(
|
|
827
|
+
pendingParams(),
|
|
828
|
+
facet,
|
|
829
|
+
choice,
|
|
830
|
+
isChoicePending(facet, choice)
|
|
831
|
+
).toString()
|
|
832
|
+
);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
router.push(hrefForChoice(facet, choice));
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
// A designer can author the option row itself. The template repeats inside
|
|
840
|
+
// each group rather than replacing the groups, so the headings, the collapse
|
|
841
|
+
// behaviour and the "show more" affordance keep working around it.
|
|
842
|
+
const hasCustomOption = hasItemTemplate(block, currentBreakpoint);
|
|
843
|
+
|
|
844
|
+
if (facets.length === 0) {
|
|
845
|
+
return isDesigner ? (
|
|
846
|
+
<div data-facet-filters="" style={listingPlaceholderStyle}>
|
|
847
|
+
Filters (no facet data)
|
|
848
|
+
</div>
|
|
849
|
+
) : null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// `panel` renders the trigger only; the groups below are drawn inside the
|
|
853
|
+
// drawer/modal instead of in the page flow.
|
|
854
|
+
const isPanel = layout === 'panel';
|
|
855
|
+
// `dropdown` is a topbar whose options live in a panel that opens over the
|
|
856
|
+
// grid instead of pushing it down — the arrangement most brands ask for on a
|
|
857
|
+
// listing page, where a left rail costs too much width.
|
|
858
|
+
const isDropdown = !isPanel && layout === 'dropdown';
|
|
859
|
+
// A row of links, which is how a brand puts its subcategories across the top
|
|
860
|
+
// of a listing. They are anchors rather than buttons on purpose: this row is
|
|
861
|
+
// navigation — a crawler has to follow it, and a shopper has to be able to
|
|
862
|
+
// open one in a new tab.
|
|
863
|
+
const isPills = !isPanel && layout === 'pills';
|
|
864
|
+
const isTopbar = isDropdown || layout === 'topbar';
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Enough of a chip to read as one before a designer touches it. Every value
|
|
868
|
+
* here is overridable through the `pill` part, and the part carries the
|
|
869
|
+
* selected state so the active subcategory can look different.
|
|
870
|
+
*/
|
|
871
|
+
const pillStyle = (selected: boolean): React.CSSProperties => ({
|
|
872
|
+
display: 'inline-flex',
|
|
873
|
+
alignItems: 'center',
|
|
874
|
+
gap: '6px',
|
|
875
|
+
padding: 'var(--facet-filters-pill-padding, 6px 14px)',
|
|
876
|
+
borderRadius: 'var(--facet-filters-pill-radius, 999px)',
|
|
877
|
+
border: '1px solid var(--facet-filters-border, #d1d5db)',
|
|
878
|
+
background: selected
|
|
879
|
+
? 'var(--facet-filters-pill-active-bg, #111827)'
|
|
880
|
+
: 'var(--facet-filters-pill-bg, transparent)',
|
|
881
|
+
color: selected
|
|
882
|
+
? 'var(--facet-filters-pill-active-color, #ffffff)'
|
|
883
|
+
: 'inherit',
|
|
884
|
+
textDecoration: 'none',
|
|
885
|
+
cursor: 'pointer',
|
|
886
|
+
whiteSpace: 'nowrap'
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const facetGroups = orderedFacets.map((facet) => {
|
|
890
|
+
// A pill row is always open: there is no heading to collapse it from.
|
|
891
|
+
const isCollapsed = isPills
|
|
892
|
+
? false
|
|
893
|
+
: isDropdown
|
|
894
|
+
? collapsedKeys[facet.key] !== false
|
|
895
|
+
: collapsible && (collapsedKeys[facet.key] ?? defaultCollapsed);
|
|
896
|
+
const isExpanded = expandedKeys[facet.key] === true;
|
|
897
|
+
|
|
898
|
+
const availableChoices = hideZeroCounts
|
|
899
|
+
? facet.choices.filter(
|
|
900
|
+
(choice) => choice.quantity > 0 || choice.isSelected
|
|
901
|
+
)
|
|
902
|
+
: facet.choices;
|
|
903
|
+
// The pill row is navigation, not a filter panel — no heading, no
|
|
904
|
+
// checkboxes and no search box either.
|
|
905
|
+
const searchVisible =
|
|
906
|
+
!isPills &&
|
|
907
|
+
showOptionSearch &&
|
|
908
|
+
(optionSearchMinOptions === 0 ||
|
|
909
|
+
availableChoices.length >= optionSearchMinOptions);
|
|
910
|
+
const searchTerm = searchVisible ? (searchTerms[facet.key] || '').trim() : '';
|
|
911
|
+
/**
|
|
912
|
+
* Lowercased with the page's locale rather than the host's: Turkish "İ"
|
|
913
|
+
* folds to "i̇" and "I" to "ı", so a shopper searching "istanbul" in a
|
|
914
|
+
* tr-TR store finds nothing under an implicit collation. There is no
|
|
915
|
+
* hydration risk — the term is always empty until someone types.
|
|
916
|
+
*/
|
|
917
|
+
const normalizedTerm = searchTerm.toLocaleLowerCase(locale);
|
|
918
|
+
const searchedChoices = normalizedTerm
|
|
919
|
+
? availableChoices.filter((choice) =>
|
|
920
|
+
String(choice.label).toLocaleLowerCase(locale).includes(normalizedTerm)
|
|
921
|
+
)
|
|
922
|
+
: availableChoices;
|
|
923
|
+
|
|
924
|
+
const visibleChoices = orderChoices(searchedChoices, optionOrder, locale);
|
|
925
|
+
const facetHasSelection = facet.choices.some(
|
|
926
|
+
(choice) => choice.isSelected
|
|
927
|
+
);
|
|
928
|
+
// Counted over every choice, not the visible ones: a selection hidden
|
|
929
|
+
// behind "show more" or filtered out by a search term is still applied.
|
|
930
|
+
const pendingCount = facet.choices.filter((choice) =>
|
|
931
|
+
isChoicePending(facet, choice)
|
|
932
|
+
).length;
|
|
933
|
+
// Per group, and by comparison rather than by count: swapping one option
|
|
934
|
+
// for another leaves the count where it was but is still a staged change.
|
|
935
|
+
const groupHasStagedChange =
|
|
936
|
+
stagedParams !== null &&
|
|
937
|
+
facet.choices.some(
|
|
938
|
+
(choice) => isChoicePending(facet, choice) !== choice.isSelected
|
|
939
|
+
);
|
|
940
|
+
const hasOverflow =
|
|
941
|
+
maxVisibleOptions > 0 && visibleChoices.length > maxVisibleOptions;
|
|
942
|
+
const shownChoices =
|
|
943
|
+
hasOverflow && !isExpanded
|
|
944
|
+
? visibleChoices.slice(0, maxVisibleOptions)
|
|
945
|
+
: visibleChoices;
|
|
946
|
+
const noMatch = normalizedTerm.length > 0 && shownChoices.length === 0;
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The count beside a group heading. It follows the ticks the shopper is
|
|
950
|
+
* looking at, which while staging is the PENDING selection — the trigger
|
|
951
|
+
* badge is the one that reports what is actually applied.
|
|
952
|
+
*/
|
|
953
|
+
const selectedCountBadge =
|
|
954
|
+
showSelectedCount && pendingCount > 0 ? (
|
|
955
|
+
<span
|
|
956
|
+
data-facet-selected-count=""
|
|
957
|
+
{...partAttrs('selected-count', { staged: groupHasStagedChange })}
|
|
958
|
+
style={{ opacity: 0.7, marginLeft: '4px' }}
|
|
959
|
+
>
|
|
960
|
+
{formatCount(selectedCountTemplate, pendingCount)}
|
|
961
|
+
</span>
|
|
962
|
+
) : null;
|
|
963
|
+
|
|
964
|
+
// A `<ul>` may only hold `<li>`; the designer's row is a block, so
|
|
965
|
+
// the custom path renders the same flex column as a plain container.
|
|
966
|
+
const OptionsTag = (hasCustomOption ? 'div' : 'ul') as 'ul';
|
|
967
|
+
const showMoreButton = (
|
|
968
|
+
<button
|
|
969
|
+
type="button"
|
|
970
|
+
data-facet-show-more=""
|
|
971
|
+
aria-expanded={isExpanded}
|
|
972
|
+
{...partAttrs('show-more', { open: isExpanded })}
|
|
973
|
+
onClick={() =>
|
|
974
|
+
setExpandedKeys((current) => ({
|
|
975
|
+
...current,
|
|
976
|
+
[facet.key]: !isExpanded
|
|
977
|
+
}))
|
|
978
|
+
}
|
|
979
|
+
style={{
|
|
980
|
+
background: 'none',
|
|
981
|
+
border: 'none',
|
|
982
|
+
padding: '2px 0',
|
|
983
|
+
cursor: 'pointer',
|
|
984
|
+
color: 'inherit',
|
|
985
|
+
font: 'inherit',
|
|
986
|
+
textDecoration: 'underline',
|
|
987
|
+
textAlign: 'left'
|
|
988
|
+
}}
|
|
989
|
+
>
|
|
990
|
+
{isExpanded ? showLessLabel : showMoreLabel}
|
|
991
|
+
</button>
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Sixty colours or four hundred brands is a scroll, not a choice. The box
|
|
996
|
+
* lives inside the options list so the floating dropdown panel keeps it
|
|
997
|
+
* and the group heading keeps its own layout; it is a row of the list
|
|
998
|
+
* rather than a sibling, which is why it is wrapped in `<li>` whenever the
|
|
999
|
+
* list is a real `<ul>`.
|
|
1000
|
+
*/
|
|
1001
|
+
const searchInput = (
|
|
1002
|
+
<input
|
|
1003
|
+
type="search"
|
|
1004
|
+
data-facet-option-search=""
|
|
1005
|
+
{...partAttrs('option-search')}
|
|
1006
|
+
value={searchTerms[facet.key] || ''}
|
|
1007
|
+
// Local state only: nothing is fetched, stored or navigated, so this
|
|
1008
|
+
// stays live in the designer where the author can style it as typed in.
|
|
1009
|
+
onChange={(event) =>
|
|
1010
|
+
setSearchTerms((current) => ({
|
|
1011
|
+
...current,
|
|
1012
|
+
[facet.key]: event.target.value
|
|
1013
|
+
}))
|
|
1014
|
+
}
|
|
1015
|
+
placeholder={optionSearchLabel}
|
|
1016
|
+
// Named per group: several of these boxes sit on one page and
|
|
1017
|
+
// "Search" alone tells a screen-reader user nothing about which.
|
|
1018
|
+
aria-label={`${optionSearchLabel} ${facet.name}`.trim()}
|
|
1019
|
+
style={{
|
|
1020
|
+
width: '100%',
|
|
1021
|
+
boxSizing: 'border-box',
|
|
1022
|
+
border: '1px solid var(--facet-filters-border, #d1d5db)',
|
|
1023
|
+
borderRadius: 'inherit',
|
|
1024
|
+
background: 'transparent',
|
|
1025
|
+
color: 'inherit',
|
|
1026
|
+
font: 'inherit',
|
|
1027
|
+
padding: '6px 8px',
|
|
1028
|
+
outline: 'none'
|
|
1029
|
+
}}
|
|
1030
|
+
/>
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
// An empty box under a search term reads as a broken filter; say so.
|
|
1034
|
+
const noMatchNotice = (
|
|
1035
|
+
<span
|
|
1036
|
+
data-facet-no-match=""
|
|
1037
|
+
{...partAttrs('no-match')}
|
|
1038
|
+
style={{ opacity: 0.7 }}
|
|
1039
|
+
>
|
|
1040
|
+
{noMatchLabel}
|
|
1041
|
+
</span>
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* The dropdown's floating panel had no control of its own: the only way
|
|
1046
|
+
* back out was the heading that opened it. Shares the `close` part with
|
|
1047
|
+
* the drawer's header button so one styling group dresses both.
|
|
1048
|
+
*/
|
|
1049
|
+
const dropdownCloseButton = (
|
|
1050
|
+
<button
|
|
1051
|
+
type="button"
|
|
1052
|
+
data-facet-dropdown-close=""
|
|
1053
|
+
{...partAttrs('close')}
|
|
1054
|
+
aria-label={closeLabel}
|
|
1055
|
+
onClick={() =>
|
|
1056
|
+
setCollapsedKeys((current) => ({ ...current, [facet.key]: true }))
|
|
1057
|
+
}
|
|
1058
|
+
style={{
|
|
1059
|
+
display: 'inline-flex',
|
|
1060
|
+
alignSelf: 'flex-end',
|
|
1061
|
+
background: 'none',
|
|
1062
|
+
border: 'none',
|
|
1063
|
+
padding: '4px',
|
|
1064
|
+
cursor: 'pointer',
|
|
1065
|
+
color: 'inherit'
|
|
1066
|
+
}}
|
|
1067
|
+
>
|
|
1068
|
+
<CloseIcon />
|
|
1069
|
+
</button>
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
return (
|
|
1073
|
+
<div
|
|
1074
|
+
key={facet.key}
|
|
1075
|
+
data-facet-group={facet.key}
|
|
1076
|
+
{...partAttrs('group', { open: !isCollapsed })}
|
|
1077
|
+
style={{
|
|
1078
|
+
minWidth: isTopbar ? '180px' : undefined,
|
|
1079
|
+
// The panel is positioned against its own facet, not the row.
|
|
1080
|
+
position: isDropdown ? 'relative' : undefined
|
|
1081
|
+
}}
|
|
1082
|
+
>
|
|
1083
|
+
{isPills ? null : collapsible || isDropdown ? (
|
|
1084
|
+
<button
|
|
1085
|
+
type="button"
|
|
1086
|
+
data-facet-toggle=""
|
|
1087
|
+
aria-expanded={!isCollapsed}
|
|
1088
|
+
{...partAttrs('label', { open: !isCollapsed })}
|
|
1089
|
+
onClick={() =>
|
|
1090
|
+
setCollapsedKeys((current) => ({
|
|
1091
|
+
...current,
|
|
1092
|
+
[facet.key]: !isCollapsed
|
|
1093
|
+
}))
|
|
1094
|
+
}
|
|
1095
|
+
style={{
|
|
1096
|
+
display: 'flex',
|
|
1097
|
+
alignItems: 'center',
|
|
1098
|
+
justifyContent: 'space-between',
|
|
1099
|
+
width: '100%',
|
|
1100
|
+
gap: '8px',
|
|
1101
|
+
background: 'none',
|
|
1102
|
+
cursor: 'pointer',
|
|
1103
|
+
color: 'inherit',
|
|
1104
|
+
font: 'inherit',
|
|
1105
|
+
fontWeight: 600,
|
|
1106
|
+
...(isDropdown
|
|
1107
|
+
? {
|
|
1108
|
+
borderStyle: 'solid',
|
|
1109
|
+
borderWidth: '1px',
|
|
1110
|
+
borderColor: 'var(--facet-filters-border, #d1d5db)',
|
|
1111
|
+
padding: '10px 12px'
|
|
1112
|
+
}
|
|
1113
|
+
: { border: 'none', padding: '8px 0' })
|
|
1114
|
+
}}
|
|
1115
|
+
>
|
|
1116
|
+
{/* The badge sits INSIDE the name span so the heading keeps its
|
|
1117
|
+
two children and `space-between` still parks the icon on the
|
|
1118
|
+
far edge. */}
|
|
1119
|
+
<span>
|
|
1120
|
+
{facet.name}
|
|
1121
|
+
{selectedCountBadge}
|
|
1122
|
+
</span>
|
|
1123
|
+
<ToggleIcon variant={toggleIcon} isCollapsed={isCollapsed} />
|
|
1124
|
+
</button>
|
|
1125
|
+
) : (
|
|
1126
|
+
<div
|
|
1127
|
+
data-facet-title=""
|
|
1128
|
+
{...partAttrs('label')}
|
|
1129
|
+
style={{ padding: '8px 0', fontWeight: 600 }}
|
|
1130
|
+
>
|
|
1131
|
+
{facet.name}
|
|
1132
|
+
{selectedCountBadge}
|
|
1133
|
+
</div>
|
|
1134
|
+
)}
|
|
1135
|
+
|
|
1136
|
+
{!isCollapsed && (
|
|
1137
|
+
<OptionsTag
|
|
1138
|
+
data-facet-choices=""
|
|
1139
|
+
{...partAttrs('option-list', {
|
|
1140
|
+
empty: shownChoices.length === 0
|
|
1141
|
+
})}
|
|
1142
|
+
{...(isDropdown ? partAttrs('panel', { open: true }) : null)}
|
|
1143
|
+
style={{
|
|
1144
|
+
listStyle: 'none',
|
|
1145
|
+
margin: 0,
|
|
1146
|
+
padding: isDropdown ? '12px' : 0,
|
|
1147
|
+
display: 'flex',
|
|
1148
|
+
flexDirection: isPills ? 'row' : 'column',
|
|
1149
|
+
flexWrap: isPills ? 'wrap' : 'nowrap',
|
|
1150
|
+
gap: isPills ? 'var(--facet-filters-pill-gap, 8px)' : '6px',
|
|
1151
|
+
...(isDropdown
|
|
1152
|
+
? {
|
|
1153
|
+
// Floating so opening a filter does not push the grid
|
|
1154
|
+
// down — the whole reason a brand moves filters up top.
|
|
1155
|
+
position: 'absolute',
|
|
1156
|
+
zIndex: 20,
|
|
1157
|
+
top: 'calc(100% + 4px)',
|
|
1158
|
+
left: 0,
|
|
1159
|
+
minWidth: '100%',
|
|
1160
|
+
maxHeight: '320px',
|
|
1161
|
+
overflowY: 'auto',
|
|
1162
|
+
background: 'var(--facet-filters-panel-bg, #ffffff)',
|
|
1163
|
+
border:
|
|
1164
|
+
'1px solid var(--facet-filters-border, #d1d5db)',
|
|
1165
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.12)'
|
|
1166
|
+
}
|
|
1167
|
+
: null)
|
|
1168
|
+
}}
|
|
1169
|
+
>
|
|
1170
|
+
{isDropdown &&
|
|
1171
|
+
showDropdownClose &&
|
|
1172
|
+
(hasCustomOption ? (
|
|
1173
|
+
dropdownCloseButton
|
|
1174
|
+
) : (
|
|
1175
|
+
<li style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
1176
|
+
{dropdownCloseButton}
|
|
1177
|
+
</li>
|
|
1178
|
+
))}
|
|
1179
|
+
|
|
1180
|
+
{searchVisible &&
|
|
1181
|
+
(hasCustomOption ? searchInput : <li>{searchInput}</li>)}
|
|
1182
|
+
|
|
1183
|
+
{hasCustomOption && (
|
|
1184
|
+
<ItemTemplateList
|
|
1185
|
+
block={block}
|
|
1186
|
+
items={shownChoices.map((choice) => ({
|
|
1187
|
+
label: choice.label,
|
|
1188
|
+
name: choice.label,
|
|
1189
|
+
value: choice.value,
|
|
1190
|
+
count: choice.quantity,
|
|
1191
|
+
// The tick the shopper sees, staged included — a custom row
|
|
1192
|
+
// binding `item.selected` has to agree with the built-in one.
|
|
1193
|
+
selected: isChoicePending(facet, choice),
|
|
1194
|
+
// Stays the immediate-apply href: an authored row renders a
|
|
1195
|
+
// real link, and a link has to lead somewhere. Staging is the
|
|
1196
|
+
// built-in row's contract, not the anchor's.
|
|
1197
|
+
url: hrefForChoice(facet, choice)
|
|
1198
|
+
}))}
|
|
1199
|
+
placeholderId={placeholderId}
|
|
1200
|
+
sectionId={sectionId}
|
|
1201
|
+
isDesigner={isDesigner}
|
|
1202
|
+
selectedBlockId={selectedBlockId}
|
|
1203
|
+
currentBreakpoint={currentBreakpoint}
|
|
1204
|
+
designerPlaceholderCount={4}
|
|
1205
|
+
/>
|
|
1206
|
+
)}
|
|
1207
|
+
|
|
1208
|
+
{isPills && !hasCustomOption && showAllOption && (
|
|
1209
|
+
<li>
|
|
1210
|
+
<a
|
|
1211
|
+
data-facet-choice="__all__"
|
|
1212
|
+
data-facet-selected={facetHasSelection ? 'false' : 'true'}
|
|
1213
|
+
{...partAttrs('pill', { selected: !facetHasSelection })}
|
|
1214
|
+
href={isDesigner ? undefined : hrefForReset(facet)}
|
|
1215
|
+
aria-current={facetHasSelection ? undefined : 'page'}
|
|
1216
|
+
onClick={(event) => {
|
|
1217
|
+
event.preventDefault();
|
|
1218
|
+
if (isDesigner) return;
|
|
1219
|
+
router.push(hrefForReset(facet));
|
|
1220
|
+
}}
|
|
1221
|
+
style={pillStyle(!facetHasSelection)}
|
|
1222
|
+
>
|
|
1223
|
+
{allLabel}
|
|
1224
|
+
</a>
|
|
1225
|
+
</li>
|
|
1226
|
+
)}
|
|
1227
|
+
|
|
1228
|
+
{isPills &&
|
|
1229
|
+
!hasCustomOption &&
|
|
1230
|
+
shownChoices.map((choice) => (
|
|
1231
|
+
<li key={`${facet.key}-${choice.value}`}>
|
|
1232
|
+
<a
|
|
1233
|
+
data-facet-choice={choice.value}
|
|
1234
|
+
data-facet-selected={choice.isSelected ? 'true' : 'false'}
|
|
1235
|
+
// One part per element: data-part holds a single value, so
|
|
1236
|
+
// a second spread would overwrite the first. A pill is its
|
|
1237
|
+
// own part with its own controls.
|
|
1238
|
+
{...partAttrs('pill', {
|
|
1239
|
+
selected: choice.isSelected,
|
|
1240
|
+
empty: choice.quantity === 0
|
|
1241
|
+
})}
|
|
1242
|
+
href={isDesigner ? undefined : hrefForChoice(facet, choice)}
|
|
1243
|
+
aria-current={choice.isSelected ? 'page' : undefined}
|
|
1244
|
+
onClick={(event) => {
|
|
1245
|
+
// The href is what makes it navigation; the handler is
|
|
1246
|
+
// what keeps it a client transition. A modified click
|
|
1247
|
+
// (new tab, download) is left to the browser.
|
|
1248
|
+
if (
|
|
1249
|
+
event.metaKey ||
|
|
1250
|
+
event.ctrlKey ||
|
|
1251
|
+
event.shiftKey ||
|
|
1252
|
+
event.altKey
|
|
1253
|
+
) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
event.preventDefault();
|
|
1257
|
+
handleSelect(facet, choice);
|
|
1258
|
+
}}
|
|
1259
|
+
style={pillStyle(choice.isSelected)}
|
|
1260
|
+
>
|
|
1261
|
+
<span>{choice.label}</span>
|
|
1262
|
+
{showCounts && choice.quantity > 0 && (
|
|
1263
|
+
<span {...partAttrs('count')} style={{ opacity: 0.6 }}>
|
|
1264
|
+
{formatCount(countTemplate, choice.quantity)}
|
|
1265
|
+
</span>
|
|
1266
|
+
)}
|
|
1267
|
+
</a>
|
|
1268
|
+
</li>
|
|
1269
|
+
))}
|
|
1270
|
+
|
|
1271
|
+
{!isPills &&
|
|
1272
|
+
!hasCustomOption &&
|
|
1273
|
+
shownChoices.map((choice) => {
|
|
1274
|
+
// While staging, the tick follows the pending query and `staged`
|
|
1275
|
+
// marks the ones that differ from what the page is actually
|
|
1276
|
+
// filtered by — that difference IS the staged-versus-applied
|
|
1277
|
+
// story the shopper needs before committing. Off staging both
|
|
1278
|
+
// read straight from commerce, exactly as before.
|
|
1279
|
+
const isPending = isChoicePending(facet, choice);
|
|
1280
|
+
const isStaged = stagedParams !== null && isPending !== choice.isSelected;
|
|
1281
|
+
|
|
1282
|
+
return (
|
|
1283
|
+
<li key={`${facet.key}-${choice.value}`}>
|
|
1284
|
+
<button
|
|
1285
|
+
type="button"
|
|
1286
|
+
data-facet-choice={choice.value}
|
|
1287
|
+
data-facet-selected={isPending ? 'true' : 'false'}
|
|
1288
|
+
aria-pressed={isPending}
|
|
1289
|
+
{...partAttrs('option', {
|
|
1290
|
+
selected: isPending,
|
|
1291
|
+
staged: isStaged,
|
|
1292
|
+
empty: choice.quantity === 0
|
|
1293
|
+
})}
|
|
1294
|
+
onClick={() => handleSelect(facet, choice)}
|
|
1295
|
+
style={{
|
|
1296
|
+
display: 'flex',
|
|
1297
|
+
alignItems: 'center',
|
|
1298
|
+
gap: '8px',
|
|
1299
|
+
width: '100%',
|
|
1300
|
+
background: 'none',
|
|
1301
|
+
border: 'none',
|
|
1302
|
+
padding: '2px 0',
|
|
1303
|
+
cursor: 'pointer',
|
|
1304
|
+
color: 'inherit',
|
|
1305
|
+
font: 'inherit',
|
|
1306
|
+
textAlign: 'left',
|
|
1307
|
+
fontWeight: isPending ? 600 : 400
|
|
1308
|
+
}}
|
|
1309
|
+
>
|
|
1310
|
+
{showCheckbox && (
|
|
1311
|
+
<span
|
|
1312
|
+
aria-hidden="true"
|
|
1313
|
+
{...partAttrs('checkbox', {
|
|
1314
|
+
checked: isPending,
|
|
1315
|
+
staged: isStaged
|
|
1316
|
+
})}
|
|
1317
|
+
style={{
|
|
1318
|
+
width: '14px',
|
|
1319
|
+
height: '14px',
|
|
1320
|
+
flex: '0 0 auto',
|
|
1321
|
+
border: '1px solid currentColor',
|
|
1322
|
+
borderRadius: '2px',
|
|
1323
|
+
background: isPending
|
|
1324
|
+
? 'currentColor'
|
|
1325
|
+
: 'transparent'
|
|
1326
|
+
}}
|
|
1327
|
+
/>
|
|
1328
|
+
)}
|
|
1329
|
+
<span>{choice.label}</span>
|
|
1330
|
+
{showCounts && choice.quantity > 0 && (
|
|
1331
|
+
<span
|
|
1332
|
+
{...partAttrs('count')}
|
|
1333
|
+
style={{ opacity: 0.6 }}
|
|
1334
|
+
>
|
|
1335
|
+
{formatCount(countTemplate, choice.quantity)}
|
|
1336
|
+
</span>
|
|
1337
|
+
)}
|
|
1338
|
+
</button>
|
|
1339
|
+
</li>
|
|
1340
|
+
);
|
|
1341
|
+
})}
|
|
1342
|
+
|
|
1343
|
+
{noMatch &&
|
|
1344
|
+
(hasCustomOption ? noMatchNotice : <li>{noMatchNotice}</li>)}
|
|
1345
|
+
|
|
1346
|
+
{hasOverflow &&
|
|
1347
|
+
(hasCustomOption ? (
|
|
1348
|
+
showMoreButton
|
|
1349
|
+
) : (
|
|
1350
|
+
<li>{showMoreButton}</li>
|
|
1351
|
+
))}
|
|
1352
|
+
</OptionsTag>
|
|
1353
|
+
)}
|
|
1354
|
+
</div>
|
|
1355
|
+
);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
if (!isPanel) {
|
|
1359
|
+
return (
|
|
1360
|
+
<div
|
|
1361
|
+
data-facet-filters=""
|
|
1362
|
+
// Only read by the dropdown's outside-click dismissal, to tell "past
|
|
1363
|
+
// the panel" from "inside this block".
|
|
1364
|
+
ref={rootRef}
|
|
1365
|
+
data-facet-layout={
|
|
1366
|
+
isPills
|
|
1367
|
+
? 'pills'
|
|
1368
|
+
: isDropdown
|
|
1369
|
+
? 'dropdown'
|
|
1370
|
+
: isTopbar
|
|
1371
|
+
? 'topbar'
|
|
1372
|
+
: 'sidebar'
|
|
1373
|
+
}
|
|
1374
|
+
{...partAttrs('list')}
|
|
1375
|
+
style={{
|
|
1376
|
+
display: 'flex',
|
|
1377
|
+
flexDirection: isTopbar ? 'row' : 'column',
|
|
1378
|
+
flexWrap: isTopbar || isPills ? 'wrap' : 'nowrap',
|
|
1379
|
+
gap: 'var(--facet-filters-gap, 16px)'
|
|
1380
|
+
}}
|
|
1381
|
+
>
|
|
1382
|
+
{facetGroups}
|
|
1383
|
+
</div>
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const isModal = panelMode === 'modal';
|
|
1388
|
+
|
|
1389
|
+
/** Drops every facet's key, which is what "clear all" means here. */
|
|
1390
|
+
const handleClear = () => {
|
|
1391
|
+
if (stagedApply) {
|
|
1392
|
+
// Nothing commits until Apply, clearing included — so the panel stays
|
|
1393
|
+
// open and the shopper can pick again before committing once.
|
|
1394
|
+
if (isDesigner) return;
|
|
1395
|
+
|
|
1396
|
+
const params = deletePageCursor(pendingParams());
|
|
1397
|
+
facets.forEach((facet) => params.delete(facet.searchKey));
|
|
1398
|
+
setStagedQuery(params.toString());
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
setPanelOpen(false);
|
|
1403
|
+
// Navigating inside the designer iframe would drop the author out of the
|
|
1404
|
+
// page they are editing, so the control is inert there.
|
|
1405
|
+
if (isDesigner) return;
|
|
1406
|
+
|
|
1407
|
+
const params = deletePageCursor(new URLSearchParams(appliedQuery));
|
|
1408
|
+
facets.forEach((facet) => params.delete(facet.searchKey));
|
|
1409
|
+
|
|
1410
|
+
router.push(buildListingHref(pathname || listing?.path || '', params));
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
const hasStagedChanges =
|
|
1414
|
+
stagedApply &&
|
|
1415
|
+
stagedQuery !== null &&
|
|
1416
|
+
!sameFilterQuery(stagedQuery, appliedQuery, pageQueryParam);
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* The only place a staged composition writes the URL. In an apply-as-you-
|
|
1420
|
+
* pick composition there is nothing staged, so this stays what it always
|
|
1421
|
+
* was: "I am done here", never a second request.
|
|
1422
|
+
*
|
|
1423
|
+
* Guarded on the very answer that lights the button up, not on "something
|
|
1424
|
+
* was staged": a shopper who ticks a box and unticks it has staged twice and
|
|
1425
|
+
* changed nothing, and in App Router a push there is a full navigation —
|
|
1426
|
+
* RSC refetch, scroll to top, a history entry that makes Back look dead.
|
|
1427
|
+
*/
|
|
1428
|
+
const handleApply = () => {
|
|
1429
|
+
if (!isDesigner && hasStagedChanges && stagedQuery !== null) {
|
|
1430
|
+
router.push(
|
|
1431
|
+
buildListingHref(
|
|
1432
|
+
pathname || listing?.path || '',
|
|
1433
|
+
new URLSearchParams(stagedQuery)
|
|
1434
|
+
)
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
setPanelOpen(false);
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* What the page is ACTUALLY filtered by. Deliberately the applied count and
|
|
1443
|
+
* not the staged one: the badge describes the results behind the panel, and
|
|
1444
|
+
* next to a group heading counting the staged ticks it is what makes the
|
|
1445
|
+
* difference between the two visible.
|
|
1446
|
+
*/
|
|
1447
|
+
const appliedFacetCount = facets.reduce(
|
|
1448
|
+
(total, facet) =>
|
|
1449
|
+
total + facet.choices.filter((choice) => choice.isSelected).length,
|
|
1450
|
+
0
|
|
1451
|
+
);
|
|
1452
|
+
|
|
1453
|
+
return (
|
|
1454
|
+
<div data-facet-filters="" data-facet-layout="panel">
|
|
1455
|
+
<button
|
|
1456
|
+
type="button"
|
|
1457
|
+
data-facet-trigger=""
|
|
1458
|
+
ref={triggerRef}
|
|
1459
|
+
{...partAttrs('trigger', { open: panelOpen })}
|
|
1460
|
+
aria-haspopup="dialog"
|
|
1461
|
+
aria-expanded={panelOpen}
|
|
1462
|
+
onClick={() => setPanelOpen(true)}
|
|
1463
|
+
style={{
|
|
1464
|
+
display: 'inline-flex',
|
|
1465
|
+
alignItems: 'center',
|
|
1466
|
+
gap: 'var(--facet-filters-trigger-gap, 8px)',
|
|
1467
|
+
background: 'none',
|
|
1468
|
+
border: 'none',
|
|
1469
|
+
padding: 0,
|
|
1470
|
+
cursor: 'pointer',
|
|
1471
|
+
color: 'inherit',
|
|
1472
|
+
font: 'inherit'
|
|
1473
|
+
}}
|
|
1474
|
+
>
|
|
1475
|
+
<TriggerIcon variant={triggerIcon} />
|
|
1476
|
+
<span data-facet-trigger-label="">{triggerLabel}</span>
|
|
1477
|
+
{showTriggerCount && appliedFacetCount > 0 && (
|
|
1478
|
+
<span
|
|
1479
|
+
data-facet-trigger-count=""
|
|
1480
|
+
{...partAttrs('trigger-count')}
|
|
1481
|
+
style={{ opacity: 0.7 }}
|
|
1482
|
+
>
|
|
1483
|
+
{formatCount(triggerCountTemplate, appliedFacetCount)}
|
|
1484
|
+
</span>
|
|
1485
|
+
)}
|
|
1486
|
+
</button>
|
|
1487
|
+
|
|
1488
|
+
{panelOpen && (
|
|
1489
|
+
<div
|
|
1490
|
+
data-facet-panel-root=""
|
|
1491
|
+
style={{
|
|
1492
|
+
position: 'fixed',
|
|
1493
|
+
inset: 0,
|
|
1494
|
+
zIndex: 1000,
|
|
1495
|
+
display: 'flex',
|
|
1496
|
+
padding: isModal ? '16px' : 0,
|
|
1497
|
+
// With the backdrop off nothing is drawn over the page, but this
|
|
1498
|
+
// root still covers the whole viewport — it would swallow every
|
|
1499
|
+
// click as an invisible sheet of glass. Letting events through it
|
|
1500
|
+
// (and re-enabling them on the panel) keeps the page alive.
|
|
1501
|
+
pointerEvents: showBackdrop ? undefined : 'none'
|
|
1502
|
+
}}
|
|
1503
|
+
>
|
|
1504
|
+
{showBackdrop && (
|
|
1505
|
+
<div
|
|
1506
|
+
data-facet-backdrop=""
|
|
1507
|
+
{...partAttrs('backdrop', { open: true })}
|
|
1508
|
+
onClick={() => {
|
|
1509
|
+
if (closeOnBackdrop) setPanelOpen(false);
|
|
1510
|
+
}}
|
|
1511
|
+
style={{
|
|
1512
|
+
position: 'absolute',
|
|
1513
|
+
inset: 0,
|
|
1514
|
+
background: 'rgba(0, 0, 0, 0.5)'
|
|
1515
|
+
}}
|
|
1516
|
+
/>
|
|
1517
|
+
)}
|
|
1518
|
+
|
|
1519
|
+
<div
|
|
1520
|
+
data-facet-panel=""
|
|
1521
|
+
data-facet-panel-mode={isModal ? 'modal' : 'drawer'}
|
|
1522
|
+
{...partAttrs('panel', { open: true })}
|
|
1523
|
+
role="dialog"
|
|
1524
|
+
// Carries the panel ref and the `aria-modal` claim, and the claim
|
|
1525
|
+
// is made only where it is true: never without a backdrop, never
|
|
1526
|
+
// in the designer, and never on the lower of two open panels,
|
|
1527
|
+
// which is not the one hiding the page. Spread after `role` so the
|
|
1528
|
+
// attribute order the golden captured stays put.
|
|
1529
|
+
{...overlay.overlayProps}
|
|
1530
|
+
aria-label={panelTitle || triggerLabel}
|
|
1531
|
+
// Focus target of last resort, for a panel whose every control the
|
|
1532
|
+
// designer turned off.
|
|
1533
|
+
tabIndex={-1}
|
|
1534
|
+
style={{
|
|
1535
|
+
display: 'flex',
|
|
1536
|
+
flexDirection: 'column',
|
|
1537
|
+
background: 'var(--facet-filters-panel-bg, #ffffff)',
|
|
1538
|
+
color: 'inherit',
|
|
1539
|
+
textAlign: 'left',
|
|
1540
|
+
outline: 'none',
|
|
1541
|
+
pointerEvents: showBackdrop ? undefined : 'auto',
|
|
1542
|
+
...(isModal
|
|
1543
|
+
? {
|
|
1544
|
+
// `margin: auto` inside the flex root is what centres it;
|
|
1545
|
+
// the backdrop is out of flow and does not compete.
|
|
1546
|
+
position: 'relative',
|
|
1547
|
+
margin: 'auto',
|
|
1548
|
+
width: '100%',
|
|
1549
|
+
maxWidth: `${panelWidth}px`,
|
|
1550
|
+
maxHeight: '85vh',
|
|
1551
|
+
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)'
|
|
1552
|
+
}
|
|
1553
|
+
: {
|
|
1554
|
+
position: 'absolute',
|
|
1555
|
+
top: 0,
|
|
1556
|
+
bottom: 0,
|
|
1557
|
+
[panelSide === 'left' ? 'left' : 'right']: 0,
|
|
1558
|
+
width: `min(${panelWidth}px, 92vw)`,
|
|
1559
|
+
boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
|
|
1560
|
+
})
|
|
1561
|
+
}}
|
|
1562
|
+
>
|
|
1563
|
+
{panelHeaderVisible && (
|
|
1564
|
+
<div
|
|
1565
|
+
data-facet-panel-header=""
|
|
1566
|
+
{...partAttrs('panel-header')}
|
|
1567
|
+
style={{
|
|
1568
|
+
display: 'flex',
|
|
1569
|
+
alignItems: 'center',
|
|
1570
|
+
justifyContent: 'space-between',
|
|
1571
|
+
gap: '12px',
|
|
1572
|
+
flex: '0 0 auto',
|
|
1573
|
+
padding: '12px 16px',
|
|
1574
|
+
borderBottom:
|
|
1575
|
+
'1px solid var(--facet-filters-border, #d1d5db)'
|
|
1576
|
+
}}
|
|
1577
|
+
>
|
|
1578
|
+
<span
|
|
1579
|
+
data-facet-panel-title=""
|
|
1580
|
+
{...partAttrs('panel-title')}
|
|
1581
|
+
style={{ fontWeight: 600 }}
|
|
1582
|
+
>
|
|
1583
|
+
{panelTitle}
|
|
1584
|
+
</span>
|
|
1585
|
+
{closeVisible && (
|
|
1586
|
+
<button
|
|
1587
|
+
type="button"
|
|
1588
|
+
data-facet-panel-close=""
|
|
1589
|
+
{...partAttrs('close')}
|
|
1590
|
+
aria-label={closeLabel}
|
|
1591
|
+
onClick={() => setPanelOpen(false)}
|
|
1592
|
+
style={{
|
|
1593
|
+
display: 'inline-flex',
|
|
1594
|
+
background: 'none',
|
|
1595
|
+
border: 'none',
|
|
1596
|
+
padding: '4px',
|
|
1597
|
+
cursor: 'pointer',
|
|
1598
|
+
color: 'inherit'
|
|
1599
|
+
}}
|
|
1600
|
+
>
|
|
1601
|
+
<CloseIcon />
|
|
1602
|
+
</button>
|
|
1603
|
+
)}
|
|
1604
|
+
</div>
|
|
1605
|
+
)}
|
|
1606
|
+
|
|
1607
|
+
<div
|
|
1608
|
+
data-facet-panel-body=""
|
|
1609
|
+
{...partAttrs('list')}
|
|
1610
|
+
style={{
|
|
1611
|
+
display: 'flex',
|
|
1612
|
+
flexDirection: 'column',
|
|
1613
|
+
flex: '1 1 auto',
|
|
1614
|
+
gap: 'var(--facet-filters-gap, 16px)',
|
|
1615
|
+
padding: '16px',
|
|
1616
|
+
overflowY: 'auto'
|
|
1617
|
+
}}
|
|
1618
|
+
>
|
|
1619
|
+
{facetGroups}
|
|
1620
|
+
</div>
|
|
1621
|
+
|
|
1622
|
+
{(showClear || showApply) && (
|
|
1623
|
+
<div
|
|
1624
|
+
data-facet-panel-footer=""
|
|
1625
|
+
{...partAttrs('panel-footer')}
|
|
1626
|
+
style={{
|
|
1627
|
+
display: 'flex',
|
|
1628
|
+
gap: '8px',
|
|
1629
|
+
flex: '0 0 auto',
|
|
1630
|
+
padding: '12px 16px',
|
|
1631
|
+
borderTop: '1px solid var(--facet-filters-border, #d1d5db)'
|
|
1632
|
+
}}
|
|
1633
|
+
>
|
|
1634
|
+
{showClear && (
|
|
1635
|
+
<button
|
|
1636
|
+
type="button"
|
|
1637
|
+
data-facet-clear=""
|
|
1638
|
+
{...partAttrs('clear')}
|
|
1639
|
+
onClick={handleClear}
|
|
1640
|
+
style={{
|
|
1641
|
+
flex: '1 1 0',
|
|
1642
|
+
padding: '10px 12px',
|
|
1643
|
+
background: 'none',
|
|
1644
|
+
borderStyle: 'solid',
|
|
1645
|
+
borderWidth: '1px',
|
|
1646
|
+
borderColor: 'var(--facet-filters-border, #d1d5db)',
|
|
1647
|
+
cursor: 'pointer',
|
|
1648
|
+
color: 'inherit',
|
|
1649
|
+
font: 'inherit'
|
|
1650
|
+
}}
|
|
1651
|
+
>
|
|
1652
|
+
{clearLabel}
|
|
1653
|
+
</button>
|
|
1654
|
+
)}
|
|
1655
|
+
{showApply && (
|
|
1656
|
+
<button
|
|
1657
|
+
type="button"
|
|
1658
|
+
data-facet-apply=""
|
|
1659
|
+
// `staged` lights the button up while there is something
|
|
1660
|
+
// waiting to be committed, so the shopper can tell a panel
|
|
1661
|
+
// they have changed from one they have not.
|
|
1662
|
+
{...partAttrs('apply', { staged: hasStagedChanges })}
|
|
1663
|
+
onClick={handleApply}
|
|
1664
|
+
style={{
|
|
1665
|
+
flex: '1 1 0',
|
|
1666
|
+
padding: '10px 12px',
|
|
1667
|
+
border: 'none',
|
|
1668
|
+
background: '#111827',
|
|
1669
|
+
color: '#ffffff',
|
|
1670
|
+
cursor: 'pointer',
|
|
1671
|
+
font: 'inherit'
|
|
1672
|
+
}}
|
|
1673
|
+
>
|
|
1674
|
+
{applyLabel}
|
|
1675
|
+
</button>
|
|
1676
|
+
)}
|
|
1677
|
+
</div>
|
|
1678
|
+
)}
|
|
1679
|
+
</div>
|
|
1680
|
+
</div>
|
|
1681
|
+
)}
|
|
1682
|
+
</div>
|
|
1683
|
+
);
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
export default FacetFiltersBlock;
|