@akinon/pz-theme 2.0.58-rc.0 → 2.0.59-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.
package/CHANGELOG.md CHANGED
@@ -1,36 +1,24 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.58-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0cf9ea239]
8
- - Updated dependencies [324f97d55]
9
- - Updated dependencies [51ea06888]
10
- - Updated dependencies
11
- - Updated dependencies [2fea4143]
12
- - Updated dependencies [b55acb768]
13
- - Updated dependencies [760258c1c]
14
- - Updated dependencies [143be2b9d]
15
- - Updated dependencies [7889b08fe]
16
- - Updated dependencies [9f8cd3bc5]
17
- - Updated dependencies [bfafa3f49]
18
- - Updated dependencies [57d7eb305]
19
- - Updated dependencies [d99a6a7d5]
20
- - Updated dependencies [9db81a714]
21
- - Updated dependencies [591e345e1]
22
- - Updated dependencies [4de5303c5]
23
- - Updated dependencies [95b139dc1]
24
- - Updated dependencies [1d00f2d06]
25
- - Updated dependencies [4ac7b2a1e]
26
- - Updated dependencies [e9598c71]
27
- - Updated dependencies [4998a9631]
28
- - Updated dependencies [804d2bd6]
29
- - Updated dependencies [3909d3224]
30
- - Updated dependencies [6a3d8a63]
31
- - Updated dependencies [e18836b20]
32
- - @akinon/next@2.0.58-rc.0
33
- - @akinon/theme-contract@2.0.58-rc.0
3
+ ## 2.0.59-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd75]
8
+ - @akinon/next@2.0.59-beta.0
9
+ - @akinon/theme-contract@2.0.59-beta.0
10
+
11
+ ## 2.0.58
12
+
13
+ ### Patch Changes
14
+
15
+ - e11783b: ZERO-4790: Draw the modal being composed in the theme editor canvas, from the editor's live entry rather than the saved one — so a modal that has never been saved is visible, and chrome edits show up without a save and a reload
16
+ - b992ca5: ZERO-4790: Read the theme editor's modal registry in the preview, so a trigger pointed at a modal that has not been saved yet actually opens it
17
+ - 08f060d: ZERO-4790: Add composable modals — a global modal host, a block action vocabulary for triggering them, and auto-open with frequency capping
18
+ - 7eb9396: ZERO-4790: Add the modal addressing foundation — modal slug family, overlay bus and theme_modals registry
19
+ - Updated dependencies [08f060d]
20
+ - @akinon/theme-contract@2.0.58
21
+ - @akinon/next@2.0.58
34
22
 
35
23
  ## 2.0.57
36
24
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.58-rc.0",
4
+ "version": "2.0.59-beta.0",
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.58-rc.0",
11
+ "@akinon/next": "2.0.59-beta.0",
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.58-rc.0",
16
+ "@akinon/theme-contract": "2.0.59-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.58-rc.0",
23
+ "@akinon/next": "2.0.59-beta.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -9,6 +9,7 @@ import {
9
9
  getResponsiveValue,
10
10
  resolveThemeCssVariables
11
11
  } from '../utils';
12
+ import { useBlockAction } from '../hooks/use-block-action';
12
13
  import { BlockRendererProps } from './block-renderer-registry';
13
14
  import { useThemeSettingsContext } from '../theme-settings-context';
14
15
 
@@ -18,6 +19,7 @@ const ButtonBlock = ({
18
19
  isDesigner = false
19
20
  }: BlockRendererProps) => {
20
21
  const themeSettings = useThemeSettingsContext();
22
+ const action = useBlockAction(block, { currentBreakpoint, isDesigner });
21
23
  const { locale } = useLocalization();
22
24
  const [emailSubscription] = useEmailSubscriptionMutation();
23
25
  const [feedbackModal, setFeedbackModal] = useState({
@@ -386,6 +388,20 @@ const ButtonBlock = ({
386
388
  return;
387
389
  }
388
390
 
391
+ /**
392
+ * An authored action wins over the href.
393
+ *
394
+ * A button that both opens a modal and navigates would race itself, so the
395
+ * action calls `preventDefault` and this returns rather than falling
396
+ * through into the newsletter submit path below. With no action authored
397
+ * the hook is inert and everything published before this existed behaves
398
+ * exactly as it did.
399
+ */
400
+ if (action.isActive) {
401
+ action.onClick?.(e);
402
+ return;
403
+ }
404
+
389
405
  if (tag === 'button' && buttonType === 'submit') {
390
406
  e.preventDefault();
391
407
  }
@@ -566,7 +582,12 @@ const ButtonBlock = ({
566
582
  if (tag === 'button') {
567
583
  return (
568
584
  <>
569
- <button type={buttonType} style={finalStyles} onClick={handleClick}>
585
+ <button
586
+ type={buttonType}
587
+ style={finalStyles}
588
+ onClick={handleClick}
589
+ {...action.ariaProps}
590
+ >
570
591
  {content}
571
592
  </button>
572
593
  {feedbackModalElement}
@@ -582,6 +603,7 @@ const ButtonBlock = ({
582
603
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
583
604
  style={finalStyles}
584
605
  onClick={handleClick}
606
+ {...action.ariaProps}
585
607
  >
586
608
  {content}
587
609
  </a>
@@ -6,6 +6,7 @@ import { useLocalization } from '@akinon/next/hooks';
6
6
  import { LocaleUrlStrategy } from '@akinon/next/localization';
7
7
  import { urlLocaleMatcherRegex, urlSchemes } from '@akinon/next/utils';
8
8
  import { getResponsiveValue } from '../utils';
9
+ import { useBlockAction } from '../hooks/use-block-action';
9
10
  import { BlockRendererProps } from './block-renderer-registry';
10
11
  import ThemeBlock from '../theme-block';
11
12
 
@@ -20,6 +21,22 @@ const LinkBlock = ({
20
21
  isDesigner = false
21
22
  }: BlockRendererProps) => {
22
23
  const { locale, defaultLocaleValue, localeUrlStrategy } = useLocalization();
24
+ const action = useBlockAction(block, { currentBreakpoint, isDesigner });
25
+
26
+ /**
27
+ * An authored action replaces the navigation rather than joining it: a link
28
+ * that both opens a modal and follows its href would race itself. With no
29
+ * action authored this is exactly the designer-only preventDefault the link
30
+ * always had.
31
+ */
32
+ const handleClick = (event: React.MouseEvent<HTMLElement>) => {
33
+ if (isDesigner) {
34
+ event.preventDefault();
35
+ return;
36
+ }
37
+
38
+ if (action.isActive) action.onClick?.(event);
39
+ };
23
40
 
24
41
  const href = block.properties?.href
25
42
  ? String(getResponsiveValue(block.properties.href, currentBreakpoint, '#'))
@@ -136,11 +153,8 @@ const LinkBlock = ({
136
153
  target={isDesigner ? undefined : target}
137
154
  rel={isDesigner ? undefined : rel}
138
155
  style={inlineStyles}
139
- onClick={(e) => {
140
- if (isDesigner) {
141
- e.preventDefault();
142
- }
143
- }}
156
+ onClick={handleClick}
157
+ {...action.ariaProps}
144
158
  >
145
159
  <div style={{ padding: '20px', color: '#6b7280' }}>
146
160
  Empty link block
@@ -155,11 +169,8 @@ const LinkBlock = ({
155
169
  target={isDesigner ? undefined : target}
156
170
  rel={isDesigner ? undefined : rel}
157
171
  style={inlineStyles}
158
- onClick={(e) => {
159
- if (isDesigner) {
160
- e.preventDefault();
161
- }
162
- }}
172
+ onClick={handleClick}
173
+ {...action.ariaProps}
163
174
  >
164
175
  {block.blocks
165
176
  .filter((childBlock) => (isDesigner ? true : !childBlock.hidden))
@@ -8,24 +8,21 @@ import {
8
8
  resolveOption
9
9
  } from '../utils';
10
10
  import { useOverlay } from '../hooks/use-overlay';
11
+ import {
12
+ CentredModalSurface,
13
+ LayeredOverlaySurface,
14
+ OverlayCloseButton,
15
+ OverlayPresentation,
16
+ OVERLAY_PRESENTATIONS,
17
+ clampOverlayNumber as clampNumber
18
+ } from '../overlays/overlay-surface';
11
19
  import ThemeBlock from '../theme-block';
12
20
  import { BlockRendererProps } from './block-renderer-registry';
13
21
  import { DesignerPlaceholder, readToggle } from './product-block-utils';
14
22
 
15
- const clampNumber = (
16
- value: unknown,
17
- fallback: number,
18
- min: number,
19
- max: number
20
- ) => {
21
- const numeric = Math.round(Number(value));
22
- if (!Number.isFinite(numeric)) return fallback;
23
- return Math.min(max, Math.max(min, numeric));
24
- };
25
-
26
- type Presentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
23
+ type Presentation = OverlayPresentation;
27
24
 
28
- const PRESENTATIONS: Presentation[] = ['modal', 'drawer', 'sheet', 'anchored'];
25
+ const PRESENTATIONS = OVERLAY_PRESENTATIONS;
29
26
 
30
27
  /**
31
28
  * Generic overlay surface: a trigger that opens a panel the author fills.
@@ -303,245 +300,78 @@ const PanelBlock = ({
303
300
  }
304
301
 
305
302
  const closeButton = showClose ? (
306
- <button
307
- type="button"
308
- data-size-guide-close=""
309
- {...partAttrs('close')}
310
- aria-label={closeLabel}
303
+ <OverlayCloseButton
304
+ label={closeLabel}
311
305
  onClick={dismiss}
312
- style={{
313
- position: 'absolute',
314
- top: '12px',
315
- right: '12px',
316
- border: 'none',
317
- background: 'transparent',
318
- color: 'inherit',
319
- cursor: 'pointer',
320
- padding: 0
321
- }}
322
- >
323
- <svg
324
- width="18"
325
- height="18"
326
- viewBox="0 0 24 24"
327
- fill="none"
328
- stroke="currentColor"
329
- strokeWidth="1.6"
330
- strokeLinecap="round"
331
- >
332
- <path d="M6 6l12 12M18 6L6 18" />
333
- </svg>
334
- </button>
306
+ markerAttrs={{ 'data-size-guide-close': '' }}
307
+ />
335
308
  ) : null;
336
309
 
337
310
  /**
338
- * The centred modal keeps the shape it published with: ONE node that is both
339
- * the fixed root and the backdrop, carrying `data-part="overlay"`, with the
340
- * panel centred inside it by flex. Refactoring it into the root + backdrop
341
- * sibling pair the other presentations use would move markup live
342
- * storefronts already style, for no gain — so the new modes get their own
343
- * branch instead. `panel-legacy-golden.json` is what holds this to it.
311
+ * Both branches come from `overlays/overlay-surface.tsx`, so this block and
312
+ * the composed modal host draw the same surface. The legacy
313
+ * `data-size-guide-*` / `data-panel-*` markers stay this block's own: live
314
+ * storefronts style them and `panel-legacy-golden.json` pins them byte for
315
+ * byte, which is also what proves the extraction moved nothing.
344
316
  */
317
+ const header = (
318
+ <>
319
+ {closeButton}
320
+ {titleNode}
321
+ </>
322
+ );
323
+
345
324
  const modalOverlay = (
346
- <div
347
- data-size-guide-overlay=""
348
- {...partAttrs('overlay', { open: true })}
349
- onClick={() => {
350
- if (closeOnOverlay) dismiss();
351
- }}
352
- style={{
353
- position: 'fixed',
354
- inset: 0,
355
- zIndex: 1000,
356
- display: 'flex',
357
- alignItems: 'center',
358
- justifyContent: 'center',
359
- padding: '16px',
360
- background: 'rgba(0, 0, 0, 0.5)'
325
+ <CentredModalSurface
326
+ overlayProps={overlay.overlayProps}
327
+ ariaLabel={modalTitle || triggerLabel}
328
+ closeOnOverlay={closeOnOverlay}
329
+ onDismiss={dismiss}
330
+ width={modalWidth}
331
+ anchorAlign={anchorAlign}
332
+ anchorOffset={anchorOffset}
333
+ header={header}
334
+ markers={{
335
+ root: { 'data-size-guide-overlay': '' },
336
+ panel: {
337
+ 'data-size-guide-content': '',
338
+ 'data-size-guide-mode': 'modal'
339
+ }
361
340
  }}
362
341
  >
363
- <div
364
- data-size-guide-content=""
365
- data-size-guide-mode="modal"
366
- {...partAttrs('modal', { open: true })}
367
- role="dialog"
368
- // Carries the panel ref and the `aria-modal` claim, which is now
369
- // made only while this really is the dialog on top. Spread after
370
- // `role` so the attribute order stays exactly where it was.
371
- //
372
- // The primitive asks a panel to carry `tabIndex={-1}` as its focus
373
- // target of last resort; this one cannot be given it without
374
- // moving markup a published storefront already renders. The gap
375
- // only shows in a composition with the close button off AND no
376
- // focusable child, where focus simply stays put — the same place
377
- // it stayed before the migration.
378
- {...overlay.overlayProps}
379
- aria-label={modalTitle || triggerLabel}
380
- // The backdrop closes the modal; clicks on the panel itself must
381
- // not bubble up into that handler.
382
- onClick={(event) => event.stopPropagation()}
383
- style={{
384
- position: 'relative',
385
- width: '100%',
386
- maxWidth: `${modalWidth}px`,
387
- maxHeight: '80vh',
388
- overflow: 'auto',
389
- padding: '24px',
390
- borderRadius: '4px',
391
- background: '#ffffff',
392
- color: '#111827',
393
- textAlign: 'left'
394
- }}
395
- >
396
- {closeButton}
397
- {titleNode}
398
- {renderChildren()}
399
- </div>
400
- </div>
342
+ {renderChildren()}
343
+ </CentredModalSurface>
401
344
  );
402
345
 
403
- const backdrop = showBackdrop ? (
404
- <div
405
- data-panel-backdrop=""
406
- {...partAttrs('overlay', { open: true })}
407
- onClick={() => {
408
- if (closeOnOverlay) dismiss();
409
- }}
410
- style={{
411
- position: 'absolute',
412
- inset: 0,
413
- background: 'rgba(0, 0, 0, 0.5)'
414
- }}
415
- />
416
- ) : null;
417
-
418
- const panelSurface: React.CSSProperties = {
419
- overflow: 'auto',
420
- padding: '24px',
421
- background: '#ffffff',
422
- color: '#111827',
423
- textAlign: 'left',
424
- outline: 'none'
425
- };
426
-
427
- const anchorPlacement: React.CSSProperties =
428
- anchorAlign === 'center'
429
- ? { left: '50%', transform: 'translateX(-50%)' }
430
- : anchorAlign === 'end'
431
- ? { right: 0 }
432
- : { left: 0 };
433
-
434
- const panelGeometry: React.CSSProperties =
435
- presentation === 'drawer'
436
- ? {
437
- position: 'absolute',
438
- top: 0,
439
- bottom: 0,
440
- [panelSide === 'left' ? 'left' : 'right']: 0,
441
- width: `min(${modalWidth}px, 92vw)`,
442
- boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
443
- }
444
- : presentation === 'sheet'
445
- ? {
446
- position: 'absolute',
447
- left: 0,
448
- right: 0,
449
- bottom: 0,
450
- width: '100%',
451
- maxHeight: `${sheetHeight}vh`,
452
- boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
453
- }
454
- : {
455
- // Anchored hangs off the block root rather than a fixed layer, so it
456
- // travels with the trigger the way a dropdown must.
457
- position: 'absolute',
458
- top: `calc(100% + ${anchorOffset}px)`,
459
- zIndex: 1000,
460
- width: `${modalWidth}px`,
461
- maxWidth: '92vw',
462
- maxHeight: '80vh',
463
- borderRadius: '4px',
464
- boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
465
- ...anchorPlacement
466
- };
467
-
468
- const panel = (
469
- <div
470
- data-size-guide-content=""
471
- data-panel-presentation={presentation}
472
- {...partAttrs('modal', { open: true })}
473
- role="dialog"
474
- // Spread after `role` for the same attribute-order reason the modal
475
- // branch documents.
476
- {...overlay.overlayProps}
477
- aria-label={modalTitle || triggerLabel}
478
- // Focus target of last resort, for a panel whose every control the
479
- // designer turned off. The centred modal deliberately does without it:
480
- // adding it there would move published markup.
481
- tabIndex={-1}
482
- onClick={(event) => event.stopPropagation()}
483
- style={{
484
- ...panelSurface,
485
- ...panelGeometry,
486
- // Turn events back on for the panel itself when the see-through root
487
- // below is letting them pass.
488
- pointerEvents: showBackdrop ? undefined : 'auto'
346
+ const layeredOverlay = (
347
+ <LayeredOverlaySurface
348
+ presentation={presentation}
349
+ overlayProps={overlay.overlayProps}
350
+ ariaLabel={modalTitle || triggerLabel}
351
+ showBackdrop={showBackdrop}
352
+ closeOnOverlay={closeOnOverlay}
353
+ onDismiss={dismiss}
354
+ width={modalWidth}
355
+ panelSide={panelSide}
356
+ sheetHeight={sheetHeight}
357
+ anchorAlign={anchorAlign}
358
+ anchorOffset={anchorOffset}
359
+ header={header}
360
+ markers={{
361
+ root: { 'data-panel-root': '' },
362
+ backdrop: { 'data-panel-backdrop': '' },
363
+ backdropRoot: { 'data-panel-backdrop-root': '' },
364
+ panel: {
365
+ 'data-size-guide-content': '',
366
+ 'data-panel-presentation': presentation
367
+ },
368
+ handle: { 'data-panel-handle': '' }
489
369
  }}
490
370
  >
491
- {presentation === 'sheet' && (
492
- <div
493
- data-panel-handle=""
494
- {...partAttrs('handle')}
495
- style={{
496
- width: '40px',
497
- height: '4px',
498
- margin: '0 auto 12px',
499
- borderRadius: '999px',
500
- background: '#d1d5db'
501
- }}
502
- />
503
- )}
504
- {closeButton}
505
- {titleNode}
506
371
  {renderChildren()}
507
- </div>
372
+ </LayeredOverlaySurface>
508
373
  );
509
374
 
510
- /**
511
- * Drawer and sheet live on a fixed layer over the page; anchored lives in
512
- * the block's own flow so it travels with the trigger. With the backdrop off
513
- * that fixed layer would still be an invisible sheet of glass over the whole
514
- * viewport, so events pass through it and the panel turns them back on for
515
- * itself.
516
- */
517
- const layeredOverlay =
518
- presentation === 'anchored' ? (
519
- <>
520
- {showBackdrop && (
521
- <div
522
- data-panel-backdrop-root=""
523
- style={{ position: 'fixed', inset: 0, zIndex: 999 }}
524
- >
525
- {backdrop}
526
- </div>
527
- )}
528
- {panel}
529
- </>
530
- ) : (
531
- <div
532
- data-panel-root=""
533
- style={{
534
- position: 'fixed',
535
- inset: 0,
536
- zIndex: 1000,
537
- pointerEvents: showBackdrop ? undefined : 'none'
538
- }}
539
- >
540
- {backdrop}
541
- {panel}
542
- </div>
543
- );
544
-
545
375
  return (
546
376
  <div
547
377
  data-size-guide=""
@@ -24,7 +24,8 @@ describe('readThemeConfig', () => {
24
24
  })
25
25
  ).toEqual({
26
26
  settings: { mainMenuSticky: true },
27
- dataSources: [{ id: 'ds-1' }]
27
+ dataSources: [{ id: 'ds-1' }],
28
+ modals: []
28
29
  });
29
30
  });
30
31
 
@@ -38,7 +39,8 @@ describe('readThemeConfig', () => {
38
39
  })
39
40
  ).toEqual({
40
41
  settings: { mainMenuSticky: true },
41
- dataSources: [{ id: 'ds-1' }]
42
+ dataSources: [{ id: 'ds-1' }],
43
+ modals: []
42
44
  });
43
45
  });
44
46
 
@@ -52,7 +54,45 @@ describe('readThemeConfig', () => {
52
54
  })
53
55
  ).toEqual({
54
56
  settings: { mainMenuSticky: false },
55
- dataSources: [{ id: 'ds-1' }]
57
+ dataSources: [{ id: 'ds-1' }],
58
+ modals: []
59
+ });
60
+ });
61
+
62
+ it('reads the modal registry alongside the rest', () => {
63
+ expect(
64
+ readThemeConfig({
65
+ attributes: {
66
+ theme_modals: JSON.stringify([
67
+ { id: 'newsletter', name: 'Newsletter', presentation: 'drawer' }
68
+ ])
69
+ }
70
+ })
71
+ ).toEqual({
72
+ settings: null,
73
+ dataSources: [],
74
+ modals: [
75
+ {
76
+ id: 'newsletter',
77
+ name: 'Newsletter',
78
+ slug: 'modal-newsletter',
79
+ presentation: 'drawer',
80
+ chrome: {
81
+ ariaLabel: 'Newsletter',
82
+ showClose: true,
83
+ closeLabel: 'Close',
84
+ showBackdrop: true,
85
+ closeOnBackdrop: true,
86
+ closeOnEscape: true,
87
+ width: 640,
88
+ panelSide: 'right',
89
+ sheetHeight: 60,
90
+ lockScroll: true
91
+ },
92
+ autoTrigger: undefined,
93
+ visibilityRules: undefined
94
+ }
95
+ ]
56
96
  });
57
97
  });
58
98
 
@@ -66,16 +106,21 @@ describe('readThemeConfig', () => {
66
106
  theme_dataSources: JSON.stringify([{ id: 'ds-1' }])
67
107
  }
68
108
  })
69
- ).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }] });
109
+ ).toEqual({ settings: null, dataSources: [{ id: 'ds-1' }], modals: [] });
70
110
 
71
111
  error.mockRestore();
72
112
  });
73
113
 
74
114
  it('answers empty for a brand with no theme at all', () => {
75
- expect(readThemeConfig(null)).toEqual({ settings: null, dataSources: [] });
115
+ expect(readThemeConfig(null)).toEqual({
116
+ settings: null,
117
+ dataSources: [],
118
+ modals: []
119
+ });
76
120
  expect(readThemeConfig({ attributes: {} })).toEqual({
77
121
  settings: null,
78
- dataSources: []
122
+ dataSources: [],
123
+ modals: []
79
124
  });
80
125
  });
81
126
  });
@@ -90,7 +135,8 @@ describe('getThemeConfig', () => {
90
135
 
91
136
  await expect(getThemeConfig()).resolves.toEqual({
92
137
  settings: null,
93
- dataSources: []
138
+ dataSources: [],
139
+ modals: []
94
140
  });
95
141
  });
96
142
  });