@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-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.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -0,0 +1,538 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useMemo, useRef, useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * One contract for every block that puts a panel over the page.
7
+ *
8
+ * Three blocks grew their own copy of this and each got the same thing wrong:
9
+ * a DOCUMENT-level `focusin` listener that yanks focus back into its OWN panel
10
+ * whenever focus lands anywhere else. Two such panels on one page — an
11
+ * ordinary composition, since a picker legitimately appears twice, a desktop
12
+ * one and a mobile one — each read the other's focus as "outside" and pull
13
+ * against each other until the tab dies:
14
+ *
15
+ * RangeError: Maximum call stack size exceeded
16
+ * at focusFirst (variant-selector-block.tsx:476:24)
17
+ * at Document.handleFocusIn (variant-selector-block.tsx:508:7)
18
+ *
19
+ * The answer is not a smarter listener, it is a stack. Overlays queue, and
20
+ * only the one on top traps focus and claims `aria-modal`; the ones beneath
21
+ * stay mounted and intact and simply stop reaching for the page. The same
22
+ * stack makes the other page-wide state honest — one reference-counted scroll
23
+ * lock instead of one per block, and one `aria-modal` claim instead of two
24
+ * dialogs each telling assistive tech the other is hidden.
25
+ *
26
+ * Consumers need to know three things and no more: pass `open`, attach
27
+ * `overlayProps` to the element that IS the overlay, and hand back a trigger
28
+ * to return focus to.
29
+ *
30
+ * const overlay = useOverlay<HTMLDivElement>({
31
+ * open: isPanelOpen,
32
+ * onClose: () => setIsPanelOpen(false),
33
+ * isDesigner,
34
+ * modal: displayMode === 'trigger',
35
+ * returnFocusRef: triggerRef,
36
+ * fallbackFocusRef: blockRootRef
37
+ * });
38
+ *
39
+ * <div {...overlay.overlayProps} role="dialog" tabIndex={-1} />
40
+ *
41
+ * `tabIndex={-1}` stays the caller's because it is markup, but it is not
42
+ * optional: it is what lets a panel with nothing focusable inside it hold
43
+ * focus instead of quietly leaving it on the page behind.
44
+ */
45
+
46
+ /**
47
+ * Every overlay currently holding the page, oldest first. Module state on
48
+ * purpose: two overlays in unrelated blocks have to see one queue, which is
49
+ * exactly what per-block copies could not do.
50
+ */
51
+ const overlayStack: string[] = [];
52
+ const stackListeners = new Set<() => void>();
53
+
54
+ const notifyStack = () => {
55
+ stackListeners.forEach((listener) => listener());
56
+ };
57
+
58
+ const subscribeToStack = (listener: () => void) => {
59
+ stackListeners.add(listener);
60
+
61
+ return () => {
62
+ stackListeners.delete(listener);
63
+ };
64
+ };
65
+
66
+ const topmostOverlay = () => overlayStack[overlayStack.length - 1];
67
+
68
+ /**
69
+ * Whether `id` may CLAIM the page: it is on top, or it is opening and has not
70
+ * been queued yet.
71
+ *
72
+ * Queueing happens in an effect, so the render that OPENS an overlay runs
73
+ * before its own push and would answer "not on top" for that one render. That
74
+ * put `aria-modal` on the commit AFTER the panel appeared — a beat late for
75
+ * assistive tech, and, because the DOM appends an attribute that first shows
76
+ * up on a later commit, enough to move the serialized markup of every panel
77
+ * carrying it, under goldens that were never about modality.
78
+ *
79
+ * Reading "not queued yet" as "about to be on top" is not a guess: `push`
80
+ * appends, so an opening overlay always lands on top. An overlay that is not
81
+ * opening and not in the stack is simply closed, and claims nothing.
82
+ *
83
+ * Only the CLAIM runs ahead like this. Installing the trap waits for the
84
+ * settled stack (`isTopmost` below): two overlays opening in the same commit
85
+ * have not sorted themselves out yet, and two live traps — even for the one
86
+ * commit before the stack settles — is the very thing this file exists to
87
+ * stop.
88
+ */
89
+ const mayClaimPage = (id: string, opening: boolean): boolean => {
90
+ const index = overlayStack.indexOf(id);
91
+
92
+ return index === -1 ? opening : index === overlayStack.length - 1;
93
+ };
94
+
95
+ const pushOverlay = (id: string) => {
96
+ if (overlayStack.includes(id)) return;
97
+
98
+ overlayStack.push(id);
99
+ notifyStack();
100
+ };
101
+
102
+ const popOverlay = (id: string) => {
103
+ const index = overlayStack.indexOf(id);
104
+ if (index === -1) return;
105
+
106
+ overlayStack.splice(index, 1);
107
+ notifyStack();
108
+ };
109
+
110
+ /** Nothing is on top of a page that has not been rendered yet. */
111
+ const noOverlayOnServer = () => false;
112
+
113
+ /**
114
+ * The page's own overflow, held by whoever locked first and written back only
115
+ * when the last holder leaves. Each block used to keep its own counter, so the
116
+ * second block to open captured `hidden` as "the page's own value" and the
117
+ * page stayed frozen after every panel had closed.
118
+ */
119
+ let scrollLockHolders = 0;
120
+ let scrollLockPreviousOverflow = '';
121
+
122
+ const acquireScrollLock = () => {
123
+ if (typeof document === 'undefined') return;
124
+
125
+ if (scrollLockHolders === 0) {
126
+ scrollLockPreviousOverflow = document.body.style.overflow;
127
+ document.body.style.overflow = 'hidden';
128
+ }
129
+
130
+ scrollLockHolders += 1;
131
+ };
132
+
133
+ const releaseScrollLock = () => {
134
+ if (typeof document === 'undefined' || scrollLockHolders === 0) return;
135
+
136
+ scrollLockHolders -= 1;
137
+
138
+ if (scrollLockHolders === 0) {
139
+ document.body.style.overflow = scrollLockPreviousOverflow;
140
+ scrollLockPreviousOverflow = '';
141
+ }
142
+ };
143
+
144
+ const FOCUSABLE_SELECTOR = [
145
+ 'a[href]',
146
+ 'button:not([disabled])',
147
+ 'input:not([disabled])',
148
+ 'select:not([disabled])',
149
+ 'textarea:not([disabled])',
150
+ '[tabindex]:not([tabindex="-1"])'
151
+ ].join(',');
152
+
153
+ const focusablesIn = (panel: HTMLElement): HTMLElement[] =>
154
+ Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
155
+
156
+ /**
157
+ * Focus the panel's first control, or the panel itself when it has none — so
158
+ * give the panel `tabIndex={-1}`, otherwise a panel with nothing focusable in
159
+ * it silently leaves focus outside.
160
+ */
161
+ const focusFirstIn = (panel: HTMLElement | null) => {
162
+ if (!panel) return;
163
+
164
+ const [first] = focusablesIn(panel);
165
+ (first || panel).focus();
166
+ };
167
+
168
+ /**
169
+ * Reports whether focus actually landed, not merely that `focus()` was called:
170
+ * an element can be connected and still refuse focus, and a caller that
171
+ * believes the handover happened stops looking for somewhere else to put it.
172
+ */
173
+ const focusIfConnected = (element: HTMLElement | null | undefined) => {
174
+ if (!element || !element.isConnected || typeof element.focus !== 'function') {
175
+ return false;
176
+ }
177
+
178
+ element.focus();
179
+
180
+ return document.activeElement === element;
181
+ };
182
+
183
+ export interface OverlayPointerPoint {
184
+ x: number;
185
+ y: number;
186
+ }
187
+
188
+ /** The shape both React and native pointer events already satisfy. */
189
+ export interface TrackablePointerEvent {
190
+ pointerId: number;
191
+ clientX: number;
192
+ clientY: number;
193
+ currentTarget?: EventTarget | null;
194
+ }
195
+
196
+ /**
197
+ * The pointers currently down on the overlay, with the capture each one took.
198
+ *
199
+ * A gesture surface has to count the fingers still down to tell a two-finger
200
+ * pinch from a one-finger drag, and a finger that is still down when the
201
+ * overlay unmounts is never followed by a `pointerup` — the id stays booked
202
+ * for the rest of the session and every later swipe is read as the second
203
+ * finger of a pinch that never started. The overlay clears the book on close,
204
+ * so no block has to remember to.
205
+ */
206
+ export interface OverlayPointerBook {
207
+ /** Remember a pointer, taking capture the first time it is seen so a fast
208
+ * drag that leaves the panel keeps reporting. Safe to call on every move. */
209
+ track: (event: TrackablePointerEvent) => void;
210
+ /** Forget one pointer and hand its capture back (pointerup / pointercancel). */
211
+ release: (pointerId: number) => void;
212
+ /** Forget every pointer and hand back every capture. */
213
+ clear: () => void;
214
+ has: (pointerId: number) => boolean;
215
+ /** The live positions, in the order the fingers landed. */
216
+ points: () => OverlayPointerPoint[];
217
+ size: () => number;
218
+ }
219
+
220
+ interface BookedPointer extends OverlayPointerPoint {
221
+ capture: Element | null;
222
+ }
223
+
224
+ type CapturingElement = Element & {
225
+ setPointerCapture?: (pointerId: number) => void;
226
+ releasePointerCapture?: (pointerId: number) => void;
227
+ hasPointerCapture?: (pointerId: number) => boolean;
228
+ };
229
+
230
+ const handBackCapture = (capture: Element | null, pointerId: number) => {
231
+ const element = capture as CapturingElement | null;
232
+ if (!element || typeof element.releasePointerCapture !== 'function') return;
233
+
234
+ // A capture the browser already dropped makes `releasePointerCapture` throw,
235
+ // and a throw mid-clear would strand every pointer after it in the book.
236
+ try {
237
+ if (element.hasPointerCapture?.(pointerId) === false) return;
238
+ element.releasePointerCapture(pointerId);
239
+ } catch {
240
+ // Already handed back; nothing to undo.
241
+ }
242
+ };
243
+
244
+ const createPointerBook = (): OverlayPointerBook => {
245
+ const booked = new Map<number, BookedPointer>();
246
+
247
+ return {
248
+ track: (event) => {
249
+ const existing = booked.get(event.pointerId);
250
+ let capture = existing ? existing.capture : null;
251
+
252
+ if (!existing) {
253
+ const target = (event.currentTarget || null) as CapturingElement | null;
254
+
255
+ if (target && typeof target.setPointerCapture === 'function') {
256
+ try {
257
+ target.setPointerCapture(event.pointerId);
258
+ capture = target;
259
+ } catch {
260
+ // The pointer ended before capture could be taken.
261
+ }
262
+ }
263
+ }
264
+
265
+ booked.set(event.pointerId, {
266
+ x: event.clientX,
267
+ y: event.clientY,
268
+ capture
269
+ });
270
+ },
271
+ release: (pointerId) => {
272
+ const entry = booked.get(pointerId);
273
+ if (!entry) return;
274
+
275
+ booked.delete(pointerId);
276
+ handBackCapture(entry.capture, pointerId);
277
+ },
278
+ clear: () => {
279
+ booked.forEach((entry, pointerId) =>
280
+ handBackCapture(entry.capture, pointerId)
281
+ );
282
+ booked.clear();
283
+ },
284
+ has: (pointerId) => booked.has(pointerId),
285
+ points: () => Array.from(booked.values(), ({ x, y }) => ({ x, y })),
286
+ size: () => booked.size
287
+ };
288
+ };
289
+
290
+ /** Accepts a ref from any React version, mutable or not. */
291
+ interface OverlayFocusRef {
292
+ readonly current: HTMLElement | null;
293
+ }
294
+
295
+ export interface UseOverlayOptions {
296
+ /** Whether the overlay is showing. Everything below hangs off this. */
297
+ open: boolean;
298
+ /** Asked to close — Escape today. */
299
+ onClose?: () => void;
300
+ /**
301
+ * The designer canvas is the author's workspace, not a shopper viewport, so
302
+ * an overlay there is fully inert: it never steals focus into the canvas,
303
+ * never freezes the page the author is styling, never claims `aria-modal`,
304
+ * and never closes itself out from under an author who pressed Escape.
305
+ */
306
+ isDesigner?: boolean;
307
+ /**
308
+ * True when the overlay really covers the page behind a backdrop. Only a
309
+ * modal overlay traps focus and claims `aria-modal`: with no backdrop the
310
+ * page underneath is deliberately usable and the claim would be a lie.
311
+ */
312
+ modal?: boolean;
313
+ /** Freeze the page behind the overlay. Defaults to `modal`. */
314
+ lockScroll?: boolean;
315
+ closeOnEscape?: boolean;
316
+ /**
317
+ * Move focus in on open, hold it while topmost, hand it back on close.
318
+ * Defaults to `modal` — an inline panel sits next in reading order and needs
319
+ * no help.
320
+ */
321
+ manageFocus?: boolean;
322
+ /** Where focus goes on close. Defaults to whatever had it when the overlay opened. */
323
+ returnFocusRef?: OverlayFocusRef;
324
+ /** Where focus goes when that trigger is gone by the time the overlay closes. */
325
+ fallbackFocusRef?: OverlayFocusRef;
326
+ /** Ran once per open-to-closed transition, unmount included. */
327
+ onDeactivate?: () => void;
328
+ }
329
+
330
+ export interface OverlayHandle<T extends HTMLElement = HTMLElement> {
331
+ /** Attach to the element that IS the overlay. */
332
+ panelRef: { current: T | null };
333
+ /** True while this overlay is the one on top of the stack. */
334
+ isTopmost: boolean;
335
+ /** `'true'` only where the claim is honest, `undefined` otherwise. */
336
+ ariaModal: 'true' | undefined;
337
+ /** Spread onto the overlay element; the role, label and tabIndex stay the
338
+ * caller's, since only the block knows what its panel is. */
339
+ overlayProps: {
340
+ ref: { current: T | null };
341
+ 'aria-modal': 'true' | undefined;
342
+ };
343
+ /** Cleared, captures and all, when the overlay closes. */
344
+ pointers: OverlayPointerBook;
345
+ }
346
+
347
+ export function useOverlay<T extends HTMLElement = HTMLElement>(
348
+ options: UseOverlayOptions
349
+ ): OverlayHandle<T> {
350
+ const {
351
+ open,
352
+ isDesigner = false,
353
+ modal = true,
354
+ lockScroll = modal,
355
+ closeOnEscape = true,
356
+ manageFocus = modal
357
+ } = options;
358
+
359
+ const id = useId();
360
+ const panelRef = useRef<T | null>(null);
361
+ const openerRef = useRef<HTMLElement | null>(null);
362
+
363
+ // A ref rather than a memo: the book has to be the same object for the life
364
+ // of the overlay, and a memo is free to be thrown away and rebuilt.
365
+ const bookRef = useRef<OverlayPointerBook | null>(null);
366
+ if (!bookRef.current) bookRef.current = createPointerBook();
367
+ const pointers = bookRef.current;
368
+
369
+ const active = open && !isDesigner;
370
+
371
+ // Subscribed before anything registers, so the very first push is heard.
372
+ const isTopmost = useSyncExternalStore(
373
+ subscribeToStack,
374
+ () => topmostOverlay() === id,
375
+ noOverlayOnServer
376
+ );
377
+
378
+ // The same question asked a beat earlier — see `mayClaimPage`.
379
+ const claimsPage = useSyncExternalStore(
380
+ subscribeToStack,
381
+ () => mayClaimPage(id, active),
382
+ noOverlayOnServer
383
+ );
384
+
385
+ /**
386
+ * Callbacks, refs and the resolved defaults are read when something happens
387
+ * rather than when the effect was set up, so a consumer re-rendering does not
388
+ * re-run the trap or move focus a second time.
389
+ */
390
+ const settingsRef = useRef({ ...options, manageFocus });
391
+ useEffect(() => {
392
+ settingsRef.current = { ...options, manageFocus };
393
+ });
394
+
395
+ /**
396
+ * Declared before the stack registration below so that on the way out this
397
+ * runs while the overlay is still in the stack and can ask whether it is the
398
+ * one that owns focus.
399
+ */
400
+ useEffect(() => {
401
+ if (!active) return undefined;
402
+
403
+ openerRef.current = document.activeElement as HTMLElement | null;
404
+
405
+ if (settingsRef.current.manageFocus) focusFirstIn(panelRef.current);
406
+
407
+ return () => {
408
+ // A finger still down when the panel goes away is never followed by a
409
+ // pointerup, so the book has to be emptied here rather than waiting for
410
+ // an event that will not arrive.
411
+ pointers.clear();
412
+
413
+ const {
414
+ manageFocus: managed,
415
+ onDeactivate,
416
+ returnFocusRef,
417
+ fallbackFocusRef
418
+ } = settingsRef.current;
419
+
420
+ onDeactivate?.();
421
+
422
+ // Only the overlay that owns focus hands it back. One closing underneath
423
+ // a modal that is still up must not drag focus out of it.
424
+ if (!managed || topmostOverlay() !== id) return;
425
+
426
+ if (focusIfConnected(returnFocusRef?.current)) return;
427
+ if (focusIfConnected(openerRef.current)) return;
428
+ // The trigger was removed while the overlay was up — a row that
429
+ // re-rendered away under it, say. Landing on the block it belonged to
430
+ // beats dropping a keyboard shopper at the top of the document.
431
+ if (focusIfConnected(fallbackFocusRef?.current)) return;
432
+
433
+ // Nowhere left to go, and a panel that is merely hidden rather than
434
+ // unmounted would otherwise keep focus a shopper cannot see.
435
+ const stranded = document.activeElement as HTMLElement | null;
436
+ if (stranded && stranded !== document.body) stranded.blur?.();
437
+ };
438
+ }, [active, id, pointers]);
439
+
440
+ useEffect(() => {
441
+ if (!active) return undefined;
442
+
443
+ pushOverlay(id);
444
+
445
+ return () => popOverlay(id);
446
+ }, [active, id]);
447
+
448
+ const trapping = active && modal && isTopmost;
449
+
450
+ useEffect(() => {
451
+ if (!trapping) return undefined;
452
+
453
+ const panel = panelRef.current;
454
+ if (!panel) return undefined;
455
+
456
+ // Backs the `aria-modal` claim: while the rest of the page is declared
457
+ // hidden, Tab must not walk out into it.
458
+ const handleKeyDown = (event: KeyboardEvent) => {
459
+ if (event.key !== 'Tab') return;
460
+
461
+ const items = focusablesIn(panel);
462
+
463
+ if (items.length === 0) {
464
+ event.preventDefault();
465
+ panel.focus();
466
+
467
+ return;
468
+ }
469
+
470
+ const first = items[0];
471
+ const last = items[items.length - 1];
472
+ const activeElement = document.activeElement;
473
+
474
+ if (
475
+ event.shiftKey &&
476
+ (activeElement === first || activeElement === panel)
477
+ ) {
478
+ event.preventDefault();
479
+ last.focus();
480
+ } else if (!event.shiftKey && activeElement === last) {
481
+ event.preventDefault();
482
+ first.focus();
483
+ }
484
+ };
485
+
486
+ // Tab is not the only way out — the browser's own focus moves land here
487
+ // too, so the trap watches where focus actually ends up. This listener is
488
+ // the one that used to fight its twin; the `trapping` gate above is what
489
+ // guarantees there is never more than one of it on the page.
490
+ const handleFocusIn = (event: FocusEvent) => {
491
+ if (panel.contains(event.target as Node)) return;
492
+
493
+ focusFirstIn(panel);
494
+ };
495
+
496
+ panel.addEventListener('keydown', handleKeyDown);
497
+ document.addEventListener('focusin', handleFocusIn);
498
+
499
+ return () => {
500
+ panel.removeEventListener('keydown', handleKeyDown);
501
+ document.removeEventListener('focusin', handleFocusIn);
502
+ };
503
+ }, [trapping]);
504
+
505
+ useEffect(() => {
506
+ if (!active || !lockScroll) return undefined;
507
+
508
+ acquireScrollLock();
509
+
510
+ return releaseScrollLock;
511
+ }, [active, lockScroll]);
512
+
513
+ useEffect(() => {
514
+ // Escape belongs to the overlay on top; the ones beneath stay open.
515
+ if (!active || !closeOnEscape || !isTopmost) return undefined;
516
+
517
+ const handleKeyDown = (event: KeyboardEvent) => {
518
+ if (event.key !== 'Escape') return;
519
+
520
+ settingsRef.current.onClose?.();
521
+ };
522
+
523
+ document.addEventListener('keydown', handleKeyDown);
524
+
525
+ return () => document.removeEventListener('keydown', handleKeyDown);
526
+ }, [active, closeOnEscape, isTopmost]);
527
+
528
+ const ariaModal = active && modal && claimsPage ? ('true' as const) : undefined;
529
+
530
+ const overlayProps = useMemo(
531
+ () => ({ ref: panelRef, 'aria-modal': ariaModal }),
532
+ [ariaModal]
533
+ );
534
+
535
+ return { panelRef, isTopmost, ariaModal, overlayProps, pointers };
536
+ }
537
+
538
+ export default useOverlay;
@@ -5,7 +5,17 @@ import { useSession } from 'next-auth/react';
5
5
 
6
6
  import { useLocalization } from '@akinon/next/hooks/use-localization';
7
7
 
8
- export const useVisibilityContext = (currentBreakpoint = 'desktop') => {
8
+ interface VisibilityContextOptions {
9
+ /** What the page is about; only data conditions read it. */
10
+ pageContext?: Record<string, unknown> | null;
11
+ isDesigner?: boolean;
12
+ }
13
+
14
+ export const useVisibilityContext = (
15
+ currentBreakpoint = 'desktop',
16
+ // Optional so every existing caller keeps the exact context it built before.
17
+ options: VisibilityContextOptions = {}
18
+ ) => {
9
19
  const pathname = usePathname();
10
20
  const searchParams = useSearchParams();
11
21
  const { status } = useSession();
@@ -22,6 +32,8 @@ export const useVisibilityContext = (currentBreakpoint = 'desktop') => {
22
32
  locale: String(locale || ''),
23
33
  currency: String(currency || ''),
24
34
  pathname: String(pathname || ''),
25
- searchParams
35
+ searchParams,
36
+ pageContext: options.pageContext ?? null,
37
+ isDesigner: options.isDesigner === true
26
38
  };
27
39
  };
@@ -147,7 +147,12 @@ describe('DividerSection', () => {
147
147
  expect(getSpan(container).style.height).toBe('6px');
148
148
  });
149
149
 
150
- it('falls back to 1px when responsive object has no matching or desktop key', () => {
150
+ // A tablet with nothing authored for it reads the mobile value, because
151
+ // the mobile media query is what covers 768-1023 on the device. This used
152
+ // to assert 1px, from back when 'tablet' was merely an unrecognised string:
153
+ // the renderer said 1px while the stylesheet on that same band said 2px,
154
+ // which is exactly the disagreement the fall-through chain exists to end.
155
+ it('reads the mobile value at tablet when no tablet value is authored', () => {
151
156
  const { container } = renderDivider(
152
157
  {
153
158
  id: 's14',
@@ -155,6 +160,28 @@ describe('DividerSection', () => {
155
160
  },
156
161
  { currentBreakpoint: 'tablet' }
157
162
  );
163
+ expect(getSpan(container).style.height).toBe('2px');
164
+ });
165
+
166
+ it('prefers the tablet value over the mobile one when authored', () => {
167
+ const { container } = renderDivider(
168
+ {
169
+ id: 's15',
170
+ styles: { height: { desktop: '6px', tablet: '4px', mobile: '2px' } }
171
+ },
172
+ { currentBreakpoint: 'tablet' }
173
+ );
174
+ expect(getSpan(container).style.height).toBe('4px');
175
+ });
176
+
177
+ it('falls back to 1px when there is nothing to fall through to', () => {
178
+ const { container } = renderDivider(
179
+ {
180
+ id: 's16',
181
+ styles: { height: { tablet: '4px' } }
182
+ },
183
+ { currentBreakpoint: 'mobile' }
184
+ );
158
185
  expect(getSpan(container).style.height).toBe('1px');
159
186
  });
160
187
  });
@@ -14,6 +14,7 @@ import {
14
14
  getResponsiveValue,
15
15
  resolveThemeCssVariables
16
16
  } from '../utils';
17
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
17
18
  import { Section } from '../theme-section';
18
19
  import { Modal } from '@akinon/next/components/modal';
19
20
  import {
@@ -74,6 +75,8 @@ const readProperty = (
74
75
  const responsive = raw as Record<string, unknown>;
75
76
  const picked =
76
77
  responsive[breakpoint] ??
78
+ // A tablet inherits mobile before desktop, matching the emitted cascade.
79
+ (breakpoint === 'tablet' ? responsive.mobile : undefined) ??
77
80
  responsive.desktop ??
78
81
  Object.values(responsive)[0];
79
82
  return picked == null ? fallback : String(picked);
@@ -449,7 +452,7 @@ const ContactFormSection: React.FC<ContactFormSectionProps> = ({
449
452
  setSelectedSubject(e.target.value);
450
453
  };
451
454
 
452
- const isMobile = currentBreakpoint === 'mobile';
455
+ const isMobile = isNarrowBreakpoint(currentBreakpoint);
453
456
 
454
457
  const errorStyle: React.CSSProperties = {
455
458
  color: '#ef4444',
@@ -13,6 +13,7 @@ import {
13
13
  getResponsiveValue,
14
14
  resolveThemeCssVariables
15
15
  } from '../utils';
16
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
16
17
  import { Section } from '../theme-section';
17
18
 
18
19
  import { postToEditor } from '../editor-bridge';
@@ -650,7 +651,7 @@ const CouponBannerSection: React.FC<CouponBannerSectionProps> = ({
650
651
  }
651
652
  };
652
653
 
653
- const isMobileLayout = currentBreakpoint === 'mobile';
654
+ const isMobileLayout = isNarrowBreakpoint(currentBreakpoint);
654
655
  const fallbackBackgroundColor =
655
656
  typeof sectionStyles.backgroundColor === 'string'
656
657
  ? sectionStyles.backgroundColor
@@ -8,6 +8,7 @@ import { useThemeSettingsContext } from '../theme-settings-context';
8
8
  import { Section } from '../theme-section';
9
9
  import { safeDeepClone } from '../utils/safe-clone';
10
10
  import { getCSSStyles, getResponsiveValue } from '../utils';
11
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
11
12
 
12
13
  import { postToEditor } from '../editor-bridge';
13
14
  interface FeaturedProductSpotlightSectionProps {
@@ -247,10 +248,9 @@ export default function FeaturedProductSpotlightSection({
247
248
 
248
249
  return {
249
250
  ...baseStyles,
250
- flexDirection:
251
- currentBreakpoint === 'mobile'
252
- ? ('column-reverse' as const)
253
- : ('row-reverse' as const)
251
+ flexDirection: isNarrowBreakpoint(currentBreakpoint)
252
+ ? ('column-reverse' as const)
253
+ : ('row-reverse' as const)
254
254
  };
255
255
  }, [
256
256
  currentBreakpoint,
@@ -27,6 +27,7 @@ import {
27
27
  getResponsiveValue,
28
28
  resolveThemeCssVariables
29
29
  } from '../utils';
30
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
30
31
 
31
32
  import { postToEditor } from '../editor-bridge';
32
33
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -1222,7 +1223,7 @@ export default function FindInStoreSection({
1222
1223
  () => ((stockResponse || []) as StoreStockItem[]).slice(0, maxResults),
1223
1224
  [maxResults, stockResponse]
1224
1225
  );
1225
- const isMobile = currentBreakpoint === 'mobile';
1226
+ const isMobile = isNarrowBreakpoint(currentBreakpoint);
1226
1227
  const isProductResolved = !!activeProductPk && !!product;
1227
1228
 
1228
1229
  const displayProductName = isProductResolved