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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +7 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/__snapshots__/facet-filters-block.test.tsx.snap +11 -0
  4. package/src/__tests__/custom-css.test.ts +181 -0
  5. package/src/__tests__/designer-chrome-clipping.test.tsx +497 -0
  6. package/src/__tests__/facet-filters-block.test.tsx +707 -0
  7. package/src/__tests__/item-template.test.tsx +184 -0
  8. package/src/__tests__/listing-controls.test.tsx +961 -0
  9. package/src/__tests__/part-style-scope.test.tsx +229 -0
  10. package/src/__tests__/part-styles.test.ts +311 -0
  11. package/src/__tests__/registry-parity.test.ts +42 -0
  12. package/src/__tests__/row-visibility.test.ts +672 -0
  13. package/src/__tests__/sort-select-page-cursor.test.tsx +336 -0
  14. package/src/__tests__/theme-block-html-id.test.tsx +167 -0
  15. package/src/blocks/__tests__/__snapshots__/product-gallery-block.test.tsx.snap +15 -0
  16. package/src/blocks/__tests__/accordion-block-state.test.tsx +201 -0
  17. package/src/blocks/__tests__/facet-filters-block.test.tsx +190 -0
  18. package/src/blocks/__tests__/listing-context-card-model.test.tsx +230 -0
  19. package/src/blocks/__tests__/listing-lookup-hardening.test.ts +80 -0
  20. package/src/blocks/__tests__/product-gallery-block.test.tsx +974 -0
  21. package/src/blocks/__tests__/product-grid-composition.test.tsx +570 -0
  22. package/src/blocks/__tests__/product-grid-lookups.test.tsx +230 -0
  23. package/src/blocks/__tests__/product-price-block.test.tsx +248 -0
  24. package/src/blocks/__tests__/product-recommendations-block.test.tsx +702 -0
  25. package/src/blocks/__tests__/share-block.test.tsx +392 -0
  26. package/src/blocks/__tests__/store-locator-block.test.tsx +691 -0
  27. package/src/blocks/__tests__/variant-selector-block.test.tsx +817 -0
  28. package/src/blocks/__tests__/variant-selector-golden.json +20 -0
  29. package/src/blocks/account-menu-block.tsx +264 -0
  30. package/src/blocks/add-to-cart-block.tsx +417 -0
  31. package/src/blocks/address-list-block.tsx +348 -0
  32. package/src/blocks/applied-filters-block.tsx +288 -0
  33. package/src/blocks/auth-form-block.tsx +295 -0
  34. package/src/blocks/basket-items-block.tsx +457 -0
  35. package/src/blocks/basket-summary-block.tsx +404 -0
  36. package/src/blocks/block-renderer-registry.tsx +94 -0
  37. package/src/blocks/change-password-form-block.tsx +240 -0
  38. package/src/blocks/checkout-steps-block.tsx +288 -0
  39. package/src/blocks/checkout-summary-block.tsx +449 -0
  40. package/src/blocks/countdown-block.tsx +182 -0
  41. package/src/blocks/coupon-list-block.tsx +292 -0
  42. package/src/blocks/facet-filters-block.tsx +1686 -0
  43. package/src/blocks/faq-accordion-block.tsx +358 -0
  44. package/src/blocks/favorite-button-block.tsx +252 -0
  45. package/src/blocks/favourites-grid-block.tsx +510 -0
  46. package/src/blocks/forgot-password-block.tsx +155 -0
  47. package/src/blocks/item-template-utils.ts +131 -0
  48. package/src/blocks/item-template.tsx +164 -0
  49. package/src/blocks/listing-breadcrumb-block.tsx +175 -0
  50. package/src/blocks/listing-context.ts +850 -0
  51. package/src/blocks/listing-pagination-block.tsx +923 -0
  52. package/src/blocks/listing-result-count-block.tsx +126 -0
  53. package/src/blocks/listing-title-block.tsx +115 -0
  54. package/src/blocks/oauth-buttons-block.tsx +205 -0
  55. package/src/blocks/order-detail-block.tsx +474 -0
  56. package/src/blocks/order-list-block.tsx +274 -0
  57. package/src/blocks/order-success-block.tsx +641 -0
  58. package/src/blocks/order-tracking-block.tsx +533 -0
  59. package/src/blocks/product-attributes-block.tsx +307 -0
  60. package/src/blocks/product-block-utils.tsx +639 -0
  61. package/src/blocks/product-breadcrumb-block.tsx +215 -0
  62. package/src/blocks/product-description-block.tsx +169 -0
  63. package/src/blocks/product-gallery-block.tsx +1523 -0
  64. package/src/blocks/product-grid-block.tsx +966 -0
  65. package/src/blocks/product-price-block.tsx +373 -0
  66. package/src/blocks/product-recommendations-block.tsx +1433 -0
  67. package/src/blocks/product-title-block.tsx +129 -0
  68. package/src/blocks/profile-form-block.tsx +297 -0
  69. package/src/blocks/recently-viewed-store.ts +201 -0
  70. package/src/blocks/register-form-block.tsx +362 -0
  71. package/src/blocks/share-block.tsx +667 -0
  72. package/src/blocks/size-guide-block.tsx +313 -0
  73. package/src/blocks/sort-select-block.tsx +636 -0
  74. package/src/blocks/stock-status-block.tsx +168 -0
  75. package/src/blocks/store-locator-block.tsx +1184 -0
  76. package/src/blocks/variant-selector-block.tsx +1110 -0
  77. package/src/sections/__tests__/tablet-raw-bucket-readers.test.tsx +186 -0
  78. package/src/utils/__tests__/part-ownership-guard.test.tsx +29 -4
@@ -0,0 +1,707 @@
1
+ import React from 'react';
2
+ import { fireEvent, render, screen, within } from '@testing-library/react';
3
+
4
+ const mockPush = jest.fn();
5
+ let mockQuery = '';
6
+
7
+ jest.mock('next/navigation', () => ({
8
+ useRouter: () => ({ push: mockPush }),
9
+ usePathname: () => '/list',
10
+ useSearchParams: () => new URLSearchParams(mockQuery)
11
+ }));
12
+
13
+ import FacetFiltersBlock from '../blocks/facet-filters-block';
14
+ import { ThemePageContextProvider } from '../theme-page-context';
15
+
16
+ type Choice = { label: string; value: string; quantity?: number; selected?: boolean };
17
+
18
+ const facet = (
19
+ key: string,
20
+ name: string,
21
+ choices: Choice[],
22
+ widgetType = 'multiselect'
23
+ ) => ({
24
+ key,
25
+ name,
26
+ search_key: `attributes_${key}`,
27
+ widget_type: widgetType,
28
+ data: {
29
+ choices: choices.map((choice) => ({
30
+ label: choice.label,
31
+ value: choice.value,
32
+ quantity: choice.quantity ?? 5,
33
+ is_selected: choice.selected ?? false
34
+ }))
35
+ }
36
+ });
37
+
38
+ const COLOR = facet('color', 'Renk', [
39
+ { label: 'Siyah', value: 'black', quantity: 12 },
40
+ { label: 'Beyaz', value: 'white', quantity: 7 },
41
+ { label: 'Lacivert', value: 'navy', quantity: 3 }
42
+ ]);
43
+
44
+ const SIZE = facet('size', 'Beden', [
45
+ { label: 'S', value: 's', quantity: 9 },
46
+ { label: 'M', value: 'm', quantity: 4 }
47
+ ]);
48
+
49
+ const pageContext = (facets: unknown[] = [COLOR, SIZE]) => ({
50
+ listing: {
51
+ title: 'Shoes',
52
+ path: '/list',
53
+ query: '',
54
+ products: [],
55
+ facets,
56
+ sorters: [],
57
+ pagination: {},
58
+ breadcrumbs: []
59
+ }
60
+ });
61
+
62
+ const renderBlock = (
63
+ properties: Record<string, unknown> = {},
64
+ options: {
65
+ isDesigner?: boolean;
66
+ facets?: unknown[];
67
+ currentBreakpoint?: string;
68
+ } = {}
69
+ ) =>
70
+ render(
71
+ <ThemePageContextProvider value={pageContext(options.facets)}>
72
+ <FacetFiltersBlock
73
+ block={
74
+ {
75
+ id: 'blk-1',
76
+ type: 'facet-filters',
77
+ label: 'Filters',
78
+ order: 0,
79
+ hidden: false,
80
+ value: '',
81
+ properties,
82
+ styles: {},
83
+ blocks: []
84
+ } as never
85
+ }
86
+ placeholderId="p"
87
+ sectionId="s"
88
+ isDesigner={options.isDesigner ?? false}
89
+ currentBreakpoint={options.currentBreakpoint ?? 'desktop'}
90
+ />
91
+ </ThemePageContextProvider>
92
+ );
93
+
94
+ beforeEach(() => {
95
+ mockPush.mockClear();
96
+ mockQuery = '';
97
+ document.body.style.overflow = '';
98
+ });
99
+
100
+ /**
101
+ * The lock. These snapshots were taken from the shipped renderer BEFORE any of
102
+ * the option-search / selected-count / facet-order / staged-apply / dropdown-
103
+ * close work landed. Every one of those arrives off by default, so a
104
+ * composition that sets no property must keep producing exactly this markup —
105
+ * if a snapshot moves, a published storefront moved with it.
106
+ */
107
+ describe('default render is frozen', () => {
108
+ it('renders the sidebar layout unchanged', () => {
109
+ const { container } = renderBlock();
110
+ expect(container.innerHTML).toMatchSnapshot();
111
+ });
112
+
113
+ it('renders the topbar layout unchanged', () => {
114
+ const { container } = renderBlock({ layout: 'topbar' });
115
+ expect(container.innerHTML).toMatchSnapshot();
116
+ });
117
+
118
+ it('renders the pills layout unchanged', () => {
119
+ const { container } = renderBlock({ layout: 'pills' });
120
+ expect(container.innerHTML).toMatchSnapshot();
121
+ });
122
+
123
+ it('renders an opened dropdown unchanged', () => {
124
+ const { container } = renderBlock({ layout: 'dropdown' });
125
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
126
+ expect(container.innerHTML).toMatchSnapshot();
127
+ });
128
+
129
+ it('renders an opened panel unchanged', () => {
130
+ const { container } = renderBlock({
131
+ layout: 'panel',
132
+ showClear: true,
133
+ showApply: true
134
+ });
135
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
136
+ expect(container.innerHTML).toMatchSnapshot();
137
+ });
138
+
139
+ it('draws no search box and no count badge anywhere by default', () => {
140
+ const { container } = renderBlock({
141
+ layout: 'panel',
142
+ showClear: true,
143
+ showApply: true
144
+ });
145
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
146
+
147
+ expect(container.querySelector('input')).toBeNull();
148
+ expect(container.querySelector('[data-part="option-search"]')).toBeNull();
149
+ expect(container.querySelector('[data-part="selected-count"]')).toBeNull();
150
+ expect(container.querySelector('[data-part="trigger-count"]')).toBeNull();
151
+ expect(container.querySelector('[data-part="no-match"]')).toBeNull();
152
+ });
153
+
154
+ it('keeps apply-as-you-pick: a tick navigates immediately', () => {
155
+ renderBlock({ layout: 'panel', showApply: true });
156
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
157
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
158
+
159
+ expect(mockPush).toHaveBeenCalledWith('/list?attributes_color=black');
160
+ });
161
+
162
+ it('keeps the default Apply button cosmetic — it only closes', () => {
163
+ renderBlock({ layout: 'panel', showApply: true });
164
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
165
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
166
+
167
+ expect(mockPush).not.toHaveBeenCalled();
168
+ expect(screen.queryByRole('dialog')).toBeNull();
169
+ });
170
+ });
171
+
172
+ describe('option search', () => {
173
+ it('is absent until a designer turns it on', () => {
174
+ const { container } = renderBlock({ showOptionSearch: false });
175
+ expect(container.querySelector('[data-part="option-search"]')).toBeNull();
176
+ });
177
+
178
+ it('narrows a group to the options that match', () => {
179
+ renderBlock({ showOptionSearch: true });
180
+
181
+ const input = screen.getAllByRole('searchbox')[0];
182
+ fireEvent.change(input, { target: { value: 'la' } });
183
+
184
+ // "Lacivert" survives; the other two colours do not. The size group is
185
+ // searched on its own term, so both of its options stay.
186
+ expect(screen.getByRole('button', { name: /Lacivert/ })).toBeTruthy();
187
+ expect(screen.queryByRole('button', { name: /Siyah/ })).toBeNull();
188
+ expect(screen.getByRole('button', { name: /^S/ })).toBeTruthy();
189
+ });
190
+
191
+ it('matches without regard to case', () => {
192
+ renderBlock({ showOptionSearch: true });
193
+ fireEvent.change(screen.getAllByRole('searchbox')[0], {
194
+ target: { value: 'SIYAH' }
195
+ });
196
+
197
+ expect(screen.getByRole('button', { name: /Siyah/ })).toBeTruthy();
198
+ });
199
+
200
+ it('says so when nothing matches, and marks the list empty', () => {
201
+ const { container } = renderBlock({ showOptionSearch: true });
202
+ fireEvent.change(screen.getAllByRole('searchbox')[0], {
203
+ target: { value: 'zzzz' }
204
+ });
205
+
206
+ expect(container.querySelector('[data-part="no-match"]')?.textContent).toBe(
207
+ 'No matches'
208
+ );
209
+ const lists = container.querySelectorAll('[data-part="option-list"]');
210
+ expect(lists[0].getAttribute('data-state')).toContain('empty');
211
+ });
212
+
213
+ it('only appears on groups past the option threshold', () => {
214
+ const { container } = renderBlock({
215
+ showOptionSearch: true,
216
+ optionSearchMinOptions: 3
217
+ });
218
+
219
+ // Renk has three options, Beden two.
220
+ expect(container.querySelectorAll('[data-part="option-search"]')).toHaveLength(
221
+ 1
222
+ );
223
+ });
224
+
225
+ it('stays out of the pill row, which is navigation', () => {
226
+ const { container } = renderBlock({
227
+ layout: 'pills',
228
+ showOptionSearch: true
229
+ });
230
+ expect(container.querySelector('[data-part="option-search"]')).toBeNull();
231
+ });
232
+ });
233
+
234
+ describe('selected counts', () => {
235
+ const withSelection = [
236
+ facet('color', 'Renk', [
237
+ { label: 'Siyah', value: 'black', selected: true },
238
+ { label: 'Beyaz', value: 'white', selected: true },
239
+ { label: 'Lacivert', value: 'navy' }
240
+ ]),
241
+ SIZE
242
+ ];
243
+
244
+ it('puts the count on the group heading when asked', () => {
245
+ const { container } = renderBlock(
246
+ { showSelectedCount: true },
247
+ { facets: withSelection }
248
+ );
249
+
250
+ const badge = container.querySelector('[data-part="selected-count"]');
251
+ expect(badge?.textContent).toBe('(2)');
252
+ });
253
+
254
+ it('honours the designer template', () => {
255
+ const { container } = renderBlock(
256
+ { showSelectedCount: true, selectedCountTemplate: '[{count}]' },
257
+ { facets: withSelection }
258
+ );
259
+
260
+ expect(
261
+ container.querySelector('[data-part="selected-count"]')?.textContent
262
+ ).toBe('[2]');
263
+ });
264
+
265
+ it('shows nothing on a group with no selection', () => {
266
+ const { container } = renderBlock(
267
+ { showSelectedCount: true },
268
+ { facets: withSelection }
269
+ );
270
+
271
+ const groups = container.querySelectorAll('[data-facet-group]');
272
+ expect(
273
+ (groups[1] as HTMLElement).querySelector('[data-part="selected-count"]')
274
+ ).toBeNull();
275
+ });
276
+
277
+ it('counts every applied filter on the panel trigger', () => {
278
+ const { container } = renderBlock(
279
+ { layout: 'panel', showTriggerCount: true },
280
+ { facets: withSelection }
281
+ );
282
+
283
+ expect(
284
+ container.querySelector('[data-part="trigger-count"]')?.textContent
285
+ ).toBe('(2)');
286
+ });
287
+
288
+ it('drops the trigger badge when nothing is applied', () => {
289
+ const { container } = renderBlock({
290
+ layout: 'panel',
291
+ showTriggerCount: true
292
+ });
293
+
294
+ expect(container.querySelector('[data-part="trigger-count"]')).toBeNull();
295
+ });
296
+ });
297
+
298
+ describe('facet order', () => {
299
+ it('follows commerce when the designer names no order', () => {
300
+ const { container } = renderBlock();
301
+ const groups = Array.from(container.querySelectorAll('[data-facet-group]'));
302
+ expect(groups.map((g) => g.getAttribute('data-facet-group'))).toEqual([
303
+ 'color',
304
+ 'size'
305
+ ]);
306
+ });
307
+
308
+ it('puts the named facets first, in the order given', () => {
309
+ const { container } = renderBlock({ facetOrder: 'size' });
310
+ const groups = Array.from(container.querySelectorAll('[data-facet-group]'));
311
+ expect(groups.map((g) => g.getAttribute('data-facet-group'))).toEqual([
312
+ 'size',
313
+ 'color'
314
+ ]);
315
+ });
316
+
317
+ it('matches the display name a designer reads off the page', () => {
318
+ const { container } = renderBlock({ facetOrder: 'Beden, Renk' });
319
+ const groups = Array.from(container.querySelectorAll('[data-facet-group]'));
320
+ expect(groups.map((g) => g.getAttribute('data-facet-group'))).toEqual([
321
+ 'size',
322
+ 'color'
323
+ ]);
324
+ });
325
+ });
326
+
327
+ describe('staged apply', () => {
328
+ const staged = {
329
+ layout: 'panel',
330
+ showApply: true,
331
+ showClear: true,
332
+ applyMode: 'staged'
333
+ };
334
+
335
+ const openPanel = () =>
336
+ fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
337
+
338
+ it('writes no URL while the shopper is picking', () => {
339
+ renderBlock(staged);
340
+ openPanel();
341
+
342
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
343
+ fireEvent.click(screen.getByRole('button', { name: /Beyaz/ }));
344
+
345
+ expect(mockPush).not.toHaveBeenCalled();
346
+ });
347
+
348
+ it('writes one URL carrying every staged choice on Apply', () => {
349
+ renderBlock(staged);
350
+ openPanel();
351
+
352
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
353
+ fireEvent.click(screen.getByRole('button', { name: /^M/ }));
354
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
355
+
356
+ expect(mockPush).toHaveBeenCalledTimes(1);
357
+ expect(mockPush).toHaveBeenCalledWith(
358
+ '/list?attributes_color=black&attributes_size=m'
359
+ );
360
+ });
361
+
362
+ it('shows the shopper what is staged and what is applied', () => {
363
+ const { container } = renderBlock(staged);
364
+ openPanel();
365
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
366
+
367
+ const option = container.querySelector('[data-facet-choice="black"]');
368
+ expect(option?.getAttribute('data-state')).toContain('selected');
369
+ expect(option?.getAttribute('data-state')).toContain('staged');
370
+
371
+ const untouched = container.querySelector('[data-facet-choice="white"]');
372
+ expect(untouched?.getAttribute('data-state') || '').not.toContain('staged');
373
+
374
+ expect(
375
+ container
376
+ .querySelector('[data-part="apply"]')
377
+ ?.getAttribute('data-state')
378
+ ).toContain('staged');
379
+ });
380
+
381
+ it('marks a staged un-tick as staged too', () => {
382
+ mockQuery = 'attributes_color=black';
383
+ const { container } = renderBlock(staged, {
384
+ facets: [
385
+ facet('color', 'Renk', [
386
+ { label: 'Siyah', value: 'black', selected: true },
387
+ { label: 'Beyaz', value: 'white' }
388
+ ]),
389
+ SIZE
390
+ ]
391
+ });
392
+ openPanel();
393
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
394
+
395
+ const option = container.querySelector('[data-facet-choice="black"]');
396
+ expect(option?.getAttribute('data-state') || '').not.toContain('selected');
397
+ expect(option?.getAttribute('data-state')).toContain('staged');
398
+ });
399
+
400
+ it('forgets a staged choice when the shopper abandons the panel', () => {
401
+ const { container } = renderBlock(staged);
402
+ openPanel();
403
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
404
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
405
+
406
+ expect(mockPush).not.toHaveBeenCalled();
407
+
408
+ openPanel();
409
+ expect(
410
+ container
411
+ .querySelector('[data-facet-choice="black"]')
412
+ ?.getAttribute('data-state') || ''
413
+ ).not.toContain('staged');
414
+ });
415
+
416
+ it('drops the page cursor when the staged URL is written', () => {
417
+ mockQuery = 'page=4';
418
+ renderBlock(staged);
419
+ openPanel();
420
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
421
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
422
+
423
+ expect(mockPush).toHaveBeenCalledWith('/list?attributes_color=black');
424
+ });
425
+
426
+ /**
427
+ * The round trip. Staging's whole promise is one page load instead of one
428
+ * per tap, so a panel the shopper ended up not changing must cost ZERO —
429
+ * in App Router a push is a real navigation (RSC refetch, scroll to top, a
430
+ * history entry that makes Back look broken).
431
+ */
432
+ it('writes no URL when the shopper undoes every staged choice', () => {
433
+ const { container } = renderBlock(staged);
434
+ openPanel();
435
+
436
+ fireEvent.click(screen.getByRole('button', { name: /^M/ }));
437
+ fireEvent.click(screen.getByRole('button', { name: /^M/ }));
438
+
439
+ expect(
440
+ container.querySelector('[data-part="apply"]')?.getAttribute('data-state') ||
441
+ ''
442
+ ).not.toContain('staged');
443
+
444
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
445
+
446
+ expect(mockPush).not.toHaveBeenCalled();
447
+ expect(screen.queryByRole('dialog')).toBeNull();
448
+ });
449
+
450
+ it('writes no URL for Clear All on an already-clean panel', () => {
451
+ // `handleClear` stages unconditionally, so an untouched panel ends up with
452
+ // a staged query that is identical to the URL.
453
+ renderBlock(staged);
454
+ openPanel();
455
+ fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
456
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
457
+
458
+ expect(mockPush).not.toHaveBeenCalled();
459
+ });
460
+
461
+ /**
462
+ * The page cursor is dropped while staging because a filter change
463
+ * invalidates it — but its absence is not itself a change. Comparing it
464
+ * lights Apply up on a panel nothing differs in, and committing then throws
465
+ * the shopper from page 4 back to page 1 for nothing.
466
+ */
467
+ it('does not count the dropped page cursor as a staged change', () => {
468
+ mockQuery = 'attributes_size=m&page=4';
469
+ const { container } = renderBlock(staged, {
470
+ facets: [
471
+ facet('size', 'Beden', [
472
+ { label: 'S', value: 's' },
473
+ { label: 'M', value: 'm', selected: true }
474
+ ]),
475
+ COLOR
476
+ ]
477
+ });
478
+ openPanel();
479
+
480
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
481
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
482
+
483
+ expect(
484
+ container.querySelector('[data-part="apply"]')?.getAttribute('data-state') ||
485
+ ''
486
+ ).not.toContain('staged');
487
+
488
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
489
+
490
+ expect(mockPush).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it('still commits when a real change also dropped the page cursor', () => {
494
+ mockQuery = 'attributes_size=m&page=4';
495
+ renderBlock(staged, {
496
+ facets: [
497
+ facet('size', 'Beden', [
498
+ { label: 'S', value: 's' },
499
+ { label: 'M', value: 'm', selected: true }
500
+ ]),
501
+ COLOR
502
+ ]
503
+ });
504
+ openPanel();
505
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
506
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
507
+
508
+ expect(mockPush).toHaveBeenCalledWith(
509
+ '/list?attributes_size=m&attributes_color=black'
510
+ );
511
+ });
512
+
513
+ /**
514
+ * The page cursor is free text, so a merchant can type a key a facet
515
+ * already owns into it. Honouring that would take the facet out of the
516
+ * staged-versus-applied comparison AND off every URL this block writes —
517
+ * the shopper's choice would be dropped on the floor by a control that
518
+ * looks like it worked.
519
+ */
520
+ describe('a page cursor a facet already owns', () => {
521
+ it('commits a choice made under the facet\'s own key', () => {
522
+ renderBlock({ ...staged, pageQueryParam: 'attributes_color' });
523
+ openPanel();
524
+
525
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
526
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
527
+
528
+ expect(mockPush).toHaveBeenCalledWith('/list?attributes_color=black');
529
+ });
530
+
531
+ it('leaves that facet on the URL when another filter changes', () => {
532
+ mockQuery = 'attributes_color=black';
533
+ renderBlock(
534
+ { layout: 'sidebar', pageQueryParam: 'attributes_color' },
535
+ {
536
+ facets: [
537
+ facet('color', 'Renk', [
538
+ { label: 'Siyah', value: 'black', selected: true },
539
+ { label: 'Beyaz', value: 'white' }
540
+ ]),
541
+ SIZE
542
+ ]
543
+ }
544
+ );
545
+
546
+ fireEvent.click(screen.getByRole('button', { name: /^M/ }));
547
+
548
+ expect(mockPush).toHaveBeenCalledWith(
549
+ '/list?attributes_color=black&attributes_size=m'
550
+ );
551
+ });
552
+
553
+ it('still drops the real cursor it fell back to', () => {
554
+ mockQuery = 'page=4';
555
+ renderBlock({ ...staged, pageQueryParam: 'attributes_color' });
556
+ openPanel();
557
+
558
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
559
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
560
+
561
+ expect(mockPush).toHaveBeenCalledWith('/list?attributes_color=black');
562
+ });
563
+ });
564
+
565
+ it('stages Clear All rather than navigating on the spot', () => {
566
+ mockQuery = 'attributes_color=black';
567
+ renderBlock(staged, {
568
+ facets: [
569
+ facet('color', 'Renk', [
570
+ { label: 'Siyah', value: 'black', selected: true },
571
+ { label: 'Beyaz', value: 'white' }
572
+ ]),
573
+ SIZE
574
+ ]
575
+ });
576
+ openPanel();
577
+ fireEvent.click(screen.getByRole('button', { name: 'Clear all' }));
578
+
579
+ expect(mockPush).not.toHaveBeenCalled();
580
+ expect(screen.getByRole('dialog')).toBeTruthy();
581
+
582
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
583
+ expect(mockPush).toHaveBeenCalledWith('/list');
584
+ });
585
+
586
+ it('reads the group count off the staged selection, the trigger off the applied one', () => {
587
+ // The URL carries what commerce reports as applied — that agreement is
588
+ // what every href in this block is already built on.
589
+ mockQuery = 'attributes_color=black';
590
+ const { container } = renderBlock(
591
+ { ...staged, showSelectedCount: true, showTriggerCount: true },
592
+ {
593
+ facets: [
594
+ facet('color', 'Renk', [
595
+ { label: 'Siyah', value: 'black', selected: true },
596
+ { label: 'Beyaz', value: 'white' }
597
+ ])
598
+ ]
599
+ }
600
+ );
601
+ openPanel();
602
+ fireEvent.click(screen.getByRole('button', { name: /Beyaz/ }));
603
+
604
+ expect(
605
+ container.querySelector('[data-part="selected-count"]')?.textContent
606
+ ).toBe('(2)');
607
+ expect(
608
+ container.querySelector('[data-part="trigger-count"]')?.textContent
609
+ ).toBe('(1)');
610
+ });
611
+
612
+ it('marks only the group the shopper actually changed', () => {
613
+ const { container } = renderBlock({
614
+ ...staged,
615
+ showSelectedCount: true
616
+ });
617
+ openPanel();
618
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
619
+
620
+ const badges = container.querySelectorAll('[data-part="selected-count"]');
621
+ // Only Renk has a staged tick; Beden has none, so it draws no badge at all.
622
+ expect(badges).toHaveLength(1);
623
+ expect(badges[0].getAttribute('data-state')).toContain('staged');
624
+ });
625
+
626
+ it('is inert in the designer', () => {
627
+ const { container } = renderBlock(staged, { isDesigner: true });
628
+ openPanel();
629
+ fireEvent.click(screen.getByRole('button', { name: /Siyah/ }));
630
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
631
+
632
+ expect(mockPush).not.toHaveBeenCalled();
633
+ expect(container).toBeTruthy();
634
+ });
635
+ });
636
+
637
+ describe('dropdown close control', () => {
638
+ // The floating list is addressed through its data hook, not its part: the
639
+ // dropdown spreads `panel` over it after `option-list`, so `data-part` reads
640
+ // "panel" in this layout.
641
+ const openList = (container: HTMLElement) =>
642
+ container.querySelector('[data-facet-choices]');
643
+
644
+ it('draws none by default', () => {
645
+ const { container } = renderBlock({ layout: 'dropdown' });
646
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
647
+
648
+ expect(openList(container)).toBeTruthy();
649
+ expect(container.querySelector('[data-part="close"]')).toBeNull();
650
+ });
651
+
652
+ it('closes the floating panel from inside it', () => {
653
+ const { container } = renderBlock({
654
+ layout: 'dropdown',
655
+ showDropdownClose: true
656
+ });
657
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
658
+
659
+ const close = container.querySelector('[data-part="close"]');
660
+ expect(close).toBeTruthy();
661
+ fireEvent.click(close as Element);
662
+
663
+ expect(openList(container)).toBeNull();
664
+ });
665
+
666
+ it('leaves an outside click alone unless the designer asked', () => {
667
+ const { container } = renderBlock({ layout: 'dropdown' });
668
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
669
+ fireEvent.mouseDown(document.body);
670
+
671
+ expect(openList(container)).toBeTruthy();
672
+ });
673
+
674
+ it('dismisses on an outside click when asked', () => {
675
+ const { container } = renderBlock({
676
+ layout: 'dropdown',
677
+ closeOnOutsideClick: true
678
+ });
679
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
680
+ expect(openList(container)).toBeTruthy();
681
+
682
+ fireEvent.mouseDown(document.body);
683
+ expect(openList(container)).toBeNull();
684
+ });
685
+
686
+ it('keeps a click inside the block from dismissing it', () => {
687
+ const { container } = renderBlock({
688
+ layout: 'dropdown',
689
+ closeOnOutsideClick: true
690
+ });
691
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
692
+ fireEvent.mouseDown(openList(container) as Element);
693
+
694
+ expect(openList(container)).toBeTruthy();
695
+ });
696
+
697
+ it('never listens for outside clicks in the designer', () => {
698
+ const { container } = renderBlock(
699
+ { layout: 'dropdown', closeOnOutsideClick: true },
700
+ { isDesigner: true }
701
+ );
702
+ fireEvent.click(screen.getByRole('button', { name: /Renk/ }));
703
+ fireEvent.mouseDown(document.body);
704
+
705
+ expect(openList(container)).toBeTruthy();
706
+ });
707
+ });