@akinon/pz-theme 2.0.37-rc.0 → 2.0.38-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +15 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. 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;