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

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,214 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import { devWarn } from '../utils/dev-warn';
5
+
6
+ /**
7
+ * Which composed modals are open, for the whole page.
8
+ *
9
+ * A modal is opened by something that is not inside it — a header button, a
10
+ * footer link, a PDP badge, a timer. Those live in DIFFERENT React roots:
11
+ * every placeholder on the page mounts its own `ThemePlaceholderWrapper`, so
12
+ * header and body share no provider, no context and no state. Passing a
13
+ * callback down is not merely awkward there, it is impossible.
14
+ *
15
+ * Module state is the one channel that crosses those roots, which is exactly
16
+ * the reasoning `use-overlay.ts` already applies to its overlay stack — and the
17
+ * two are deliberately separate concerns. This module answers *which modals
18
+ * the page wants open*; `useOverlay` answers *what an open overlay does to the
19
+ * page* (focus, scroll, `aria-modal`, Escape). The modal surface subscribes
20
+ * here and hands the answer to `useOverlay`, so the stack, the focus trap and
21
+ * the scroll lock stay in one place for every overlay in the system.
22
+ *
23
+ * // trigger, anywhere on the page
24
+ * openModal('newsletter');
25
+ *
26
+ * // the surface
27
+ * const open = useModalOpen(modal.id);
28
+ * const overlay = useOverlay({ open, onClose: () => closeModal(modal.id) });
29
+ */
30
+
31
+ /**
32
+ * Open modal ids, oldest first. An id appears AT MOST ONCE, and that is the
33
+ * loop guard: a trigger inside modal A that points at A — or a chain A → B → A,
34
+ * which is the same bug one indirection further out — finds A already open and
35
+ * stops, instead of pushing forever.
36
+ */
37
+ let openModalIds: string[] = [];
38
+
39
+ /**
40
+ * A page cannot meaningfully stack this many dialogs, so passing it means
41
+ * something is opening modals in a loop the id guard cannot see (an effect that
42
+ * re-opens on every render, say). Refusing keeps the tab alive and names the
43
+ * modal that tipped it over.
44
+ */
45
+ const MAX_OPEN_MODALS = 8;
46
+
47
+ const listeners = new Set<() => void>();
48
+
49
+ const notify = () => {
50
+ listeners.forEach((listener) => listener());
51
+ };
52
+
53
+ export const subscribeToModals = (listener: () => void): (() => void) => {
54
+ listeners.add(listener);
55
+
56
+ return () => {
57
+ listeners.delete(listener);
58
+ };
59
+ };
60
+
61
+ /**
62
+ * Mutations are refused outside the browser.
63
+ *
64
+ * Module state on a server is per PROCESS, not per request: one request opening
65
+ * a modal here would leave it open for the next visitor's render, on a server
66
+ * that serves everyone. Nothing legitimate calls these outside an event handler
67
+ * or an effect, so the guard costs nothing and closes a cross-request leak that
68
+ * would be very hard to see.
69
+ */
70
+ const canMutate = () => typeof window !== 'undefined';
71
+
72
+ /**
73
+ * Whether `<ThemeModals />` is mounted, so a trigger that reaches nothing can
74
+ * say why. The host is one line in the brand's root layout, and forgetting it
75
+ * otherwise looks exactly like a broken trigger.
76
+ */
77
+ let mountedHosts = 0;
78
+
79
+ export const registerModalHost = (): (() => void) => {
80
+ mountedHosts += 1;
81
+
82
+ /**
83
+ * The disposer releases ITS OWN registration and only once.
84
+ *
85
+ * A plain decrement would be called twice under React StrictMode, which
86
+ * double-invokes effect cleanups in development — the count would fall below
87
+ * the number of live hosts and every trigger would warn about a host that is
88
+ * mounted and working.
89
+ */
90
+ let released = false;
91
+
92
+ return () => {
93
+ if (released) return;
94
+
95
+ released = true;
96
+ mountedHosts = Math.max(0, mountedHosts - 1);
97
+ };
98
+ };
99
+
100
+ export const isModalHostMounted = (): boolean => mountedHosts > 0;
101
+
102
+ export const getOpenModalIds = (): readonly string[] => openModalIds;
103
+
104
+ export const isModalOpen = (id: string): boolean => openModalIds.includes(id);
105
+
106
+ export const openModal = (id: string): void => {
107
+ if (!canMutate() || !id) return;
108
+
109
+ // Already open: the no-op IS the loop guard. See `openModalIds`.
110
+ if (openModalIds.includes(id)) return;
111
+
112
+ if (!isModalHostMounted()) {
113
+ devWarn(
114
+ 'overlayBus',
115
+ `Nothing can show modal "${id}": <ThemeModals /> is not mounted. Add it once to the brand's root layout, next to the footer slot.`
116
+ );
117
+ // Still queued: the host may simply not have mounted yet this tick, and
118
+ // refusing would turn a race into a dead trigger.
119
+ }
120
+
121
+ if (openModalIds.length >= MAX_OPEN_MODALS) {
122
+ devWarn(
123
+ 'overlayBus',
124
+ `Refusing to open modal "${id}": ${MAX_OPEN_MODALS} modals are already open, which means something is opening them in a loop`
125
+ );
126
+ return;
127
+ }
128
+
129
+ openModalIds = [...openModalIds, id];
130
+ notify();
131
+ };
132
+
133
+ export const closeModal = (id: string): void => {
134
+ if (!canMutate() || !openModalIds.includes(id)) return;
135
+
136
+ openModalIds = openModalIds.filter((openId) => openId !== id);
137
+ notify();
138
+ };
139
+
140
+ export const toggleModal = (id: string): void => {
141
+ if (isModalOpen(id)) {
142
+ closeModal(id);
143
+ return;
144
+ }
145
+
146
+ openModal(id);
147
+ };
148
+
149
+ /** Close the most recently opened modal — what a generic "close" control means. */
150
+ export const closeTopModal = (): void => {
151
+ const top = openModalIds[openModalIds.length - 1];
152
+ if (top) closeModal(top);
153
+ };
154
+
155
+ export const closeAllModals = (): void => {
156
+ if (!canMutate() || openModalIds.length === 0) return;
157
+
158
+ openModalIds = [];
159
+ notify();
160
+ };
161
+
162
+ /**
163
+ * Nothing is open during SSR.
164
+ *
165
+ * This is the answer the server snapshot must give even if the module state
166
+ * somehow says otherwise: a modal opened during render would put a focus trap
167
+ * and an `aria-modal` claim into the serialized markup, and the client — which
168
+ * starts from an empty page-load state — would hydrate into a different tree.
169
+ * Auto-triggers therefore fire from an effect, never during render.
170
+ */
171
+ const closedOnServer = () => false;
172
+
173
+ /**
174
+ * Subscription for a caller with no modal to watch.
175
+ *
176
+ * `useBlockAction` runs on EVERY block on the page — hooks cannot be called
177
+ * conditionally — and almost none of them target a modal. Handing those the
178
+ * real subscribe would put a listener per block in the set and walk all of
179
+ * them on every open, so a page with hundreds of blocks would pay for a
180
+ * feature it is not using.
181
+ */
182
+ const neverNotifies = () => () => undefined;
183
+
184
+ const alwaysClosed = () => false;
185
+
186
+ /** Pass an empty id for "nothing to watch" — it costs no subscription. */
187
+ export const useModalOpen = (id: string): boolean =>
188
+ useSyncExternalStore(
189
+ id ? subscribeToModals : neverNotifies,
190
+ id ? () => openModalIds.includes(id) : alwaysClosed,
191
+ closedOnServer
192
+ );
193
+
194
+ /**
195
+ * Stable empty array for the server snapshot: `useSyncExternalStore` compares
196
+ * snapshots by reference and a fresh `[]` on every call is an infinite loop.
197
+ * The live snapshot is safe for the same reason — `openModalIds` is replaced
198
+ * only when it actually changes, never mutated in place.
199
+ */
200
+ const NO_OPEN_MODALS: readonly string[] = Object.freeze([]);
201
+
202
+ export const useOpenModalIds = (): readonly string[] =>
203
+ useSyncExternalStore(
204
+ subscribeToModals,
205
+ getOpenModalIds,
206
+ () => NO_OPEN_MODALS
207
+ );
208
+
209
+ /** Test-only: drop all state so cases cannot leak into each other. */
210
+ export const __resetOverlayBusForTests = (): void => {
211
+ openModalIds = [];
212
+ listeners.clear();
213
+ mountedHosts = 0;
214
+ };
@@ -0,0 +1,386 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { partAttrs } from '../utils';
5
+
6
+ /**
7
+ * The overlay surface: how a panel arrives over the page, and what it looks
8
+ * like when it does.
9
+ *
10
+ * Extracted from `panel-block.tsx` so the composed modal host and the panel
11
+ * block draw the SAME thing. Two consumers of one visual language is exactly
12
+ * the situation that produced three incompatible focus traps before
13
+ * `use-overlay.ts` unified them, and geometry is the next thing that would
14
+ * have drifted: "what does a bottom sheet look like" must not have two
15
+ * answers.
16
+ *
17
+ * What lives here is the surface. What lives in `use-overlay.ts` is the page:
18
+ * focus, scroll lock, `aria-modal`, Escape. A consumer wires both.
19
+ *
20
+ * The data attributes are the caller's, because the panel block's published
21
+ * markup carries `data-size-guide-*` names that live storefronts style and
22
+ * `panel-legacy-golden.json` pins byte for byte. New callers pass their own.
23
+ */
24
+
25
+ export type OverlayPresentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
26
+
27
+ export const OVERLAY_PRESENTATIONS: OverlayPresentation[] = [
28
+ 'modal',
29
+ 'drawer',
30
+ 'sheet',
31
+ 'anchored'
32
+ ];
33
+
34
+ export const clampOverlayNumber = (
35
+ value: unknown,
36
+ fallback: number,
37
+ min: number,
38
+ max: number
39
+ ): number => {
40
+ const numeric = Math.round(Number(value));
41
+ if (!Number.isFinite(numeric)) return fallback;
42
+
43
+ return Math.min(max, Math.max(min, numeric));
44
+ };
45
+
46
+ /** The panel's own box, before geometry places it. */
47
+ export const overlayPanelSurface: React.CSSProperties = {
48
+ overflow: 'auto',
49
+ padding: '24px',
50
+ background: '#ffffff',
51
+ color: '#111827',
52
+ textAlign: 'left',
53
+ outline: 'none'
54
+ };
55
+
56
+ export interface OverlayGeometryInput {
57
+ presentation: OverlayPresentation;
58
+ /** Drawer/anchored width and the centred modal's max width, in px. */
59
+ width: number;
60
+ panelSide?: string;
61
+ /** Sheet height as a percentage of the viewport. */
62
+ sheetHeight?: number;
63
+ anchorAlign?: 'start' | 'center' | 'end';
64
+ anchorOffset?: number;
65
+ }
66
+
67
+ /**
68
+ * Where the panel sits for a given presentation.
69
+ *
70
+ * Anchored is the odd one: it hangs off the block's own root rather than a
71
+ * fixed layer, because a dropdown has to travel with the trigger that opened
72
+ * it. Everything else lives on a layer over the page.
73
+ */
74
+ export const resolveOverlayPanelGeometry = ({
75
+ presentation,
76
+ width,
77
+ panelSide = 'right',
78
+ sheetHeight = 60,
79
+ anchorAlign = 'start',
80
+ anchorOffset = 8
81
+ }: OverlayGeometryInput): React.CSSProperties => {
82
+ if (presentation === 'drawer') {
83
+ return {
84
+ position: 'absolute',
85
+ top: 0,
86
+ bottom: 0,
87
+ [panelSide === 'left' ? 'left' : 'right']: 0,
88
+ width: `min(${width}px, 92vw)`,
89
+ boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
90
+ };
91
+ }
92
+
93
+ if (presentation === 'sheet') {
94
+ return {
95
+ position: 'absolute',
96
+ left: 0,
97
+ right: 0,
98
+ bottom: 0,
99
+ width: '100%',
100
+ maxHeight: `${sheetHeight}vh`,
101
+ boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
102
+ };
103
+ }
104
+
105
+ const anchorPlacement: React.CSSProperties =
106
+ anchorAlign === 'center'
107
+ ? { left: '50%', transform: 'translateX(-50%)' }
108
+ : anchorAlign === 'end'
109
+ ? { right: 0 }
110
+ : { left: 0 };
111
+
112
+ return {
113
+ position: 'absolute',
114
+ top: `calc(100% + ${anchorOffset}px)`,
115
+ zIndex: 1000,
116
+ width: `${width}px`,
117
+ maxWidth: '92vw',
118
+ maxHeight: '80vh',
119
+ borderRadius: '4px',
120
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
121
+ ...anchorPlacement
122
+ };
123
+ };
124
+
125
+ export interface OverlayCloseButtonProps {
126
+ label: string;
127
+ onClick: () => void;
128
+ /** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
129
+ markerAttrs?: Record<string, string>;
130
+ }
131
+
132
+ export const OverlayCloseButton = ({
133
+ label,
134
+ onClick,
135
+ markerAttrs
136
+ }: OverlayCloseButtonProps) => (
137
+ <button
138
+ type="button"
139
+ {...markerAttrs}
140
+ {...partAttrs('close')}
141
+ aria-label={label}
142
+ onClick={onClick}
143
+ style={{
144
+ position: 'absolute',
145
+ top: '12px',
146
+ right: '12px',
147
+ border: 'none',
148
+ background: 'transparent',
149
+ color: 'inherit',
150
+ cursor: 'pointer',
151
+ padding: 0
152
+ }}
153
+ >
154
+ <svg
155
+ width="18"
156
+ height="18"
157
+ viewBox="0 0 24 24"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ strokeWidth="1.6"
161
+ strokeLinecap="round"
162
+ >
163
+ <path d="M6 6l12 12M18 6L6 18" />
164
+ </svg>
165
+ </button>
166
+ );
167
+
168
+ /** The grab handle a bottom sheet draws above its content. */
169
+ export const OverlaySheetHandle = ({
170
+ markerAttrs
171
+ }: {
172
+ markerAttrs?: Record<string, string>;
173
+ }) => (
174
+ <div
175
+ {...markerAttrs}
176
+ {...partAttrs('handle')}
177
+ style={{
178
+ width: '40px',
179
+ height: '4px',
180
+ margin: '0 auto 12px',
181
+ borderRadius: '999px',
182
+ background: '#d1d5db'
183
+ }}
184
+ />
185
+ );
186
+
187
+ /** Attributes `useOverlay` hands back for the element that IS the overlay. */
188
+ export interface OverlayHandleProps {
189
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
190
+ ref: any;
191
+ 'aria-modal': 'true' | undefined;
192
+ }
193
+
194
+ export interface OverlaySurfaceProps extends OverlayGeometryInput {
195
+ overlayProps: OverlayHandleProps;
196
+ ariaLabel: string;
197
+ showBackdrop: boolean;
198
+ closeOnOverlay: boolean;
199
+ onDismiss: () => void;
200
+ /** Rendered inside the panel, above the authored content. */
201
+ header?: React.ReactNode;
202
+ children: React.ReactNode;
203
+ /**
204
+ * Legacy marker attributes per node. Order matters: they are spread BEFORE
205
+ * the part attributes, which is where the panel block already put them and
206
+ * therefore where the golden expects them.
207
+ */
208
+ markers?: {
209
+ root?: Record<string, string>;
210
+ backdrop?: Record<string, string>;
211
+ backdropRoot?: Record<string, string>;
212
+ panel?: Record<string, string>;
213
+ handle?: Record<string, string>;
214
+ };
215
+ }
216
+
217
+ /**
218
+ * The centred modal, kept as ONE node that is both the fixed root and the
219
+ * backdrop.
220
+ *
221
+ * Refactoring it into the root + backdrop sibling pair the other
222
+ * presentations use would move markup live storefronts already style, for no
223
+ * gain — so it keeps its own branch, and `panel-legacy-golden.json` holds it
224
+ * there. It also deliberately does without the panel's `tabIndex={-1}`: adding
225
+ * it would move published markup, and the gap only shows in a composition with
226
+ * the close button off AND no focusable child.
227
+ */
228
+ export const CentredModalSurface = ({
229
+ overlayProps,
230
+ ariaLabel,
231
+ closeOnOverlay,
232
+ onDismiss,
233
+ width,
234
+ header,
235
+ children,
236
+ markers
237
+ }: Omit<
238
+ OverlaySurfaceProps,
239
+ 'presentation' | 'showBackdrop' | 'panelSide' | 'sheetHeight'
240
+ >) => (
241
+ <div
242
+ {...markers?.root}
243
+ {...partAttrs('overlay', { open: true })}
244
+ onClick={() => {
245
+ if (closeOnOverlay) onDismiss();
246
+ }}
247
+ style={{
248
+ position: 'fixed',
249
+ inset: 0,
250
+ zIndex: 1000,
251
+ display: 'flex',
252
+ alignItems: 'center',
253
+ justifyContent: 'center',
254
+ padding: '16px',
255
+ background: 'rgba(0, 0, 0, 0.5)'
256
+ }}
257
+ >
258
+ <div
259
+ {...markers?.panel}
260
+ {...partAttrs('modal', { open: true })}
261
+ role="dialog"
262
+ {...overlayProps}
263
+ aria-label={ariaLabel}
264
+ // The backdrop closes the modal; clicks on the panel itself must not
265
+ // bubble up into that handler.
266
+ onClick={(event) => event.stopPropagation()}
267
+ style={{
268
+ position: 'relative',
269
+ width: '100%',
270
+ maxWidth: `${width}px`,
271
+ maxHeight: '80vh',
272
+ overflow: 'auto',
273
+ padding: '24px',
274
+ borderRadius: '4px',
275
+ background: '#ffffff',
276
+ color: '#111827',
277
+ textAlign: 'left'
278
+ }}
279
+ >
280
+ {header}
281
+ {children}
282
+ </div>
283
+ </div>
284
+ );
285
+
286
+ /**
287
+ * Drawer, sheet and anchored: a panel plus an optional backdrop.
288
+ *
289
+ * With the backdrop off, the fixed layer would still be an invisible sheet of
290
+ * glass over the whole viewport, so events pass through it and the panel turns
291
+ * them back on for itself.
292
+ */
293
+ export const LayeredOverlaySurface = ({
294
+ presentation,
295
+ overlayProps,
296
+ ariaLabel,
297
+ showBackdrop,
298
+ closeOnOverlay,
299
+ onDismiss,
300
+ width,
301
+ panelSide,
302
+ sheetHeight,
303
+ anchorAlign,
304
+ anchorOffset,
305
+ header,
306
+ children,
307
+ markers
308
+ }: OverlaySurfaceProps) => {
309
+ const backdrop = showBackdrop ? (
310
+ <div
311
+ {...markers?.backdrop}
312
+ {...partAttrs('overlay', { open: true })}
313
+ onClick={() => {
314
+ if (closeOnOverlay) onDismiss();
315
+ }}
316
+ style={{
317
+ position: 'absolute',
318
+ inset: 0,
319
+ background: 'rgba(0, 0, 0, 0.5)'
320
+ }}
321
+ />
322
+ ) : null;
323
+
324
+ const panel = (
325
+ <div
326
+ {...markers?.panel}
327
+ {...partAttrs('modal', { open: true })}
328
+ role="dialog"
329
+ {...overlayProps}
330
+ aria-label={ariaLabel}
331
+ // Focus target of last resort, for a panel whose every control the
332
+ // designer turned off.
333
+ tabIndex={-1}
334
+ onClick={(event) => event.stopPropagation()}
335
+ style={{
336
+ ...overlayPanelSurface,
337
+ ...resolveOverlayPanelGeometry({
338
+ presentation,
339
+ width,
340
+ panelSide,
341
+ sheetHeight,
342
+ anchorAlign,
343
+ anchorOffset
344
+ }),
345
+ pointerEvents: showBackdrop ? undefined : 'auto'
346
+ }}
347
+ >
348
+ {presentation === 'sheet' && (
349
+ <OverlaySheetHandle markerAttrs={markers?.handle} />
350
+ )}
351
+ {header}
352
+ {children}
353
+ </div>
354
+ );
355
+
356
+ if (presentation === 'anchored') {
357
+ return (
358
+ <>
359
+ {showBackdrop && (
360
+ <div
361
+ {...markers?.backdropRoot}
362
+ style={{ position: 'fixed', inset: 0, zIndex: 999 }}
363
+ >
364
+ {backdrop}
365
+ </div>
366
+ )}
367
+ {panel}
368
+ </>
369
+ );
370
+ }
371
+
372
+ return (
373
+ <div
374
+ {...markers?.root}
375
+ style={{
376
+ position: 'fixed',
377
+ inset: 0,
378
+ zIndex: 1000,
379
+ pointerEvents: showBackdrop ? undefined : 'none'
380
+ }}
381
+ >
382
+ {backdrop}
383
+ {panel}
384
+ </div>
385
+ );
386
+ };