@akinon/pz-theme 2.0.83-rc.0 → 2.0.84-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.
Files changed (63) hide show
  1. package/CHANGELOG.md +39 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-flow-block.test.tsx +211 -0
  4. package/src/__tests__/auth-flow-bus.test.ts +290 -0
  5. package/src/__tests__/auth-form-error-block.test.tsx +326 -0
  6. package/src/__tests__/auth-form-validation.test.tsx +204 -0
  7. package/src/__tests__/auth-step-title.test.tsx +202 -0
  8. package/src/__tests__/detached-submit-state.test.tsx +216 -0
  9. package/src/__tests__/forgot-password-composed.test.tsx +313 -0
  10. package/src/__tests__/otp-form-composed.test.tsx +487 -0
  11. package/src/__tests__/password-reset-step.test.tsx +269 -0
  12. package/src/__tests__/register-form-composed.test.tsx +739 -0
  13. package/src/__tests__/registry-parity.test.ts +9 -0
  14. package/src/auth/auth-field-errors.tsx +151 -0
  15. package/src/auth/auth-flow-bus.ts +415 -0
  16. package/src/auth/form-error-context.tsx +55 -0
  17. package/src/auth/form-submit-bus.ts +93 -0
  18. package/src/auth/otp-form-context.tsx +80 -0
  19. package/src/blocks/__tests__/button-block.test.tsx +55 -0
  20. package/src/blocks/__tests__/link-block.test.tsx +48 -30
  21. package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
  22. package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
  23. package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
  24. package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
  25. package/src/blocks/__tests__/text-block.test.tsx +37 -3
  26. package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
  27. package/src/blocks/auth-consent-block.tsx +214 -0
  28. package/src/blocks/auth-error-block.tsx +82 -0
  29. package/src/blocks/auth-field-block.tsx +270 -0
  30. package/src/blocks/auth-flow-block.tsx +251 -0
  31. package/src/blocks/auth-form-block.tsx +391 -47
  32. package/src/blocks/auth-step-block.tsx +81 -0
  33. package/src/blocks/auth-step-title-block.tsx +81 -0
  34. package/src/blocks/block-renderer-registry.tsx +18 -0
  35. package/src/blocks/button-block.tsx +91 -13
  36. package/src/blocks/forgot-password-block.tsx +318 -24
  37. package/src/blocks/link-block.tsx +22 -84
  38. package/src/blocks/otp-code-input-block.tsx +180 -0
  39. package/src/blocks/otp-form-block.tsx +1092 -0
  40. package/src/blocks/otp-phase-block.tsx +71 -0
  41. package/src/blocks/register-form-block.tsx +541 -74
  42. package/src/blocks/text-block.tsx +17 -40
  43. package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
  44. package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
  45. package/src/hooks/use-block-action.ts +87 -4
  46. package/src/hooks/use-overlay.ts +60 -9
  47. package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
  48. package/src/overlays/__tests__/modal-registry.test.ts +6 -1
  49. package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
  50. package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
  51. package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
  52. package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
  53. package/src/overlays/modal-preview-host.tsx +39 -2
  54. package/src/overlays/modal-registry.ts +344 -4
  55. package/src/overlays/overlay-surface.tsx +249 -19
  56. package/src/overlays/overlay-tokens.ts +94 -0
  57. package/src/overlays/theme-modal-mount.tsx +34 -4
  58. package/src/theme-block.tsx +6 -0
  59. package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
  60. package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
  61. package/src/utils/index.ts +107 -6
  62. package/src/utils/iterator-utils.ts +39 -2
  63. package/src/utils/part-styles.ts +4 -0
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { useActiveAuthStep } from '../auth/auth-flow-bus';
6
+ import { getResponsiveValue, partAttrs } from '../utils';
7
+ import { BlockRendererProps } from './block-renderer-registry';
8
+ import { DesignerPlaceholder } from './product-block-utils';
9
+
10
+ /**
11
+ * The title of the step an auth flow is showing.
12
+ *
13
+ * A drawer that walks a shopper through several steps has ONE close button and
14
+ * ONE title row — the row is a sibling of the steps, so it renders once — but
15
+ * the title changes with the step. Authoring the title inside each step cannot
16
+ * produce that row, and repeating the close button in every step is not a
17
+ * shared button. So the step carries its own title, the flow says which step is
18
+ * up, and this block renders that title wherever the design puts it.
19
+ *
20
+ * Properties: tag, fallbackTitle.
21
+ * Parts: root.
22
+ */
23
+ const AuthStepTitleBlock = ({
24
+ block,
25
+ isDesigner = false,
26
+ currentBreakpoint = 'desktop'
27
+ }: BlockRendererProps) => {
28
+ const activeStep = useActiveAuthStep();
29
+
30
+ const prop = (key: string, fallback: string) =>
31
+ String(
32
+ getResponsiveValue(block.properties?.[key], currentBreakpoint, fallback)
33
+ ).trim();
34
+
35
+ /**
36
+ * A heading by default: this is the drawer's title, and a shopper on a screen
37
+ * reader navigating by headings is exactly who needs it to be one.
38
+ */
39
+ const tag = prop('tag', 'h2');
40
+ const fallbackTitle = prop('fallbackTitle', '');
41
+ const title = activeStep?.title || fallbackTitle;
42
+
43
+ // Outside a flow there is no step to name, and inside one a step may simply
44
+ // not carry a title yet. Either way a shopper must not see an empty heading —
45
+ // but the canvas has to say what the block is waiting for, or dropping it
46
+ // looks like nothing happened.
47
+ if (!title) {
48
+ if (!isDesigner) return null;
49
+
50
+ return (
51
+ <DesignerPlaceholder
52
+ label={
53
+ activeStep
54
+ ? 'Step title (give this step a Title)'
55
+ : 'Step title (put this inside an Auth Flow)'
56
+ }
57
+ />
58
+ );
59
+ }
60
+
61
+ const Tag = (
62
+ ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div'].includes(tag)
63
+ ? tag
64
+ : 'h2'
65
+ ) as keyof React.JSX.IntrinsicElements;
66
+
67
+ return (
68
+ <Tag
69
+ data-auth-step-title=""
70
+ // Fills its block wrapper; see the note in auth-step-block.tsx.
71
+ className="w-full"
72
+ data-auth-step-key={activeStep?.key || undefined}
73
+ {...partAttrs('root')}
74
+ style={{ margin: 0 }}
75
+ >
76
+ {title}
77
+ </Tag>
78
+ );
79
+ };
80
+
81
+ export default AuthStepTitleBlock;
@@ -74,6 +74,15 @@ import CountdownBlock from './countdown-block';
74
74
  import RegisterFormBlock from './register-form-block';
75
75
  import ForgotPasswordBlock from './forgot-password-block';
76
76
  import OauthButtonsBlock from './oauth-buttons-block';
77
+ import OtpFormBlock from './otp-form-block';
78
+ import AuthFlowBlock from './auth-flow-block';
79
+ import AuthStepBlock from './auth-step-block';
80
+ import AuthStepTitleBlock from './auth-step-title-block';
81
+ import AuthErrorBlock from './auth-error-block';
82
+ import AuthConsentBlock from './auth-consent-block';
83
+ import AuthFieldBlock from './auth-field-block';
84
+ import OtpPhaseBlock from './otp-phase-block';
85
+ import OtpCodeInputBlock from './otp-code-input-block';
77
86
  import StoreLocatorBlock from './store-locator-block';
78
87
  import FaqAccordionBlock from './faq-accordion-block';
79
88
  import ShareBlock from './share-block';
@@ -210,6 +219,15 @@ blockRendererRegistry.register('countdown', CountdownBlock);
210
219
  blockRendererRegistry.register('register-form', RegisterFormBlock);
211
220
  blockRendererRegistry.register('forgot-password', ForgotPasswordBlock);
212
221
  blockRendererRegistry.register('oauth-buttons', OauthButtonsBlock);
222
+ blockRendererRegistry.register('otp-form', OtpFormBlock);
223
+ blockRendererRegistry.register('auth-flow', AuthFlowBlock);
224
+ blockRendererRegistry.register('auth-step', AuthStepBlock);
225
+ blockRendererRegistry.register('auth-step-title', AuthStepTitleBlock);
226
+ blockRendererRegistry.register('auth-error', AuthErrorBlock);
227
+ blockRendererRegistry.register('auth-consent', AuthConsentBlock);
228
+ blockRendererRegistry.register('auth-field', AuthFieldBlock);
229
+ blockRendererRegistry.register('otp-phase', OtpPhaseBlock);
230
+ blockRendererRegistry.register('otp-code-input', OtpCodeInputBlock);
213
231
  blockRendererRegistry.register('store-locator', StoreLocatorBlock);
214
232
  blockRendererRegistry.register('faq-accordion', FaqAccordionBlock);
215
233
  blockRendererRegistry.register('share', ShareBlock);
@@ -6,10 +6,13 @@ import { useEmailSubscriptionMutation } from '@akinon/next/data/client/misc';
6
6
  import { Modal } from '@akinon/next/components/modal';
7
7
  import {
8
8
  colorToRgba,
9
+ getInnerRootInlineStyles,
9
10
  getResponsiveValue,
11
+ partAttrs,
10
12
  resolveThemeCssVariables
11
13
  } from '../utils';
12
14
  import { useBlockAction } from '../hooks/use-block-action';
15
+ import { useFormSubmitState } from '../auth/form-submit-bus';
13
16
  import { BlockRendererProps } from './block-renderer-registry';
14
17
  import { useThemeSettingsContext } from '../theme-settings-context';
15
18
 
@@ -20,6 +23,7 @@ const ButtonBlock = ({
20
23
  }: BlockRendererProps) => {
21
24
  const themeSettings = useThemeSettingsContext();
22
25
  const action = useBlockAction(block, { currentBreakpoint, isDesigner });
26
+
23
27
  const { locale } = useLocalization();
24
28
  const [emailSubscription] = useEmailSubscriptionMutation();
25
29
  const [feedbackModal, setFeedbackModal] = useState({
@@ -123,6 +127,23 @@ const ButtonBlock = ({
123
127
  )
124
128
  : 'button';
125
129
 
130
+ /**
131
+ * Detached submit. Names the auth form (its "Form HTML ID") this button
132
+ * submits through the native `form` attribute — which is what lets a CTA
133
+ * live in a pinned bottom bar while the fields stay in the flow above.
134
+ * Forces the <button> branch: an anchor cannot submit anything.
135
+ */
136
+ const submitFormId = String(
137
+ getResponsiveValue(block.properties?.submitFormId, currentBreakpoint, '')
138
+ ).trim();
139
+ /**
140
+ * A detached CTA is a real submit button and has to behave like one while the
141
+ * form it drives is working: staying bright and clickable through a request
142
+ * is what makes a shopper press it twice. An empty id costs no subscription,
143
+ * so this is safe to read unconditionally — as a hook must be.
144
+ */
145
+ const isSubmittedFormBusy = useFormSubmitState(submitFormId);
146
+
126
147
  const iconUrl = block.properties?.icon
127
148
  ? String(getResponsiveValue(block.properties.icon, currentBreakpoint, ''))
128
149
  : '';
@@ -326,13 +347,38 @@ const ButtonBlock = ({
326
347
 
327
348
  const buttonStyles = getButtonStyles();
328
349
 
350
+ /**
351
+ * The generated stylesheet writes this block's root styles against THIS
352
+ * element (`data-part="root"` — see INNER_ROOT_BLOCK_TYPES), so whatever the
353
+ * sheet can express must not be inlined as well. Inlining it was applying
354
+ * every padding twice, once on the designer wrapper and once here, and an
355
+ * inline declaration also outranks the media query and the `:hover` the same
356
+ * sheet emits for this element. What stays inline is only what a stylesheet
357
+ * cannot say: a theme token, and a colour with text-opacity folded in.
358
+ */
359
+ const sheetCannotSay = getInnerRootInlineStyles(
360
+ block.styles as Record<string, unknown> | undefined,
361
+ themeSettings,
362
+ currentBreakpoint,
363
+ block.rowStyleKeys
364
+ );
365
+ const inlineFromStyles = Object.fromEntries(
366
+ Object.entries(buttonStyles).filter(([key]) => key in sheetCannotSay)
367
+ ) as React.CSSProperties;
368
+
329
369
  const finalStyles: React.CSSProperties = {
330
370
  textDecoration: 'none',
331
- display: showIcon ? 'inline-flex' : buttonStyles.display || 'inline-block',
371
+ // Only as a fallback: an authored display now comes from the sheet, and an
372
+ // inline one would beat it.
373
+ display: showIcon
374
+ ? 'inline-flex'
375
+ : 'display' in buttonStyles
376
+ ? undefined
377
+ : 'inline-block',
332
378
  alignItems: showIcon ? 'center' : undefined,
333
379
  justifyContent: showIcon ? 'center' : undefined,
334
380
  gap: showIcon ? iconGap : undefined,
335
- ...buttonStyles
381
+ ...inlineFromStyles
336
382
  };
337
383
 
338
384
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -388,6 +434,10 @@ const ButtonBlock = ({
388
434
  return;
389
435
  }
390
436
 
437
+ // Detached submit: the browser fires the named form's submit event
438
+ // natively; nothing here may preventDefault it or reroute the click.
439
+ if (submitFormId) return;
440
+
391
441
  /**
392
442
  * An authored action wins over the href.
393
443
  *
@@ -402,10 +452,6 @@ const ButtonBlock = ({
402
452
  return;
403
453
  }
404
454
 
405
- if (tag === 'button' && buttonType === 'submit') {
406
- e.preventDefault();
407
- }
408
-
409
455
  if (tag !== 'button' || buttonType !== 'submit') {
410
456
  return;
411
457
  }
@@ -415,12 +461,23 @@ const ButtonBlock = ({
415
461
  '[data-newsletter-signup="true"]'
416
462
  ) as HTMLElement | null;
417
463
 
464
+ /**
465
+ * Outside a newsletter section the click is left alone, so a submit
466
+ * button inside a real <form> — an auth form composed of blocks — submits
467
+ * it natively. The blanket `preventDefault` that used to run here made
468
+ * every composed submit button inert, and nothing said why.
469
+ */
418
470
  if (!newsletterContainer) {
419
471
  return;
420
472
  }
421
473
 
474
+ // The newsletter section posts through fetch below, so the native submit
475
+ // must not also navigate.
476
+ e.preventDefault();
477
+
422
478
  const rawEndpoint =
423
- newsletterContainer.getAttribute('data-newsletter-endpoint')?.trim() || '';
479
+ newsletterContainer.getAttribute('data-newsletter-endpoint')?.trim() ||
480
+ '';
424
481
  if (!rawEndpoint) {
425
482
  return;
426
483
  }
@@ -431,8 +488,8 @@ const ButtonBlock = ({
431
488
  rawEndpoint.startsWith('https://')
432
489
  ? rawEndpoint
433
490
  : rawEndpoint.startsWith('/')
434
- ? `/api/client${rawEndpoint}`
435
- : `/api/client/${rawEndpoint}`;
491
+ ? `/api/client${rawEndpoint}`
492
+ : `/api/client/${rawEndpoint}`;
436
493
 
437
494
  const isEmailSubscriptionEndpoint =
438
495
  rawEndpoint.includes('email-subscription') ||
@@ -559,7 +616,19 @@ const ButtonBlock = ({
559
616
  </>
560
617
  );
561
618
 
562
- const feedbackModalElement = (
619
+ /**
620
+ * Mounted only while it has something to say.
621
+ *
622
+ * The commerce `Modal` writes `document.body.style.overflow` from an effect
623
+ * that runs on MOUNT, open or closed, and a closed one draws nothing — so a
624
+ * composition full of buttons was a composition full of components each
625
+ * handing the page its scrolling back. A button appearing inside an open
626
+ * overlay (a step of an auth flow arriving in a drawer) therefore unfroze the
627
+ * page behind it. `useOverlay`'s lock defends itself against writers like this
628
+ * now, but a closed dialog nobody can see has no business touching the page
629
+ * at all.
630
+ */
631
+ const feedbackModalElement = feedbackModal.open ? (
563
632
  <Modal
564
633
  portalId={`newsletter-feedback-modal-${block.id}`}
565
634
  open={feedbackModal.open}
@@ -577,13 +646,21 @@ const ButtonBlock = ({
577
646
  {feedbackModal.message}
578
647
  </div>
579
648
  </Modal>
580
- );
649
+ ) : null;
581
650
 
582
- if (tag === 'button') {
651
+ if (tag === 'button' || submitFormId) {
583
652
  return (
584
653
  <>
585
654
  <button
586
- type={buttonType}
655
+ type={submitFormId ? 'submit' : buttonType}
656
+ form={submitFormId || undefined}
657
+ disabled={isSubmittedFormBusy || undefined}
658
+ aria-busy={isSubmittedFormBusy || undefined}
659
+ // `data-part="root"`: the generated stylesheet routes this block's
660
+ // root styles HERE instead of onto the designer wrapper, so padding
661
+ // and border land once, on the element the shopper actually clicks —
662
+ // not once on the wrapper and again inline (which doubled them).
663
+ {...partAttrs('root')}
587
664
  style={finalStyles}
588
665
  onClick={handleClick}
589
666
  {...action.ariaProps}
@@ -601,6 +678,7 @@ const ButtonBlock = ({
601
678
  href={url}
602
679
  target={target}
603
680
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
681
+ {...partAttrs('root')}
604
682
  style={finalStyles}
605
683
  onClick={handleClick}
606
684
  {...action.ariaProps}