@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.
@@ -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
- * Remembered per block for the session: a shopper who chose a denser grid
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
- * An optional shopper-facing density switcher (off by default) lets the
329
- * column count be changed on the page and remembers it for the session; the
330
- * server always renders the count the designer authored, so the markup is
331
- * complete before any script runs.
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 = String(
506
- getResponsiveValue(block.properties?.densityStyle, currentBreakpoint, 'icon')
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
- React.useEffect(() => {
547
- // Read after mount only: the server has no session storage, so deciding
548
- // during render would hydrate as a mismatch. The designer always sees the
549
- // authored count — a remembered choice there would look like a bug.
550
- if (!hasSwitcher || isDesigner) return;
551
-
552
- const stored = readStoredDensity(densityKey);
553
- if (stored !== null && densityOptions.includes(stored)) {
554
- setChosenColumns(stored);
555
- }
556
- }, [hasSwitcher, isDesigner, densityKey, densityOptions]);
557
-
558
- React.useEffect(() => {
559
- // A real phone gets its column count from the media query the block emits,
560
- // never from currentBreakpoint — which is 'desktop' on the storefront no
561
- // matter how narrow the viewport is. The switcher has to read the same
562
- // cascade to mark the option the shopper is actually looking at, so it
563
- // watches the identical cutoff, taken from the same constant the emitted
564
- // media query is built from rather than retyped.
565
- if (!hasSwitcher || typeof window.matchMedia !== 'function') return;
566
-
567
- const query = window.matchMedia(`(max-width: ${DESKTOP_MIN_WIDTH - 1}px)`);
568
- const sync = () => setIsNarrowViewport(query.matches);
569
-
570
- sync();
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
- // The three rules below in the order the browser applies them: the authored
710
- // base, the mobile override, then an explicit choice. Deriving the mark from
711
- // baseColumns alone highlighted the desktop count on every phone.
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
- <div
717
- data-product-grid-density=""
718
- {...partAttrs('density')}
719
- role="group"
720
- aria-label={densityAriaLabel}
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 and unconditional, so an explicit choice outranks both the
980
- // authored count and the mobile override above it.
981
- (chosenColumns !== null
982
- ? `[data-product-grid-id="${gridId}"]{grid-template-columns:repeat(${chosenColumns},minmax(0,1fr));}` +
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
- chosenColumns,
891
+ chosen,
987
892
  currentPage
988
- )
989
- : '')
893
+ ) +
894
+ `}`
895
+ );
896
+ }).join('')
990
897
  )
991
898
  }}
992
899
  />