@akinon/pz-theme 2.0.136-rc.0 → 2.0.137-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.
- package/CHANGELOG.md +15 -35
- package/package.json +4 -4
- package/src/__tests__/registry-parity.test.ts +1 -0
- package/src/blocks/__tests__/listing-density.test.tsx +608 -0
- package/src/blocks/block-renderer-registry.tsx +2 -0
- package/src/blocks/density-switcher.tsx +336 -0
- package/src/blocks/listing-density-block.tsx +103 -0
- package/src/blocks/listing-density-store.ts +303 -0
- package/src/blocks/product-grid-block.tsx +86 -179
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
DESKTOP_MIN_WIDTH,
|
|
6
|
+
TABLET_MEDIA_QUERY_COMPACT,
|
|
7
|
+
TABLET_MIN_WIDTH
|
|
8
|
+
} from '../utils/breakpoints';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shopper's grid density, shared between whoever offers it and the grid
|
|
12
|
+
* that draws it (ZERO-5151).
|
|
13
|
+
*
|
|
14
|
+
* The switcher used to live inside the product grid, so a piece of React state
|
|
15
|
+
* was all it needed. Brands draw it in the listing toolbar instead — next to
|
|
16
|
+
* the filter and sort controls, well away from the grid — so it is now a block
|
|
17
|
+
* of its own, and two blocks that never see each other need one answer to
|
|
18
|
+
* "how many across". A module store read through `useSyncExternalStore` is the
|
|
19
|
+
* same shape `browse-mode.ts` and the auth flow bus already use.
|
|
20
|
+
*
|
|
21
|
+
* The choice is kept per band, not as one number. A phone offered 1 or 2 and a
|
|
22
|
+
* desktop offered 2, 3 or 4 are different questions, and a desktop answer of 4
|
|
23
|
+
* must not follow the shopper onto a phone — or onto the same tablet turned
|
|
24
|
+
* sideways.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here runs on the server: every write happens in an event handler or
|
|
27
|
+
* an effect, and the server snapshot is always empty. A module-level store on
|
|
28
|
+
* the server would otherwise be shared by every request the process serves.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type DensityBand = 'desktop' | 'tablet' | 'mobile';
|
|
32
|
+
|
|
33
|
+
/** Widest first, the order the editor authors them in. */
|
|
34
|
+
export const DENSITY_BANDS: readonly DensityBand[] = [
|
|
35
|
+
'desktop',
|
|
36
|
+
'tablet',
|
|
37
|
+
'mobile'
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export type DensityCounts = Partial<Record<DensityBand, number>>;
|
|
41
|
+
|
|
42
|
+
export type DensityOptionSets = Record<DensityBand, number[]>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One media query per band, and unlike the theme's mobile query they do not
|
|
46
|
+
* overlap: a choice made on a phone is a statement about phones only. Written
|
|
47
|
+
* compact to sit beside the grid's own compact queries.
|
|
48
|
+
*/
|
|
49
|
+
export const DENSITY_BAND_MEDIA_QUERIES: Record<DensityBand, string> = {
|
|
50
|
+
desktop: `@media (min-width:${DESKTOP_MIN_WIDTH}px)`,
|
|
51
|
+
tablet: TABLET_MEDIA_QUERY_COMPACT,
|
|
52
|
+
mobile: `@media (max-width:${TABLET_MIN_WIDTH - 1}px)`
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Who a choice belongs to.
|
|
57
|
+
*
|
|
58
|
+
* The standalone block speaks for the page: every grid on it that carries no
|
|
59
|
+
* switcher of its own follows it. A grid that does carry its own switcher
|
|
60
|
+
* answers only to that switcher, under its own scope — which is how that
|
|
61
|
+
* switcher always behaved, so a page with two grids does not find one
|
|
62
|
+
* rearranging the other.
|
|
63
|
+
*/
|
|
64
|
+
export const LISTING_DENSITY_SCOPE = 'listing';
|
|
65
|
+
|
|
66
|
+
export const gridDensityScope = (gridId: string) => `grid:${gridId}`;
|
|
67
|
+
|
|
68
|
+
export interface ListingDensitySnapshot {
|
|
69
|
+
/**
|
|
70
|
+
* The shopper's choice per band, but only where a mounted switcher of the
|
|
71
|
+
* same scope still offers it. A count remembered from another page, or from
|
|
72
|
+
* a switcher the designer has since removed, applies nowhere.
|
|
73
|
+
*/
|
|
74
|
+
applied: DensityCounts;
|
|
75
|
+
/** What the grid shows per band before any choice — its authored counts. */
|
|
76
|
+
authored: DensityCounts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remembered for the session: a shopper who chose a denser grid expects it to
|
|
81
|
+
* survive paging through the category, and to be forgotten by the time they
|
|
82
|
+
* come back tomorrow.
|
|
83
|
+
*/
|
|
84
|
+
const STORAGE_KEY = 'pz-listing-density';
|
|
85
|
+
|
|
86
|
+
const EMPTY_SNAPSHOT: ListingDensitySnapshot = Object.freeze({
|
|
87
|
+
applied: Object.freeze({}),
|
|
88
|
+
authored: Object.freeze({})
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
interface ScopedEntry<T> {
|
|
92
|
+
scope: string;
|
|
93
|
+
value: T;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let choice: Record<string, DensityCounts> = {};
|
|
97
|
+
let hasLoaded = false;
|
|
98
|
+
|
|
99
|
+
const offered = new Map<symbol, ScopedEntry<DensityOptionSets>>();
|
|
100
|
+
const authoredByGrid = new Map<symbol, ScopedEntry<DensityCounts>>();
|
|
101
|
+
const listeners = new Set<() => void>();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Built on first read after each change and kept until the next one, so every
|
|
105
|
+
* read in between returns the same object — what `useSyncExternalStore`
|
|
106
|
+
* requires of a snapshot.
|
|
107
|
+
*/
|
|
108
|
+
let snapshots = new Map<string, ListingDensitySnapshot>();
|
|
109
|
+
|
|
110
|
+
const isCount = (value: unknown): value is number =>
|
|
111
|
+
typeof value === 'number' &&
|
|
112
|
+
Number.isInteger(value) &&
|
|
113
|
+
value >= 1 &&
|
|
114
|
+
value <= 6;
|
|
115
|
+
|
|
116
|
+
const readCounts = (raw: unknown): DensityCounts => {
|
|
117
|
+
const counts: DensityCounts = {};
|
|
118
|
+
if (!raw || typeof raw !== 'object') return counts;
|
|
119
|
+
|
|
120
|
+
DENSITY_BANDS.forEach((band) => {
|
|
121
|
+
const value = (raw as Record<string, unknown>)[band];
|
|
122
|
+
if (isCount(value)) counts[band] = value;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return counts;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const readStoredChoice = (): Record<string, DensityCounts> => {
|
|
129
|
+
// sessionStorage throws in Safari private mode.
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(
|
|
132
|
+
window.sessionStorage.getItem(STORAGE_KEY) || '{}'
|
|
133
|
+
) as Record<string, unknown>;
|
|
134
|
+
const stored: Record<string, DensityCounts> = {};
|
|
135
|
+
|
|
136
|
+
Object.keys(parsed || {}).forEach((scope) => {
|
|
137
|
+
const counts = readCounts(parsed[scope]);
|
|
138
|
+
if (Object.keys(counts).length > 0) stored[scope] = counts;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return stored;
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const writeStoredChoice = () => {
|
|
148
|
+
try {
|
|
149
|
+
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(choice));
|
|
150
|
+
} catch {
|
|
151
|
+
// A storage failure only costs the shopper the memory of the choice.
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const buildSnapshot = (scope: string): ListingDensitySnapshot => {
|
|
156
|
+
const scoped = choice[scope] || {};
|
|
157
|
+
const applied: DensityCounts = {};
|
|
158
|
+
|
|
159
|
+
DENSITY_BANDS.forEach((band) => {
|
|
160
|
+
const count = scoped[band];
|
|
161
|
+
if (count === undefined) return;
|
|
162
|
+
|
|
163
|
+
for (const entry of Array.from(offered.values())) {
|
|
164
|
+
if (entry.scope === scope && entry.value[band].includes(count)) {
|
|
165
|
+
applied[band] = count;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The grid mounted last is the one on screen; there is normally just one.
|
|
172
|
+
let authored: DensityCounts = {};
|
|
173
|
+
authoredByGrid.forEach((entry) => {
|
|
174
|
+
if (entry.scope === scope) authored = entry.value;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return { applied, authored };
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const publish = () => {
|
|
181
|
+
snapshots = new Map();
|
|
182
|
+
listeners.forEach((listener) => listener());
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The stored choice is read on first subscription rather than at import: the
|
|
187
|
+
* module is evaluated on the server too, where there is no storage to read.
|
|
188
|
+
*/
|
|
189
|
+
const loadOnce = () => {
|
|
190
|
+
if (hasLoaded || typeof window === 'undefined') return;
|
|
191
|
+
|
|
192
|
+
hasLoaded = true;
|
|
193
|
+
const stored = readStoredChoice();
|
|
194
|
+
|
|
195
|
+
Object.keys(choice).forEach((scope) => {
|
|
196
|
+
stored[scope] = { ...stored[scope], ...choice[scope] };
|
|
197
|
+
});
|
|
198
|
+
choice = stored;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const subscribe = (listener: () => void): (() => void) => {
|
|
202
|
+
listeners.add(listener);
|
|
203
|
+
|
|
204
|
+
if (!hasLoaded) {
|
|
205
|
+
loadOnce();
|
|
206
|
+
publish();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return () => {
|
|
210
|
+
listeners.delete(listener);
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const readSnapshot = (scope: string): ListingDensitySnapshot => {
|
|
215
|
+
let snapshot = snapshots.get(scope);
|
|
216
|
+
|
|
217
|
+
if (!snapshot) {
|
|
218
|
+
snapshot = buildSnapshot(scope);
|
|
219
|
+
snapshots.set(scope, snapshot);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return snapshot;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const readServerSnapshot = (): ListingDensitySnapshot => EMPTY_SNAPSHOT;
|
|
226
|
+
|
|
227
|
+
export const useListingDensity = (scope: string): ListingDensitySnapshot =>
|
|
228
|
+
useSyncExternalStore(
|
|
229
|
+
subscribe,
|
|
230
|
+
() => readSnapshot(scope),
|
|
231
|
+
readServerSnapshot
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A switcher announcing what it offers, from an effect. The returned function
|
|
236
|
+
* withdraws the offer, and with it any choice only this switcher allowed.
|
|
237
|
+
*/
|
|
238
|
+
export const registerDensityOptions = (
|
|
239
|
+
scope: string,
|
|
240
|
+
sets: DensityOptionSets
|
|
241
|
+
): (() => void) => {
|
|
242
|
+
const token = Symbol('density-options');
|
|
243
|
+
|
|
244
|
+
offered.set(token, { scope, value: sets });
|
|
245
|
+
publish();
|
|
246
|
+
|
|
247
|
+
return () => {
|
|
248
|
+
offered.delete(token);
|
|
249
|
+
publish();
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** A grid announcing the counts it draws before any choice, from an effect. */
|
|
254
|
+
export const registerAuthoredColumns = (
|
|
255
|
+
scope: string,
|
|
256
|
+
counts: DensityCounts
|
|
257
|
+
): (() => void) => {
|
|
258
|
+
const token = Symbol('density-authored');
|
|
259
|
+
|
|
260
|
+
authoredByGrid.set(token, { scope, value: counts });
|
|
261
|
+
publish();
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
authoredByGrid.delete(token);
|
|
265
|
+
publish();
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The shopper picking a count for the band they are looking at. `persist` is
|
|
271
|
+
* off when the choice should not outlive the page.
|
|
272
|
+
*/
|
|
273
|
+
export const chooseDensity = (
|
|
274
|
+
scope: string,
|
|
275
|
+
band: DensityBand,
|
|
276
|
+
count: number,
|
|
277
|
+
persist = true
|
|
278
|
+
) => {
|
|
279
|
+
if (!isCount(count)) return;
|
|
280
|
+
|
|
281
|
+
loadOnce();
|
|
282
|
+
if (choice[scope]?.[band] === count) return;
|
|
283
|
+
|
|
284
|
+
choice = { ...choice, [scope]: { ...choice[scope], [band]: count } };
|
|
285
|
+
if (persist) writeStoredChoice();
|
|
286
|
+
publish();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Test-only: forget every choice, registration and subscriber. */
|
|
290
|
+
export const __resetListingDensityForTests = () => {
|
|
291
|
+
choice = {};
|
|
292
|
+
hasLoaded = false;
|
|
293
|
+
snapshots = new Map();
|
|
294
|
+
offered.clear();
|
|
295
|
+
authoredByGrid.clear();
|
|
296
|
+
listeners.clear();
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
window.sessionStorage.removeItem(STORAGE_KEY);
|
|
300
|
+
} catch {
|
|
301
|
+
// Nothing to forget.
|
|
302
|
+
}
|
|
303
|
+
};
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
emitCommerceEvent
|
|
9
9
|
} from '../utils/analytics';
|
|
10
10
|
import {
|
|
11
|
-
DESKTOP_MIN_WIDTH,
|
|
12
11
|
MOBILE_MEDIA_QUERY_COMPACT,
|
|
13
12
|
TABLET_MEDIA_QUERY_COMPACT
|
|
14
13
|
} from '../utils/breakpoints';
|
|
@@ -16,6 +15,20 @@ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
|
|
|
16
15
|
import ThemeBlock, { Block } from '../theme-block';
|
|
17
16
|
import { hasItemTemplate, ItemTemplateList } from './item-template';
|
|
18
17
|
import { BlockRendererProps } from './block-renderer-registry';
|
|
18
|
+
import DensitySwitcher, {
|
|
19
|
+
DENSITY_GLYPH_STYLES,
|
|
20
|
+
DensityGlyphStyle,
|
|
21
|
+
parseDensityOptions
|
|
22
|
+
} from './density-switcher';
|
|
23
|
+
import {
|
|
24
|
+
DENSITY_BAND_MEDIA_QUERIES,
|
|
25
|
+
DENSITY_BANDS,
|
|
26
|
+
DensityCounts,
|
|
27
|
+
gridDensityScope,
|
|
28
|
+
LISTING_DENSITY_SCOPE,
|
|
29
|
+
registerAuthoredColumns,
|
|
30
|
+
useListingDensity
|
|
31
|
+
} from './listing-density-store';
|
|
19
32
|
import {
|
|
20
33
|
MoneyCurrencyDisplay,
|
|
21
34
|
MoneyCurrencyPosition,
|
|
@@ -42,75 +55,8 @@ const readCount = (value: unknown, fallback: number): number => {
|
|
|
42
55
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
43
56
|
};
|
|
44
57
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
* expects it to survive paging through the category, and to be forgotten by
|
|
48
|
-
* the time they come back tomorrow.
|
|
49
|
-
*/
|
|
50
|
-
const DENSITY_KEY_PREFIX = 'pz-grid-density:';
|
|
51
|
-
|
|
52
|
-
const readStoredDensity = (key: string): number | null => {
|
|
53
|
-
// sessionStorage throws in Safari private mode and is absent during SSR.
|
|
54
|
-
try {
|
|
55
|
-
const numeric = Math.round(Number(window.sessionStorage.getItem(key)));
|
|
56
|
-
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const writeStoredDensity = (key: string, value: number) => {
|
|
63
|
-
try {
|
|
64
|
-
window.sessionStorage.setItem(key, String(value));
|
|
65
|
-
} catch {
|
|
66
|
-
// A storage failure only costs the shopper the memory of the choice.
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/** The column counts the designer allows, as an ascending, deduped list. */
|
|
71
|
-
const parseDensityOptions = (raw: string): number[] => {
|
|
72
|
-
const counts = new Set<number>();
|
|
73
|
-
|
|
74
|
-
raw.split(',').forEach((entry) => {
|
|
75
|
-
const numeric = Math.round(Number(entry.trim()));
|
|
76
|
-
if (!Number.isFinite(numeric) || numeric < 1 || numeric > 6) return;
|
|
77
|
-
counts.add(numeric);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
return Array.from(counts).sort((a, b) => a - b);
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The switcher's mark: as many bars as the layout has columns, which is how
|
|
85
|
-
* both audited designs draw it — the shopper reads the density rather than a
|
|
86
|
-
* number.
|
|
87
|
-
*/
|
|
88
|
-
const DensityGlyph = ({ columns }: { columns: number }) => {
|
|
89
|
-
const gap = 2;
|
|
90
|
-
const cell = (16 - gap * (columns - 1)) / columns;
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<svg
|
|
94
|
-
width="16"
|
|
95
|
-
height="16"
|
|
96
|
-
viewBox="0 0 16 16"
|
|
97
|
-
fill="currentColor"
|
|
98
|
-
aria-hidden="true"
|
|
99
|
-
{...partAttrs('density-icon')}
|
|
100
|
-
>
|
|
101
|
-
{Array.from({ length: columns }, (_, index) => (
|
|
102
|
-
<rect
|
|
103
|
-
key={index}
|
|
104
|
-
x={index * (cell + gap)}
|
|
105
|
-
y={0}
|
|
106
|
-
width={cell}
|
|
107
|
-
height={16}
|
|
108
|
-
rx={1}
|
|
109
|
-
/>
|
|
110
|
-
))}
|
|
111
|
-
</svg>
|
|
112
|
-
);
|
|
113
|
-
};
|
|
58
|
+
/** The designer's canvas shows the authored counts, never a choice. */
|
|
59
|
+
const NO_CHOICE: DensityCounts = Object.freeze({});
|
|
114
60
|
|
|
115
61
|
/**
|
|
116
62
|
* Two things a grid child can be besides a product card.
|
|
@@ -325,10 +271,11 @@ const readOption = <T extends string>(
|
|
|
325
271
|
* context built server side); the editor owns column counts, which card
|
|
326
272
|
* fields appear, the image frame and styling.
|
|
327
273
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
274
|
+
* A shopper's density choice — made on the standalone `listing-density` block
|
|
275
|
+
* or on the switcher this block can carry itself (off by default) — reaches
|
|
276
|
+
* the grid through `listing-density-store`, per band and remembered for the
|
|
277
|
+
* session. The server always renders the count the designer authored, so the
|
|
278
|
+
* markup is complete before any script runs.
|
|
332
279
|
*
|
|
333
280
|
* Two kinds of authored child extend what the grid can express: a child marked
|
|
334
281
|
* `grid-insertion` is a merchandiser tile that takes a cell of the grid, and a
|
|
@@ -502,8 +449,11 @@ const ProductGridBlock = ({
|
|
|
502
449
|
'1,2,4'
|
|
503
450
|
)
|
|
504
451
|
);
|
|
505
|
-
const densityStyle =
|
|
506
|
-
|
|
452
|
+
const densityStyle = readOption<DensityGlyphStyle>(
|
|
453
|
+
block.properties?.densityStyle,
|
|
454
|
+
currentBreakpoint,
|
|
455
|
+
DENSITY_GLYPH_STYLES,
|
|
456
|
+
'icon'
|
|
507
457
|
);
|
|
508
458
|
const densityPosition = String(
|
|
509
459
|
getResponsiveValue(
|
|
@@ -535,49 +485,35 @@ const ProductGridBlock = ({
|
|
|
535
485
|
[densityOptionsRaw]
|
|
536
486
|
);
|
|
537
487
|
|
|
538
|
-
// null until the shopper picks one, which is what keeps the server markup and
|
|
539
|
-
// the first client render identical.
|
|
540
|
-
const [chosenColumns, setChosenColumns] = React.useState<number | null>(null);
|
|
541
|
-
// false on the server and on the first client render, for the same reason.
|
|
542
|
-
const [isNarrowViewport, setIsNarrowViewport] = React.useState(false);
|
|
543
|
-
const densityKey = `${DENSITY_KEY_PREFIX}${block.id || 'grid'}`;
|
|
544
488
|
const hasSwitcher = showDensitySwitcher && densityOptions.length > 1;
|
|
489
|
+
const gridId = String(block.id || 'grid').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
545
490
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
// Safari below 14 only carries the deprecated listener pair.
|
|
573
|
-
if (typeof query.addEventListener === 'function') {
|
|
574
|
-
query.addEventListener('change', sync);
|
|
575
|
-
return () => query.removeEventListener('change', sync);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
query.addListener(sync);
|
|
579
|
-
return () => query.removeListener(sync);
|
|
580
|
-
}, [hasSwitcher]);
|
|
491
|
+
// A grid carrying its own switcher answers only to it, as it always has;
|
|
492
|
+
// every other grid follows the page's standalone Density Switcher block.
|
|
493
|
+
const densityScope = hasSwitcher
|
|
494
|
+
? gridDensityScope(gridId)
|
|
495
|
+
: LISTING_DENSITY_SCOPE;
|
|
496
|
+
// Empty on the server and on the first client render — the store's server
|
|
497
|
+
// snapshot — which is what keeps the two identical. The designer always
|
|
498
|
+
// sees the authored count; a remembered choice there would look like a bug.
|
|
499
|
+
const density = useListingDensity(densityScope);
|
|
500
|
+
const appliedColumns = isDesigner ? NO_CHOICE : density.applied;
|
|
501
|
+
// What each band shows before any choice, so a switcher anywhere on the page
|
|
502
|
+
// can mark the count the shopper is actually looking at.
|
|
503
|
+
const authoredTablet = columnsTablet ?? columnsMobile;
|
|
504
|
+
const authoredCounts = React.useMemo<DensityCounts>(
|
|
505
|
+
() => ({
|
|
506
|
+
desktop: columnsDesktop,
|
|
507
|
+
tablet: authoredTablet,
|
|
508
|
+
mobile: columnsMobile
|
|
509
|
+
}),
|
|
510
|
+
[columnsDesktop, authoredTablet, columnsMobile]
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
React.useEffect(
|
|
514
|
+
() => registerAuthoredColumns(densityScope, authoredCounts),
|
|
515
|
+
[densityScope, authoredCounts]
|
|
516
|
+
);
|
|
581
517
|
|
|
582
518
|
const allProducts = listing?.products || [];
|
|
583
519
|
const products = limit > 0 ? allProducts.slice(0, limit) : allProducts;
|
|
@@ -686,7 +622,6 @@ const ProductGridBlock = ({
|
|
|
686
622
|
// designer breakpoint only — on the live storefront currentBreakpoint is
|
|
687
623
|
// always 'desktop'. A scoped rule with the same 1023px cutoff the theme CSS
|
|
688
624
|
// generator uses keeps real mobile viewports in sync with the editor.
|
|
689
|
-
const gridId = String(block.id || 'grid').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
690
625
|
const baseColumns =
|
|
691
626
|
currentBreakpoint === 'tablet'
|
|
692
627
|
? columnsTablet ?? columnsMobile
|
|
@@ -706,18 +641,25 @@ const ProductGridBlock = ({
|
|
|
706
641
|
}
|
|
707
642
|
: { fontSize: 'inherit' };
|
|
708
643
|
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
const activeColumns =
|
|
713
|
-
chosenColumns ?? (isNarrowViewport ? columnsMobile : baseColumns);
|
|
714
|
-
|
|
644
|
+
// One set for every band: this switcher predates per-device counts, which
|
|
645
|
+
// are the standalone block's to offer. The marks are still per band, so a
|
|
646
|
+
// tablet no longer highlights the phone's count.
|
|
715
647
|
const densitySwitcher = hasSwitcher ? (
|
|
716
|
-
<
|
|
717
|
-
|
|
718
|
-
{
|
|
719
|
-
|
|
720
|
-
|
|
648
|
+
<DensitySwitcher
|
|
649
|
+
host="product-grid"
|
|
650
|
+
scope={densityScope}
|
|
651
|
+
defaultCounts={authoredCounts}
|
|
652
|
+
switcherId={`${gridId}-density`}
|
|
653
|
+
optionSets={{
|
|
654
|
+
desktop: densityOptions,
|
|
655
|
+
tablet: densityOptions,
|
|
656
|
+
mobile: densityOptions
|
|
657
|
+
}}
|
|
658
|
+
glyph={densityStyle}
|
|
659
|
+
label={densityLabel}
|
|
660
|
+
ariaLabel={densityAriaLabel}
|
|
661
|
+
isDesigner={isDesigner}
|
|
662
|
+
currentBreakpoint={currentBreakpoint}
|
|
721
663
|
style={{
|
|
722
664
|
display: 'flex',
|
|
723
665
|
alignItems: 'center',
|
|
@@ -725,49 +667,7 @@ const ProductGridBlock = ({
|
|
|
725
667
|
marginBottom: densityPosition === 'after' ? undefined : '12px',
|
|
726
668
|
marginTop: densityPosition === 'after' ? '12px' : undefined
|
|
727
669
|
}}
|
|
728
|
-
|
|
729
|
-
{densityLabel && (
|
|
730
|
-
<span
|
|
731
|
-
data-product-grid-density-label=""
|
|
732
|
-
{...partAttrs('density-label')}
|
|
733
|
-
>
|
|
734
|
-
{densityLabel}
|
|
735
|
-
</span>
|
|
736
|
-
)}
|
|
737
|
-
{densityOptions.map((count) => {
|
|
738
|
-
const isActive = count === activeColumns;
|
|
739
|
-
|
|
740
|
-
return (
|
|
741
|
-
<button
|
|
742
|
-
key={count}
|
|
743
|
-
type="button"
|
|
744
|
-
data-product-grid-density-option={String(count)}
|
|
745
|
-
{...partAttrs('density-option', { selected: isActive })}
|
|
746
|
-
aria-pressed={isActive}
|
|
747
|
-
aria-label={String(count)}
|
|
748
|
-
onClick={() => {
|
|
749
|
-
setChosenColumns(count);
|
|
750
|
-
// Writing the author's canvas experiments into their own session
|
|
751
|
-
// would follow them onto the storefront.
|
|
752
|
-
if (!isDesigner) writeStoredDensity(densityKey, count);
|
|
753
|
-
}}
|
|
754
|
-
style={{
|
|
755
|
-
display: 'inline-flex',
|
|
756
|
-
alignItems: 'center',
|
|
757
|
-
background: 'none',
|
|
758
|
-
border: 'none',
|
|
759
|
-
padding: '4px',
|
|
760
|
-
cursor: 'pointer',
|
|
761
|
-
color: 'inherit',
|
|
762
|
-
font: 'inherit',
|
|
763
|
-
opacity: isActive ? 1 : 0.45
|
|
764
|
-
}}
|
|
765
|
-
>
|
|
766
|
-
{densityStyle === 'number' ? count : <DensityGlyph columns={count} />}
|
|
767
|
-
</button>
|
|
768
|
-
);
|
|
769
|
-
})}
|
|
770
|
-
</div>
|
|
670
|
+
/>
|
|
771
671
|
) : null;
|
|
772
672
|
|
|
773
673
|
const insertionTiles = placedInsertions.map((insertion, index) => (
|
|
@@ -976,17 +876,24 @@ const ProductGridBlock = ({
|
|
|
976
876
|
) +
|
|
977
877
|
`}`
|
|
978
878
|
: '') +
|
|
979
|
-
// Last
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
879
|
+
// Last, so an explicit choice outranks the authored count and
|
|
880
|
+
// the overrides above it — each inside its own band, so a
|
|
881
|
+
// choice made on a phone never reaches a tablet or a desktop.
|
|
882
|
+
DENSITY_BANDS.map((band) => {
|
|
883
|
+
const chosen = appliedColumns[band];
|
|
884
|
+
if (chosen === undefined) return '';
|
|
885
|
+
|
|
886
|
+
return (
|
|
887
|
+
`${DENSITY_BAND_MEDIA_QUERIES[band]}{[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${chosen},minmax(0,1fr));}` +
|
|
983
888
|
buildInsertionRules(
|
|
984
889
|
gridId,
|
|
985
890
|
placedInsertions,
|
|
986
|
-
|
|
891
|
+
chosen,
|
|
987
892
|
currentPage
|
|
988
|
-
)
|
|
989
|
-
|
|
893
|
+
) +
|
|
894
|
+
`}`
|
|
895
|
+
);
|
|
896
|
+
}).join('')
|
|
990
897
|
)
|
|
991
898
|
}}
|
|
992
899
|
/>
|