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

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,789 @@
1
+ import React from 'react';
2
+ import { renderToString } from 'react-dom/server';
3
+ import { hydrateRoot } from 'react-dom/client';
4
+ import { act, render } from '@testing-library/react';
5
+
6
+ // The registry first, as the app loads it: importing a block module ahead of
7
+ // it would close the registry ↔ group-block loop with `group` unregistered.
8
+ import '../../blocks/block-renderer-registry';
9
+ import GroupBlock from '../../blocks/group-block';
10
+ import ImageBlock from '../../blocks/image-block';
11
+ import ProductGridBlock from '../../blocks/product-grid-block';
12
+ import {
13
+ buildCardSliderCss,
14
+ CardListSlider,
15
+ readGapLength
16
+ } from '../../components/card-list-slider';
17
+ import type { Block } from '../../theme-block';
18
+ import { ThemePageContextProvider } from '../../theme-page-context';
19
+ import type { Section } from '../../theme-section';
20
+ import {
21
+ CARD_ZOOM_CSS,
22
+ CARD_ZOOM_STYLE_ID,
23
+ type CardsSliderSettings,
24
+ isCardPictureBlock,
25
+ readCardsPerView,
26
+ readCardsSliderArrows
27
+ } from '../card-media';
28
+ import {
29
+ applyPropertySwitchesToSections,
30
+ resolveOwnCardsSlider,
31
+ resolveOwnCardZoom
32
+ } from '../property-switches';
33
+
34
+ /**
35
+ * ZERO-5173 — the product card list as a slider ("Görsel Sayısı" cards in
36
+ * view, the chevrons in the top-right corner, as the GANT rail draws it) and
37
+ * the card picture zooming in on hover. The settings live where the card's
38
+ * switches live; a list nobody configured must render what it always did.
39
+ */
40
+
41
+ const node = (
42
+ id: string,
43
+ type: string,
44
+ properties: Record<string, unknown> = {},
45
+ blocks: Block[] = [],
46
+ extra: Partial<Block> = {}
47
+ ): Block => ({
48
+ id,
49
+ type,
50
+ label: id,
51
+ value: type === 'image' ? '/placeholder.jpg' : undefined,
52
+ properties,
53
+ styles: {},
54
+ order: 0,
55
+ hidden: false,
56
+ blocks,
57
+ ...extra
58
+ });
59
+
60
+ /** The Product Card's picture column, as `product-card.json` composes it. */
61
+ const productCard = (cardProperties: Record<string, unknown> = {}): Block =>
62
+ node(
63
+ 'card',
64
+ 'group',
65
+ {
66
+ tag: 'div',
67
+ role: 'item-template',
68
+ blockTemplate: 'product-card',
69
+ hoverScope: true,
70
+ ...cardProperties
71
+ },
72
+ [
73
+ node('media', 'group', { tag: 'div' }, [
74
+ node(
75
+ 'image-link',
76
+ 'group',
77
+ { tag: 'a', dataBinding: 'item.url', dataBindingTarget: 'href' },
78
+ [node('picture', 'image', { dataBinding: 'item.image' })]
79
+ )
80
+ ])
81
+ ]
82
+ );
83
+
84
+ const section = (
85
+ blocks: Block[],
86
+ properties: Record<string, unknown> = {}
87
+ ): Section => ({
88
+ id: 's1',
89
+ type: 'custom',
90
+ name: 's1',
91
+ label: 'S1',
92
+ order: 0,
93
+ hidden: false,
94
+ properties,
95
+ styles: {},
96
+ blocks
97
+ });
98
+
99
+ const find = (blocks: Block[] | undefined, id: string): Block | undefined => {
100
+ for (const entry of blocks || []) {
101
+ if (entry.id === id) return entry;
102
+ const nested = find(entry.blocks, id);
103
+ if (nested) return nested;
104
+ }
105
+ return undefined;
106
+ };
107
+
108
+ const resolve = (
109
+ blocks: Block[],
110
+ sectionProperties: Record<string, unknown> = {}
111
+ ) =>
112
+ applyPropertySwitchesToSections([section(blocks, sectionProperties)])[0]
113
+ .blocks;
114
+
115
+ /** The generated iterator a collection wraps around the Product Cards card. */
116
+ const productCardsRepeat = (card = productCard()) =>
117
+ node('repeat', 'group', { tag: 'div' }, [card], {
118
+ isIterator: true,
119
+ iteratorDataPath: 'products'
120
+ });
121
+
122
+ /** The rail's title as a list sets it, the way the flash deals slider sets its own. */
123
+ const RAIL_TITLE = { cardsSliderTitle: 'SİZİN İÇİN SEÇTİKLERİMİZ' };
124
+
125
+ describe('the zoom', () => {
126
+ it('is read for the picture bound to item.image only', () => {
127
+ expect(
128
+ isCardPictureBlock(
129
+ node('a', 'image', { dataBinding: '{{ item.image }}' })
130
+ )
131
+ ).toBe(true);
132
+ expect(
133
+ isCardPictureBlock(
134
+ node('b', 'image', { dataBinding: 'item.badge.image' })
135
+ )
136
+ ).toBe(false);
137
+ });
138
+
139
+ it('leaves an unconfigured card untouched', () => {
140
+ const picture = find(
141
+ resolve([node('grid', 'product-grid', {}, [productCard()])]),
142
+ 'picture'
143
+ );
144
+ expect(picture?.cardZoom).toBeUndefined();
145
+ });
146
+
147
+ it('comes from the list, and a card can say otherwise', () => {
148
+ const fromGrid = resolve([
149
+ node('grid', 'product-grid', { cardImageHoverZoom: true }, [
150
+ productCard()
151
+ ])
152
+ ]);
153
+ expect(find(fromGrid, 'picture')?.cardZoom).toBe(true);
154
+ // Only the picture: the link and the card around it are untouched.
155
+ expect(find(fromGrid, 'image-link')?.cardZoom).toBeUndefined();
156
+
157
+ const cardOff = resolve([
158
+ node('grid', 'product-grid', { cardImageHoverZoom: true }, [
159
+ productCard({ cardImageHoverZoom: 'false' })
160
+ ])
161
+ ]);
162
+ expect(find(cardOff, 'picture')?.cardZoom).toBeUndefined();
163
+
164
+ const fromSection = resolve([productCardsRepeat()], {
165
+ cardImageHoverZoom: true
166
+ });
167
+ expect(find(fromSection, 'picture')?.cardZoom).toBe(true);
168
+ });
169
+
170
+ it('wraps the picture in a clipping frame, and leaves it alone otherwise', () => {
171
+ const picture = (over: Partial<Block> = {}): Block => ({
172
+ ...node('picture', 'image', { dataBinding: 'item.image', alt: 'Oxford' }),
173
+ value: '/ox-1.jpg',
174
+ ...over
175
+ });
176
+ const draw = (block: Block) =>
177
+ render(
178
+ <ImageBlock
179
+ block={block}
180
+ placeholderId="p"
181
+ sectionId="s"
182
+ isDesigner={false}
183
+ />
184
+ ).container;
185
+
186
+ const plain = draw(picture());
187
+ expect(plain.querySelector('[data-card-zoom]')).toBeNull();
188
+ expect(plain.firstElementChild?.tagName).toBe('IMG');
189
+
190
+ const zoomed = draw(picture({ cardZoom: true }));
191
+ const frame = zoomed.querySelector('[data-card-zoom]') as HTMLElement;
192
+ expect(frame.style.overflow).toBe('hidden');
193
+ expect(frame.querySelector('img')?.getAttribute('src')).toBe('/ox-1.jpg');
194
+
195
+ const styles = document.querySelectorAll(`style#${CARD_ZOOM_STYLE_ID}`);
196
+ expect(styles).toHaveLength(1);
197
+ expect(styles[0].textContent).toBe(CARD_ZOOM_CSS);
198
+ // Hover devices only, so a tap never leaves a picture enlarged.
199
+ expect(CARD_ZOOM_CSS).toContain('@media (hover:hover){');
200
+ });
201
+ });
202
+
203
+ describe('the slider', () => {
204
+ it('is off unless asked for, whatever else is set', () => {
205
+ expect(resolveOwnCardsSlider({})).toBeUndefined();
206
+ expect(resolveOwnCardsSlider({ cardsPerView: 3 })).toBeUndefined();
207
+ });
208
+
209
+ it('shows four in view on desktop and two below, with the arrows top right', () => {
210
+ expect(resolveOwnCardsSlider({ cardsSlider: true })).toEqual({
211
+ perView: 4,
212
+ perViewMobile: 2,
213
+ arrows: 'top-right'
214
+ });
215
+ expect(
216
+ resolveOwnCardsSlider({
217
+ cardsSlider: 'true',
218
+ cardsPerView: '5',
219
+ cardsPerViewMobile: 9,
220
+ cardsSliderArrows: 'sides'
221
+ })
222
+ ).toEqual({
223
+ perView: 5,
224
+ perViewMobile: 3,
225
+ arrows: 'sides'
226
+ });
227
+ });
228
+
229
+ it('reaches the repeat that holds a section’s cards, and nothing else', () => {
230
+ const resolved = resolve([productCardsRepeat()], {
231
+ cardsSlider: true,
232
+ cardsPerView: 4
233
+ });
234
+ expect(find(resolved, 'repeat')?.cardsSlider).toEqual(
235
+ expect.objectContaining({ perView: 4, perViewMobile: 2 })
236
+ );
237
+ expect(find(resolved, 'card')?.cardsSlider).toBeUndefined();
238
+
239
+ // A swatch strip is a repeat too, but of colours, not cards.
240
+ const swatches = node(
241
+ 'swatches',
242
+ 'group',
243
+ { tag: 'div' },
244
+ [node('chip', 'group', { tag: 'a' })],
245
+ { isIterator: true, iteratorDataPath: 'item.colors' }
246
+ );
247
+ const withStrip = resolve([productCardsRepeat(), swatches], {
248
+ cardsSlider: true
249
+ });
250
+ expect(find(withStrip, 'swatches')?.cardsSlider).toBeUndefined();
251
+
252
+ expect(
253
+ find(resolve([productCardsRepeat()]), 'repeat')?.cardsSlider
254
+ ).toBeUndefined();
255
+ });
256
+
257
+ it('carries the list’s title with its slider, typed as the rail types its own', () => {
258
+ const titled = (properties: Record<string, unknown>) =>
259
+ find(
260
+ resolve([productCardsRepeat()], { cardsSlider: true, ...properties }),
261
+ 'repeat'
262
+ )?.cardsSlider?.title;
263
+
264
+ // The rail's defaults: 14px, regular, black, capitals, the body font.
265
+ expect(titled(RAIL_TITLE)).toEqual({
266
+ text: 'SİZİN İÇİN SEÇTİKLERİMİZ',
267
+ color: '#000000',
268
+ size: 14,
269
+ weight: '400',
270
+ font: '',
271
+ transform: 'uppercase'
272
+ });
273
+ // Per breakpoint, as the editor stores it; each part of its type authored.
274
+ expect(
275
+ titled({
276
+ cardsSliderTitle: { desktop: 'Seçtiklerimiz' },
277
+ cardsSliderTitleColor: { desktop: '#781526' },
278
+ cardsSliderTitleSize: { desktop: 18 },
279
+ cardsSliderTitleWeight: { desktop: '600' },
280
+ cardsSliderTitleFont: { desktop: "'GantModernV2-Regular', Jost" },
281
+ cardsSliderTitleTransform: { desktop: 'none' }
282
+ })
283
+ ).toEqual({
284
+ text: 'Seçtiklerimiz',
285
+ color: '#781526',
286
+ size: 18,
287
+ weight: '600',
288
+ font: "'GantModernV2-Regular', Jost",
289
+ transform: 'none'
290
+ });
291
+ // A value that could not be a colour, a size, a weight or a font stack
292
+ // falls back to the rail's own.
293
+ expect(
294
+ titled({
295
+ ...RAIL_TITLE,
296
+ cardsSliderTitleColor: 'red; background: url(x)',
297
+ cardsSliderTitleSize: 'huge',
298
+ cardsSliderTitleWeight: 'heavy',
299
+ cardsSliderTitleFont: 'x;}body{display:none'
300
+ })
301
+ ).toEqual(
302
+ expect.objectContaining({
303
+ color: '#000000',
304
+ size: 14,
305
+ weight: '400',
306
+ font: ''
307
+ })
308
+ );
309
+ });
310
+
311
+ it('draws no title when there is none, when it is off, or off a stale on/off value', () => {
312
+ const titled = (properties: Record<string, unknown>) =>
313
+ find(
314
+ resolve([productCardsRepeat()], { cardsSlider: true, ...properties }),
315
+ 'repeat'
316
+ )?.cardsSlider;
317
+
318
+ expect(titled({})).not.toHaveProperty('title');
319
+ expect(titled({ cardsSliderTitle: ' ' })).not.toHaveProperty('title');
320
+ expect(
321
+ titled({ ...RAIL_TITLE, cardsSliderShowTitle: { desktop: false } })
322
+ ).not.toHaveProperty('title');
323
+ // An on/off value from before the title had text says nothing.
324
+ expect(titled({ cardsSliderTitle: { desktop: true } })).not.toHaveProperty(
325
+ 'title'
326
+ );
327
+ // And a list that is not sliding has no title row at all.
328
+ expect(
329
+ find(resolve([productCardsRepeat()], RAIL_TITLE), 'repeat')?.cardsSlider
330
+ ).toBeUndefined();
331
+ });
332
+
333
+ it('reads the zoom off a block for the grid’s built-in card', () => {
334
+ expect(resolveOwnCardZoom({})).toBe(false);
335
+ expect(resolveOwnCardZoom({ cardImageHoverZoom: 'true' })).toBe(true);
336
+ });
337
+
338
+ it('reads counts, positions and gaps defensively', () => {
339
+ expect(readCardsPerView('', 6)).toBeUndefined();
340
+ expect(readCardsPerView(0, 6)).toBeUndefined();
341
+ expect(readCardsPerView('4', 6)).toBe(4);
342
+ expect(readCardsPerView(12, 6)).toBe(6);
343
+ expect(readCardsSliderArrows('top-right')).toBe('top-right');
344
+ expect(readCardsSliderArrows('bottom')).toBeUndefined();
345
+ expect(readGapLength(0)).toBe('0px');
346
+ expect(readGapLength('12')).toBe('12px');
347
+ expect(readGapLength('1rem')).toBe('1rem');
348
+ // It lands in a stylesheet: anything but a plain length is refused.
349
+ expect(readGapLength('1px;}body{display:none')).toBeUndefined();
350
+ });
351
+
352
+ it('sizes its cards per side of the 1024px line, before any script runs', () => {
353
+ const settings: CardsSliderSettings = {
354
+ perView: 4,
355
+ perViewMobile: 2,
356
+ arrows: 'top-right'
357
+ };
358
+ const css = buildCardSliderCss('g1', settings, '0px', '8px', false);
359
+ expect(css).toContain('[data-card-slider="g1"]{--card-slider-gap:0px}');
360
+ expect(css).toContain(
361
+ 'flex:0 0 calc((100% - 3 * var(--card-slider-gap)) / 4)'
362
+ );
363
+ expect(css).toContain(
364
+ '@media (max-width:1023px){[data-card-slider="g1"]{--card-slider-gap:8px}'
365
+ );
366
+ expect(css).toContain(
367
+ 'flex-basis:calc((100% - 1 * var(--card-slider-gap)) / 2)'
368
+ );
369
+
370
+ // The phone canvas takes the phone count outright.
371
+ const phone = buildCardSliderCss('g1', settings, '0px', '8px', true);
372
+ expect(phone).not.toContain('@media');
373
+ expect(phone).toContain(
374
+ 'flex-basis:calc((100% - 1 * var(--card-slider-gap)) / 2)'
375
+ );
376
+ });
377
+ });
378
+
379
+ describe('drawing the slider', () => {
380
+ const settings: CardsSliderSettings = {
381
+ perView: 4,
382
+ perViewMobile: 2,
383
+ arrows: 'top-right'
384
+ };
385
+ const title = {
386
+ text: 'SİZİN İÇİN SEÇTİKLERİMİZ',
387
+ color: '#000000',
388
+ size: 14,
389
+ weight: '400',
390
+ font: '',
391
+ transform: 'uppercase' as const
392
+ };
393
+
394
+ const cards = (count: number) =>
395
+ Array.from({ length: count }, (_, index) => (
396
+ <a key={index} href={`/p${index}`} data-card="">
397
+ P{index}
398
+ </a>
399
+ ));
400
+
401
+ /** jsdom lays nothing out: give the strip the geometry a browser would. */
402
+ const layOut = (
403
+ container: HTMLElement,
404
+ {
405
+ client,
406
+ scroll,
407
+ left = 0
408
+ }: { client: number; scroll: number; left?: number }
409
+ ) => {
410
+ const track = container.querySelector(
411
+ '[data-card-slider-track]'
412
+ ) as HTMLElement;
413
+ Object.defineProperty(track, 'clientWidth', {
414
+ configurable: true,
415
+ value: client
416
+ });
417
+ Object.defineProperty(track, 'scrollWidth', {
418
+ configurable: true,
419
+ value: scroll
420
+ });
421
+ Object.defineProperty(track, 'scrollLeft', {
422
+ configurable: true,
423
+ writable: true,
424
+ value: left
425
+ });
426
+ return track;
427
+ };
428
+
429
+ it('puts the title on the left of the chevrons, over the cards', () => {
430
+ const { container } = render(
431
+ <CardListSlider sliderId="s1" settings={{ ...settings, title }}>
432
+ {cards(8)}
433
+ </CardListSlider>
434
+ );
435
+ const track = layOut(container, { client: 1200, scroll: 2400 });
436
+ act(() => {
437
+ track.dispatchEvent(new Event('scroll'));
438
+ });
439
+
440
+ const header = container.querySelector(
441
+ '[data-card-slider-header]'
442
+ ) as HTMLElement;
443
+ expect(header.getAttribute('data-part')).toBe('slider-header');
444
+ const heading = header.querySelector(
445
+ 'h2[data-card-slider-title]'
446
+ ) as HTMLElement;
447
+ expect(heading.textContent).toBe('SİZİN İÇİN SEÇTİKLERİMİZ');
448
+ // No part of its own: a click selects the list that holds its settings.
449
+ expect(heading.hasAttribute('data-part')).toBe(false);
450
+ // Typed as the rail types its title, in the body font when none is named.
451
+ expect(heading.style.fontSize).toBe('14px');
452
+ expect(heading.style.fontWeight).toBe('400');
453
+ expect(heading.style.lineHeight).toBe('1.4');
454
+ expect(heading.style.textTransform).toBe('uppercase');
455
+ expect(heading.style.color).toBe('rgb(0, 0, 0)');
456
+ const arrows = header.querySelector(
457
+ '[data-card-slider-arrows="top-right"]'
458
+ );
459
+ expect(arrows?.querySelectorAll('button')).toHaveLength(2);
460
+ // The cards are the strip's own children — no wrapper per card.
461
+ expect(
462
+ container.querySelectorAll('[data-card-slider-track] > a[data-card]')
463
+ ).toHaveLength(8);
464
+ // First chevron disabled at the start and stamped for the panel's state,
465
+ // but drawn like the other, as the GANT rail draws its arrows.
466
+ const prev = container.querySelector(
467
+ '[data-card-slider-arrow="prev"]'
468
+ ) as HTMLButtonElement;
469
+ expect(prev.disabled).toBe(true);
470
+ expect(prev.getAttribute('data-state')).toBe('disabled');
471
+ expect(prev.style.opacity).toBe('');
472
+ // The rail's row: title 24px in, chevrons 14px in from the right (read
473
+ // off the server markup — jsdom drops a shorthand holding a var()), and
474
+ // the rail's own 8 × 12 chevron, mirrored for "next".
475
+ expect(
476
+ renderToString(
477
+ <CardListSlider sliderId="s1" settings={{ ...settings, title }}>
478
+ {cards(8)}
479
+ </CardListSlider>
480
+ )
481
+ ).toContain(
482
+ 'padding:var(--card-slider-header-padding, 16px 14px 16px 24px)'
483
+ );
484
+ const chevron = prev.querySelector('svg') as SVGElement;
485
+ expect(chevron.getAttribute('viewBox')).toBe('0 0 8 12');
486
+ expect(chevron.querySelector('path')?.getAttribute('d')).toBe(
487
+ 'M6.5 1L1.5 6l5 5'
488
+ );
489
+ const nextChevron = container.querySelector(
490
+ '[data-card-slider-arrow="next"] svg'
491
+ ) as SVGElement;
492
+ expect(nextChevron.style.transform).toBe('scaleX(-1)');
493
+ });
494
+
495
+ it('hands a click on its title to the list that owns it, in the canvas only', () => {
496
+ const select = jest.fn();
497
+ const onBlock = jest.fn();
498
+ const view = (isDesigner: boolean) =>
499
+ render(
500
+ <div onClick={onBlock}>
501
+ <CardListSlider
502
+ sliderId="s9"
503
+ settings={{ ...settings, title }}
504
+ isDesigner={isDesigner}
505
+ onTitleSelect={select}
506
+ >
507
+ {cards(8)}
508
+ </CardListSlider>
509
+ </div>
510
+ );
511
+
512
+ const canvas = view(true);
513
+ act(() => {
514
+ (
515
+ canvas.container.querySelector(
516
+ '[data-card-slider-title]'
517
+ ) as HTMLElement
518
+ ).click();
519
+ });
520
+ expect(select).toHaveBeenCalledTimes(1);
521
+ // The block it is drawn in never sees it: that is not where it is set.
522
+ expect(onBlock).not.toHaveBeenCalled();
523
+ canvas.unmount();
524
+
525
+ const storefront = view(false);
526
+ act(() => {
527
+ (
528
+ storefront.container.querySelector(
529
+ '[data-card-slider-title]'
530
+ ) as HTMLElement
531
+ ).click();
532
+ });
533
+ expect(select).toHaveBeenCalledTimes(1);
534
+ });
535
+
536
+ it('steps one card, and the click never reaches a link around it', () => {
537
+ const onLinkClick = jest.fn();
538
+ const { container } = render(
539
+ <a href="/list" onClick={onLinkClick}>
540
+ <CardListSlider sliderId="s2" settings={settings}>
541
+ {cards(8)}
542
+ </CardListSlider>
543
+ </a>
544
+ );
545
+ const track = layOut(container, { client: 1200, scroll: 2400 });
546
+ track.scrollBy = jest.fn();
547
+ (track.firstElementChild as HTMLElement).getBoundingClientRect = () =>
548
+ ({ width: 300 } as DOMRect);
549
+
550
+ act(() => {
551
+ track.dispatchEvent(new Event('scroll'));
552
+ });
553
+ const next = container.querySelector(
554
+ '[data-card-slider-arrow="next"]'
555
+ ) as HTMLButtonElement;
556
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true });
557
+ act(() => {
558
+ next.dispatchEvent(click);
559
+ });
560
+
561
+ expect(track.scrollBy).toHaveBeenCalledWith(
562
+ expect.objectContaining({ left: 300 })
563
+ );
564
+ expect(click.defaultPrevented).toBe(true);
565
+ expect(onLinkClick).not.toHaveBeenCalled();
566
+ });
567
+
568
+ it('dims the last chevron at the end and drops both once everything fits', () => {
569
+ const untitled = settings;
570
+ const { container, rerender } = render(
571
+ <CardListSlider sliderId="s3" settings={untitled}>
572
+ {cards(8)}
573
+ </CardListSlider>
574
+ );
575
+ const track = layOut(container, {
576
+ client: 1200,
577
+ scroll: 2400,
578
+ left: 1200
579
+ });
580
+ act(() => {
581
+ track.dispatchEvent(new Event('scroll'));
582
+ });
583
+ expect(
584
+ (
585
+ container.querySelector(
586
+ '[data-card-slider-arrow="next"]'
587
+ ) as HTMLButtonElement
588
+ ).disabled
589
+ ).toBe(true);
590
+
591
+ layOut(container, { client: 1200, scroll: 1200 });
592
+ rerender(
593
+ <CardListSlider sliderId="s3" settings={untitled}>
594
+ {cards(3)}
595
+ </CardListSlider>
596
+ );
597
+ // No title and nothing to page: no row above the cards at all.
598
+ expect(container.querySelector('[data-card-slider-header]')).toBeNull();
599
+ });
600
+
601
+ it('can put one chevron on either side instead', () => {
602
+ const { container } = render(
603
+ <CardListSlider sliderId="s4" settings={{ ...settings, arrows: 'sides' }}>
604
+ {cards(8)}
605
+ </CardListSlider>
606
+ );
607
+ expect(container.querySelector('[data-card-slider-header]')).toBeNull();
608
+ const track = layOut(container, { client: 1200, scroll: 2400 });
609
+ act(() => {
610
+ track.dispatchEvent(new Event('scroll'));
611
+ });
612
+ const prev = container.querySelector(
613
+ '[data-card-slider-frame] > [data-card-slider-arrow="prev"]'
614
+ ) as HTMLElement;
615
+ expect(prev.style.left).toBe('8px');
616
+ expect(prev.style.top).toBe('50%');
617
+ });
618
+ });
619
+
620
+ describe('the product grid as a slider', () => {
621
+ const context = {
622
+ listing: {
623
+ title: 'Kazak',
624
+ path: '/kadin-kazak/',
625
+ products: Array.from({ length: 8 }, (_, index) => ({
626
+ pk: index + 1,
627
+ name: `Kazak ${index + 1}`,
628
+ url: `/kazak-${index + 1}/`,
629
+ image: `/kazak-${index + 1}.jpg`,
630
+ price: 100 + index
631
+ })),
632
+ facets: [],
633
+ sorters: [],
634
+ pagination: { current_page: 1, count: 8 }
635
+ }
636
+ };
637
+
638
+ const grid = (properties: Record<string, unknown>, blocks: Block[] = []) =>
639
+ node('grid-1', 'product-grid', properties, blocks);
640
+
641
+ const page = (block: Block) => (
642
+ <ThemePageContextProvider value={context}>
643
+ <ProductGridBlock
644
+ block={block}
645
+ placeholderId="p"
646
+ sectionId="s"
647
+ isDesigner={false}
648
+ />
649
+ </ThemePageContextProvider>
650
+ );
651
+
652
+ it('keeps the grid while the slider is off', () => {
653
+ const { container } = render(page(grid({ cardsPerView: 4 })));
654
+ expect(container.querySelector('[data-card-slider]')).toBeNull();
655
+ expect(
656
+ (container.querySelector('[data-product-grid]') as HTMLElement).style
657
+ .display
658
+ ).toBe('grid');
659
+ });
660
+
661
+ it('puts the built-in cards in one row, the strip carrying the list part', () => {
662
+ const { container } = render(page(grid({ cardsSlider: true })));
663
+ const track = container.querySelector(
664
+ '[data-card-slider-track]'
665
+ ) as HTMLElement;
666
+ expect(track.getAttribute('data-part')).toBe('list');
667
+ expect(
668
+ track.querySelectorAll(':scope > a[data-product-card]')
669
+ ).toHaveLength(8);
670
+ // The gap the grid had, through its own custom property.
671
+ expect(container.querySelector('style')?.textContent).toContain(
672
+ '--card-slider-gap:var(--product-grid-gap, 16px)'
673
+ );
674
+ });
675
+
676
+ it('keeps an authored grid gap', () => {
677
+ const block = {
678
+ ...grid({ cardsSlider: true }),
679
+ styles: { 'list/gap': { desktop: '0px', mobile: '4px' } }
680
+ };
681
+ const { container } = render(page(block));
682
+ const css = container.querySelector('style')?.textContent ?? '';
683
+ expect(css).toContain(
684
+ '[data-card-slider="grid-grid-1"]{--card-slider-gap:0px}'
685
+ );
686
+ expect(css).toContain('{--card-slider-gap:4px}');
687
+ });
688
+
689
+ it('repeats a Product Card in the row, and zooms its picture', () => {
690
+ const [resolved] = applyPropertySwitchesToSections([
691
+ section([
692
+ grid({ cardsSlider: true, cardImageHoverZoom: true }, [productCard()])
693
+ ])
694
+ ]);
695
+ const { container } = render(page(resolved.blocks[0]));
696
+ const track = container.querySelector(
697
+ '[data-card-slider-track]'
698
+ ) as HTMLElement;
699
+ // One card wrapper per product, each a direct child of the strip.
700
+ expect(track.children).toHaveLength(8);
701
+ const pictures = track.querySelectorAll('[data-card-zoom] img');
702
+ expect(pictures).toHaveLength(8);
703
+ expect(pictures[1].getAttribute('src')).toBe('/kazak-2.jpg');
704
+ });
705
+
706
+ it('the server renders the row complete and it hydrates without a mismatch', async () => {
707
+ const [resolved] = applyPropertySwitchesToSections([
708
+ section([grid({ cardsSlider: true, ...RAIL_TITLE }, [productCard()])])
709
+ ]);
710
+ const markup = page(resolved.blocks[0]);
711
+ const container = document.createElement('div');
712
+ container.innerHTML = renderToString(markup);
713
+ document.body.appendChild(container);
714
+
715
+ expect(
716
+ container.querySelectorAll('[data-card-slider-track] > *')
717
+ ).toHaveLength(8);
718
+ expect(
719
+ container.querySelector('[data-card-slider-title]')?.textContent
720
+ ).toBe('SİZİN İÇİN SEÇTİKLERİMİZ');
721
+ expect(container.querySelectorAll('[data-card-slider-arrow]')).toHaveLength(
722
+ 2
723
+ );
724
+
725
+ const recoverable: unknown[] = [];
726
+ const errors = jest.spyOn(console, 'error').mockImplementation(() => {});
727
+ await act(async () => {
728
+ hydrateRoot(container, markup, {
729
+ onRecoverableError: (error) => recoverable.push(error)
730
+ });
731
+ });
732
+ const mismatches = errors.mock.calls.filter((call) =>
733
+ /hydrat/i.test(String(call[0]))
734
+ );
735
+ errors.mockRestore();
736
+
737
+ expect(recoverable).toEqual([]);
738
+ expect(mismatches).toEqual([]);
739
+ container.remove();
740
+ });
741
+ });
742
+
743
+ describe('the Product Cards section as a slider', () => {
744
+ it('draws the repeat’s cards in one row, inside the repeat’s grid', () => {
745
+ const repeat: Block = {
746
+ ...node('repeat-1', 'group', { tag: 'div' }, [
747
+ node('c1', 'text', {}, [], { value: 'Kart 1' }),
748
+ node('c2', 'text', {}, [], { value: 'Kart 2' })
749
+ ]),
750
+ cardsSlider: {
751
+ perView: 4,
752
+ perViewMobile: 2,
753
+ arrows: 'top-right',
754
+ title: {
755
+ text: 'Seçtiklerimiz',
756
+ color: '#000000',
757
+ size: 14,
758
+ weight: '400',
759
+ font: '',
760
+ transform: 'uppercase'
761
+ }
762
+ },
763
+ styles: { gap: { desktop: '0px', mobile: '0px' } }
764
+ };
765
+ const { container } = render(
766
+ <GroupBlock
767
+ block={repeat}
768
+ placeholderId="p"
769
+ sectionId="s"
770
+ isDesigner={false}
771
+ />
772
+ );
773
+
774
+ const slider = container.querySelector('[data-card-slider]') as HTMLElement;
775
+ // Spans the repeat's own column template.
776
+ expect(slider.style.gridColumn).toBe('1 / -1');
777
+ expect(
778
+ container.querySelector(
779
+ '[data-card-slider-header] [data-card-slider-title]'
780
+ )?.textContent
781
+ ).toBe('Seçtiklerimiz');
782
+ expect(
783
+ container.querySelectorAll('[data-card-slider-track] > [data-block-id]')
784
+ ).toHaveLength(2);
785
+ expect(container.querySelector('style')?.textContent).toContain(
786
+ '--card-slider-gap:0px'
787
+ );
788
+ });
789
+ });