@akinon/pz-theme 2.0.136-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.136-beta.0
3
+ ## 2.0.136
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.136-beta.0
9
- - @akinon/theme-contract@2.0.136-beta.0
7
+ - cc7cd90: ZERO-5151: Add the listing density switcher block with per-device column counts
8
+ - @akinon/next@2.0.136
9
+ - @akinon/theme-contract@2.0.136
10
10
 
11
11
  ## 2.0.135
12
12
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.136-beta.0",
4
+ "version": "2.0.136",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.136-beta.0",
11
+ "@akinon/next": "2.0.136",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.136-beta.0",
16
+ "@akinon/theme-contract": "2.0.136",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.136-beta.0",
24
+ "@akinon/next": "2.0.136",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -80,6 +80,7 @@ const EXPECTED_BLOCK_TYPES = [
80
80
  'language-selector',
81
81
  'link',
82
82
  'listing-breadcrumb',
83
+ 'listing-density',
83
84
  'listing-pagination',
84
85
  'listing-result-count',
85
86
  'listing-title',
@@ -0,0 +1,608 @@
1
+ import React from 'react';
2
+ import { renderToString } from 'react-dom/server';
3
+ import { hydrateRoot } from 'react-dom/client';
4
+ import { act, fireEvent, render } from '@testing-library/react';
5
+ import ListingDensityBlock from '../listing-density-block';
6
+ import ProductGridBlock from '../product-grid-block';
7
+ import {
8
+ parseDensityOptions,
9
+ resolveDensityOptionSets
10
+ } from '../density-switcher';
11
+ import {
12
+ __resetListingDensityForTests,
13
+ chooseDensity,
14
+ LISTING_DENSITY_SCOPE,
15
+ registerDensityOptions
16
+ } from '../listing-density-store';
17
+ import { ThemePageContextProvider } from '../../theme-page-context';
18
+ import type { Block } from '../../theme-block';
19
+
20
+ /**
21
+ * ZERO-5151: the density switcher as a block of its own, with per-device
22
+ * counts, talking to the product grid through a shared store.
23
+ */
24
+
25
+ const listingContext = (count = 3) => ({
26
+ listing: {
27
+ title: 'Shoes',
28
+ path: '/shoes',
29
+ products: Array.from({ length: count }, (_, index) => ({
30
+ pk: index + 1,
31
+ name: `P${index + 1}`,
32
+ url: `/p${index + 1}`,
33
+ image: `/p${index + 1}.jpg`,
34
+ price: 10
35
+ })),
36
+ facets: [],
37
+ sorters: [],
38
+ pagination: { current_page: 1, count }
39
+ }
40
+ });
41
+
42
+ const block = (
43
+ type: string,
44
+ id: string,
45
+ properties: Record<string, unknown> = {}
46
+ ): Block =>
47
+ ({
48
+ id,
49
+ type,
50
+ label: type,
51
+ order: 0,
52
+ hidden: false,
53
+ value: '',
54
+ properties,
55
+ styles: {},
56
+ blocks: []
57
+ } as Block);
58
+
59
+ const DENSITY_PROPS = {
60
+ optionsDesktop: '2,3,4',
61
+ optionsTablet: '2,3',
62
+ optionsMobile: '1,2'
63
+ };
64
+
65
+ const GRID_PROPS = { columnsDesktop: 4, columnsTablet: 3, columnsMobile: 2 };
66
+
67
+ const drawPage = ({
68
+ densityProps = DENSITY_PROPS,
69
+ gridProps = GRID_PROPS,
70
+ isDesigner = false,
71
+ currentBreakpoint = 'desktop',
72
+ context = listingContext() as unknown,
73
+ withSwitcher = true
74
+ }: {
75
+ densityProps?: Record<string, unknown>;
76
+ gridProps?: Record<string, unknown>;
77
+ isDesigner?: boolean;
78
+ currentBreakpoint?: string;
79
+ context?: unknown;
80
+ withSwitcher?: boolean;
81
+ } = {}) =>
82
+ render(
83
+ <ThemePageContextProvider value={context as Record<string, unknown>}>
84
+ {withSwitcher && (
85
+ <ListingDensityBlock
86
+ block={block('listing-density', 'density-1', densityProps)}
87
+ placeholderId="p"
88
+ sectionId="s"
89
+ isDesigner={isDesigner}
90
+ currentBreakpoint={currentBreakpoint}
91
+ />
92
+ )}
93
+ <ProductGridBlock
94
+ block={block('product-grid', 'grid-1', gridProps)}
95
+ placeholderId="p"
96
+ sectionId="s"
97
+ isDesigner={isDesigner}
98
+ currentBreakpoint={currentBreakpoint}
99
+ />
100
+ </ThemePageContextProvider>
101
+ ).container;
102
+
103
+ const gridCss = (container: HTMLElement) =>
104
+ container.querySelector('[data-product-grid] > style')?.innerHTML ?? '';
105
+
106
+ const switcherCss = (container: HTMLElement) =>
107
+ container.querySelector('[data-density-switcher-id] > style')?.innerHTML ??
108
+ '';
109
+
110
+ const optionsIn = (container: HTMLElement, band: string) =>
111
+ Array.from(
112
+ container.querySelectorAll<HTMLButtonElement>(
113
+ `[data-density-band="${band}"] button`
114
+ )
115
+ );
116
+
117
+ const countsIn = (container: HTMLElement, band: string) =>
118
+ optionsIn(container, band).map((button) =>
119
+ button.getAttribute('data-listing-density-option')
120
+ );
121
+
122
+ const selectedIn = (container: HTMLElement, band: string) =>
123
+ optionsIn(container, band)
124
+ .filter((button) => button.getAttribute('aria-pressed') === 'true')
125
+ .map((button) => button.getAttribute('data-listing-density-option'));
126
+
127
+ const PHONE = '@media (max-width:767px)';
128
+ const TABLET = '@media (min-width:768px) and (max-width:1023px)';
129
+ const DESKTOP = '@media (min-width:1024px)';
130
+
131
+ const GRID_DEFAULT_CSS =
132
+ '[data-product-grid-id="grid-1"]{grid-template-columns:repeat(4,minmax(0,1fr));}' +
133
+ '@media (max-width:1023px){[data-product-grid-id="grid-1"]{grid-template-columns:repeat(2,minmax(0,1fr));}}' +
134
+ `${TABLET}{[data-product-grid-id="grid-1"]{grid-template-columns:repeat(3,minmax(0,1fr));}}`;
135
+
136
+ beforeEach(() => {
137
+ __resetListingDensityForTests();
138
+ });
139
+
140
+ describe('option sets', () => {
141
+ it('parses a comma list into ascending, deduped counts between 1 and 6', () => {
142
+ expect(parseDensityOptions(' 4, 2,2 ,9,0,x,3')).toEqual([2, 3, 4]);
143
+ });
144
+
145
+ it('falls back tablet -> mobile -> desktop when a band is left empty', () => {
146
+ expect(resolveDensityOptionSets([2, 3, 4], [], [1, 2])).toEqual({
147
+ desktop: [2, 3, 4],
148
+ tablet: [1, 2],
149
+ mobile: [1, 2]
150
+ });
151
+ expect(resolveDensityOptionSets([2, 3, 4], [], [])).toEqual({
152
+ desktop: [2, 3, 4],
153
+ tablet: [2, 3, 4],
154
+ mobile: [2, 3, 4]
155
+ });
156
+ });
157
+ });
158
+
159
+ describe('ListingDensityBlock on the storefront', () => {
160
+ it('draws every band with its own counts and a stylesheet that shows one per viewport', () => {
161
+ const container = drawPage();
162
+
163
+ expect(countsIn(container, 'desktop')).toEqual(['2', '3', '4']);
164
+ expect(countsIn(container, 'tablet')).toEqual(['2', '3']);
165
+ expect(countsIn(container, 'mobile')).toEqual(['1', '2']);
166
+
167
+ const scope = '[data-density-switcher-id="density-1"]';
168
+ expect(switcherCss(container)).toBe(
169
+ `${scope} [data-density-band]{display:contents;}` +
170
+ `${DESKTOP}{${scope} [data-density-band="tablet"],${scope} [data-density-band="mobile"]{display:none;}}` +
171
+ `${TABLET}{${scope} [data-density-band="desktop"],${scope} [data-density-band="mobile"]{display:none;}}` +
172
+ `${PHONE}{${scope} [data-density-band="desktop"],${scope} [data-density-band="tablet"]{display:none;}}`
173
+ );
174
+ });
175
+
176
+ it("offers the panel's defaults when nothing was authored, the tablet following the phone", () => {
177
+ const container = drawPage({ densityProps: {} });
178
+
179
+ expect(countsIn(container, 'desktop')).toEqual(['2', '3', '4']);
180
+ expect(countsIn(container, 'tablet')).toEqual(['1', '2']);
181
+ expect(countsIn(container, 'mobile')).toEqual(['1', '2']);
182
+ });
183
+
184
+ it("marks each band with the grid's authored count for that band", () => {
185
+ const container = drawPage();
186
+
187
+ expect(selectedIn(container, 'desktop')).toEqual(['4']);
188
+ expect(selectedIn(container, 'tablet')).toEqual(['3']);
189
+ expect(selectedIn(container, 'mobile')).toEqual(['2']);
190
+ });
191
+
192
+ it('marks the mobile count on a tablet when the grid authored no tablet count', () => {
193
+ const container = drawPage({
194
+ gridProps: { columnsDesktop: 4, columnsMobile: 2 }
195
+ });
196
+
197
+ expect(selectedIn(container, 'tablet')).toEqual(['2']);
198
+ });
199
+
200
+ it('applies a choice to its own band only, and remembers it for the session', () => {
201
+ const container = drawPage();
202
+
203
+ expect(gridCss(container)).toBe(GRID_DEFAULT_CSS);
204
+
205
+ fireEvent.click(optionsIn(container, 'mobile')[0]);
206
+
207
+ expect(gridCss(container)).toBe(
208
+ GRID_DEFAULT_CSS +
209
+ `${PHONE}{[data-product-grid-id="grid-1"]{grid-template-columns:repeat(1,minmax(0,1fr));}}`
210
+ );
211
+ expect(selectedIn(container, 'mobile')).toEqual(['1']);
212
+ // The other bands keep what they showed.
213
+ expect(selectedIn(container, 'desktop')).toEqual(['4']);
214
+ expect(selectedIn(container, 'tablet')).toEqual(['3']);
215
+ expect(
216
+ JSON.parse(window.sessionStorage.getItem('pz-listing-density') || '{}')
217
+ ).toEqual({ listing: { mobile: 1 } });
218
+ });
219
+
220
+ it('restores a remembered choice once mounted', () => {
221
+ window.sessionStorage.setItem(
222
+ 'pz-listing-density',
223
+ JSON.stringify({ listing: { desktop: 2 } })
224
+ );
225
+
226
+ const container = drawPage();
227
+
228
+ expect(selectedIn(container, 'desktop')).toEqual(['2']);
229
+ expect(gridCss(container)).toContain(
230
+ `${DESKTOP}{[data-product-grid-id="grid-1"]{grid-template-columns:repeat(2,minmax(0,1fr));}}`
231
+ );
232
+ });
233
+
234
+ it('never applies a remembered choice when no switcher on the page offers it', () => {
235
+ window.sessionStorage.setItem(
236
+ 'pz-listing-density',
237
+ JSON.stringify({ listing: { desktop: 2, mobile: 3 } })
238
+ );
239
+
240
+ const withoutSwitcher = drawPage({ withSwitcher: false });
241
+ expect(gridCss(withoutSwitcher)).toBe(GRID_DEFAULT_CSS);
242
+ });
243
+
244
+ it('ignores a remembered count the band no longer offers', () => {
245
+ window.sessionStorage.setItem(
246
+ 'pz-listing-density',
247
+ JSON.stringify({ listing: { mobile: 3 } })
248
+ );
249
+
250
+ const container = drawPage();
251
+
252
+ expect(gridCss(container)).toBe(GRID_DEFAULT_CSS);
253
+ expect(selectedIn(container, 'mobile')).toEqual(['2']);
254
+ });
255
+
256
+ it('hides the whole switcher in a band offering fewer than two counts', () => {
257
+ const container = drawPage({
258
+ densityProps: { optionsDesktop: '2,3,4', optionsMobile: '2' }
259
+ });
260
+
261
+ expect(countsIn(container, 'mobile')).toEqual([]);
262
+ expect(countsIn(container, 'tablet')).toEqual([]);
263
+ expect(switcherCss(container)).toContain(
264
+ `${PHONE}{[data-density-switcher-id="density-1"] [data-density-band="desktop"],` +
265
+ `[data-density-switcher-id="density-1"] [data-density-band="tablet"]{display:none;}` +
266
+ `[data-density-switcher-id="density-1"]{display:none!important;}}`
267
+ );
268
+ });
269
+
270
+ it('draws nothing without a listing or when the listing came back empty', () => {
271
+ const noListing = drawPage({ context: {} });
272
+ expect(noListing.querySelector('[data-listing-density]')).toBeNull();
273
+
274
+ const empty = drawPage({ context: listingContext(0) });
275
+ expect(empty.querySelector('[data-listing-density]')).toBeNull();
276
+ });
277
+
278
+ it('draws the chosen glyph, the label and the accessible name', () => {
279
+ const container = drawPage({
280
+ densityProps: {
281
+ ...DENSITY_PROPS,
282
+ glyphStyle: 'number',
283
+ label: 'View',
284
+ ariaLabel: 'Grid density'
285
+ }
286
+ });
287
+
288
+ const root = container.querySelector('[data-listing-density]')!;
289
+ expect(root.getAttribute('role')).toBe('group');
290
+ expect(root.getAttribute('aria-label')).toBe('Grid density');
291
+ expect(root.getAttribute('data-part')).toBe('options');
292
+ expect(
293
+ container.querySelector('[data-listing-density-label]')?.textContent
294
+ ).toBe('View');
295
+ expect(optionsIn(container, 'desktop').map((b) => b.textContent)).toEqual([
296
+ '2',
297
+ '3',
298
+ '4'
299
+ ]);
300
+
301
+ const grid = drawPage();
302
+ const glyph = grid.querySelector('[data-density-band="desktop"] svg')!;
303
+ expect(glyph.getAttribute('data-part')).toBe('icon');
304
+ // The default is the outlined grid: a frame plus one inner line pair per
305
+ // extra column, so two columns draw one vertical and one horizontal line.
306
+ expect(glyph.querySelectorAll('rect')).toHaveLength(1);
307
+ expect(glyph.querySelectorAll('line')).toHaveLength(2);
308
+ });
309
+ });
310
+
311
+ describe('ListingDensityBlock on the canvas', () => {
312
+ it('draws only the band being edited, marked with the authored count', () => {
313
+ const container = drawPage({
314
+ isDesigner: true,
315
+ currentBreakpoint: 'mobile'
316
+ });
317
+
318
+ expect(countsIn(container, 'desktop')).toEqual([]);
319
+ expect(countsIn(container, 'mobile')).toEqual(['1', '2']);
320
+ expect(selectedIn(container, 'mobile')).toEqual(['2']);
321
+ expect(switcherCss(container)).toBe(
322
+ '[data-density-switcher-id="density-1"] [data-density-band]{display:contents;}'
323
+ );
324
+ });
325
+
326
+ it('keeps a click inert and the grid on its authored counts', () => {
327
+ const container = drawPage({ isDesigner: true });
328
+ const before = gridCss(container);
329
+
330
+ fireEvent.click(optionsIn(container, 'desktop')[0]);
331
+
332
+ expect(gridCss(container)).toBe(before);
333
+ expect(selectedIn(container, 'desktop')).toEqual(['4']);
334
+ expect(window.sessionStorage.getItem('pz-listing-density')).toBeNull();
335
+ });
336
+
337
+ it('ignores a choice made elsewhere', () => {
338
+ const container = drawPage({ isDesigner: true });
339
+
340
+ act(() => {
341
+ chooseDensity(LISTING_DENSITY_SCOPE, 'desktop', 2, false);
342
+ });
343
+
344
+ expect(gridCss(container)).not.toContain('min-width:1024px');
345
+ });
346
+
347
+ it('explains itself when the band being edited offers fewer than two counts', () => {
348
+ const container = drawPage({
349
+ isDesigner: true,
350
+ currentBreakpoint: 'mobile',
351
+ densityProps: { optionsDesktop: '2,3,4', optionsMobile: '2' }
352
+ });
353
+
354
+ expect(container.querySelector('[data-listing-density]')?.textContent).toBe(
355
+ 'Density switcher (enter at least two column counts for this breakpoint)'
356
+ );
357
+ });
358
+ });
359
+
360
+ describe("the product grid's own switcher", () => {
361
+ const drawGridWithSwitcher = (properties: Record<string, unknown> = {}) =>
362
+ drawPage({
363
+ withSwitcher: false,
364
+ gridProps: {
365
+ ...GRID_PROPS,
366
+ showDensitySwitcher: true,
367
+ densityOptions: '2,3,4',
368
+ ...properties
369
+ }
370
+ });
371
+
372
+ it('keeps its attributes and parts, one mark per band', () => {
373
+ const container = drawGridWithSwitcher();
374
+ const root = container.querySelector('[data-product-grid-density]')!;
375
+
376
+ expect(root.getAttribute('data-part')).toBe('density');
377
+ expect(
378
+ container.querySelectorAll('[data-product-grid-density-option]')
379
+ ).toHaveLength(9);
380
+ // A tablet now marks the tablet count, not the phone's.
381
+ const pressed = (band: string) =>
382
+ Array.from(
383
+ container.querySelectorAll(`[data-density-band="${band}"] button`)
384
+ )
385
+ .filter((button) => button.getAttribute('aria-pressed') === 'true')
386
+ .map((button) =>
387
+ button.getAttribute('data-product-grid-density-option')
388
+ );
389
+ expect(pressed('desktop')).toEqual(['4']);
390
+ expect(pressed('tablet')).toEqual(['3']);
391
+ expect(pressed('mobile')).toEqual(['2']);
392
+ expect(
393
+ container
394
+ .querySelector('[data-density-band="desktop"] svg')
395
+ ?.getAttribute('data-part')
396
+ ).toBe('density-icon');
397
+ });
398
+
399
+ it('applies a choice inside its band rather than at every width', () => {
400
+ const container = drawGridWithSwitcher();
401
+ const desktopOptions = container.querySelectorAll<HTMLButtonElement>(
402
+ '[data-density-band="desktop"] button'
403
+ );
404
+
405
+ fireEvent.click(desktopOptions[0]);
406
+
407
+ expect(gridCss(container)).toBe(
408
+ GRID_DEFAULT_CSS +
409
+ `${DESKTOP}{[data-product-grid-id="grid-1"]{grid-template-columns:repeat(2,minmax(0,1fr));}}`
410
+ );
411
+ });
412
+
413
+ it('still draws the column-bar glyph by default', () => {
414
+ const container = drawGridWithSwitcher();
415
+ const glyph = container.querySelector('[data-density-band="desktop"] svg')!;
416
+
417
+ expect(glyph.getAttribute('fill')).toBe('currentColor');
418
+ expect(glyph.querySelectorAll('rect')).toHaveLength(2);
419
+ });
420
+ });
421
+
422
+ describe('a choice reaching the grid through the store', () => {
423
+ it('recomputes merchandising tiles for the chosen count', () => {
424
+ const tile = {
425
+ ...block('group', 'tile-1', {
426
+ role: { desktop: 'grid-insertion' },
427
+ insertPosition: 3,
428
+ insertColumnSpan: 2
429
+ })
430
+ } as Block;
431
+ const container = render(
432
+ <ThemePageContextProvider
433
+ value={listingContext(6) as unknown as Record<string, unknown>}
434
+ >
435
+ <ProductGridBlock
436
+ block={{
437
+ ...block('product-grid', 'grid-1', GRID_PROPS),
438
+ blocks: [tile]
439
+ }}
440
+ placeholderId="p"
441
+ sectionId="s"
442
+ isDesigner={false}
443
+ currentBreakpoint="desktop"
444
+ />
445
+ </ThemePageContextProvider>
446
+ ).container;
447
+
448
+ act(() => {
449
+ registerDensityOptions(LISTING_DENSITY_SCOPE, {
450
+ desktop: [2, 3, 4],
451
+ tablet: [2, 3],
452
+ mobile: [1, 2]
453
+ });
454
+ chooseDensity(LISTING_DENSITY_SCOPE, 'desktop', 2, false);
455
+ });
456
+
457
+ expect(gridCss(container)).toContain(
458
+ `${DESKTOP}{[data-product-grid-id="grid-1"]{grid-template-columns:repeat(2,minmax(0,1fr));}` +
459
+ '[data-product-grid-id="grid-1"] [data-product-grid-page="1"][data-product-grid-insert="0"]{grid-column:1/span 2;grid-row:2/span 1;}}'
460
+ );
461
+ });
462
+ });
463
+
464
+ describe('whose choice reaches which grid', () => {
465
+ const grid = (id: string, properties: Record<string, unknown> = {}) => (
466
+ <ProductGridBlock
467
+ block={block('product-grid', id, { ...GRID_PROPS, ...properties })}
468
+ placeholderId="p"
469
+ sectionId="s"
470
+ isDesigner={false}
471
+ currentBreakpoint="desktop"
472
+ />
473
+ );
474
+
475
+ const cssOf = (container: HTMLElement, id: string) =>
476
+ container.querySelector(`[data-product-grid-id="${id}"] > style`)
477
+ ?.innerHTML ?? '';
478
+
479
+ const desktopRule = (id: string, count: number) =>
480
+ `${DESKTOP}{[data-product-grid-id="${id}"]{grid-template-columns:repeat(${count},minmax(0,1fr));}}`;
481
+
482
+ const pressIn = (root: Element, count: number) =>
483
+ fireEvent.click(
484
+ root.querySelector(
485
+ `[data-density-band="desktop"] button[data-product-grid-density-option="${count}"], ` +
486
+ `[data-density-band="desktop"] button[data-listing-density-option="${count}"]`
487
+ )!
488
+ );
489
+
490
+ it("keeps a grid with its own switcher out of the page's choice, and the page out of its", () => {
491
+ const container = render(
492
+ <ThemePageContextProvider
493
+ value={listingContext() as unknown as Record<string, unknown>}
494
+ >
495
+ <ListingDensityBlock
496
+ block={block('listing-density', 'density-1', DENSITY_PROPS)}
497
+ placeholderId="p"
498
+ sectionId="s"
499
+ isDesigner={false}
500
+ currentBreakpoint="desktop"
501
+ />
502
+ {grid('own', { showDensitySwitcher: true, densityOptions: '2,3,4' })}
503
+ {grid('follower')}
504
+ </ThemePageContextProvider>
505
+ ).container;
506
+
507
+ pressIn(container.querySelector('[data-listing-density]')!, 2);
508
+
509
+ expect(cssOf(container, 'follower')).toContain(desktopRule('follower', 2));
510
+ expect(cssOf(container, 'own')).not.toContain('min-width:1024px');
511
+
512
+ pressIn(container.querySelector('[data-product-grid-density]')!, 3);
513
+
514
+ expect(cssOf(container, 'own')).toContain(desktopRule('own', 3));
515
+ expect(cssOf(container, 'follower')).toContain(desktopRule('follower', 2));
516
+ expect(cssOf(container, 'follower')).not.toContain(
517
+ desktopRule('follower', 3)
518
+ );
519
+ });
520
+
521
+ it('keeps two grids that carry their own switchers independent, as they always were', () => {
522
+ const container = render(
523
+ <ThemePageContextProvider
524
+ value={listingContext() as unknown as Record<string, unknown>}
525
+ >
526
+ {grid('a', { showDensitySwitcher: true, densityOptions: '2,3,4' })}
527
+ {grid('b', { showDensitySwitcher: true, densityOptions: '2,3,4' })}
528
+ </ThemePageContextProvider>
529
+ ).container;
530
+
531
+ pressIn(
532
+ container.querySelector('[data-density-switcher-id="a-density"]')!,
533
+ 2
534
+ );
535
+
536
+ expect(cssOf(container, 'a')).toContain(desktopRule('a', 2));
537
+ expect(cssOf(container, 'b')).not.toContain('min-width:1024px');
538
+ });
539
+
540
+ it("marks the grid's own switcher in the server markup, per band", () => {
541
+ const html = renderToString(
542
+ <ThemePageContextProvider
543
+ value={listingContext() as unknown as Record<string, unknown>}
544
+ >
545
+ {grid('ssr', { showDensitySwitcher: true, densityOptions: '2,3,4' })}
546
+ </ThemePageContextProvider>
547
+ );
548
+ const doc = new DOMParser().parseFromString(html, 'text/html');
549
+ const selected = (band: string) =>
550
+ Array.from(
551
+ doc.querySelectorAll(
552
+ `[data-density-band="${band}"] [data-state~="selected"]`
553
+ )
554
+ ).map((button) =>
555
+ button.getAttribute('data-product-grid-density-option')
556
+ );
557
+
558
+ expect(selected('desktop')).toEqual(['4']);
559
+ expect(selected('tablet')).toEqual(['3']);
560
+ expect(selected('mobile')).toEqual(['2']);
561
+ });
562
+
563
+ it('hydrates the block and the grid without a mismatch, remembered choice or not', async () => {
564
+ window.sessionStorage.setItem(
565
+ 'pz-listing-density',
566
+ JSON.stringify({ listing: { desktop: 2 } })
567
+ );
568
+
569
+ const page = (
570
+ <ThemePageContextProvider
571
+ value={listingContext() as unknown as Record<string, unknown>}
572
+ >
573
+ <ListingDensityBlock
574
+ block={block('listing-density', 'density-1', DENSITY_PROPS)}
575
+ placeholderId="p"
576
+ sectionId="s"
577
+ isDesigner={false}
578
+ currentBreakpoint="desktop"
579
+ />
580
+ {grid('own', { showDensitySwitcher: true, densityOptions: '2,3,4' })}
581
+ {grid('follower')}
582
+ </ThemePageContextProvider>
583
+ );
584
+ const container = document.createElement('div');
585
+ container.innerHTML = renderToString(page);
586
+ document.body.appendChild(container);
587
+
588
+ const recoverable: unknown[] = [];
589
+ const errors = jest.spyOn(console, 'error').mockImplementation(() => {});
590
+
591
+ await act(async () => {
592
+ hydrateRoot(container, page, {
593
+ onRecoverableError: (error) => recoverable.push(error)
594
+ });
595
+ });
596
+
597
+ const mismatches = errors.mock.calls.filter((call) =>
598
+ /hydrat/i.test(String(call[0]))
599
+ );
600
+ errors.mockRestore();
601
+
602
+ expect(recoverable).toEqual([]);
603
+ expect(mismatches).toEqual([]);
604
+ // And once mounted, the remembered choice has reached the follower.
605
+ expect(cssOf(container, 'follower')).toContain(desktopRule('follower', 2));
606
+ container.remove();
607
+ });
608
+ });
@@ -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