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

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 CHANGED
@@ -1,38 +1,13 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.100-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.100-rc.0
35
- - @akinon/theme-contract@2.0.100-rc.0
3
+ ## 2.0.100
4
+
5
+ ### Patch Changes
6
+
7
+ - dabdde88: ZERO-5014: Add the collapsible block — any stack of composed children clamped to a line budget behind a fading read-more trigger, with the whole composition kept in the server HTML.
8
+ - 6a8255c1: ZERO-5015: Let the image block serve a different image per device — optional tablet and phone uploads are emitted as `<picture><source media>` so the browser picks before any JS runs, while a block with no override keeps rendering exactly the `<img>` it renders today.
9
+ - @akinon/next@2.0.100
10
+ - @akinon/theme-contract@2.0.100
36
11
 
37
12
  ## 2.0.99
38
13
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.100-rc.0",
4
+ "version": "2.0.100",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.100-rc.0",
11
+ "@akinon/next": "2.0.100",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.100-rc.0",
16
+ "@akinon/theme-contract": "2.0.100",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.100-rc.0",
24
+ "@akinon/next": "2.0.100",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -51,6 +51,7 @@ const EXPECTED_BLOCK_TYPES = [
51
51
  'checkout-steps-outlet',
52
52
  'checkout-summary',
53
53
  'checkout-summary-outlet',
54
+ 'collapsible',
54
55
  'container',
55
56
  'countdown',
56
57
  'counter',
@@ -0,0 +1,521 @@
1
+ /**
2
+ * The collapsible block: what it emits, and the two things that would silently
3
+ * destroy it.
4
+ *
5
+ * The first is SEO. The whole point of this block is that the clipped half is
6
+ * present in the server HTML, so there is a `renderToString` test that asserts
7
+ * every child marker survives the collapsed render. A refactor that reaches for
8
+ * `{isExpanded && …}` passes every interaction test in this file and fails that
9
+ * one.
10
+ *
11
+ * The second is the clamp mechanism. `-webkit-line-clamp` and
12
+ * `overflow: hidden` both LOOK correct in a browser and are both wrong here
13
+ * (they cannot count lines across block children, and a hidden box scrolls the
14
+ * visible lines away when a clipped link takes focus), so the emitted sheet is
15
+ * asserted for `1lh`, for `overflow:clip`, and for the `hidden` fallback that
16
+ * has to precede it.
17
+ */
18
+ import React from 'react';
19
+ import { fireEvent, render, screen } from '@testing-library/react';
20
+ import { renderToString } from 'react-dom/server';
21
+
22
+ jest.mock('../../theme-block', () => ({
23
+ __esModule: true,
24
+ default: ({ block }: any) => <div data-testid="tb" data-id={block.id} />
25
+ }));
26
+
27
+ import CollapsibleBlock, {
28
+ COLLAPSIBLE_FALLBACK_LABELS
29
+ } from '../collapsible-block';
30
+
31
+ const child = (id: string, over: Record<string, unknown> = {}) => ({
32
+ id,
33
+ type: 'text',
34
+ label: `child ${id}`,
35
+ hidden: false,
36
+ order: 0,
37
+ properties: {},
38
+ styles: {},
39
+ ...over
40
+ });
41
+
42
+ const props = (over: Record<string, unknown> = {}) =>
43
+ ({
44
+ block: {
45
+ id: 'col-1',
46
+ type: 'collapsible',
47
+ label: 'Collapsible',
48
+ properties: {},
49
+ styles: {},
50
+ blocks: [child('a'), child('b', { order: 1 })]
51
+ },
52
+ placeholderId: 'p',
53
+ sectionId: 's',
54
+ isDesigner: false,
55
+ currentBreakpoint: 'desktop',
56
+ ...over
57
+ }) as any;
58
+
59
+ const withProperties = (
60
+ properties: Record<string, unknown>,
61
+ over: Record<string, unknown> = {}
62
+ ) =>
63
+ props({
64
+ block: {
65
+ ...props().block,
66
+ properties
67
+ },
68
+ ...over
69
+ });
70
+
71
+ const sheet = (container: HTMLElement) =>
72
+ Array.from(container.querySelectorAll('style'))
73
+ .map((el) => el.textContent || '')
74
+ .join('');
75
+
76
+ describe('CollapsibleBlock', () => {
77
+ describe('the hidden half is never unmounted', () => {
78
+ it('ships every child in the collapsed server render', () => {
79
+ const html = renderToString(<CollapsibleBlock {...props()} />);
80
+
81
+ expect(html).toContain('data-id="a"');
82
+ expect(html).toContain('data-id="b"');
83
+ expect(html).toContain('aria-expanded="false"');
84
+ });
85
+
86
+ it('keeps the same children mounted across a toggle', () => {
87
+ render(<CollapsibleBlock {...props()} />);
88
+
89
+ expect(screen.getAllByTestId('tb')).toHaveLength(2);
90
+ fireEvent.click(screen.getByRole('button'));
91
+ expect(screen.getAllByTestId('tb')).toHaveLength(2);
92
+ fireEvent.click(screen.getByRole('button'));
93
+ expect(screen.getAllByTestId('tb')).toHaveLength(2);
94
+ });
95
+ });
96
+
97
+ describe('the clamp', () => {
98
+ it('clips in line units with the em fallback ahead of it', () => {
99
+ const { container } = render(<CollapsibleBlock {...props()} />);
100
+ const css = sheet(container);
101
+
102
+ expect(css).toContain(
103
+ 'max-height:calc(var(--pz-collapse-lines) * 1.5em)'
104
+ );
105
+ expect(css).toContain('max-height:calc(var(--pz-collapse-lines) * 1lh)');
106
+ expect(css.indexOf('1.5em')).toBeLessThan(css.indexOf('1lh'));
107
+ expect(css).not.toContain('-webkit-line-clamp');
108
+ });
109
+
110
+ it('clips rather than hides, so a focused child cannot scroll the visible lines away', () => {
111
+ const { container } = render(<CollapsibleBlock {...props()} />);
112
+ const css = sheet(container);
113
+
114
+ expect(css).toContain('overflow:hidden;overflow:clip');
115
+ });
116
+
117
+ it('reads the collapsed and fade heights per breakpoint on the storefront', () => {
118
+ const { container } = render(
119
+ <CollapsibleBlock
120
+ {...withProperties({
121
+ collapsedLines: { desktop: 10, tablet: 7, mobile: 5 },
122
+ fadeLines: { desktop: 4, mobile: 2 }
123
+ })}
124
+ />
125
+ );
126
+ const css = sheet(container);
127
+
128
+ expect(css).toContain('--pz-collapse-lines:10');
129
+ expect(css).toContain(
130
+ '@media (max-width:1023px){[data-pz-collapsible="col-1"]{--pz-collapse-lines:5'
131
+ );
132
+ expect(css).toContain('--pz-collapse-lines:7');
133
+ });
134
+
135
+ it('resolves one band in the designer, because the canvas frame would answer a media query', () => {
136
+ const { container } = render(
137
+ <CollapsibleBlock
138
+ {...withProperties(
139
+ { collapsedLines: { desktop: 10, mobile: 5 } },
140
+ { isDesigner: true, currentBreakpoint: 'mobile' }
141
+ )}
142
+ />
143
+ );
144
+ const css = sheet(container);
145
+
146
+ expect(css).toContain('--pz-collapse-lines:5');
147
+ expect(css).not.toContain('@media (max-width');
148
+ });
149
+
150
+ it('clamps a stored line count that would emit an invalid declaration', () => {
151
+ const { container } = render(
152
+ <CollapsibleBlock
153
+ {...withProperties({ collapsedLines: { desktop: -4 } })}
154
+ />
155
+ );
156
+
157
+ expect(sheet(container)).toContain('--pz-collapse-lines:2');
158
+ });
159
+
160
+ it('drops the clamp and the trigger entirely while the designer has it open', () => {
161
+ const { container } = render(
162
+ <CollapsibleBlock {...props({ isDesigner: true })} />
163
+ );
164
+
165
+ expect(container.querySelector('[data-designer-open]')).toHaveAttribute(
166
+ 'data-designer-open',
167
+ 'true'
168
+ );
169
+ expect(sheet(container)).toContain(
170
+ '[data-pz-collapsible="col-1"][data-designer-open="true"] [data-part="clip"]{max-height:none;overflow:visible'
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('the fade', () => {
176
+ it('masks the text by default, so it is correct over any section background', () => {
177
+ const { container } = render(<CollapsibleBlock {...props()} />);
178
+ const css = sheet(container);
179
+
180
+ expect(css).toContain('-webkit-mask-image:linear-gradient(');
181
+ expect(css).toContain('mask-image:linear-gradient(');
182
+ expect(container.querySelector('[data-part="fade"]')).toBeNull();
183
+ });
184
+
185
+ it('paints an authored colour band instead when asked', () => {
186
+ const { container } = render(
187
+ <CollapsibleBlock
188
+ {...withProperties({
189
+ fadeStyle: { desktop: 'color' },
190
+ fadeColor: { desktop: '#101010' }
191
+ })}
192
+ />
193
+ );
194
+
195
+ expect(sheet(container)).not.toContain('mask-image:linear-gradient(');
196
+ const fade = container.querySelector('[data-part="fade"]');
197
+ expect(fade).toBeInTheDocument();
198
+ expect(fade).toHaveAttribute('aria-hidden', 'true');
199
+ expect(sheet(container)).toContain(
200
+ 'background-image:linear-gradient(to bottom,transparent,var(--pz-collapse-fade-color'
201
+ );
202
+ });
203
+
204
+ /**
205
+ * The authored colour reaches CSS as a custom property VALUE, never as a
206
+ * substring of the sheet — which is what stops a crafted colour from
207
+ * closing the rule and writing its own.
208
+ */
209
+ it('hands the authored colour to the sheet as a variable, not as text', () => {
210
+ const { container } = render(
211
+ <CollapsibleBlock
212
+ {...withProperties({
213
+ fadeStyle: { desktop: 'color' },
214
+ fadeColor: { desktop: '#101010' }
215
+ })}
216
+ />
217
+ );
218
+
219
+ expect(
220
+ container.querySelector('[data-pz-collapsible]')?.getAttribute('style')
221
+ ).toContain('--pz-collapse-fade-color: #101010');
222
+ expect(sheet(container)).not.toContain('#101010');
223
+ });
224
+
225
+ it('falls back to the theme background when the colour is not authored', () => {
226
+ const { container } = render(
227
+ <CollapsibleBlock
228
+ {...withProperties({ fadeStyle: { desktop: 'color' } })}
229
+ />
230
+ );
231
+
232
+ expect(sheet(container)).toContain('var(--theme-background,#fff)');
233
+ expect(
234
+ container.querySelector('[data-pz-collapsible]')?.getAttribute('style')
235
+ ).not.toContain('--pz-collapse-fade-color');
236
+ });
237
+ });
238
+
239
+ describe('the trigger', () => {
240
+ it('is a real button wired to the panel it controls', () => {
241
+ const { container } = render(<CollapsibleBlock {...props()} />);
242
+ const toggle = screen.getByRole('button');
243
+
244
+ expect(toggle).toHaveAttribute('type', 'button');
245
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
246
+ expect(toggle.getAttribute('aria-controls')).toBe(
247
+ container.querySelector('[data-part="content"]')?.getAttribute('id')
248
+ );
249
+ expect(toggle.getAttribute('aria-controls')).toBeTruthy();
250
+ });
251
+
252
+ it('swaps the caption and marks itself open', () => {
253
+ render(<CollapsibleBlock {...props()} />);
254
+ const toggle = screen.getByRole('button');
255
+
256
+ expect(toggle).toHaveTextContent(COLLAPSIBLE_FALLBACK_LABELS.more);
257
+
258
+ fireEvent.click(toggle);
259
+
260
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
261
+ expect(toggle).toHaveAttribute('data-state', 'open');
262
+ expect(toggle).toHaveTextContent(COLLAPSIBLE_FALLBACK_LABELS.less);
263
+ });
264
+
265
+ it('prefers the authored captions over the fallback constant', () => {
266
+ render(
267
+ <CollapsibleBlock
268
+ {...withProperties({ moreLabel: 'Daha Fazla', lessLabel: 'Daha Az' })}
269
+ />
270
+ );
271
+ const toggle = screen.getByRole('button');
272
+
273
+ expect(toggle).toHaveTextContent('Daha Fazla');
274
+ fireEvent.click(toggle);
275
+ expect(toggle).toHaveTextContent('Daha Az');
276
+ });
277
+
278
+ it('reads a locale map caption at the active locale', () => {
279
+ render(
280
+ <CollapsibleBlock
281
+ {...withProperties({
282
+ moreLabel: { 'en-us': 'Read more', 'tr-tr': 'Devamını Gör' }
283
+ })}
284
+ />
285
+ );
286
+
287
+ expect(screen.getByRole('button')).toHaveTextContent('Read more');
288
+ });
289
+
290
+ it('carries an icon the stylesheet can rotate, hidden from assistive tech', () => {
291
+ const { container } = render(<CollapsibleBlock {...props()} />);
292
+
293
+ expect(
294
+ container.querySelector('[data-part="toggle-icon"]')
295
+ ).toHaveAttribute('aria-hidden', 'true');
296
+ expect(sheet(container)).toContain(
297
+ '[data-part="toggle"][data-state~="open"] [data-part="toggle-icon"]{transform:rotate(180deg)}'
298
+ );
299
+ });
300
+
301
+ it('can be dropped', () => {
302
+ const { container } = render(
303
+ <CollapsibleBlock
304
+ {...withProperties({ showToggleIcon: { desktop: false } })}
305
+ />
306
+ );
307
+
308
+ expect(container.querySelector('[data-part="toggle-icon"]')).toBeNull();
309
+ });
310
+
311
+ it('is aligned with a margin, so it works on the flex box it is', () => {
312
+ const { container } = render(
313
+ <CollapsibleBlock
314
+ {...withProperties({ toggleAlign: { desktop: 'center' } })}
315
+ />
316
+ );
317
+
318
+ expect(
319
+ container.querySelector('[data-part="toggle"]')?.getAttribute('style')
320
+ ).toContain('margin: 0px auto');
321
+ });
322
+ });
323
+
324
+ describe('reaching the clipped half', () => {
325
+ it('opens when a child inside the clipped region takes focus', () => {
326
+ const { container } = render(<CollapsibleBlock {...props()} />);
327
+ const viewport = container.querySelector(
328
+ '[data-part="viewport"]'
329
+ ) as HTMLElement;
330
+
331
+ fireEvent.focus(viewport, { bubbles: true });
332
+
333
+ expect(screen.getByRole('button')).toHaveAttribute(
334
+ 'aria-expanded',
335
+ 'true'
336
+ );
337
+ });
338
+
339
+ it('leaves it shut when the merchant turned that off', () => {
340
+ const { container } = render(
341
+ <CollapsibleBlock
342
+ {...withProperties({ expandOnFocus: { desktop: false } })}
343
+ />
344
+ );
345
+ const viewport = container.querySelector(
346
+ '[data-part="viewport"]'
347
+ ) as HTMLElement;
348
+
349
+ fireEvent.focus(viewport, { bubbles: true });
350
+
351
+ expect(screen.getByRole('button')).toHaveAttribute(
352
+ 'aria-expanded',
353
+ 'false'
354
+ );
355
+ });
356
+
357
+ it('does not open when the trigger itself takes focus', () => {
358
+ render(<CollapsibleBlock {...props()} />);
359
+
360
+ fireEvent.focus(screen.getByRole('button'), { bubbles: true });
361
+
362
+ expect(screen.getByRole('button')).toHaveAttribute(
363
+ 'aria-expanded',
364
+ 'false'
365
+ );
366
+ });
367
+ });
368
+
369
+ describe('degrading', () => {
370
+ /**
371
+ * The server render is where this matters: the affordance is only ever
372
+ * taken AWAY by the observer, so a long article — what the block exists
373
+ * for — never shifts on hydration. (In jsdom the observer then measures a
374
+ * zero-height content box and removes it, which is the next assertion.)
375
+ */
376
+ it('starts overflowing in the server render, so a long article never shifts', () => {
377
+ expect(renderToString(<CollapsibleBlock {...props()} />)).toContain(
378
+ 'data-overflowing="true"'
379
+ );
380
+ });
381
+
382
+ it('hides the trigger once the content is measured as fitting', () => {
383
+ const { container } = render(<CollapsibleBlock {...props()} />);
384
+
385
+ expect(container.querySelector('[data-overflowing]')).toHaveAttribute(
386
+ 'data-overflowing',
387
+ 'false'
388
+ );
389
+ expect(sheet(container)).toContain(
390
+ '[data-pz-collapsible="col-1"][data-overflowing="false"] [data-part="toggle"]{display:none}'
391
+ );
392
+ });
393
+
394
+ it('unclips itself with JS off', () => {
395
+ const { container } = render(<CollapsibleBlock {...props()} />);
396
+ const noscript = container.querySelector('noscript');
397
+
398
+ expect(noscript?.innerHTML).toContain('max-height:none');
399
+ expect(noscript?.innerHTML).toContain(
400
+ '[data-part="toggle"]{display:none}'
401
+ );
402
+ });
403
+
404
+ it('stops animating for a reduced-motion visitor', () => {
405
+ const { container } = render(<CollapsibleBlock {...props()} />);
406
+
407
+ expect(sheet(container)).toContain(
408
+ '@media (prefers-reduced-motion:reduce)'
409
+ );
410
+ });
411
+
412
+ it('opens on its own when the merchant asked for it', () => {
413
+ render(
414
+ <CollapsibleBlock
415
+ {...withProperties({ defaultExpanded: { desktop: true } })}
416
+ />
417
+ );
418
+
419
+ expect(screen.getByRole('button')).toHaveAttribute(
420
+ 'aria-expanded',
421
+ 'true'
422
+ );
423
+ });
424
+ });
425
+
426
+ describe('composing', () => {
427
+ it('renders children in order and drops hidden ones on the storefront', () => {
428
+ render(
429
+ <CollapsibleBlock
430
+ {...props({
431
+ block: {
432
+ ...props().block,
433
+ blocks: [
434
+ child('second', { order: 2 }),
435
+ child('first', { order: 1 }),
436
+ child('gone', { order: 3, hidden: true })
437
+ ]
438
+ }
439
+ })}
440
+ />
441
+ );
442
+
443
+ expect(
444
+ screen.getAllByTestId('tb').map((el) => el.getAttribute('data-id'))
445
+ ).toEqual(['first', 'second']);
446
+ });
447
+
448
+ it('keeps hidden children selectable on the canvas', () => {
449
+ render(
450
+ <CollapsibleBlock
451
+ {...props({
452
+ isDesigner: true,
453
+ block: {
454
+ ...props().block,
455
+ blocks: [
456
+ child('shown'),
457
+ child('gone', { order: 1, hidden: true })
458
+ ]
459
+ }
460
+ })}
461
+ />
462
+ );
463
+
464
+ expect(screen.getAllByTestId('tb')).toHaveLength(2);
465
+ });
466
+
467
+ it('says what it is missing on the canvas and renders nothing on the storefront', () => {
468
+ const empty = { ...props().block, blocks: [] };
469
+
470
+ const { container } = render(
471
+ <CollapsibleBlock {...props({ block: empty, isDesigner: true })} />
472
+ );
473
+ expect(
474
+ container.querySelector('[data-part="empty"]')
475
+ ).toBeInTheDocument();
476
+
477
+ const live = render(<CollapsibleBlock {...props({ block: empty })} />);
478
+ expect(live.container.querySelector('[data-part="empty"]')).toBeNull();
479
+ expect(live.container.querySelector('[data-part="toggle"]')).toBeNull();
480
+ });
481
+ });
482
+
483
+ describe('every part the editor can style is emitted', () => {
484
+ it.each([
485
+ 'viewport',
486
+ 'clip',
487
+ 'content',
488
+ 'toggle',
489
+ 'toggle-label',
490
+ 'toggle-icon'
491
+ ])('draws %s', (part) => {
492
+ const { container } = render(<CollapsibleBlock {...props()} />);
493
+ expect(
494
+ container.querySelector(`[data-part="${part}"]`)
495
+ ).toBeInTheDocument();
496
+ });
497
+
498
+ it('draws fade and empty in the states that have them', () => {
499
+ const colour = render(
500
+ <CollapsibleBlock
501
+ {...withProperties({ fadeStyle: { desktop: 'color' } })}
502
+ />
503
+ );
504
+ expect(
505
+ colour.container.querySelector('[data-part="fade"]')
506
+ ).toBeInTheDocument();
507
+
508
+ const bare = render(
509
+ <CollapsibleBlock
510
+ {...props({
511
+ block: { ...props().block, blocks: [] },
512
+ isDesigner: true
513
+ })}
514
+ />
515
+ );
516
+ expect(
517
+ bare.container.querySelector('[data-part="empty"]')
518
+ ).toBeInTheDocument();
519
+ });
520
+ });
521
+ });