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