@akinon/pz-theme 2.0.36-beta.0 → 2.0.36
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 +5 -4
- 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,503 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
3
|
+
// The Node build, not the browser one: it is the path a storefront actually
|
|
4
|
+
// server-renders through, and the browser build wants a MessageChannel jsdom
|
|
5
|
+
// does not have.
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server.node';
|
|
7
|
+
|
|
8
|
+
import { useOverlay } from '../use-overlay';
|
|
9
|
+
import type { OverlayPointerBook } from '../use-overlay';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The defect this primitive exists for, in the shape the blocks copied it in:
|
|
13
|
+
* `store-locator-block.tsx:679`, `variant-selector-block.tsx:508` and
|
|
14
|
+
* `facet-filters-block.tsx:757` each register a DOCUMENT-level `focusin`
|
|
15
|
+
* listener that pulls focus back into its own panel. Two of them on one page
|
|
16
|
+
* is an ordinary composition — those files' own comments say so — and each one
|
|
17
|
+
* reads the other's focus as "outside".
|
|
18
|
+
*
|
|
19
|
+
* Reproduced below without React so the loop is the subject and nothing else:
|
|
20
|
+
* the pull counter aborts at 50 instead of waiting for the real
|
|
21
|
+
* `RangeError: Maximum call stack size exceeded`.
|
|
22
|
+
*/
|
|
23
|
+
interface PullBudget {
|
|
24
|
+
pulls: number;
|
|
25
|
+
limit: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const installLegacyTrap = (panel: HTMLElement, budget: PullBudget) => {
|
|
29
|
+
const focusFirst = () => {
|
|
30
|
+
// Stands in for the call stack running out, so the test reports a number
|
|
31
|
+
// instead of hanging on the way to a RangeError.
|
|
32
|
+
if (budget.pulls >= budget.limit) return;
|
|
33
|
+
budget.pulls += 1;
|
|
34
|
+
|
|
35
|
+
const [first] = Array.from(panel.querySelectorAll<HTMLElement>('button'));
|
|
36
|
+
(first || panel).focus();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleFocusIn = (event: FocusEvent) => {
|
|
40
|
+
if (panel.contains(event.target as Node)) return;
|
|
41
|
+
focusFirst();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
document.addEventListener('focusin', handleFocusIn);
|
|
45
|
+
|
|
46
|
+
return () => document.removeEventListener('focusin', handleFocusIn);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const buildLegacyPanel = (name: string) => {
|
|
50
|
+
const panel = document.createElement('div');
|
|
51
|
+
panel.tabIndex = -1;
|
|
52
|
+
const button = document.createElement('button');
|
|
53
|
+
button.textContent = name;
|
|
54
|
+
panel.appendChild(button);
|
|
55
|
+
document.body.appendChild(panel);
|
|
56
|
+
|
|
57
|
+
return panel;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface ProbeProps {
|
|
61
|
+
name: string;
|
|
62
|
+
open: boolean;
|
|
63
|
+
modal?: boolean;
|
|
64
|
+
lockScroll?: boolean;
|
|
65
|
+
closeOnEscape?: boolean;
|
|
66
|
+
manageFocus?: boolean;
|
|
67
|
+
isDesigner?: boolean;
|
|
68
|
+
withTrigger?: boolean;
|
|
69
|
+
onClose?: () => void;
|
|
70
|
+
onDeactivate?: () => void;
|
|
71
|
+
bookRef?: { current: OverlayPointerBook | null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A block-shaped consumer: a trigger, a root that survives the trigger, and a
|
|
76
|
+
* panel that only exists while open — the same composition every block that
|
|
77
|
+
* opens a picker over the page already has.
|
|
78
|
+
*/
|
|
79
|
+
function OverlayProbe(props: ProbeProps) {
|
|
80
|
+
const {
|
|
81
|
+
name,
|
|
82
|
+
open,
|
|
83
|
+
withTrigger = true,
|
|
84
|
+
bookRef,
|
|
85
|
+
modal,
|
|
86
|
+
lockScroll,
|
|
87
|
+
closeOnEscape,
|
|
88
|
+
manageFocus,
|
|
89
|
+
isDesigner,
|
|
90
|
+
onClose,
|
|
91
|
+
onDeactivate
|
|
92
|
+
} = props;
|
|
93
|
+
|
|
94
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
95
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
|
|
97
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
98
|
+
open,
|
|
99
|
+
modal,
|
|
100
|
+
lockScroll,
|
|
101
|
+
closeOnEscape,
|
|
102
|
+
manageFocus,
|
|
103
|
+
isDesigner,
|
|
104
|
+
onClose,
|
|
105
|
+
onDeactivate,
|
|
106
|
+
returnFocusRef: triggerRef,
|
|
107
|
+
fallbackFocusRef: rootRef
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (bookRef) bookRef.current = overlay.pointers;
|
|
112
|
+
}, [bookRef, overlay.pointers]);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div ref={rootRef} tabIndex={-1} data-testid={`${name}-root`}>
|
|
116
|
+
{withTrigger ? (
|
|
117
|
+
<button type="button" ref={triggerRef} data-testid={`${name}-trigger`}>
|
|
118
|
+
open {name}
|
|
119
|
+
</button>
|
|
120
|
+
) : null}
|
|
121
|
+
{open ? (
|
|
122
|
+
<div
|
|
123
|
+
{...overlay.overlayProps}
|
|
124
|
+
role="dialog"
|
|
125
|
+
tabIndex={-1}
|
|
126
|
+
data-testid={`${name}-panel`}
|
|
127
|
+
>
|
|
128
|
+
<button type="button" data-testid={`${name}-first`}>
|
|
129
|
+
first
|
|
130
|
+
</button>
|
|
131
|
+
<button type="button" data-testid={`${name}-last`}>
|
|
132
|
+
last
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
) : null}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface PageProps {
|
|
141
|
+
a?: Partial<ProbeProps>;
|
|
142
|
+
b?: Partial<ProbeProps> | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Page({ a, b }: PageProps) {
|
|
146
|
+
return (
|
|
147
|
+
<div>
|
|
148
|
+
<button type="button" data-testid="outside">
|
|
149
|
+
outside
|
|
150
|
+
</button>
|
|
151
|
+
<OverlayProbe name="a" open={false} {...a} />
|
|
152
|
+
{b === null ? null : <OverlayProbe name="b" open={false} {...b} />}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('useOverlay', () => {
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
document.body.style.overflow = '';
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('reproduces the loop the per-block traps have: two document-level traps pull focus at each other forever', () => {
|
|
163
|
+
const panelA = buildLegacyPanel('a');
|
|
164
|
+
const panelB = buildLegacyPanel('b');
|
|
165
|
+
const outside = document.createElement('button');
|
|
166
|
+
document.body.appendChild(outside);
|
|
167
|
+
|
|
168
|
+
const budget: PullBudget = { pulls: 0, limit: 50 };
|
|
169
|
+
const removeA = installLegacyTrap(panelA, budget);
|
|
170
|
+
const removeB = installLegacyTrap(panelB, budget);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
outside.focus();
|
|
174
|
+
} finally {
|
|
175
|
+
removeA();
|
|
176
|
+
removeB();
|
|
177
|
+
panelA.remove();
|
|
178
|
+
panelB.remove();
|
|
179
|
+
outside.remove();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Neither trap ever wins: one move of focus spends the whole budget on the
|
|
183
|
+
// two of them handing it back and forth, which in a browser is
|
|
184
|
+
// `RangeError: Maximum call stack size exceeded` and a frozen tab.
|
|
185
|
+
expect(budget.pulls).toBe(budget.limit);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('lets only the topmost overlay pull focus back when two are open and focus moves', () => {
|
|
189
|
+
const { getByTestId } = render(
|
|
190
|
+
<Page a={{ open: true }} b={{ open: true }} />
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
let focusMoves = 0;
|
|
194
|
+
const countMove = () => {
|
|
195
|
+
focusMoves += 1;
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener('focusin', countMove);
|
|
198
|
+
|
|
199
|
+
// The scenario the per-block tests never reached: both panels open, and
|
|
200
|
+
// THEN focus moves.
|
|
201
|
+
try {
|
|
202
|
+
getByTestId('outside').focus();
|
|
203
|
+
} finally {
|
|
204
|
+
document.removeEventListener('focusin', countMove);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Focus landing outside, and one pull back in. That is the whole exchange.
|
|
208
|
+
expect(focusMoves).toBe(2);
|
|
209
|
+
expect(getByTestId('b-panel').contains(document.activeElement)).toBe(true);
|
|
210
|
+
// The one beneath is inert, not gone.
|
|
211
|
+
expect(getByTestId('a-panel')).toBeInTheDocument();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('pulls focus out of the overlay beneath exactly once, without the two trading it', () => {
|
|
215
|
+
const { getByTestId } = render(
|
|
216
|
+
<Page a={{ open: true }} b={{ open: true }} />
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
getByTestId('a-first').focus();
|
|
220
|
+
|
|
221
|
+
expect(document.activeElement).toBe(getByTestId('b-first'));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('claims aria-modal on the topmost only, and hands the claim back when it closes', () => {
|
|
225
|
+
const { getByTestId, rerender } = render(
|
|
226
|
+
<Page a={{ open: true }} b={{ open: false }} />
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(getByTestId('a-panel')).toHaveAttribute('aria-modal', 'true');
|
|
230
|
+
|
|
231
|
+
rerender(<Page a={{ open: true }} b={{ open: true }} />);
|
|
232
|
+
|
|
233
|
+
// Two live dialogs both claiming it makes neither claim true.
|
|
234
|
+
expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
|
|
235
|
+
expect(getByTestId('b-panel')).toHaveAttribute('aria-modal', 'true');
|
|
236
|
+
|
|
237
|
+
rerender(<Page a={{ open: true }} b={{ open: false }} />);
|
|
238
|
+
|
|
239
|
+
expect(getByTestId('a-panel')).toHaveAttribute('aria-modal', 'true');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `innerHTML` serializes attributes in the order the DOM took them, so an
|
|
244
|
+
* attribute that first appears on a LATER commit is appended after
|
|
245
|
+
* everything the block wrote. Registration happens in an effect, so a claim
|
|
246
|
+
* that waits for it lands one commit late and quietly moves the markup of
|
|
247
|
+
* every panel that carries it — under goldens that are not about modality
|
|
248
|
+
* at all.
|
|
249
|
+
*/
|
|
250
|
+
it('claims aria-modal on the commit that draws the panel, not the one after', () => {
|
|
251
|
+
function ClaimProbe({ open }: { open: boolean }) {
|
|
252
|
+
const overlay = useOverlay<HTMLDivElement>({ open });
|
|
253
|
+
|
|
254
|
+
return open ? (
|
|
255
|
+
<div
|
|
256
|
+
{...overlay.overlayProps}
|
|
257
|
+
role="dialog"
|
|
258
|
+
tabIndex={-1}
|
|
259
|
+
data-testid="claim"
|
|
260
|
+
/>
|
|
261
|
+
) : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const { getByTestId, rerender } = render(<ClaimProbe open={false} />);
|
|
265
|
+
rerender(<ClaimProbe open />);
|
|
266
|
+
|
|
267
|
+
// The order the props were written in, not `aria-modal` tacked on the end.
|
|
268
|
+
expect(getByTestId('claim').getAttributeNames()).toEqual([
|
|
269
|
+
'aria-modal',
|
|
270
|
+
'role',
|
|
271
|
+
'tabindex',
|
|
272
|
+
'data-testid'
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('never claims aria-modal for an overlay that is not modal', () => {
|
|
277
|
+
const { getByTestId } = render(<Page a={{ open: true, modal: false }} />);
|
|
278
|
+
|
|
279
|
+
// With no backdrop the page underneath is deliberately usable, so the
|
|
280
|
+
// claim would be a lie.
|
|
281
|
+
expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('wraps Tab both ways inside the topmost overlay', () => {
|
|
285
|
+
const { getByTestId } = render(<Page a={{ open: true }} />);
|
|
286
|
+
|
|
287
|
+
const panel = getByTestId('a-panel');
|
|
288
|
+
const first = getByTestId('a-first');
|
|
289
|
+
const last = getByTestId('a-last');
|
|
290
|
+
|
|
291
|
+
last.focus();
|
|
292
|
+
fireEvent.keyDown(last, { key: 'Tab' });
|
|
293
|
+
expect(document.activeElement).toBe(first);
|
|
294
|
+
|
|
295
|
+
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true });
|
|
296
|
+
expect(document.activeElement).toBe(last);
|
|
297
|
+
|
|
298
|
+
expect(panel).toBeInTheDocument();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('leaves focus with the overlay on top when the one beneath closes out of order', () => {
|
|
302
|
+
const { getByTestId, rerender } = render(
|
|
303
|
+
<Page a={{ open: true }} b={{ open: true }} />
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const inTop = getByTestId('b-last');
|
|
307
|
+
inTop.focus();
|
|
308
|
+
|
|
309
|
+
rerender(<Page a={{ open: false }} b={{ open: true }} />);
|
|
310
|
+
|
|
311
|
+
// A closing overlay hands focus back to its trigger only when it is the
|
|
312
|
+
// one that owns focus; the modal still on top keeps it.
|
|
313
|
+
expect(document.activeElement).toBe(inTop);
|
|
314
|
+
expect(getByTestId('b-panel')).toHaveAttribute('aria-modal', 'true');
|
|
315
|
+
|
|
316
|
+
// And the top one still traps after the out-of-order close.
|
|
317
|
+
getByTestId('outside').focus();
|
|
318
|
+
expect(getByTestId('b-panel').contains(document.activeElement)).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('returns focus to the trigger on close', () => {
|
|
322
|
+
const { getByTestId, rerender } = render(<Page a={{ open: false }} />);
|
|
323
|
+
|
|
324
|
+
getByTestId('a-trigger').focus();
|
|
325
|
+
rerender(<Page a={{ open: true }} />);
|
|
326
|
+
expect(getByTestId('a-panel').contains(document.activeElement)).toBe(true);
|
|
327
|
+
|
|
328
|
+
rerender(<Page a={{ open: false }} />);
|
|
329
|
+
expect(document.activeElement).toBe(getByTestId('a-trigger'));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('lands focus somewhere real when the trigger is gone by the time it closes', () => {
|
|
333
|
+
const { getByTestId, rerender } = render(<Page a={{ open: false }} />);
|
|
334
|
+
|
|
335
|
+
getByTestId('a-trigger').focus();
|
|
336
|
+
rerender(<Page a={{ open: true }} />);
|
|
337
|
+
|
|
338
|
+
// The row that carried the trigger re-renders away while the panel is up.
|
|
339
|
+
rerender(<Page a={{ open: true, withTrigger: false }} />);
|
|
340
|
+
rerender(<Page a={{ open: false, withTrigger: false }} />);
|
|
341
|
+
|
|
342
|
+
expect(document.activeElement).toBe(getByTestId('a-root'));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('does not leave focus inside a panel that is hidden rather than unmounted', () => {
|
|
346
|
+
function StickyOverlay({ open }: { open: boolean }) {
|
|
347
|
+
const overlay = useOverlay<HTMLDivElement>({ open });
|
|
348
|
+
|
|
349
|
+
// Some panels animate out: they stay mounted and merely stop being
|
|
350
|
+
// reachable, and focus left inside one is focus a shopper cannot see.
|
|
351
|
+
return (
|
|
352
|
+
<div {...overlay.overlayProps} tabIndex={-1} hidden={!open}>
|
|
353
|
+
<button type="button" data-testid="sticky-first">
|
|
354
|
+
first
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const { getByTestId, rerender } = render(<StickyOverlay open />);
|
|
361
|
+
expect(document.activeElement).toBe(getByTestId('sticky-first'));
|
|
362
|
+
|
|
363
|
+
rerender(<StickyOverlay open={false} />);
|
|
364
|
+
|
|
365
|
+
expect(document.activeElement).not.toBe(getByTestId('sticky-first'));
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('releases everything it took when it unmounts while still open', () => {
|
|
369
|
+
document.body.style.overflow = 'auto';
|
|
370
|
+
const onDeactivate = jest.fn();
|
|
371
|
+
const bookRef: { current: OverlayPointerBook | null } = { current: null };
|
|
372
|
+
|
|
373
|
+
const { getByTestId, unmount } = render(
|
|
374
|
+
<Page a={{ open: true, onDeactivate, bookRef }} />
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
getByTestId('a-first').focus();
|
|
378
|
+
bookRef.current?.track({ pointerId: 7, clientX: 10, clientY: 10 });
|
|
379
|
+
expect(bookRef.current?.size()).toBe(1);
|
|
380
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
381
|
+
|
|
382
|
+
unmount();
|
|
383
|
+
|
|
384
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
385
|
+
expect(onDeactivate).toHaveBeenCalledTimes(1);
|
|
386
|
+
expect(bookRef.current?.size()).toBe(0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('counts every scroll-lock holder and restores the page’s own overflow only for the last', () => {
|
|
390
|
+
document.body.style.overflow = 'auto';
|
|
391
|
+
|
|
392
|
+
const { rerender } = render(<Page a={{ open: true }} b={{ open: true }} />);
|
|
393
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
394
|
+
|
|
395
|
+
rerender(<Page a={{ open: false }} b={{ open: true }} />);
|
|
396
|
+
// One holder left: the page must stay frozen behind the panel still up.
|
|
397
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
398
|
+
|
|
399
|
+
rerender(<Page a={{ open: false }} b={{ open: false }} />);
|
|
400
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('leaves the page alone for an overlay that does not ask for the lock', () => {
|
|
404
|
+
document.body.style.overflow = 'auto';
|
|
405
|
+
|
|
406
|
+
render(<Page a={{ open: true, lockScroll: false }} />);
|
|
407
|
+
|
|
408
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('closes the topmost overlay on Escape and nothing beneath it', () => {
|
|
412
|
+
const closeA = jest.fn();
|
|
413
|
+
const closeB = jest.fn();
|
|
414
|
+
|
|
415
|
+
render(
|
|
416
|
+
<Page
|
|
417
|
+
a={{ open: true, onClose: closeA }}
|
|
418
|
+
b={{ open: true, onClose: closeB }}
|
|
419
|
+
/>
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
423
|
+
|
|
424
|
+
expect(closeB).toHaveBeenCalledTimes(1);
|
|
425
|
+
expect(closeA).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('honours closeOnEscape: false', () => {
|
|
429
|
+
const closeA = jest.fn();
|
|
430
|
+
|
|
431
|
+
render(<Page a={{ open: true, closeOnEscape: false, onClose: closeA }} />);
|
|
432
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
433
|
+
|
|
434
|
+
expect(closeA).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('hands back every pointer the overlay booked when it closes', () => {
|
|
438
|
+
const bookRef: { current: OverlayPointerBook | null } = { current: null };
|
|
439
|
+
const released: number[] = [];
|
|
440
|
+
const stage = {
|
|
441
|
+
setPointerCapture: jest.fn(),
|
|
442
|
+
releasePointerCapture: (id: number) => released.push(id)
|
|
443
|
+
} as unknown as Element;
|
|
444
|
+
|
|
445
|
+
const { rerender } = render(<Page a={{ open: true, bookRef }} />);
|
|
446
|
+
|
|
447
|
+
const book = bookRef.current as OverlayPointerBook;
|
|
448
|
+
book.track({ pointerId: 1, clientX: 0, clientY: 0, currentTarget: stage });
|
|
449
|
+
book.track({ pointerId: 2, clientX: 8, clientY: 4, currentTarget: stage });
|
|
450
|
+
book.track({ pointerId: 2, clientX: 9, clientY: 4, currentTarget: stage });
|
|
451
|
+
|
|
452
|
+
expect(book.size()).toBe(2);
|
|
453
|
+
expect(book.has(1)).toBe(true);
|
|
454
|
+
expect(book.points()).toEqual([
|
|
455
|
+
{ x: 0, y: 0 },
|
|
456
|
+
{ x: 9, y: 4 }
|
|
457
|
+
]);
|
|
458
|
+
// Capture is taken once per pointer, not again on every move.
|
|
459
|
+
expect(stage.setPointerCapture).toHaveBeenCalledTimes(2);
|
|
460
|
+
|
|
461
|
+
rerender(<Page a={{ open: false, bookRef }} />);
|
|
462
|
+
|
|
463
|
+
// A finger still down when the panel goes away must not stay booked: the
|
|
464
|
+
// next swipe would be read as the second finger of a pinch that never
|
|
465
|
+
// started, for the rest of the session.
|
|
466
|
+
expect(book.size()).toBe(0);
|
|
467
|
+
expect(released.sort()).toEqual([1, 2]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('stays inert in the designer', () => {
|
|
471
|
+
const closeA = jest.fn();
|
|
472
|
+
document.body.style.overflow = 'auto';
|
|
473
|
+
|
|
474
|
+
const { getByTestId } = render(
|
|
475
|
+
<Page a={{ open: true, isDesigner: true, onClose: closeA }} />
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const outside = getByTestId('outside');
|
|
479
|
+
outside.focus();
|
|
480
|
+
|
|
481
|
+
// The canvas is the author's workspace: nothing may reach into it.
|
|
482
|
+
expect(document.activeElement).toBe(outside);
|
|
483
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
484
|
+
expect(getByTestId('a-panel')).not.toHaveAttribute('aria-modal');
|
|
485
|
+
|
|
486
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
487
|
+
expect(closeA).not.toHaveBeenCalled();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('does not touch the document while rendering on the server', () => {
|
|
491
|
+
const addEventListener = jest.spyOn(document, 'addEventListener');
|
|
492
|
+
document.body.style.overflow = 'auto';
|
|
493
|
+
|
|
494
|
+
const markup = renderToStaticMarkup(<Page a={{ open: true }} />);
|
|
495
|
+
|
|
496
|
+
expect(markup).toContain('data-testid="a-panel"');
|
|
497
|
+
expect(markup).not.toContain('aria-modal');
|
|
498
|
+
expect(document.body.style.overflow).toBe('auto');
|
|
499
|
+
expect(addEventListener).not.toHaveBeenCalled();
|
|
500
|
+
|
|
501
|
+
addEventListener.mockRestore();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -31,6 +31,20 @@ export function useDesignerFeatures({
|
|
|
31
31
|
e.preventDefault();
|
|
32
32
|
e.stopPropagation();
|
|
33
33
|
|
|
34
|
+
// Clicking straight on the dropdown, a swatch or a menu item should
|
|
35
|
+
// select *that*, not just the block it lives in — otherwise a
|
|
36
|
+
// designer has to guess which entry in the style list is the element
|
|
37
|
+
// under the cursor.
|
|
38
|
+
const partElement =
|
|
39
|
+
e.target instanceof Element
|
|
40
|
+
? e.target.closest<HTMLElement>('[data-part]')
|
|
41
|
+
: null;
|
|
42
|
+
const partOwner = partElement?.closest<HTMLElement>('[data-block-id]');
|
|
43
|
+
const partName =
|
|
44
|
+
partElement && partOwner?.getAttribute('data-block-id') === blockId
|
|
45
|
+
? partElement.getAttribute('data-part')
|
|
46
|
+
: null;
|
|
47
|
+
|
|
34
48
|
if (window.parent) {
|
|
35
49
|
postToEditor({
|
|
36
50
|
type: 'SELECT_BLOCK',
|
|
@@ -39,7 +53,8 @@ export function useDesignerFeatures({
|
|
|
39
53
|
sectionId,
|
|
40
54
|
blockId,
|
|
41
55
|
blockType: blockInfo?.type,
|
|
42
|
-
blockLabel: blockInfo?.label
|
|
56
|
+
blockLabel: blockInfo?.label,
|
|
57
|
+
partName: partName || undefined
|
|
43
58
|
}
|
|
44
59
|
});
|
|
45
60
|
}
|