@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,636 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
+ import { getResponsiveValue, partAttrs, resolveOption } from '../utils';
6
+ import { useOverlay } from '../hooks/use-overlay';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import {
9
+ buildListingHref,
10
+ listingPlaceholderStyle,
11
+ readToggle,
12
+ useListingContext
13
+ } from './listing-context';
14
+
15
+ type SortVariant = 'native' | 'panel';
16
+ const SORT_VARIANTS: SortVariant[] = ['native', 'panel'];
17
+
18
+ type TriggerIcon = 'chevron' | 'none';
19
+ const TRIGGER_ICONS: TriggerIcon[] = ['chevron', 'none'];
20
+
21
+ type OptionMark = 'radio' | 'check' | 'none';
22
+ const OPTION_MARKS: OptionMark[] = ['radio', 'check', 'none'];
23
+
24
+ type PanelPosition = 'below' | 'above';
25
+ const PANEL_POSITIONS: PanelPosition[] = ['below', 'above'];
26
+
27
+ type PanelAlign = 'start' | 'end';
28
+ const PANEL_ALIGNS: PanelAlign[] = ['start', 'end'];
29
+
30
+ /** What the properties fall back to when a facet has claimed the typed key. */
31
+ const DEFAULT_PAGE_QUERY_PARAM = 'page';
32
+ const DEFAULT_SORT_QUERY_PARAM = 'sorter';
33
+
34
+ const ChevronIcon = ({ isOpen }: { isOpen: boolean }) => (
35
+ <svg
36
+ width="12"
37
+ height="12"
38
+ viewBox="0 0 24 24"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ strokeWidth="2"
42
+ aria-hidden="true"
43
+ {...partAttrs('trigger-icon', { open: isOpen })}
44
+ style={{
45
+ flexShrink: 0,
46
+ transform: isOpen ? 'rotate(180deg)' : undefined
47
+ }}
48
+ >
49
+ <path d="M6 9l6 6 6-6" />
50
+ </svg>
51
+ );
52
+
53
+ /**
54
+ * The mark that says which row is the applied sort. Both audited brands draw a
55
+ * radio; the check is the second common spelling and `none` leaves the row to
56
+ * say it with type alone. The element is emitted in every state so the row's
57
+ * columns do not shift when the selection moves.
58
+ */
59
+ const OptionMarkIcon = ({
60
+ variant,
61
+ isSelected
62
+ }: {
63
+ variant: OptionMark;
64
+ isSelected: boolean;
65
+ }) => {
66
+ if (variant === 'none') return null;
67
+
68
+ if (variant === 'check') {
69
+ return (
70
+ <svg
71
+ width="14"
72
+ height="14"
73
+ viewBox="0 0 24 24"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ strokeWidth="2.4"
77
+ aria-hidden="true"
78
+ {...partAttrs('option-mark', { selected: isSelected })}
79
+ style={{ flexShrink: 0 }}
80
+ >
81
+ {isSelected && <path d="M5 13l4 4L19 7" />}
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <svg
88
+ width="14"
89
+ height="14"
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ strokeWidth="1.6"
94
+ aria-hidden="true"
95
+ {...partAttrs('option-mark', { selected: isSelected })}
96
+ style={{ flexShrink: 0 }}
97
+ >
98
+ <circle cx="12" cy="12" r="9" />
99
+ {isSelected && (
100
+ <circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none" />
101
+ )}
102
+ </svg>
103
+ );
104
+ };
105
+
106
+ /**
107
+ * Functional listing block: the sort dropdown. Options are commerce-driven
108
+ * (the `sorters` payload of the category API, including which one is
109
+ * currently applied); the editor owns the label, its position and styling.
110
+ *
111
+ * The default is the native `<select>`, and it renders exactly as it always
112
+ * has. Both audited brands draw a bordered panel of options instead, which no
113
+ * amount of CSS can produce from a `<select>` — the browser owns that popup.
114
+ * The 'panel' variant renders the same options as real elements: a trigger
115
+ * that names the applied sort and a listbox of rows.
116
+ *
117
+ * `role="listbox"` is claimed only because its keyboard contract is
118
+ * implemented: roving tab focus over the rows, Up/Down to move, Home/End to
119
+ * jump, Enter/Space to apply, Escape or an outside pointer to dismiss, and
120
+ * focus handed back to the trigger on the way out. Type-ahead is deliberately
121
+ * NOT claimed — a sorter list is short and unordered, and a half-implemented
122
+ * one is worse than none.
123
+ *
124
+ * Parts: group, label, dropdown, option, trigger, trigger-icon, panel,
125
+ * option-mark, option-label.
126
+ * Properties: label, showLabel, labelPosition, placeholderLabel, queryParam,
127
+ * pageQueryParam, variant, triggerTemplate, triggerIcon, optionMark,
128
+ * panelPosition, panelAlign.
129
+ */
130
+ const SortSelectBlock = ({
131
+ block,
132
+ currentBreakpoint = 'desktop',
133
+ isDesigner = false
134
+ }: BlockRendererProps) => {
135
+ const listing = useListingContext();
136
+ const router = useRouter();
137
+ const pathname = usePathname();
138
+ const searchParams = useSearchParams();
139
+
140
+ const label = String(
141
+ getResponsiveValue(block.properties?.label, currentBreakpoint, 'Sort by')
142
+ );
143
+ const showLabel = readToggle(
144
+ block.properties?.showLabel,
145
+ currentBreakpoint,
146
+ true
147
+ );
148
+ const labelPosition = String(
149
+ getResponsiveValue(
150
+ block.properties?.labelPosition,
151
+ currentBreakpoint,
152
+ 'before'
153
+ )
154
+ );
155
+ // The empty option the controlled <select> falls back to when commerce
156
+ // reports no applied sort.
157
+ const placeholderLabel = String(
158
+ getResponsiveValue(
159
+ block.properties?.placeholderLabel,
160
+ currentBreakpoint,
161
+ '—'
162
+ )
163
+ );
164
+ const queryParamRaw = String(
165
+ getResponsiveValue(
166
+ block.properties?.queryParam,
167
+ currentBreakpoint,
168
+ DEFAULT_SORT_QUERY_PARAM
169
+ )
170
+ );
171
+ const pageQueryParamRaw = String(
172
+ getResponsiveValue(
173
+ block.properties?.pageQueryParam,
174
+ currentBreakpoint,
175
+ DEFAULT_PAGE_QUERY_PARAM
176
+ )
177
+ );
178
+
179
+ const variant = resolveOption<SortVariant>(
180
+ block.properties?.variant,
181
+ currentBreakpoint,
182
+ 'native',
183
+ SORT_VARIANTS
184
+ );
185
+ const triggerTemplate = String(
186
+ getResponsiveValue(
187
+ block.properties?.triggerTemplate,
188
+ currentBreakpoint,
189
+ '{value}'
190
+ )
191
+ );
192
+ const triggerIcon = resolveOption<TriggerIcon>(
193
+ block.properties?.triggerIcon,
194
+ currentBreakpoint,
195
+ 'chevron',
196
+ TRIGGER_ICONS
197
+ );
198
+ const optionMark = resolveOption<OptionMark>(
199
+ block.properties?.optionMark,
200
+ currentBreakpoint,
201
+ 'radio',
202
+ OPTION_MARKS
203
+ );
204
+ const panelPosition = resolveOption<PanelPosition>(
205
+ block.properties?.panelPosition,
206
+ currentBreakpoint,
207
+ 'below',
208
+ PANEL_POSITIONS
209
+ );
210
+ const panelAlign = resolveOption<PanelAlign>(
211
+ block.properties?.panelAlign,
212
+ currentBreakpoint,
213
+ 'start',
214
+ PANEL_ALIGNS
215
+ );
216
+
217
+ const sorters = listing?.sorters || [];
218
+ // No selected sorter means no sort is applied: falling back to the first
219
+ // option would claim a sort that is not in effect, and re-picking it would
220
+ // fire no change event at all.
221
+ const selectedValue = sorters.find((sorter) => sorter.isSelected)?.value ?? '';
222
+ const selectedIndex = sorters.findIndex((sorter) => sorter.isSelected);
223
+
224
+ /**
225
+ * The page cursor, refused when a facet already owns the key — the same
226
+ * resolution the facet block makes, because two controls on one listing
227
+ * answering "which key is the cursor" differently is how a filter goes
228
+ * missing from one of them.
229
+ *
230
+ * `pageQueryParam` is free text, and a merchant who types a facet's own
231
+ * `search_key` into it turns every "drop the cursor" into "drop that
232
+ * filter": reordering the results would take the shopper's colour choice
233
+ * off the URL with it.
234
+ *
235
+ * Commerce owns the search keys, so a collision means the property is wrong,
236
+ * not the facet. Falling back to the shipped default keeps a real cursor
237
+ * being dropped on a sort change; an empty key (when the default is claimed
238
+ * too) means this listing has no cursor to drop, which is strictly better
239
+ * than erasing a filter.
240
+ */
241
+ const facets = listing?.facets || [];
242
+ const ownedByFacet = (key: string) =>
243
+ key !== '' && facets.some((facet) => facet.searchKey === key);
244
+
245
+ const pageQueryParam = ownedByFacet(pageQueryParamRaw)
246
+ ? ownedByFacet(DEFAULT_PAGE_QUERY_PARAM)
247
+ ? ''
248
+ : DEFAULT_PAGE_QUERY_PARAM
249
+ : pageQueryParamRaw;
250
+
251
+ /**
252
+ * The sort key, held to the same rule — and the collision bites harder here.
253
+ * The cursor is only deleted, but the sort key is WRITTEN
254
+ * (`params.set(queryParam, …)`), so a merchant who types a facet's
255
+ * `search_key` into this field does not merely drop the shopper's colour, it
256
+ * overwrites it with a sorter value: `?attributes_color=price_desc`, which
257
+ * commerce reads as a colour nobody stocks, and the grid comes back empty.
258
+ *
259
+ * The rewrite is withheld when a facet owns the shipped default as well:
260
+ * unlike the cursor there is no "write nothing" option — the control has to
261
+ * put the sort somewhere — so on a listing that genuinely facets on
262
+ * `sorter`, the merchant's key is left exactly as typed.
263
+ */
264
+ const queryParam =
265
+ ownedByFacet(queryParamRaw) && !ownedByFacet(DEFAULT_SORT_QUERY_PARAM)
266
+ ? DEFAULT_SORT_QUERY_PARAM
267
+ : queryParamRaw || DEFAULT_SORT_QUERY_PARAM;
268
+
269
+ const rootRef = React.useRef<HTMLDivElement | null>(null);
270
+ const triggerRef = React.useRef<HTMLButtonElement | null>(null);
271
+ const optionRefs = React.useRef<Array<HTMLDivElement | null>>([]);
272
+ const [isOpen, setIsOpen] = React.useState(false);
273
+ // Roving tab focus: exactly one row is tabbable, and it is the one the
274
+ // keyboard is on. Preferred over aria-activedescendant because the row that
275
+ // reads out is then the row that actually holds focus.
276
+ const [activeIndex, setActiveIndex] = React.useState(-1);
277
+
278
+ const applySorter = React.useCallback(
279
+ (value: string) => {
280
+ // Inert in the designer: navigating would drop the author out of the page.
281
+ if (isDesigner || !value) return;
282
+
283
+ const params = new URLSearchParams(
284
+ searchParams ? searchParams.toString() : ''
285
+ );
286
+ // A new sort order invalidates the current page cursor, when this
287
+ // composition has one to drop.
288
+ if (pageQueryParam) params.delete(pageQueryParam);
289
+ params.set(queryParam, value);
290
+
291
+ router.push(buildListingHref(pathname || listing?.path || '', params));
292
+ },
293
+ [
294
+ isDesigner,
295
+ searchParams,
296
+ pageQueryParam,
297
+ queryParam,
298
+ router,
299
+ pathname,
300
+ listing?.path
301
+ ]
302
+ );
303
+
304
+ const closePanel = React.useCallback(
305
+ (returnFocus: boolean) => {
306
+ setIsOpen(false);
307
+ setActiveIndex(-1);
308
+ // Skipped in the designer: pulling focus inside the canvas iframe would
309
+ // yank the author out of the property field they are typing into.
310
+ if (returnFocus && !isDesigner && triggerRef.current) {
311
+ triggerRef.current.focus();
312
+ }
313
+ },
314
+ [isDesigner]
315
+ );
316
+
317
+ /**
318
+ * Escape is page-wide state, so the shared overlay stack decides whose it
319
+ * is. This panel used to answer it from its own document listener, which
320
+ * meant an Escape aimed at a modal opened over the listing dismissed this
321
+ * panel underneath at the same time.
322
+ *
323
+ * Everything else here stays the block's, because this is a listbox popup
324
+ * and not an overlay: `modal: false` (no backdrop, nothing covered, so no
325
+ * `aria-modal` and no focus trap), `manageFocus: false` because the roving
326
+ * tab focus below already owns where focus sits, and the return to the
327
+ * trigger is the combobox pattern's, run from `onClose` so Escape still
328
+ * lands the shopper back where they opened from.
329
+ */
330
+ useOverlay({
331
+ open: isOpen,
332
+ onClose: () => closePanel(true),
333
+ isDesigner,
334
+ modal: false,
335
+ manageFocus: false
336
+ });
337
+
338
+ React.useEffect(() => {
339
+ if (!isOpen) return undefined;
340
+
341
+ // mousedown rather than click: the panel must be gone before the pointer
342
+ // press moves focus, or the trigger re-opens it on the way back up. This
343
+ // one stays here — pointer dismissal is not something the overlay stack
344
+ // models, since it depends on what counts as "inside this block".
345
+ const handlePointerDown = (event: MouseEvent) => {
346
+ const root = rootRef.current;
347
+ if (root && !root.contains(event.target as Node)) closePanel(false);
348
+ };
349
+
350
+ document.addEventListener('mousedown', handlePointerDown);
351
+
352
+ return () => document.removeEventListener('mousedown', handlePointerDown);
353
+ }, [isOpen, closePanel]);
354
+
355
+ React.useEffect(() => {
356
+ if (!isOpen || activeIndex < 0 || isDesigner) return;
357
+
358
+ const option = optionRefs.current[activeIndex];
359
+ if (option) option.focus();
360
+ }, [isOpen, activeIndex, isDesigner]);
361
+
362
+ if (sorters.length === 0) {
363
+ return isDesigner ? (
364
+ <div data-sort-select="" style={listingPlaceholderStyle}>
365
+ Sort (no sorter data)
366
+ </div>
367
+ ) : null;
368
+ }
369
+
370
+ const controlId = String(block.id || 'sort').replace(/[^a-zA-Z0-9_-]/g, '');
371
+ const labelId = `sort-${controlId}-label`;
372
+ const triggerId = `sort-${controlId}-trigger`;
373
+ const listboxId = `sort-${controlId}-listbox`;
374
+
375
+ const renderLabel = (withId: boolean) =>
376
+ showLabel && (
377
+ <span
378
+ data-sort-select-label=""
379
+ {...partAttrs('label')}
380
+ {...(withId ? { id: labelId } : null)}
381
+ >
382
+ {label}
383
+ </span>
384
+ );
385
+
386
+ if (variant !== 'panel') {
387
+ const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
388
+ applySorter(event.target.value);
389
+ };
390
+
391
+ const labelNode = renderLabel(false);
392
+
393
+ return (
394
+ <label
395
+ data-sort-select=""
396
+ {...partAttrs('group')}
397
+ style={{
398
+ display: 'inline-flex',
399
+ alignItems: 'center',
400
+ gap: '8px',
401
+ color: 'inherit'
402
+ }}
403
+ >
404
+ {labelPosition !== 'after' && labelNode}
405
+ <select
406
+ data-sort-select-input=""
407
+ {...partAttrs('dropdown')}
408
+ aria-label={label}
409
+ value={selectedValue}
410
+ onChange={handleChange}
411
+ style={{
412
+ border: '1px solid currentColor',
413
+ borderRadius: 'inherit',
414
+ background: 'transparent',
415
+ color: 'inherit',
416
+ font: 'inherit',
417
+ padding: '6px 8px',
418
+ cursor: 'pointer'
419
+ }}
420
+ >
421
+ {/* The <select> is controlled, so the empty value needs a real
422
+ matching option or React drops the selection on every render. */}
423
+ {!selectedValue && <option value="">{placeholderLabel}</option>}
424
+ {sorters.map((sorter) => (
425
+ <option
426
+ key={sorter.value}
427
+ value={sorter.value}
428
+ {...partAttrs('option', { selected: sorter.isSelected })}
429
+ >
430
+ {sorter.label}
431
+ </option>
432
+ ))}
433
+ </select>
434
+ {labelPosition === 'after' && labelNode}
435
+ </label>
436
+ );
437
+ }
438
+
439
+ const labelNode = renderLabel(true);
440
+ const currentLabel =
441
+ selectedIndex >= 0 ? sorters[selectedIndex].label : placeholderLabel;
442
+ const triggerText = triggerTemplate.includes('{value}')
443
+ ? triggerTemplate.replace(/\{value\}/g, currentLabel)
444
+ : triggerTemplate;
445
+ const lastIndex = sorters.length - 1;
446
+
447
+ const openPanel = (from: 'first' | 'last' | 'selected') => {
448
+ setIsOpen(true);
449
+ setActiveIndex(
450
+ selectedIndex >= 0 ? selectedIndex : from === 'last' ? lastIndex : 0
451
+ );
452
+ };
453
+
454
+ const handleTriggerKeyDown = (event: React.KeyboardEvent) => {
455
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
456
+
457
+ event.preventDefault();
458
+ if (isOpen) {
459
+ setActiveIndex((index) =>
460
+ event.key === 'ArrowUp'
461
+ ? Math.max(0, index - 1)
462
+ : Math.min(lastIndex, index + 1)
463
+ );
464
+ return;
465
+ }
466
+
467
+ openPanel(event.key === 'ArrowUp' ? 'last' : 'first');
468
+ };
469
+
470
+ const handlePanelKeyDown = (event: React.KeyboardEvent) => {
471
+ if (event.key === 'ArrowDown') {
472
+ event.preventDefault();
473
+ setActiveIndex((index) => Math.min(lastIndex, index + 1));
474
+ return;
475
+ }
476
+
477
+ if (event.key === 'ArrowUp') {
478
+ event.preventDefault();
479
+ setActiveIndex((index) => Math.max(0, index - 1));
480
+ return;
481
+ }
482
+
483
+ if (event.key === 'Home') {
484
+ event.preventDefault();
485
+ setActiveIndex(0);
486
+ return;
487
+ }
488
+
489
+ if (event.key === 'End') {
490
+ event.preventDefault();
491
+ setActiveIndex(lastIndex);
492
+ return;
493
+ }
494
+
495
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
496
+ event.preventDefault();
497
+ const sorter = sorters[activeIndex];
498
+ if (sorter) {
499
+ applySorter(sorter.value);
500
+ closePanel(true);
501
+ }
502
+ return;
503
+ }
504
+
505
+ // Tab is the shopper leaving the control, not dismissing it: the panel
506
+ // closes but focus is allowed to move on rather than snapping back.
507
+ if (event.key === 'Tab') closePanel(false);
508
+ };
509
+
510
+ const control = (
511
+ <div
512
+ ref={rootRef}
513
+ data-sort-select-control=""
514
+ style={{ position: 'relative', display: 'inline-flex' }}
515
+ >
516
+ <button
517
+ ref={triggerRef}
518
+ type="button"
519
+ data-sort-select-trigger=""
520
+ {...partAttrs('trigger', { open: isOpen })}
521
+ id={triggerId}
522
+ aria-haspopup="listbox"
523
+ aria-expanded={isOpen}
524
+ aria-controls={listboxId}
525
+ // The trigger has to say WHAT is selected, not just "Sort by". With a
526
+ // visible label the two are concatenated, which is what a native
527
+ // select reports; without one the label is folded into aria-label.
528
+ {...(showLabel
529
+ ? { 'aria-labelledby': `${labelId} ${triggerId}` }
530
+ : { 'aria-label': `${label}: ${currentLabel}` })}
531
+ onClick={() => (isOpen ? closePanel(false) : openPanel('selected'))}
532
+ onKeyDown={handleTriggerKeyDown}
533
+ style={{
534
+ display: 'inline-flex',
535
+ alignItems: 'center',
536
+ gap: '8px',
537
+ border: '1px solid currentColor',
538
+ borderRadius: 'inherit',
539
+ background: 'transparent',
540
+ color: 'inherit',
541
+ font: 'inherit',
542
+ padding: '6px 8px',
543
+ cursor: 'pointer'
544
+ }}
545
+ >
546
+ <span data-sort-select-trigger-value="">{triggerText}</span>
547
+ {triggerIcon === 'chevron' && <ChevronIcon isOpen={isOpen} />}
548
+ </button>
549
+
550
+ {isOpen && (
551
+ <div
552
+ id={listboxId}
553
+ role="listbox"
554
+ data-sort-select-panel=""
555
+ {...partAttrs('panel')}
556
+ aria-label={label}
557
+ onKeyDown={handlePanelKeyDown}
558
+ style={{
559
+ position: 'absolute',
560
+ zIndex: 20,
561
+ minWidth: '100%',
562
+ display: 'flex',
563
+ flexDirection: 'column',
564
+ border: '1px solid currentColor',
565
+ borderRadius: 'inherit',
566
+ background: '#ffffff',
567
+ color: 'inherit',
568
+ ...(panelPosition === 'above'
569
+ ? { bottom: '100%', marginBottom: '4px' }
570
+ : { top: '100%', marginTop: '4px' }),
571
+ ...(panelAlign === 'end' ? { right: 0 } : { left: 0 })
572
+ }}
573
+ >
574
+ {sorters.map((sorter, index) => (
575
+ <div
576
+ key={sorter.value}
577
+ ref={(node) => {
578
+ optionRefs.current[index] = node;
579
+ }}
580
+ role="option"
581
+ id={`${listboxId}-option-${index}`}
582
+ aria-selected={sorter.isSelected}
583
+ tabIndex={index === activeIndex ? 0 : -1}
584
+ data-sort-select-option={sorter.value}
585
+ {...partAttrs('option', { selected: sorter.isSelected })}
586
+ onClick={() => {
587
+ applySorter(sorter.value);
588
+ closePanel(true);
589
+ }}
590
+ onFocus={() => setActiveIndex(index)}
591
+ style={{
592
+ display: 'flex',
593
+ alignItems: 'center',
594
+ gap: '8px',
595
+ padding: '8px 12px',
596
+ cursor: 'pointer',
597
+ whiteSpace: 'nowrap'
598
+ }}
599
+ >
600
+ <OptionMarkIcon
601
+ variant={optionMark}
602
+ isSelected={sorter.isSelected}
603
+ />
604
+ <span
605
+ data-sort-select-option-label=""
606
+ {...partAttrs('option-label')}
607
+ >
608
+ {sorter.label}
609
+ </span>
610
+ </div>
611
+ ))}
612
+ </div>
613
+ )}
614
+ </div>
615
+ );
616
+
617
+ return (
618
+ <div
619
+ data-sort-select=""
620
+ data-sort-select-variant="panel"
621
+ {...partAttrs('group')}
622
+ style={{
623
+ display: 'inline-flex',
624
+ alignItems: 'center',
625
+ gap: '8px',
626
+ color: 'inherit'
627
+ }}
628
+ >
629
+ {labelPosition !== 'after' && labelNode}
630
+ {control}
631
+ {labelPosition === 'after' && labelNode}
632
+ </div>
633
+ );
634
+ };
635
+
636
+ export default SortSelectBlock;