@akinon/pz-theme 2.0.136-rc.0 → 2.0.136

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.
@@ -70,6 +70,7 @@ import ProductRecommendationsBlock from './product-recommendations-block';
70
70
  import PanelBlock from './panel-block';
71
71
  import AppliedFiltersBlock from './applied-filters-block';
72
72
  import ListingResultCountBlock from './listing-result-count-block';
73
+ import ListingDensityBlock from './listing-density-block';
73
74
  import CountdownBlock from './countdown-block';
74
75
  import RegisterFormBlock from './register-form-block';
75
76
  import ForgotPasswordBlock from './forgot-password-block';
@@ -227,6 +228,7 @@ blockRendererRegistry.register('panel', PanelBlock);
227
228
  blockRendererRegistry.register('size-guide', PanelBlock);
228
229
  blockRendererRegistry.register('applied-filters', AppliedFiltersBlock);
229
230
  blockRendererRegistry.register('listing-result-count', ListingResultCountBlock);
231
+ blockRendererRegistry.register('listing-density', ListingDensityBlock);
230
232
  blockRendererRegistry.register('countdown', CountdownBlock);
231
233
 
232
234
  // Auth and content
@@ -0,0 +1,336 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { partAttrs } from '../utils';
5
+ import { builtInBreakpointOf } from '../utils/breakpoints';
6
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
7
+ import {
8
+ chooseDensity,
9
+ DENSITY_BAND_MEDIA_QUERIES,
10
+ DENSITY_BANDS,
11
+ DensityBand,
12
+ DensityCounts,
13
+ DensityOptionSets,
14
+ registerDensityOptions,
15
+ useListingDensity
16
+ } from './listing-density-store';
17
+
18
+ /**
19
+ * The shopper-facing "how many across" control, drawn by the standalone
20
+ * `listing-density` block and by the switcher the product grid can still
21
+ * carry inside itself. One component so the two cannot drift: both read and
22
+ * write the same store, draw the same marks and hide per band the same way.
23
+ */
24
+
25
+ export type DensityGlyphStyle = 'icon' | 'grid' | 'number';
26
+
27
+ export const DENSITY_GLYPH_STYLES: DensityGlyphStyle[] = [
28
+ 'icon',
29
+ 'grid',
30
+ 'number'
31
+ ];
32
+
33
+ /** The column counts the designer allows, as an ascending, deduped list. */
34
+ export const parseDensityOptions = (raw: string): number[] => {
35
+ const counts = new Set<number>();
36
+
37
+ raw.split(',').forEach((entry) => {
38
+ const numeric = Math.round(Number(entry.trim()));
39
+ if (!Number.isFinite(numeric) || numeric < 1 || numeric > 6) return;
40
+ counts.add(numeric);
41
+ });
42
+
43
+ return Array.from(counts).sort((a, b) => a - b);
44
+ };
45
+
46
+ /**
47
+ * An empty band reads the next one down the chain the breakpoints use
48
+ * everywhere else: tablet falls back to mobile, mobile to desktop.
49
+ */
50
+ export const resolveDensityOptionSets = (
51
+ desktop: number[],
52
+ tablet: number[],
53
+ mobile: number[]
54
+ ): DensityOptionSets => {
55
+ const mobileSet = mobile.length > 0 ? mobile : desktop;
56
+
57
+ return {
58
+ desktop,
59
+ tablet: tablet.length > 0 ? tablet : mobileSet,
60
+ mobile: mobileSet
61
+ };
62
+ };
63
+
64
+ /** The band the designer's canvas is on; a custom breakpoint reads its base. */
65
+ export const designerDensityBand = (currentBreakpoint: string): DensityBand => {
66
+ const builtIn = builtInBreakpointOf(currentBreakpoint);
67
+ return (DENSITY_BANDS as string[]).includes(builtIn)
68
+ ? (builtIn as DensityBand)
69
+ : 'desktop';
70
+ };
71
+
72
+ /**
73
+ * Which block draws the switcher. The grid's own switcher keeps the part names
74
+ * and data attributes it always had — published styles and brand CSS target
75
+ * them — and the standalone block has its own.
76
+ *
77
+ * Every part is stamped with a LITERAL `partAttrs('…')` on the element it
78
+ * dresses, one per host, rather than a name passed in: the editor's part-stamp
79
+ * parity check reads these calls from source to hold each part's tag and
80
+ * states against what the panel offers.
81
+ */
82
+ export type DensitySwitcherHost = 'product-grid' | 'listing-density';
83
+
84
+ /**
85
+ * As many bars as the layout has columns — the shopper reads the density
86
+ * rather than a number.
87
+ */
88
+ const BarsGlyph = ({
89
+ columns,
90
+ inGrid
91
+ }: {
92
+ columns: number;
93
+ inGrid: boolean;
94
+ }) => {
95
+ const gap = 2;
96
+ const cell = (16 - gap * (columns - 1)) / columns;
97
+
98
+ return (
99
+ <svg
100
+ width="16"
101
+ height="16"
102
+ viewBox="0 0 16 16"
103
+ fill="currentColor"
104
+ aria-hidden="true"
105
+ {...(inGrid ? partAttrs('density-icon') : partAttrs('icon'))}
106
+ >
107
+ {Array.from({ length: columns }, (_, index) => (
108
+ <rect
109
+ key={index}
110
+ x={index * (cell + gap)}
111
+ y={0}
112
+ width={cell}
113
+ height={16}
114
+ rx={1}
115
+ />
116
+ ))}
117
+ </svg>
118
+ );
119
+ };
120
+
121
+ const roundTo2 = (value: number) => Math.round(value * 100) / 100;
122
+
123
+ /**
124
+ * A square split into as many columns and rows as the layout has columns: one
125
+ * frame, two by two, three by three — the outline marks listing toolbars draw.
126
+ */
127
+ const GridGlyph = ({
128
+ columns,
129
+ inGrid
130
+ }: {
131
+ columns: number;
132
+ inGrid: boolean;
133
+ }) => {
134
+ const step = 15 / columns;
135
+
136
+ return (
137
+ <svg
138
+ width="16"
139
+ height="16"
140
+ viewBox="0 0 16 16"
141
+ fill="none"
142
+ stroke="currentColor"
143
+ strokeWidth={1}
144
+ aria-hidden="true"
145
+ {...(inGrid ? partAttrs('density-icon') : partAttrs('icon'))}
146
+ >
147
+ <rect x={0.5} y={0.5} width={15} height={15} />
148
+ {Array.from({ length: columns - 1 }, (_, index) => {
149
+ const at = roundTo2(0.5 + step * (index + 1));
150
+
151
+ return (
152
+ <React.Fragment key={index}>
153
+ <line x1={at} y1={0.5} x2={at} y2={15.5} />
154
+ <line x1={0.5} y1={at} x2={15.5} y2={at} />
155
+ </React.Fragment>
156
+ );
157
+ })}
158
+ </svg>
159
+ );
160
+ };
161
+
162
+ export interface DensitySwitcherProps {
163
+ host: DensitySwitcherHost;
164
+ /** Whose choice this switcher writes — see `listing-density-store`. */
165
+ scope: string;
166
+ /**
167
+ * The counts each band shows before any choice, when the caller already
168
+ * knows them. The grid's own switcher does, so its marks are right in the
169
+ * server markup; the standalone block learns them from the grid once the
170
+ * page has mounted.
171
+ */
172
+ defaultCounts?: DensityCounts;
173
+ /** Selector-safe; scopes the per-band rules to this switcher. */
174
+ switcherId: string;
175
+ optionSets: DensityOptionSets;
176
+ glyph: DensityGlyphStyle;
177
+ label?: string;
178
+ ariaLabel: string;
179
+ isDesigner: boolean;
180
+ currentBreakpoint: string;
181
+ style?: React.CSSProperties;
182
+ }
183
+
184
+ /**
185
+ * On the storefront every band is drawn and a stylesheet shows the one the
186
+ * viewport is in. The server cannot know the viewport, and on the live site
187
+ * `currentBreakpoint` is always `desktop` — so this is what lets a phone see
188
+ * its own counts in the first paint, with nothing to re-render once scripts
189
+ * run. Each band keeps its own mark, which is what fixed the tablet showing
190
+ * the phone's count as active.
191
+ *
192
+ * On the designer's canvas only the band being edited is drawn, the mark is
193
+ * the grid's authored count and a click changes nothing: the canvas has to
194
+ * show what the designer authored, and a click there selects the block. "Use
195
+ * preview" hands the canvas the storefront behaviour instead.
196
+ */
197
+ const DensitySwitcher = ({
198
+ host,
199
+ scope,
200
+ defaultCounts,
201
+ switcherId,
202
+ optionSets,
203
+ glyph,
204
+ label = '',
205
+ ariaLabel,
206
+ isDesigner,
207
+ currentBreakpoint,
208
+ style
209
+ }: DensitySwitcherProps) => {
210
+ const { applied, authored } = useListingDensity(scope);
211
+ const inGrid = host === 'product-grid';
212
+
213
+ const setsKey = DENSITY_BANDS.map((band) => optionSets[band].join(',')).join(
214
+ '|'
215
+ );
216
+
217
+ React.useEffect(
218
+ () => registerDensityOptions(scope, optionSets),
219
+ // The sets are rebuilt on every render; their contents are what matter.
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ [scope, setsKey]
222
+ );
223
+
224
+ const offersSomething = (band: DensityBand) => optionSets[band].length > 1;
225
+
226
+ const bands = isDesigner
227
+ ? [designerDensityBand(currentBreakpoint)].filter(offersSomething)
228
+ : DENSITY_BANDS.filter(offersSomething);
229
+
230
+ if (bands.length === 0) return null;
231
+
232
+ const root = `[data-density-switcher-id="${switcherId}"]`;
233
+ // Band wrappers stay out of the layout, so the options sit in the root's
234
+ // flex row exactly where they sat before there were bands.
235
+ let css = `${root} [data-density-band]{display:contents;}`;
236
+
237
+ if (!isDesigner) {
238
+ DENSITY_BANDS.forEach((band) => {
239
+ const others = DENSITY_BANDS.filter((other) => other !== band)
240
+ .map((other) => `${root} [data-density-band="${other}"]`)
241
+ .join(',');
242
+ // Fewer than two counts is no choice at all: that band shows no
243
+ // switcher, label included.
244
+ const hideRoot = offersSomething(band)
245
+ ? ''
246
+ : `${root}{display:none!important;}`;
247
+
248
+ css += `${DENSITY_BAND_MEDIA_QUERIES[band]}{${others}{display:none;}${hideRoot}}`;
249
+ });
250
+ }
251
+
252
+ return (
253
+ <div
254
+ data-density-switcher-id={switcherId}
255
+ {...(inGrid
256
+ ? { 'data-product-grid-density': '' }
257
+ : { 'data-listing-density': '' })}
258
+ {...(inGrid ? partAttrs('density') : partAttrs('options'))}
259
+ role="group"
260
+ aria-label={ariaLabel}
261
+ style={style}
262
+ >
263
+ {/* Verbatim, not a text child: React 18's server renderer would escape
264
+ the selectors' quotes (see `css-safety.ts`). */}
265
+ <style
266
+ dangerouslySetInnerHTML={{ __html: neutralizeStyleTagBreakout(css) }}
267
+ />
268
+
269
+ {label && (
270
+ <span
271
+ {...(inGrid
272
+ ? { 'data-product-grid-density-label': '' }
273
+ : { 'data-listing-density-label': '' })}
274
+ {...(inGrid ? partAttrs('density-label') : partAttrs('label'))}
275
+ >
276
+ {label}
277
+ </span>
278
+ )}
279
+
280
+ {bands.map((band) => {
281
+ const authoredCount = authored[band] ?? defaultCounts?.[band];
282
+ const activeCount = isDesigner
283
+ ? authoredCount
284
+ : applied[band] ?? authoredCount;
285
+
286
+ return (
287
+ <div key={band} data-density-band={band}>
288
+ {optionSets[band].map((count) => {
289
+ const isActive = count === activeCount;
290
+
291
+ return (
292
+ <button
293
+ key={count}
294
+ type="button"
295
+ {...(inGrid
296
+ ? { 'data-product-grid-density-option': String(count) }
297
+ : { 'data-listing-density-option': String(count) })}
298
+ {...(inGrid
299
+ ? partAttrs('density-option', { selected: isActive })
300
+ : partAttrs('option', { selected: isActive }))}
301
+ aria-pressed={isActive}
302
+ aria-label={String(count)}
303
+ onClick={() => {
304
+ if (isDesigner) return;
305
+ chooseDensity(scope, band, count);
306
+ }}
307
+ style={{
308
+ display: 'inline-flex',
309
+ alignItems: 'center',
310
+ background: 'none',
311
+ border: 'none',
312
+ padding: '4px',
313
+ cursor: 'pointer',
314
+ color: 'inherit',
315
+ font: 'inherit',
316
+ opacity: isActive ? 1 : 0.45
317
+ }}
318
+ >
319
+ {glyph === 'number' ? (
320
+ count
321
+ ) : glyph === 'grid' ? (
322
+ <GridGlyph columns={count} inGrid={inGrid} />
323
+ ) : (
324
+ <BarsGlyph columns={count} inGrid={inGrid} />
325
+ )}
326
+ </button>
327
+ );
328
+ })}
329
+ </div>
330
+ );
331
+ })}
332
+ </div>
333
+ );
334
+ };
335
+
336
+ export default DensitySwitcher;
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { getResponsiveValue } from '../utils';
5
+ import { BlockRendererProps } from './block-renderer-registry';
6
+ import DensitySwitcher, {
7
+ DENSITY_GLYPH_STYLES,
8
+ DensityGlyphStyle,
9
+ designerDensityBand,
10
+ parseDensityOptions,
11
+ resolveDensityOptionSets
12
+ } from './density-switcher';
13
+ import { listingPlaceholderStyle, useListingContext } from './listing-context';
14
+ import { LISTING_DENSITY_SCOPE } from './listing-density-store';
15
+
16
+ /**
17
+ * Functional listing block: the shopper's "how many products across" switcher
18
+ * (ZERO-5151), on its own so it can sit wherever the design puts it — in the
19
+ * listing toolbar beside the filter and sort controls, typically — rather than
20
+ * glued to the top or bottom of the grid.
21
+ *
22
+ * Each device band offers its own counts (by default a desktop 2, 3 or 4 and a
23
+ * phone 1 or 2, with the tablet following the phone), and the choice is kept
24
+ * per band. It reaches every product grid on the page that carries no
25
+ * switcher of its own, through `listing-density-store`; the grid's own column
26
+ * counts stay the default each band shows until the shopper picks.
27
+ *
28
+ * Parts: options, label, option, icon.
29
+ * Properties: optionsDesktop, optionsTablet, optionsMobile, glyphStyle,
30
+ * label, ariaLabel.
31
+ */
32
+ const ListingDensityBlock = ({
33
+ block,
34
+ currentBreakpoint = 'desktop',
35
+ isDesigner = false
36
+ }: BlockRendererProps) => {
37
+ const listing = useListingContext();
38
+
39
+ const read = (key: string, fallback: string) =>
40
+ String(
41
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
42
+ );
43
+
44
+ // The same defaults the editor's panel shows. Tablet is empty by default and
45
+ // reads the mobile counts, the way a tablet reads mobile everywhere else.
46
+ const desktopRaw = read('optionsDesktop', '2,3,4');
47
+ const tabletRaw = read('optionsTablet', '');
48
+ const mobileRaw = read('optionsMobile', '1,2');
49
+ const optionSets = React.useMemo(
50
+ () =>
51
+ resolveDensityOptionSets(
52
+ parseDensityOptions(desktopRaw),
53
+ parseDensityOptions(tabletRaw),
54
+ parseDensityOptions(mobileRaw)
55
+ ),
56
+ [desktopRaw, tabletRaw, mobileRaw]
57
+ );
58
+
59
+ const glyphRaw = read('glyphStyle', 'grid');
60
+ const glyph = (DENSITY_GLYPH_STYLES as string[]).includes(glyphRaw)
61
+ ? (glyphRaw as DensityGlyphStyle)
62
+ : 'grid';
63
+ const label = read('label', '');
64
+ const ariaLabel = read('ariaLabel', 'Products per row');
65
+
66
+ if (isDesigner) {
67
+ // The canvas draws one band at a time; with fewer than two counts there
68
+ // it would draw nothing the designer could select.
69
+ if (optionSets[designerDensityBand(currentBreakpoint)].length < 2) {
70
+ return (
71
+ <div data-listing-density="" style={listingPlaceholderStyle}>
72
+ Density switcher (enter at least two column counts for this
73
+ breakpoint)
74
+ </div>
75
+ );
76
+ }
77
+ } else if (!listing || listing.products.length === 0) {
78
+ // Nothing to rearrange: no listing on this page, or one that came back
79
+ // empty and is showing its no-results layout instead of a grid.
80
+ return null;
81
+ }
82
+
83
+ return (
84
+ <DensitySwitcher
85
+ host="listing-density"
86
+ scope={LISTING_DENSITY_SCOPE}
87
+ switcherId={String(block.id || 'density').replace(/[^a-zA-Z0-9_-]/g, '')}
88
+ optionSets={optionSets}
89
+ glyph={glyph}
90
+ label={label}
91
+ ariaLabel={ariaLabel}
92
+ isDesigner={isDesigner}
93
+ currentBreakpoint={currentBreakpoint}
94
+ style={{
95
+ display: 'flex',
96
+ alignItems: 'center',
97
+ gap: 'var(--listing-density-gap, 8px)'
98
+ }}
99
+ />
100
+ );
101
+ };
102
+
103
+ export default ListingDensityBlock;