@akinon/pz-theme 2.0.143-beta.0 → 2.0.143

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,278 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { MOBILE_MEDIA_QUERY_COMPACT } from '../utils/breakpoints';
6
+ import {
7
+ CARD_VIEW_COLUMNS,
8
+ CARD_VIEWS,
9
+ type CardView,
10
+ cardViewFromHash,
11
+ type CardViewSettings,
12
+ type CardViewToggles,
13
+ HIDE_IN_CARD_VIEW_ATTRIBUTE
14
+ } from '../utils/card-media';
15
+ import { neutralizeStyleTagBreakout } from '../utils/css-safety';
16
+
17
+ /**
18
+ * The GANT listing's grid views for a card list that is not sliding
19
+ * (ZERO-5173): the cards laid out as the view says — catalogue three across
20
+ * (one below 1024px), classic the list's own grid, archive eight across (four
21
+ * below) with the cards' captions left out — in the view the designer picked,
22
+ * and, when the shopper is offered them, the listing toolbar's three toggles.
23
+ *
24
+ * A SHOPPER'S SWITCH IS THE PAGE'S, IN THE HASH — the listing's own contract
25
+ * (`#catalogue` / `#classic` / `#archive`): read on mount and on `hashchange`,
26
+ * rewritten in place on a switch, so a shared link opens the view it was
27
+ * shared in and every list offering the toggles on a page shows the same one.
28
+ * No hash is no choice: each list shows its designer's view, which is also
29
+ * what the server — which never sees the hash — renders.
30
+ *
31
+ * The layout is CSS only: the cards are the list's own elements and keep
32
+ * rendering exactly as they are; a view only overrides the list's column
33
+ * template and hides what the archive leaves out.
34
+ */
35
+
36
+ /** The shopper's choice: undefined before the hash is read, null for none. */
37
+ let current: CardView | null | undefined;
38
+ const listeners = new Set<() => void>();
39
+
40
+ const read = (): CardView | null => {
41
+ if (current === undefined) {
42
+ current = cardViewFromHash(
43
+ typeof window === 'undefined' ? '' : window.location.hash
44
+ );
45
+ }
46
+ return current;
47
+ };
48
+
49
+ const notify = () => listeners.forEach((listener) => listener());
50
+
51
+ const onHashChange = () => {
52
+ current = cardViewFromHash(window.location.hash);
53
+ notify();
54
+ };
55
+
56
+ const subscribe = (listener: () => void) => {
57
+ if (listeners.size === 0) {
58
+ window.addEventListener('hashchange', onHashChange);
59
+ }
60
+ listeners.add(listener);
61
+
62
+ return () => {
63
+ listeners.delete(listener);
64
+ if (listeners.size === 0) {
65
+ window.removeEventListener('hashchange', onHashChange);
66
+ // The next page reads its own hash.
67
+ current = undefined;
68
+ }
69
+ };
70
+ };
71
+
72
+ /**
73
+ * Switch the page's view. The hash is rewritten in place — no navigation, no
74
+ * scroll — except on the canvas, where the preview's URL is the editor's.
75
+ */
76
+ export const setCardView = (
77
+ view: CardView,
78
+ { writeHash = true }: { writeHash?: boolean } = {}
79
+ ): void => {
80
+ current = view;
81
+ if (writeHash && typeof window !== 'undefined') {
82
+ const url = new URL(window.location.href);
83
+ url.hash = `#${view}`;
84
+ window.history.replaceState(window.history.state, '', url.toString());
85
+ }
86
+ notify();
87
+ };
88
+
89
+ /** The shopper's choice on this page — none on the server and while hydrating. */
90
+ export const useCardViewChoice = (): CardView | null =>
91
+ React.useSyncExternalStore(subscribe, read, () => null);
92
+
93
+ /** Test-only: forget the view between cases. */
94
+ export const __resetCardViewForTests = (): void => {
95
+ current = undefined;
96
+ };
97
+
98
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
99
+
100
+ /**
101
+ * The view's layout for the list whose wrapper is `[data-block-id=scopeId]`.
102
+ * The attribute twice outranks the list's own column template without
103
+ * `!important`; classic writes nothing, so it is the list as authored.
104
+ */
105
+ export const buildCardViewCss = (
106
+ scopeId: string,
107
+ view: CardView,
108
+ onPhoneCanvas: boolean,
109
+ toggles?: CardViewToggles
110
+ ): string => {
111
+ if (!SAFE_ID.test(scopeId)) return '';
112
+
113
+ const list = `[data-block-id="${scopeId}"][data-block-id="${scopeId}"]`;
114
+ let css = '';
115
+
116
+ if (toggles) {
117
+ // The toolbar's toggles: buttons 8px wider than their glyph from 1024px
118
+ // up, the gap between them authored; 44px squares flush together below.
119
+ const switcher = `[data-card-view-switcher="${scopeId}"]`;
120
+ const button = toggles.size + 8;
121
+ const touch = Math.max(44, button);
122
+ const phoneSwitcher = `${switcher}{gap:0}${switcher}>button{width:${touch}px;height:${touch}px}`;
123
+ css +=
124
+ `${switcher}{gap:${toggles.gap}px}${switcher}>button{width:${button}px;height:${button}px}` +
125
+ (onPhoneCanvas
126
+ ? phoneSwitcher
127
+ : `${MOBILE_MEDIA_QUERY_COMPACT}{${phoneSwitcher}}`);
128
+ }
129
+
130
+ if (view !== 'classic') {
131
+ const { desktop, mobile } = CARD_VIEW_COLUMNS[view];
132
+ const columns = (count: number) =>
133
+ `${list}{grid-template-columns:repeat(${count},minmax(0,1fr))}`;
134
+ css += onPhoneCanvas
135
+ ? columns(mobile)
136
+ : `${columns(desktop)}${MOBILE_MEDIA_QUERY_COMPACT}{${columns(mobile)}}`;
137
+ css += `${list} [${HIDE_IN_CARD_VIEW_ATTRIBUTE}~="${view}"]{display:none}`;
138
+ }
139
+
140
+ return neutralizeStyleTagBreakout(css);
141
+ };
142
+
143
+ /** The listing toolbar's 24-unit glyphs: one cell, four, six. */
144
+ const VIEW_GLYPHS: Record<CardView, string> = {
145
+ catalogue: 'M18.5 17.5h-13v-11h13zm-12-1h11v-9h-11z',
146
+ classic:
147
+ 'M18.5 17.5h-13v-11h13zm-12-1h5v-4h-5zm6 0h5v-4h-5zm-6-5h5v-4h-5zm6 0h5v-4h-5z',
148
+ archive:
149
+ 'M18.5 17.5h-13v-11h13zm-12-1h3v-4h-3zm4 0h3v-4h-3zm4 0h3v-4h-3zm-8-5h3v-4h-3zm4 0h3v-4h-3zm4 0h3v-4h-3z'
150
+ };
151
+
152
+ const VIEW_LABELS: Record<CardView, string> = {
153
+ catalogue: 'Catalogue view',
154
+ classic: 'Classic view',
155
+ archive: 'Archive view'
156
+ };
157
+
158
+ export interface CardListViewsProps {
159
+ /** The list's wrapper `data-block-id` — what its own grid styles target. */
160
+ scopeId: string;
161
+ settings: CardViewSettings;
162
+ isDesigner?: boolean;
163
+ currentBreakpoint?: string;
164
+ /**
165
+ * The canvas's selection for a click on the toggles: the list whose settings
166
+ * hold them — they are no block of their own.
167
+ */
168
+ onSelect?: () => void;
169
+ }
170
+
171
+ const JUSTIFY: Record<CardViewToggles['align'], string> = {
172
+ left: 'flex-start',
173
+ center: 'center',
174
+ right: 'flex-end'
175
+ };
176
+
177
+ /**
178
+ * The view's stylesheet and, when offered, the toggles — drawn as the first
179
+ * item of the list's grid and spanning its columns. Its own component, so only
180
+ * it re-renders when the view changes; the cards never do.
181
+ */
182
+ export const CardListViews = ({
183
+ scopeId,
184
+ settings,
185
+ isDesigner = false,
186
+ currentBreakpoint = 'desktop',
187
+ onSelect
188
+ }: CardListViewsProps) => {
189
+ const choice = useCardViewChoice();
190
+ const { toggles } = settings;
191
+ // Only a list that offers the toggles answers to the shopper's switch; one
192
+ // that does not is the view its designer picked, whatever the page says.
193
+ const view = toggles ? choice ?? settings.view : settings.view;
194
+ const css = buildCardViewCss(
195
+ scopeId,
196
+ view,
197
+ isDesigner && currentBreakpoint !== 'desktop',
198
+ toggles
199
+ );
200
+
201
+ return (
202
+ <>
203
+ <style dangerouslySetInnerHTML={{ __html: css }} />
204
+ {toggles ? (
205
+ <div
206
+ data-card-view-switcher={scopeId}
207
+ role="group"
208
+ aria-label="Grid views"
209
+ style={{
210
+ gridColumn: '1 / -1',
211
+ display: 'flex',
212
+ alignItems: 'center',
213
+ justifyContent: JUSTIFY[toggles.align],
214
+ padding: toggles.padding,
215
+ color: toggles.color
216
+ }}
217
+ >
218
+ {CARD_VIEWS.map((option) => {
219
+ const active = option === view;
220
+ return (
221
+ <button
222
+ key={option}
223
+ type="button"
224
+ data-card-view-option={option}
225
+ aria-pressed={active}
226
+ aria-label={VIEW_LABELS[option]}
227
+ onClick={(event) => {
228
+ // Inside a list the canvas selects on click: the toggle is
229
+ // the whole of what this click means — and on the canvas it
230
+ // also opens the list's settings, where the toggles are set.
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ setCardView(option, { writeHash: !isDesigner });
234
+ if (isDesigner) onSelect?.();
235
+ }}
236
+ style={{
237
+ display: 'flex',
238
+ alignItems: 'center',
239
+ justifyContent: 'center',
240
+ padding: 0,
241
+ border: 0,
242
+ background: 'transparent',
243
+ color: 'inherit',
244
+ cursor: 'pointer'
245
+ }}
246
+ >
247
+ <span
248
+ style={{
249
+ display: 'flex',
250
+ alignItems: 'flex-start',
251
+ width: `${toggles.size}px`,
252
+ height: `${toggles.size + 1}px`,
253
+ borderBottom: `1px solid ${
254
+ active ? 'currentColor' : 'transparent'
255
+ }`
256
+ }}
257
+ >
258
+ <svg
259
+ viewBox="0 0 24 24"
260
+ width={toggles.size}
261
+ height={toggles.size}
262
+ fill="none"
263
+ aria-hidden="true"
264
+ style={{ display: 'block' }}
265
+ >
266
+ <path fill="currentColor" d={VIEW_GLYPHS[option]} />
267
+ </svg>
268
+ </span>
269
+ </button>
270
+ );
271
+ })}
272
+ </div>
273
+ ) : null}
274
+ </>
275
+ );
276
+ };
277
+
278
+ export default CardListViews;
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { CARD_ZOOM_CSS, CARD_ZOOM_STYLE_ID } from '../utils/card-media';
6
+
7
+ const ensureCardZoomStyles = () => {
8
+ if (typeof document === 'undefined') return;
9
+ if (document.getElementById(CARD_ZOOM_STYLE_ID)) return;
10
+
11
+ const style = document.createElement('style');
12
+ style.id = CARD_ZOOM_STYLE_ID;
13
+ style.textContent = CARD_ZOOM_CSS;
14
+ document.head.appendChild(style);
15
+ };
16
+
17
+ interface CardZoomProps {
18
+ /** The box the picture was drawn in, so the card around it does not move. */
19
+ style?: React.CSSProperties;
20
+ /** The host's own marks for its picture, written on the frame. */
21
+ attributes?: Record<string, string>;
22
+ children: React.ReactNode;
23
+ }
24
+
25
+ /**
26
+ * The product card's picture, zooming in on hover (ZERO-5173).
27
+ *
28
+ * A frame that clips the picture to its own box, so the enlarged picture
29
+ * never spills over the caption or a neighbouring card. The hover rule is a
30
+ * stylesheet injected once per document: it only does anything where a
31
+ * pointer hovers, so the server render — and a phone — draw the picture
32
+ * exactly as before.
33
+ */
34
+ export const CardZoom = ({ style, attributes, children }: CardZoomProps) => {
35
+ React.useInsertionEffect(ensureCardZoomStyles, []);
36
+
37
+ return (
38
+ <div
39
+ data-card-zoom=""
40
+ {...attributes}
41
+ style={{
42
+ position: 'relative',
43
+ display: 'block',
44
+ overflow: 'hidden',
45
+ ...style
46
+ }}
47
+ >
48
+ {children}
49
+ </div>
50
+ );
51
+ };
52
+
53
+ export default CardZoom;
@@ -15,6 +15,10 @@ import {
15
15
  HOVER_SCOPE_ATTRIBUTE,
16
16
  REVEAL_ON_HOVER_ATTRIBUTE
17
17
  } from '../utils/property-switches';
18
+ import {
19
+ HIDE_IN_CARD_VIEW_ATTRIBUTE,
20
+ HIDE_IN_CARD_VIEW_PROPERTY
21
+ } from '../utils/card-media';
18
22
 
19
23
  /** Elements that already act on Enter/Space themselves. */
20
24
  export const NATIVE_KEYBOARD_CONTROLS =
@@ -159,6 +163,17 @@ export function WithDesignerFeatures({
159
163
 
160
164
  // In designer mode, keep hidden blocks mounted (prevents remount-driven side effects)
161
165
  // but visually hide them.
166
+ // A part of a card that one of the listing's grid views leaves out — the
167
+ // Product Card's caption in the picture-only archive (utils/card-media).
168
+ // On the wrapper, which carries the part's box, so nothing of it remains.
169
+ const hideInCardViewRaw = getResponsiveValue(
170
+ block.properties?.[HIDE_IN_CARD_VIEW_PROPERTY],
171
+ currentBreakpoint,
172
+ ''
173
+ );
174
+ const hideInCardView =
175
+ typeof hideInCardViewRaw === 'string' ? hideInCardViewRaw.trim() : '';
176
+
162
177
  const mergedStyle: React.CSSProperties = {
163
178
  ...(style || {}),
164
179
  ...(isDesigner && block.hidden ? { display: 'none' } : null),
@@ -173,6 +188,9 @@ export function WithDesignerFeatures({
173
188
  data-block-id={block.styleSourceId || block.id}
174
189
  {...(block.hoverScope ? { [HOVER_SCOPE_ATTRIBUTE]: '' } : null)}
175
190
  {...(block.revealOnHover ? { [REVEAL_ON_HOVER_ATTRIBUTE]: '' } : null)}
191
+ {...(hideInCardView
192
+ ? { [HIDE_IN_CARD_VIEW_ATTRIBUTE]: hideInCardView }
193
+ : null)}
176
194
  className={clsx(
177
195
  'relative',
178
196
  className,
@@ -14,6 +14,7 @@ import {
14
14
  isPublishWindowVisible
15
15
  } from './utils';
16
16
  import { devWarn } from './utils/dev-warn';
17
+ import type { CardsSliderSettings, CardViewSettings } from './utils/card-media';
17
18
 
18
19
  /**
19
20
  * Narrows what a designer typed into something usable as a document-wide id.
@@ -70,6 +71,16 @@ export interface Block {
70
71
  */
71
72
  revealOnHover?: boolean;
72
73
  hoverScope?: boolean;
74
+ /**
75
+ * Written by the same pass, never authored (utils/card-media.ts): the card's
76
+ * picture zooms in on hover, and this repeat lays the cards it holds out as
77
+ * a slider. Absent on every block no setting reaches, which is every block
78
+ * of an unconfigured list.
79
+ */
80
+ cardZoom?: boolean;
81
+ cardsSlider?: CardsSliderSettings;
82
+ /** The listing's grid view this repeat is drawn in, and its toggles when offered. */
83
+ cardsViews?: CardViewSettings;
73
84
  /**
74
85
  * Properties this clone resolved for itself from its row. One generated rule
75
86
  * serves every clone, so these are the values that have to be inlined on the
@@ -36,7 +36,9 @@ import {
36
36
  import { isPublishWindowVisible } from './utils/publish-window';
37
37
  import {
38
38
  collectionBindingKey,
39
+ collectionPreviewWidget,
39
40
  fetchDesignerLiveRows,
41
+ fetchDesignerPreviewRows,
40
42
  hangDesignerDataSource,
41
43
  liveRowsEntryKey,
42
44
  LiveRowsStore,
@@ -799,7 +801,8 @@ export default function ThemePlaceholderWrapper({
799
801
  dataSource: hangDesignerDataSource(
800
802
  section,
801
803
  dataSource || section.dataSource,
802
- liveRowsRef.current
804
+ liveRowsRef.current,
805
+ { previous: section.dataSource }
803
806
  )
804
807
  };
805
808
  }
@@ -814,11 +817,45 @@ export default function ThemePlaceholderWrapper({
814
817
  // preview (whose blob path writes no live rows), or the server could not
815
818
  // resolve them — asks the storefront for them once. Until they arrive the
816
819
  // picked snapshot stays on the canvas: it is the same collection.
820
+ //
821
+ // An unsaved pick asks for its binding's preview widget instead, once per
822
+ // binding, when the editor prepared one: those are the rows the section
823
+ // will show once published, where the snapshot is Omnitron's catalogue.
817
824
  useEffect(() => {
818
825
  if (!isDesigner) return;
819
826
 
820
827
  sections.forEach((section) => {
821
- if (section.dataSourceSaved !== true || !section.id) return;
828
+ if (!section.id) return;
829
+
830
+ if (section.dataSourceSaved !== true) {
831
+ // The editor's record, not the one the section is drawn with: while
832
+ // its preview rows are asked for, the section keeps the source it
833
+ // showed before (see hangDesignerDataSource).
834
+ const record =
835
+ dataSources?.find(
836
+ (ds: { id?: string }) => ds.id === section.dataSourceId
837
+ ) ?? section.dataSource;
838
+ const previewWidget = collectionPreviewWidget(record);
839
+ if (
840
+ !previewWidget ||
841
+ liveRowsRef.current.has(previewWidget) ||
842
+ liveRowsAttemptedRef.current.has(previewWidget)
843
+ ) {
844
+ return;
845
+ }
846
+
847
+ liveRowsAttemptedRef.current.add(previewWidget);
848
+
849
+ fetchDesignerPreviewRows(previewWidget).then((answer) => {
850
+ if (!liveRowsMountedRef.current) return;
851
+
852
+ // Held as `null` when it gave nothing: the section stops holding
853
+ // its previous rows and falls back to the snapshot.
854
+ liveRowsRef.current.set(previewWidget, answer);
855
+ setLiveRowsRevision((revision) => revision + 1);
856
+ });
857
+ return;
858
+ }
822
859
 
823
860
  const bindingKey = collectionBindingKey(section.dataSource);
824
861
  if (!bindingKey) return;
@@ -844,8 +881,9 @@ export default function ThemePlaceholderWrapper({
844
881
  });
845
882
  });
846
883
  // `sections` already follows `dataSources`: every change to the records is
847
- // hung over the sections before this runs.
848
- }, [sections, isDesigner]);
884
+ // hung over the sections before this runs — except a record held back
885
+ // while its preview rows are asked for, which is read from `dataSources`.
886
+ }, [sections, dataSources, isDesigner]);
849
887
 
850
888
  useEffect(() => {
851
889
  if (typeof window === 'undefined') {
@@ -940,7 +978,7 @@ export default function ThemePlaceholderWrapper({
940
978
 
941
979
  // In editor mode: Complete override with theme editor's data
942
980
  // This allows unsaved sections to render with editor's product data
943
- setSections(() => {
981
+ setSections((previousSections) => {
944
982
  return placeholder.sections.map((incomingSection: any) => {
945
983
  let dataSource = null;
946
984
  if (incomingSection.dataSourceId && theme.dataSources) {
@@ -963,11 +1001,18 @@ export default function ThemePlaceholderWrapper({
963
1001
  ? { ...incomingSection.properties }
964
1002
  : {},
965
1003
  // Live rows only for a pick the editor reports as
966
- // saved; otherwise the picked snapshot.
1004
+ // saved; otherwise the preview widget's rows or the
1005
+ // picked snapshot — and what the section showed so
1006
+ // far while the preview widget is asked.
967
1007
  dataSource: hangDesignerDataSource(
968
1008
  incomingSection,
969
1009
  dataSource || null,
970
- liveRowsRef.current
1010
+ liveRowsRef.current,
1011
+ {
1012
+ previous: previousSections.find(
1013
+ (section) => section.id === incomingSection.id
1014
+ )?.dataSource
1015
+ }
971
1016
  )
972
1017
  };
973
1018
  });