@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,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;
|