@akinon/pz-theme 2.0.38-rc.0 → 2.0.39-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.
@@ -0,0 +1,592 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ const push = jest.fn();
5
+
6
+ jest.mock('next/navigation', () => ({
7
+ useRouter: () => ({ push: (...args: unknown[]) => push(...args) }),
8
+ usePathname: () => '/list',
9
+ useSearchParams: () => new URLSearchParams('')
10
+ }));
11
+
12
+ jest.mock('@akinon/next/data/client/misc', () => ({
13
+ useGetMenuQuery: () => ({
14
+ data: [
15
+ { pk: 1, label: 'Kadın', url: '/kadin', parent_pk: null },
16
+ { pk: 2, label: 'Ayakkabı', url: '/kadin/ayakkabi', parent_pk: 1 }
17
+ ],
18
+ isLoading: false,
19
+ isFetching: false,
20
+ isSuccess: true,
21
+ isError: false
22
+ })
23
+ }));
24
+
25
+ import FacetFiltersBlock from '../facet-filters-block';
26
+ import SizeGuideBlock from '../size-guide-block';
27
+ import HamburgerMenuBlock from '../hamburger-menu-block';
28
+ import MegaMenuBlock from '../mega-menu-block';
29
+ import SortSelectBlock from '../sort-select-block';
30
+ import { useOverlay } from '../../hooks/use-overlay';
31
+ import { ThemePageContextProvider } from '../../theme-page-context';
32
+
33
+ /**
34
+ * The sweep, from the outside.
35
+ *
36
+ * Every one of these blocks used to own a piece of state the whole PAGE shares
37
+ * — the focus trap, the `aria-modal` claim, Escape, the scroll lock — and a
38
+ * listing page composes several of them at once. A shopper opening the filter
39
+ * drawer over a store panel, a size picker or a quick view is the ordinary
40
+ * case, not an edge one, so each pair below is a composition a brand ships.
41
+ *
42
+ * The overlay these blocks are tested AGAINST is a plain `useOverlay`
43
+ * consumer: it is the same primitive `store-locator`, `variant-selector`,
44
+ * `product-gallery` and `product-recommendations` were migrated onto, so a
45
+ * block that behaves next to it behaves next to all four.
46
+ */
47
+ function MigratedPanel({
48
+ open,
49
+ modal = true
50
+ }: {
51
+ open: boolean;
52
+ modal?: boolean;
53
+ }) {
54
+ const overlay = useOverlay<HTMLDivElement>({ open, modal });
55
+
56
+ if (!open) return null;
57
+
58
+ return (
59
+ <div
60
+ {...overlay.overlayProps}
61
+ role="dialog"
62
+ tabIndex={-1}
63
+ data-testid="migrated-panel"
64
+ >
65
+ <button type="button" data-testid="migrated-first">
66
+ Kadıköy
67
+ </button>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ const facet = (key: string, name: string, choices: unknown[]) => ({
73
+ key,
74
+ name,
75
+ search_key: `attributes_${key}`,
76
+ widget_type: 'multiselect',
77
+ data: { choices }
78
+ });
79
+
80
+ const listingContext = {
81
+ listing: {
82
+ title: 'Shoes',
83
+ path: '/list',
84
+ query: '',
85
+ products: [],
86
+ facets: [
87
+ facet('color', 'Renk', [
88
+ { label: 'Siyah', value: 'black', quantity: 12, is_selected: false },
89
+ { label: 'Beyaz', value: 'white', quantity: 7, is_selected: false }
90
+ ])
91
+ ],
92
+ sorters: [
93
+ { key: 'newest', label: 'Newest' },
94
+ { key: 'price_asc', label: 'Price low to high' }
95
+ ],
96
+ pagination: {},
97
+ breadcrumbs: []
98
+ }
99
+ };
100
+
101
+ const mkBlock = (
102
+ type: string,
103
+ properties: Record<string, unknown>,
104
+ blocks: unknown[] = []
105
+ ) =>
106
+ ({
107
+ id: `${type}-1`,
108
+ type,
109
+ label: type,
110
+ order: 0,
111
+ hidden: false,
112
+ value: '',
113
+ properties,
114
+ styles: {},
115
+ blocks
116
+ } as never);
117
+
118
+ interface PageProps {
119
+ facetProperties?: Record<string, unknown>;
120
+ migratedOpen?: boolean;
121
+ isDesigner?: boolean;
122
+ }
123
+
124
+ const Page = ({
125
+ facetProperties = { layout: 'panel' },
126
+ migratedOpen = false,
127
+ isDesigner = false
128
+ }: PageProps) => (
129
+ <ThemePageContextProvider value={listingContext}>
130
+ <button type="button" data-testid="outside">
131
+ outside
132
+ </button>
133
+ <FacetFiltersBlock
134
+ block={mkBlock('facet-filters', facetProperties)}
135
+ placeholderId="p"
136
+ sectionId="s"
137
+ isDesigner={isDesigner}
138
+ currentBreakpoint="desktop"
139
+ />
140
+ <MigratedPanel open={migratedOpen} />
141
+ </ThemePageContextProvider>
142
+ );
143
+
144
+ const openDrawer = () =>
145
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
146
+
147
+ beforeEach(() => {
148
+ push.mockClear();
149
+ document.body.style.overflow = '';
150
+ });
151
+
152
+ describe('the filter drawer next to an overlay already on the primitive', () => {
153
+ /**
154
+ * The tab-killer itself. Two DOCUMENT-level `focusin` listeners, each
155
+ * pulling focus back into its own panel, read each other's panel as
156
+ * "outside" and hand focus back and forth until the call stack runs out —
157
+ * `RangeError: Maximum call stack size exceeded`, and a tab a shopper has to
158
+ * kill. One move of focus is all it takes.
159
+ */
160
+ it('does not trade focus with the overlay on top until the stack runs out', () => {
161
+ const { rerender } = render(<Page />);
162
+ openDrawer();
163
+ // The shopper opens a store panel over the drawer. Both are up.
164
+ rerender(<Page migratedOpen />);
165
+
166
+ const seen: string[] = [];
167
+ const recordMove = (event: Event) => {
168
+ // Bounded: the point is WHICH panels answer, and an unbounded log of a
169
+ // runaway loop is unreadable.
170
+ if (seen.length < 8) {
171
+ seen.push(
172
+ (event.target as HTMLElement).getAttribute('data-testid') ||
173
+ (event.target as HTMLElement).getAttribute('data-facet-choice') ||
174
+ (event.target as HTMLElement).tagName
175
+ );
176
+ }
177
+ };
178
+
179
+ document.addEventListener('focusin', recordMove);
180
+
181
+ let thrown: unknown = null;
182
+ try {
183
+ screen.getByTestId('outside').focus();
184
+ } catch (error) {
185
+ thrown = error;
186
+ } finally {
187
+ document.removeEventListener('focusin', recordMove);
188
+ }
189
+
190
+ expect(thrown).toBeNull();
191
+ // Focus landing outside, and one pull back into the overlay on top. That
192
+ // is the whole exchange a single stack allows. (Order is jsdom's: the
193
+ // nested dispatch finishes before the outer listener list does.)
194
+ expect(seen).toHaveLength(2);
195
+ expect(seen).toContain('outside');
196
+ expect(seen).toContain('migrated-first');
197
+
198
+ expect(
199
+ screen.getByTestId('migrated-panel').contains(document.activeElement)
200
+ ).toBe(true);
201
+ // The drawer beneath is inert, not gone: it stays mounted and intact and
202
+ // simply stops reaching for the page.
203
+ expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
204
+ });
205
+
206
+ it('leaves the trap to the overlay on top, and takes it back when that one closes', () => {
207
+ const { rerender } = render(<Page />);
208
+ openDrawer();
209
+ rerender(<Page migratedOpen />);
210
+
211
+ expect(screen.getByTestId('migrated-panel')).toHaveAttribute(
212
+ 'aria-modal',
213
+ 'true'
214
+ );
215
+ // Two live dialogs both claiming modality makes neither claim true.
216
+ expect(screen.getByRole('dialog', { name: 'Filters' })).not.toHaveAttribute(
217
+ 'aria-modal'
218
+ );
219
+
220
+ rerender(<Page migratedOpen={false} />);
221
+
222
+ const drawer = screen.getByRole('dialog', { name: 'Filters' });
223
+ expect(drawer).toHaveAttribute('aria-modal', 'true');
224
+
225
+ // And the drawer traps again now that it is the one hiding the page.
226
+ screen.getByTestId('outside').focus();
227
+ expect(drawer.contains(document.activeElement)).toBe(true);
228
+ });
229
+
230
+ it('leaves Escape to the overlay on top', () => {
231
+ const { rerender } = render(<Page />);
232
+ openDrawer();
233
+ rerender(<Page migratedOpen />);
234
+
235
+ fireEvent.keyDown(document, { key: 'Escape' });
236
+
237
+ // The drawer is underneath: Escape backed out of the panel on top, not out
238
+ // of every panel on the page.
239
+ expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
240
+ });
241
+
242
+ it('counts its scroll lock with every other overlay and gives the page its own overflow back', () => {
243
+ document.body.style.overflow = 'auto';
244
+
245
+ const { rerender } = render(<Page />);
246
+ openDrawer();
247
+ expect(document.body.style.overflow).toBe('hidden');
248
+
249
+ rerender(<Page migratedOpen />);
250
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
251
+ // One holder left: the page must stay frozen behind the panel still up.
252
+ expect(document.body.style.overflow).toBe('hidden');
253
+
254
+ rerender(<Page migratedOpen={false} />);
255
+ expect(document.body.style.overflow).toBe('auto');
256
+ });
257
+
258
+ it('still moves focus into the drawer on open and hands it back on close', () => {
259
+ render(<Page />);
260
+
261
+ const trigger = screen.getByRole('button', { name: /Filters/ });
262
+ trigger.focus();
263
+ fireEvent.click(trigger);
264
+
265
+ expect(
266
+ screen.getByRole('dialog', { name: 'Filters' }).contains(document.activeElement)
267
+ ).toBe(true);
268
+
269
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
270
+ expect(document.activeElement).toBe(trigger);
271
+ });
272
+
273
+ it('stays inert in the designer', () => {
274
+ document.body.style.overflow = 'auto';
275
+ render(<Page isDesigner />);
276
+ openDrawer();
277
+
278
+ const drawer = screen.getByRole('dialog', { name: 'Filters' });
279
+
280
+ // The canvas is the author's workspace: nothing may steal focus into it,
281
+ // freeze the page being styled, or claim the page is hidden.
282
+ expect(drawer.contains(document.activeElement)).toBe(false);
283
+ expect(document.body.style.overflow).toBe('auto');
284
+ expect(drawer).not.toHaveAttribute('aria-modal');
285
+
286
+ // And it must not close itself out from under an author who pressed
287
+ // Escape while typing in a property field. The block used to answer
288
+ // Escape on the canvas too; the primitive is inert there.
289
+ fireEvent.keyDown(document, { key: 'Escape' });
290
+ expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
291
+ });
292
+
293
+ it('leaves the page alone when the designer turned the lock off', () => {
294
+ document.body.style.overflow = 'auto';
295
+ render(<Page facetProperties={{ layout: 'panel', lockScroll: false }} />);
296
+ openDrawer();
297
+
298
+ expect(document.body.style.overflow).toBe('auto');
299
+ });
300
+
301
+ it('makes no modal claim for a drawer with no backdrop behind it', () => {
302
+ render(<Page facetProperties={{ layout: 'panel', showBackdrop: false }} />);
303
+ openDrawer();
304
+
305
+ // The page underneath stays clickable, so the claim would be a lie.
306
+ expect(screen.getByRole('dialog', { name: 'Filters' })).not.toHaveAttribute(
307
+ 'aria-modal'
308
+ );
309
+ });
310
+
311
+ it('still answers Escape when it is the panel on top', () => {
312
+ render(<Page />);
313
+ openDrawer();
314
+
315
+ fireEvent.keyDown(document, { key: 'Escape' });
316
+
317
+ expect(screen.queryByRole('dialog', { name: 'Filters' })).toBeNull();
318
+ });
319
+
320
+ it('honours a composition that turned Escape off', () => {
321
+ render(
322
+ <Page facetProperties={{ layout: 'panel', closeOnEscape: false }} />
323
+ );
324
+ openDrawer();
325
+
326
+ fireEvent.keyDown(document, { key: 'Escape' });
327
+
328
+ expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument();
329
+ });
330
+
331
+ /**
332
+ * Every pointer route out is a designer toggle, so a composition can legally
333
+ * turn all of them off. Escape is then re-armed no matter what the property
334
+ * says — an authored composition must never seal a shopper in.
335
+ */
336
+ it('re-arms Escape for a panel with no pointer route out', () => {
337
+ render(
338
+ <Page
339
+ facetProperties={{
340
+ layout: 'panel',
341
+ showPanelHeader: false,
342
+ showClose: false,
343
+ showBackdrop: false,
344
+ closeOnEscape: false
345
+ }}
346
+ />
347
+ );
348
+ openDrawer();
349
+
350
+ fireEvent.keyDown(document, { key: 'Escape' });
351
+
352
+ expect(screen.queryByRole('dialog', { name: 'Filters' })).toBeNull();
353
+ });
354
+ });
355
+
356
+ describe('the size guide next to an overlay already on the primitive', () => {
357
+ const SizeGuidePage = ({ migratedOpen = false }: { migratedOpen?: boolean }) => (
358
+ <>
359
+ <SizeGuideBlock
360
+ block={mkBlock('size-guide', {}, [
361
+ {
362
+ id: 'row-1',
363
+ type: 'text',
364
+ label: 'Row',
365
+ order: 0,
366
+ hidden: false,
367
+ value: 'S / M / L',
368
+ properties: {},
369
+ styles: {},
370
+ blocks: []
371
+ }
372
+ ])}
373
+ placeholderId="p"
374
+ sectionId="s"
375
+ isDesigner={false}
376
+ currentBreakpoint="desktop"
377
+ />
378
+ <MigratedPanel open={migratedOpen} />
379
+ </>
380
+ );
381
+
382
+ const openGuide = () =>
383
+ fireEvent.click(screen.getByRole('button', { name: 'Size guide' }));
384
+
385
+ it('drops its modal claim while another dialog is on top', () => {
386
+ const { rerender } = render(<SizeGuidePage />);
387
+ openGuide();
388
+ rerender(<SizeGuidePage migratedOpen />);
389
+
390
+ // It used to hard-code `aria-modal="true"`, so a quick view over it left
391
+ // two dialogs each telling assistive tech the other was hidden.
392
+ expect(
393
+ screen.getByRole('dialog', { name: 'Size guide' })
394
+ ).not.toHaveAttribute('aria-modal');
395
+ expect(screen.getByTestId('migrated-panel')).toHaveAttribute(
396
+ 'aria-modal',
397
+ 'true'
398
+ );
399
+ });
400
+
401
+ it('leaves Escape to the overlay on top', () => {
402
+ const { rerender } = render(<SizeGuidePage />);
403
+ openGuide();
404
+ rerender(<SizeGuidePage migratedOpen />);
405
+
406
+ fireEvent.keyDown(document, { key: 'Escape' });
407
+
408
+ expect(
409
+ screen.getByRole('dialog', { name: 'Size guide' })
410
+ ).toBeInTheDocument();
411
+ });
412
+
413
+ it('still closes on Escape when it is the one on top', () => {
414
+ render(<SizeGuidePage />);
415
+ openGuide();
416
+
417
+ fireEvent.keyDown(document, { key: 'Escape' });
418
+
419
+ expect(screen.queryByRole('dialog', { name: 'Size guide' })).toBeNull();
420
+ });
421
+
422
+ it('honours a composition that turned Escape off', () => {
423
+ render(
424
+ <SizeGuideBlock
425
+ block={mkBlock('size-guide', { closeOnEscape: false }, [
426
+ {
427
+ id: 'row-1',
428
+ type: 'text',
429
+ label: 'Row',
430
+ order: 0,
431
+ hidden: false,
432
+ value: 'S / M / L',
433
+ properties: {},
434
+ styles: {},
435
+ blocks: []
436
+ }
437
+ ])}
438
+ placeholderId="p"
439
+ sectionId="s"
440
+ isDesigner={false}
441
+ currentBreakpoint="desktop"
442
+ />
443
+ );
444
+ openGuide();
445
+
446
+ fireEvent.keyDown(document, { key: 'Escape' });
447
+
448
+ expect(
449
+ screen.getByRole('dialog', { name: 'Size guide' })
450
+ ).toBeInTheDocument();
451
+ });
452
+ });
453
+
454
+ describe('the hamburger drawer next to an overlay already on the primitive', () => {
455
+ const HamburgerPage = ({
456
+ migratedOpen = false,
457
+ properties = {}
458
+ }: {
459
+ migratedOpen?: boolean;
460
+ properties?: Record<string, unknown>;
461
+ }) => (
462
+ <>
463
+ <HamburgerMenuBlock
464
+ block={mkBlock('hamburger-menu', properties)}
465
+ placeholderId="p"
466
+ sectionId="s"
467
+ isDesigner={false}
468
+ currentBreakpoint="desktop"
469
+ />
470
+ <MigratedPanel open={migratedOpen} />
471
+ </>
472
+ );
473
+
474
+ const openDrawerMenu = () =>
475
+ fireEvent.click(
476
+ document.querySelector('[data-hamburger-menu]') as HTMLElement
477
+ );
478
+
479
+ /**
480
+ * The drawer captured `document.body.style.overflow` itself, with no counter
481
+ * at all. Opened over a panel that had already frozen the page it recorded
482
+ * `hidden` as "the page's own value", so the other panel closing thawed the
483
+ * page under a drawer still up — and the drawer closing wrote `hidden` back
484
+ * for good, leaving a storefront no one can scroll.
485
+ */
486
+ it('counts its scroll lock with every other overlay', () => {
487
+ document.body.style.overflow = 'auto';
488
+
489
+ const { rerender } = render(<HamburgerPage migratedOpen />);
490
+ openDrawerMenu();
491
+ expect(document.body.style.overflow).toBe('hidden');
492
+
493
+ // The panel that froze the page first closes while the drawer is still up.
494
+ rerender(<HamburgerPage migratedOpen={false} />);
495
+ expect(document.body.style.overflow).toBe('hidden');
496
+
497
+ fireEvent.click(
498
+ document.querySelector('[data-hamburger-close]') as HTMLElement
499
+ );
500
+ expect(document.body.style.overflow).toBe('auto');
501
+ });
502
+
503
+ it('leaves the page alone when the designer turned the lock off', () => {
504
+ document.body.style.overflow = 'auto';
505
+ render(<HamburgerPage properties={{ lockScroll: false }} />);
506
+ openDrawerMenu();
507
+
508
+ expect(document.body.style.overflow).toBe('auto');
509
+ });
510
+ });
511
+
512
+ /**
513
+ * Neither of these is an overlay — no backdrop, nothing covered, no scroll
514
+ * lock, no focus trap — so they keep their own focus handling. What they do
515
+ * share with the page is Escape, and a document listener each meant a key
516
+ * aimed at the modal on top collapsed them underneath it at the same time.
517
+ */
518
+ describe('the popups that share the page keyboard', () => {
519
+ const SortPage = ({ migratedOpen = false }: { migratedOpen?: boolean }) => (
520
+ <ThemePageContextProvider value={listingContext}>
521
+ <SortSelectBlock
522
+ block={mkBlock('sort-select', { variant: 'panel' })}
523
+ placeholderId="p"
524
+ sectionId="s"
525
+ isDesigner={false}
526
+ currentBreakpoint="desktop"
527
+ />
528
+ <MigratedPanel open={migratedOpen} />
529
+ </ThemePageContextProvider>
530
+ );
531
+
532
+ const MegaPage = ({ migratedOpen = false }: { migratedOpen?: boolean }) => (
533
+ <>
534
+ <MegaMenuBlock
535
+ block={mkBlock('mega-menu', { trigger: 'click' })}
536
+ placeholderId="p"
537
+ sectionId="s"
538
+ isDesigner={false}
539
+ currentBreakpoint="desktop"
540
+ />
541
+ <MigratedPanel open={migratedOpen} />
542
+ </>
543
+ );
544
+
545
+ it('leaves Escape to the overlay on top of the sort panel', () => {
546
+ const { rerender } = render(<SortPage />);
547
+
548
+ fireEvent.click(screen.getByRole('button', { name: /Sort/ }));
549
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
550
+
551
+ rerender(<SortPage migratedOpen />);
552
+ fireEvent.keyDown(document, { key: 'Escape' });
553
+
554
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
555
+ });
556
+
557
+ it('still closes the sort panel on Escape and hands focus back', () => {
558
+ render(<SortPage />);
559
+
560
+ const trigger = screen.getByRole('button', { name: /Sort/ });
561
+ fireEvent.click(trigger);
562
+ fireEvent.keyDown(document, { key: 'Escape' });
563
+
564
+ expect(screen.queryByRole('listbox')).toBeNull();
565
+ expect(document.activeElement).toBe(trigger);
566
+ });
567
+
568
+ it('leaves Escape to the overlay on top of the mega menu panel', () => {
569
+ const { rerender } = render(<MegaPage />);
570
+
571
+ fireEvent.click(screen.getByRole('button', { name: 'Kadın' }));
572
+ expect(
573
+ document.querySelector('[data-mega-menu-panel]')
574
+ ).toBeInTheDocument();
575
+
576
+ rerender(<MegaPage migratedOpen />);
577
+ fireEvent.keyDown(document, { key: 'Escape' });
578
+
579
+ expect(
580
+ document.querySelector('[data-mega-menu-panel]')
581
+ ).toBeInTheDocument();
582
+ });
583
+
584
+ it('still closes the mega menu on Escape when it is the one on top', () => {
585
+ render(<MegaPage />);
586
+
587
+ fireEvent.click(screen.getByRole('button', { name: 'Kadın' }));
588
+ fireEvent.keyDown(document, { key: 'Escape' });
589
+
590
+ expect(document.querySelector('[data-mega-menu-panel]')).toBeNull();
591
+ });
592
+ });