@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.
- package/CHANGELOG.md +16 -31
- package/jest/__mocks__/hookform-resolvers.js +5 -0
- package/jest/__mocks__/next-auth-react.js +5 -0
- package/jest.config.js +3 -0
- package/package.json +4 -4
- package/src/blocks/accordion-block.tsx +35 -3
- package/src/blocks/commerce-format.ts +220 -0
- package/src/blocks/icon-block.tsx +6 -1
- package/src/blocks/image-gallery-block.tsx +1 -1
- package/src/blocks/link-block.tsx +9 -1
- package/src/blocks/slider-block.tsx +2 -1
- package/src/blocks/tab-block.tsx +15 -2
- package/src/blocks/video-block.tsx +6 -0
- package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
- package/src/components/action-toolbar.tsx +182 -3
- package/src/components/part-state-preview.tsx +92 -0
- package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
- package/src/hooks/use-designer-features.tsx +16 -1
- package/src/hooks/use-overlay.ts +538 -0
- package/src/hooks/use-visibility-context.ts +14 -2
- package/src/sections/__tests__/divider-section.test.tsx +28 -1
- package/src/sections/contact-form-section.tsx +4 -1
- package/src/sections/coupon-banner-section.tsx +2 -1
- package/src/sections/featured-product-spotlight-section.tsx +4 -4
- package/src/sections/find-in-store-section.tsx +2 -1
- package/src/sections/hover-showcase-section.tsx +2 -1
- package/src/sections/installment-options-section.tsx +2 -1
- package/src/sections/order-tracking-lookup-section.tsx +6 -1
- package/src/sections/posts-slider-section.tsx +8 -3
- package/src/sections/pre-order-launch-banner-section.tsx +2 -1
- package/src/sections/tabs-section.tsx +7 -13
- package/src/theme-block.tsx +81 -16
- package/src/theme-placeholder-client.tsx +8 -1
- package/src/theme-placeholder-wrapper.tsx +17 -3
- package/src/theme-placeholder.tsx +51 -5
- package/src/theme-section.tsx +20 -4
- package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
- package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
- package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
- package/src/utils/breakpoints.ts +116 -0
- package/src/utils/custom-css.ts +233 -0
- package/src/utils/index.ts +353 -42
- package/src/utils/iterator-utils.ts +675 -62
- package/src/utils/page-context-discovery.test.ts +188 -0
- package/src/utils/page-context-discovery.ts +48 -4
- package/src/utils/part-styles.ts +229 -0
- package/src/utils/row-visibility.ts +273 -0
- package/src/utils/visibility-rules.test.ts +579 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
1226
|
+
const isMobile = isNarrowBreakpoint(currentBreakpoint);
|
|
1226
1227
|
const isProductResolved = !!activeProductPk && !!product;
|
|
1227
1228
|
|
|
1228
1229
|
const displayProductName = isProductResolved
|