@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.
- package/CHANGELOG.md +39 -33
- package/package.json +4 -4
- package/src/__tests__/auth-flow-block.test.tsx +211 -0
- package/src/__tests__/auth-flow-bus.test.ts +290 -0
- package/src/__tests__/auth-form-error-block.test.tsx +326 -0
- package/src/__tests__/auth-form-validation.test.tsx +204 -0
- package/src/__tests__/auth-step-title.test.tsx +202 -0
- package/src/__tests__/detached-submit-state.test.tsx +216 -0
- package/src/__tests__/forgot-password-composed.test.tsx +313 -0
- package/src/__tests__/otp-form-composed.test.tsx +487 -0
- package/src/__tests__/password-reset-step.test.tsx +269 -0
- package/src/__tests__/register-form-composed.test.tsx +739 -0
- package/src/__tests__/registry-parity.test.ts +9 -0
- package/src/auth/auth-field-errors.tsx +151 -0
- package/src/auth/auth-flow-bus.ts +415 -0
- package/src/auth/form-error-context.tsx +55 -0
- package/src/auth/form-submit-bus.ts +93 -0
- package/src/auth/otp-form-context.tsx +80 -0
- package/src/blocks/__tests__/button-block.test.tsx +55 -0
- package/src/blocks/__tests__/link-block.test.tsx +48 -30
- package/src/blocks/__tests__/overlay-sweep.test.tsx +31 -8
- package/src/blocks/__tests__/product-gallery-block.test.tsx +12 -4
- package/src/blocks/__tests__/product-recommendations-block.test.tsx +9 -2
- package/src/blocks/__tests__/store-locator-block.test.tsx +35 -8
- package/src/blocks/__tests__/text-block.test.tsx +37 -3
- package/src/blocks/__tests__/variant-selector-block.test.tsx +16 -4
- package/src/blocks/auth-consent-block.tsx +214 -0
- package/src/blocks/auth-error-block.tsx +82 -0
- package/src/blocks/auth-field-block.tsx +270 -0
- package/src/blocks/auth-flow-block.tsx +251 -0
- package/src/blocks/auth-form-block.tsx +391 -47
- package/src/blocks/auth-step-block.tsx +81 -0
- package/src/blocks/auth-step-title-block.tsx +81 -0
- package/src/blocks/block-renderer-registry.tsx +18 -0
- package/src/blocks/button-block.tsx +91 -13
- package/src/blocks/forgot-password-block.tsx +318 -24
- package/src/blocks/link-block.tsx +22 -84
- package/src/blocks/otp-code-input-block.tsx +180 -0
- package/src/blocks/otp-form-block.tsx +1092 -0
- package/src/blocks/otp-phase-block.tsx +71 -0
- package/src/blocks/register-form-block.tsx +541 -74
- package/src/blocks/text-block.tsx +17 -40
- package/src/chrome/__tests__/theme-config-source.test.ts +4 -1
- package/src/hooks/__tests__/use-overlay.test.tsx +97 -5
- package/src/hooks/use-block-action.ts +87 -4
- package/src/hooks/use-overlay.ts +60 -9
- package/src/overlays/__tests__/modal-chrome-padding.test.tsx +119 -0
- package/src/overlays/__tests__/modal-registry.test.ts +6 -1
- package/src/overlays/__tests__/panel-stretch.test.tsx +60 -0
- package/src/overlays/__tests__/responsive-chrome.test.tsx +446 -0
- package/src/overlays/__tests__/responsive-presentation.test.tsx +192 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +12 -1
- package/src/overlays/modal-preview-host.tsx +39 -2
- package/src/overlays/modal-registry.ts +344 -4
- package/src/overlays/overlay-surface.tsx +249 -19
- package/src/overlays/overlay-tokens.ts +94 -0
- package/src/overlays/theme-modal-mount.tsx +34 -4
- package/src/theme-block.tsx +6 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +139 -0
- package/src/utils/__tests__/iterator-bindings.test.tsx +9 -3
- package/src/utils/index.ts +107 -6
- package/src/utils/iterator-utils.ts +39 -2
- 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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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}
|