@akinon/pz-theme 2.0.95-rc.0 → 2.0.95
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 +8 -33
- package/package.json +4 -4
- package/src/__tests__/auth-captcha.test.tsx +261 -0
- package/src/__tests__/auth-commerce-events.test.tsx +5 -1
- package/src/__tests__/auth-destination.test.tsx +348 -0
- package/src/__tests__/auth-error-reading.test.ts +232 -0
- package/src/__tests__/auth-flow-scope.test.tsx +162 -0
- package/src/__tests__/auth-form-error-block.test.tsx +2 -0
- package/src/__tests__/auth-form-success-block.test.tsx +429 -0
- package/src/__tests__/auth-form-validation.test.tsx +2 -0
- package/src/__tests__/auth-slot-coverage.test.ts +9 -1
- package/src/__tests__/auth-step-title.test.tsx +2 -0
- package/src/__tests__/block-action-audience.test.ts +154 -0
- package/src/__tests__/detached-submit-state.test.tsx +80 -2
- package/src/__tests__/forgot-password-composed.test.tsx +2 -0
- package/src/__tests__/form-state-composition.test.tsx +484 -0
- package/src/__tests__/modal-login-close.test.tsx +366 -0
- package/src/__tests__/otp-form-composed.test.tsx +2 -0
- package/src/__tests__/password-reset-step.test.tsx +2 -0
- package/src/__tests__/register-form-composed.test.tsx +253 -4
- package/src/__tests__/registry-parity.test.ts +12 -0
- package/src/__tests__/reset-password-composed.test.tsx +385 -0
- package/src/auth/auth-captcha.tsx +125 -0
- package/src/auth/auth-destination.ts +168 -0
- package/src/auth/auth-field-errors.tsx +31 -0
- package/src/auth/form-state-context.tsx +170 -0
- package/src/auth/form-success-context.tsx +56 -0
- package/src/auth/read-auth-error.ts +332 -0
- package/src/blocks/__tests__/slider-block.test.tsx +265 -1
- package/src/blocks/account-button-block.tsx +25 -0
- package/src/blocks/add-to-cart-block.tsx +15 -0
- package/src/blocks/auth-field-block.tsx +13 -3
- package/src/blocks/auth-flow-block.tsx +45 -3
- package/src/blocks/auth-form-block.tsx +337 -54
- package/src/blocks/auth-success-block.tsx +85 -0
- package/src/blocks/block-renderer-registry.tsx +8 -0
- package/src/blocks/button-block.tsx +43 -13
- package/src/blocks/captcha-block.tsx +61 -0
- package/src/blocks/change-password-form-block.tsx +15 -0
- package/src/blocks/forgot-password-block.tsx +132 -9
- package/src/blocks/form-state-block.tsx +84 -0
- package/src/blocks/newsletter-form-block.tsx +16 -0
- package/src/blocks/otp-form-block.tsx +161 -67
- package/src/blocks/profile-form-block.tsx +13 -0
- package/src/blocks/register-form-block.tsx +362 -110
- package/src/blocks/reset-password-block.tsx +717 -0
- package/src/blocks/slider-block.tsx +170 -4
- package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
- package/src/chrome/placeholder-slugs.ts +3 -0
- package/src/chrome/route-slug.ts +11 -0
- package/src/index.ts +10 -0
- package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
- package/src/overlays/__tests__/modal-registry.test.ts +5 -1
- package/src/overlays/modal-chrome-css.ts +8 -1
- package/src/overlays/modal-host-context.tsx +66 -0
- package/src/overlays/modal-preview-host.tsx +7 -2
- package/src/overlays/modal-registry.ts +38 -1
- package/src/overlays/overlay-loading.tsx +144 -0
- package/src/overlays/overlay-surface.tsx +23 -0
- package/src/overlays/overlay-tokens.ts +12 -0
- package/src/overlays/theme-modal-mount.tsx +85 -6
- package/src/utils/visibility-rules.ts +114 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
applyFirstSlideImageLoading,
|
|
11
11
|
getCSSStyles,
|
|
12
12
|
getResponsiveValue,
|
|
13
|
+
partAttrs,
|
|
13
14
|
resolveThemeCssVariables
|
|
14
15
|
} from '../utils';
|
|
15
16
|
import { DESKTOP_MIN_WIDTH, isNarrowBreakpoint } from '../utils/breakpoints';
|
|
@@ -44,6 +45,51 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
44
45
|
const themeSettings = useThemeSettingsContext();
|
|
45
46
|
const carouselRef = React.useRef<Carousel>(null);
|
|
46
47
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
48
|
+
/**
|
|
49
|
+
* Whether the pointer (or the keyboard) is inside the slider, for the
|
|
50
|
+
* arrows-on-hover option below.
|
|
51
|
+
*
|
|
52
|
+
* REACT STATE RATHER THAN A `group-hover:` CLASS, FOR TWO REASONS THAT BOTH
|
|
53
|
+
* END WITH THE ARROWS NEVER COMING BACK.
|
|
54
|
+
*
|
|
55
|
+
* Tailwind 4 compiles `group-hover:` inside `@media (hover: hover)` while the
|
|
56
|
+
* `opacity-0` it pairs with is unconditional — so a phone would get the
|
|
57
|
+
* hiding half of the rule and never the revealing half. State can ask about
|
|
58
|
+
* hover capability directly, which is what `canHover` below does.
|
|
59
|
+
*
|
|
60
|
+
* And scanning is not guaranteed: the reference app lists this package in its
|
|
61
|
+
* Tailwind `content` (apps/projectzeronext/tailwind.config.js), but a brand
|
|
62
|
+
* whose config only lists `@akinon/next` plus its payment plugins does not —
|
|
63
|
+
* so a class only this file writes may never be generated there. The static
|
|
64
|
+
* classes the arrows already rely on (`left-4`, `-translate-y-1/2`) come from
|
|
65
|
+
* the brand's own source using them too.
|
|
66
|
+
*/
|
|
67
|
+
const [pointerInside, setPointerInside] = React.useState(false);
|
|
68
|
+
/**
|
|
69
|
+
* AND WHETHER THIS DEVICE CAN HOVER AT ALL.
|
|
70
|
+
*
|
|
71
|
+
* A phone never sends the pointer event that brings the arrows back, so
|
|
72
|
+
* "arrows on hover" would mean "no arrows, ever" there — the option is
|
|
73
|
+
* therefore ignored wherever the pointer cannot hover. Assumed true until the
|
|
74
|
+
* client answers: the server has no media queries, and a desktop flashing its
|
|
75
|
+
* arrows before hiding them reads worse than a phone showing them a frame
|
|
76
|
+
* late.
|
|
77
|
+
*/
|
|
78
|
+
const [canHover, setCanHover] = React.useState(true);
|
|
79
|
+
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
82
|
+
|
|
83
|
+
const query = window.matchMedia('(hover: hover)');
|
|
84
|
+
setCanHover(query.matches);
|
|
85
|
+
|
|
86
|
+
const handleChange = (event: MediaQueryListEvent) =>
|
|
87
|
+
setCanHover(event.matches);
|
|
88
|
+
|
|
89
|
+
// A mouse plugged into a tablet flips this mid-session.
|
|
90
|
+
query.addEventListener?.('change', handleChange);
|
|
91
|
+
return () => query.removeEventListener?.('change', handleChange);
|
|
92
|
+
}, []);
|
|
47
93
|
|
|
48
94
|
const leftArrowIconBlock = block.blocks?.find(
|
|
49
95
|
(b: any) => b.type === 'icon' && b.label === 'Left Arrow Icon'
|
|
@@ -164,6 +210,11 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
164
210
|
currentBreakpoint,
|
|
165
211
|
false
|
|
166
212
|
);
|
|
213
|
+
// Arrows that only appear while the slider is hovered. Off by default, so a
|
|
214
|
+
// slider that never authored it renders exactly as before.
|
|
215
|
+
const arrowsOnHover = Boolean(
|
|
216
|
+
getResponsiveValue(getConfigEntry('arrows-on-hover'), currentBreakpoint, false)
|
|
217
|
+
);
|
|
167
218
|
const useAutoPlay = getResponsiveValue(
|
|
168
219
|
getConfigEntry('autoplay'),
|
|
169
220
|
currentBreakpoint,
|
|
@@ -437,6 +488,66 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
437
488
|
)
|
|
438
489
|
) || 1;
|
|
439
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The angle the panel's Rotate box holds. It stores a bare number, which is
|
|
493
|
+
* not valid CSS on its own, so a number becomes degrees and anything with a
|
|
494
|
+
* unit (deg/turn/rad/grad) is passed through untouched.
|
|
495
|
+
*/
|
|
496
|
+
const arrowAngle = (value: unknown): string | undefined => {
|
|
497
|
+
let text = String(value ?? '').trim();
|
|
498
|
+
if (!text) return undefined;
|
|
499
|
+
// The panel's Rotate box is free text, so it invites the function spelling
|
|
500
|
+
// as readily as the value one. Left as typed it would emit
|
|
501
|
+
// `rotate: rotate(45deg)`, which the browser drops without a word.
|
|
502
|
+
const wrapped = /^rotate\(([^)]+)\)$/i.exec(text);
|
|
503
|
+
if (wrapped) text = wrapped[1].trim();
|
|
504
|
+
// A bare number is degrees — including the leading-dot spelling, which the
|
|
505
|
+
// earlier pattern rejected and then emitted as the invalid `rotate: .5`.
|
|
506
|
+
return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(text) ? `${text}deg` : text;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* ON THE GLYPH, NOT ON THE BUTTON, AND VIA `rotate` RATHER THAN `transform`.
|
|
511
|
+
*
|
|
512
|
+
* The rotation a designer wants is the arrow pointing the other way, not the
|
|
513
|
+
* button's background box turning with it — the same SVG ships for both
|
|
514
|
+
* sides, so mirroring the left one is the whole point.
|
|
515
|
+
*
|
|
516
|
+
* `rotate` rather than `transform` because of the centring class it has to
|
|
517
|
+
* live beside. Tailwind 4 compiles `-translate-y-1/2` to the individual
|
|
518
|
+
* `translate` property, and the individual properties are composed in a fixed
|
|
519
|
+
* order — translate, then rotate, then scale, then `transform` — so a rotation
|
|
520
|
+
* written here cannot displace the centring, and it turns the glyph about its
|
|
521
|
+
* own centre. A raw `transform` would have to restate the translate (and did
|
|
522
|
+
* clobber it under Tailwind 3).
|
|
523
|
+
*
|
|
524
|
+
* NOTE THE DIVERGENCE, which is a real one: the section arrows in
|
|
525
|
+
* utils/slider-arrows.tsx put `data-block-id` on the BUTTON, so there the
|
|
526
|
+
* generated rule for this same `rotate` key turns the plate and the glyph
|
|
527
|
+
* together. Here only the glyph turns.
|
|
528
|
+
*
|
|
529
|
+
* A rule for this icon block's `rotate` is in fact already in the generated
|
|
530
|
+
* stylesheet — `collectBlockCSS` recurses into every child block — and
|
|
531
|
+
* matches nothing only because this button carries no `data-block-id`. If one
|
|
532
|
+
* is ever added (to let borders, shadows or part keys reach the plate), that
|
|
533
|
+
* rule and the inline value below would both apply and the glyph would sit at
|
|
534
|
+
* twice the authored angle; drop one of the two when that happens.
|
|
535
|
+
*/
|
|
536
|
+
const leftArrowIconRotate = arrowAngle(
|
|
537
|
+
getResponsiveValue(
|
|
538
|
+
leftArrowIconBlock?.styles?.['rotate'],
|
|
539
|
+
currentBreakpoint,
|
|
540
|
+
''
|
|
541
|
+
)
|
|
542
|
+
);
|
|
543
|
+
const rightArrowIconRotate = arrowAngle(
|
|
544
|
+
getResponsiveValue(
|
|
545
|
+
rightArrowIconBlock?.styles?.['rotate'],
|
|
546
|
+
currentBreakpoint,
|
|
547
|
+
''
|
|
548
|
+
)
|
|
549
|
+
);
|
|
550
|
+
|
|
440
551
|
// Process SVGs with color
|
|
441
552
|
const processedLeftArrowUrl = isInlineSvg(leftArrowIconUrl)
|
|
442
553
|
? processInlineSVG(leftArrowIconUrl, leftArrowIconColor)
|
|
@@ -569,6 +680,25 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
569
680
|
const leftArrowButtonStyle = arrowGeometry(leftArrowIconBlock);
|
|
570
681
|
const rightArrowButtonStyle = arrowGeometry(rightArrowIconBlock);
|
|
571
682
|
|
|
683
|
+
/**
|
|
684
|
+
* What the arrows-on-hover option adds to every arrow button.
|
|
685
|
+
*
|
|
686
|
+
* The designer canvas is exempt: an arrow a merchant cannot see is an arrow
|
|
687
|
+
* they cannot select, and the panel is where the option is being switched on
|
|
688
|
+
* in the first place. `pointer-events` follows the opacity so an invisible
|
|
689
|
+
* arrow cannot be clicked, and the transition is inline for the same reason
|
|
690
|
+
* the state above is React — no class this package writes reaches the brand's
|
|
691
|
+
* stylesheet.
|
|
692
|
+
*/
|
|
693
|
+
const arrowRevealStyle: React.CSSProperties =
|
|
694
|
+
arrowsOnHover && canHover && !isDesigner
|
|
695
|
+
? {
|
|
696
|
+
opacity: pointerInside ? 1 : 0,
|
|
697
|
+
pointerEvents: pointerInside ? 'auto' : 'none',
|
|
698
|
+
transition: 'opacity 200ms ease'
|
|
699
|
+
}
|
|
700
|
+
: {};
|
|
701
|
+
|
|
572
702
|
/**
|
|
573
703
|
* The class defaults step aside per axis for whatever the author set.
|
|
574
704
|
* An inline `left` already outranks the `left-4` class, but the pair that
|
|
@@ -633,7 +763,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
633
763
|
alignItems: 'center',
|
|
634
764
|
justifyContent: 'center',
|
|
635
765
|
cursor: 'pointer',
|
|
636
|
-
padding: 0
|
|
766
|
+
padding: 0,
|
|
767
|
+
...arrowRevealStyle
|
|
637
768
|
}}
|
|
638
769
|
aria-label="Previous"
|
|
639
770
|
>
|
|
@@ -644,6 +775,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
644
775
|
width: `${leftArrowIconSize}px`,
|
|
645
776
|
height: `${leftArrowIconSize}px`,
|
|
646
777
|
opacity: leftArrowIconOpacity,
|
|
778
|
+
rotate: leftArrowIconRotate,
|
|
647
779
|
pointerEvents: 'none'
|
|
648
780
|
}}
|
|
649
781
|
/>
|
|
@@ -673,7 +805,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
673
805
|
alignItems: 'center',
|
|
674
806
|
justifyContent: 'center',
|
|
675
807
|
cursor: 'pointer',
|
|
676
|
-
padding: 0
|
|
808
|
+
padding: 0,
|
|
809
|
+
...arrowRevealStyle
|
|
677
810
|
}}
|
|
678
811
|
aria-label="Next"
|
|
679
812
|
>
|
|
@@ -684,6 +817,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
684
817
|
width: `${rightArrowIconSize}px`,
|
|
685
818
|
height: `${rightArrowIconSize}px`,
|
|
686
819
|
opacity: rightArrowIconOpacity,
|
|
820
|
+
rotate: rightArrowIconRotate,
|
|
687
821
|
pointerEvents: 'none'
|
|
688
822
|
}}
|
|
689
823
|
/>
|
|
@@ -708,6 +842,16 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
708
842
|
)}
|
|
709
843
|
style={sectionStyles}
|
|
710
844
|
{...carouselSsr.gateProps}
|
|
845
|
+
// Focus rides along with the pointer so the arrows are reachable by
|
|
846
|
+
// keyboard; both events bubble in React, so tabbing INTO an arrow counts.
|
|
847
|
+
onMouseEnter={
|
|
848
|
+
arrowsOnHover ? () => setPointerInside(true) : undefined
|
|
849
|
+
}
|
|
850
|
+
onMouseLeave={
|
|
851
|
+
arrowsOnHover ? () => setPointerInside(false) : undefined
|
|
852
|
+
}
|
|
853
|
+
onFocus={arrowsOnHover ? () => setPointerInside(true) : undefined}
|
|
854
|
+
onBlur={arrowsOnHover ? () => setPointerInside(false) : undefined}
|
|
711
855
|
>
|
|
712
856
|
<div style={{ position: 'relative' }}>
|
|
713
857
|
<Carousel
|
|
@@ -794,8 +938,26 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
794
938
|
: childBlock;
|
|
795
939
|
|
|
796
940
|
return (
|
|
941
|
+
/*
|
|
942
|
+
* THE ONLY BOX BETWEEN A SLIDE'S CARD AND THE TRACK, AND THE ONE
|
|
943
|
+
* LINK IN THE CHAIN THAT USED TO HAVE NO HEIGHT.
|
|
944
|
+
*
|
|
945
|
+
* react-multi-carousel stretches every `li` to the tallest slide,
|
|
946
|
+
* so a card asking for `height: 100%` has a definite height two
|
|
947
|
+
* levels up and an auto-height one directly above it — this
|
|
948
|
+
* wrapper. The card's own percentage therefore resolved to `auto`
|
|
949
|
+
* and every slide was as tall as its own text, which is what made
|
|
950
|
+
* a row of cards with a divider border look ragged.
|
|
951
|
+
*
|
|
952
|
+
* Named as a part so the panel can give it a height rather than
|
|
953
|
+
* a brand carrying scoped custom CSS per section. The inline
|
|
954
|
+
* padding stays: part declarations are emitted `!important`
|
|
955
|
+
* (generatePartCSS), so an authored `slide/padding-left` still
|
|
956
|
+
* wins over it.
|
|
957
|
+
*/
|
|
797
958
|
<div
|
|
798
959
|
key={childBlock.id}
|
|
960
|
+
{...partAttrs('slide')}
|
|
799
961
|
style={
|
|
800
962
|
flushEdges
|
|
801
963
|
? {
|
|
@@ -860,7 +1022,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
860
1022
|
alignItems: 'center',
|
|
861
1023
|
justifyContent: 'center',
|
|
862
1024
|
cursor: 'pointer',
|
|
863
|
-
padding: 0
|
|
1025
|
+
padding: 0,
|
|
1026
|
+
...arrowRevealStyle
|
|
864
1027
|
}}
|
|
865
1028
|
aria-label="Previous"
|
|
866
1029
|
>
|
|
@@ -871,6 +1034,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
871
1034
|
width: `${leftArrowIconSize}px`,
|
|
872
1035
|
height: `${leftArrowIconSize}px`,
|
|
873
1036
|
opacity: leftArrowIconOpacity,
|
|
1037
|
+
rotate: leftArrowIconRotate,
|
|
874
1038
|
pointerEvents: 'none'
|
|
875
1039
|
}}
|
|
876
1040
|
/>
|
|
@@ -891,7 +1055,8 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
891
1055
|
alignItems: 'center',
|
|
892
1056
|
justifyContent: 'center',
|
|
893
1057
|
cursor: 'pointer',
|
|
894
|
-
padding: 0
|
|
1058
|
+
padding: 0,
|
|
1059
|
+
...arrowRevealStyle
|
|
895
1060
|
}}
|
|
896
1061
|
aria-label="Next"
|
|
897
1062
|
>
|
|
@@ -902,6 +1067,7 @@ const SliderBlock: React.FC<BlockRendererProps> = ({
|
|
|
902
1067
|
width: `${rightArrowIconSize}px`,
|
|
903
1068
|
height: `${rightArrowIconSize}px`,
|
|
904
1069
|
opacity: rightArrowIconOpacity,
|
|
1070
|
+
rotate: rightArrowIconRotate,
|
|
905
1071
|
pointerEvents: 'none'
|
|
906
1072
|
}}
|
|
907
1073
|
/>
|
|
@@ -63,6 +63,9 @@ export const PLACEHOLDER_SLUGS = {
|
|
|
63
63
|
// Auth
|
|
64
64
|
register: 'register-page-body',
|
|
65
65
|
forgotPassword: 'forgot-password-body',
|
|
66
|
+
// The screen the reset MAIL lands on — a different page from the one that
|
|
67
|
+
// ASKS for the mail. `reset-password-block` is what fills it.
|
|
68
|
+
resetPassword: 'reset-password-body',
|
|
66
69
|
guestLogin: 'guest-login-body',
|
|
67
70
|
|
|
68
71
|
// Content
|
package/src/chrome/route-slug.ts
CHANGED
|
@@ -71,6 +71,13 @@ const DEFAULT_RULES: RouteSlugRule[] = [
|
|
|
71
71
|
PLACEHOLDER_SLUGS.forgotPassword
|
|
72
72
|
],
|
|
73
73
|
[/^\/users\/password\/reset(\/|$)/, PLACEHOLDER_SLUGS.forgotPassword],
|
|
74
|
+
// The screen the reset MAIL lands on, which is a different page from the one
|
|
75
|
+
// above: that one ASKS for the mail, this one is where the link goes. The
|
|
76
|
+
// token rides in the path as a catch-all — and may itself contain slashes —
|
|
77
|
+
// so the rule stops at the first boundary and lets the block read the rest.
|
|
78
|
+
// Ordering is safe either way: `/users/password/reset` and `/users/reset`
|
|
79
|
+
// cannot both match a path.
|
|
80
|
+
[/^\/users\/reset(\/|$)/, PLACEHOLDER_SLUGS.resetPassword],
|
|
74
81
|
[/^\/(users\/)?auth\/guest(\/|$)/, PLACEHOLDER_SLUGS.guestLogin],
|
|
75
82
|
[/^\/(users\/)?auth\/?$/, PLACEHOLDER_SLUGS.auth],
|
|
76
83
|
|
|
@@ -143,6 +150,10 @@ const PAGE_MOUNTED_SLUGS = new Set<string>([
|
|
|
143
150
|
PLACEHOLDER_SLUGS.auth,
|
|
144
151
|
PLACEHOLDER_SLUGS.register,
|
|
145
152
|
PLACEHOLDER_SLUGS.forgotPassword,
|
|
153
|
+
// Mounted by its own layout beside the catch-all page that reads the token,
|
|
154
|
+
// for the same reason the two above are: the page is a client component, so
|
|
155
|
+
// the handover cannot live in it.
|
|
156
|
+
PLACEHOLDER_SLUGS.resetPassword,
|
|
146
157
|
PLACEHOLDER_SLUGS.guestLogin,
|
|
147
158
|
PLACEHOLDER_SLUGS.flatPage,
|
|
148
159
|
// Mounted by the not-found boundary itself, which renders on any unmatched
|
package/src/index.ts
CHANGED
|
@@ -99,6 +99,16 @@ export {
|
|
|
99
99
|
useOpenModalIds
|
|
100
100
|
} from './overlays/overlay-bus';
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Which modal the composition is inside, for a brand's OWN auth surface: a
|
|
104
|
+
* hand-written form composed into a drawer can now close the drawer the way
|
|
105
|
+
* the built-in ones do. See `overlays/modal-host-context.tsx`.
|
|
106
|
+
*/
|
|
107
|
+
export {
|
|
108
|
+
useModalHostId,
|
|
109
|
+
useHostModalCloser
|
|
110
|
+
} from './overlays/modal-host-context';
|
|
111
|
+
|
|
102
112
|
export {
|
|
103
113
|
readThemeModals,
|
|
104
114
|
MODAL_PRESENTATIONS
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
usePathname: () => '/',
|
|
6
|
+
useSearchParams: () => new URLSearchParams()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('next-auth/react', () => ({
|
|
10
|
+
useSession: () => ({ status: 'unauthenticated' })
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
jest.mock('@akinon/next/hooks/use-localization', () => ({
|
|
14
|
+
useLocalization: () => ({ locale: 'en', currency: 'USD' })
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { clearAutoOpenRecord } from '../modal-frequency';
|
|
18
|
+
import { buildModalChromeCss } from '../modal-chrome-css';
|
|
19
|
+
import { readThemeModals, ThemeModal } from '../modal-registry';
|
|
20
|
+
import {
|
|
21
|
+
__resetOverlayBusForTests,
|
|
22
|
+
openModal,
|
|
23
|
+
closeModal,
|
|
24
|
+
registerModalHost
|
|
25
|
+
} from '../overlay-bus';
|
|
26
|
+
import { OverlayLoadingLayer } from '../overlay-loading';
|
|
27
|
+
import { LayeredOverlaySurface } from '../overlay-surface';
|
|
28
|
+
import ThemeModalMount from '../theme-modal-mount';
|
|
29
|
+
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a modal shows while its composition is still committing.
|
|
33
|
+
*
|
|
34
|
+
* The pause is the commit, not a fetch: a composed modal's content ships with
|
|
35
|
+
* the page, and a large one mounts ninety blocks at once. So the host draws
|
|
36
|
+
* the panel and a loading layer in an urgent commit and reveals the
|
|
37
|
+
* composition in a transition — which is also why nothing here has to fight
|
|
38
|
+
* the clock: `act()` flushes transitions, so the tests see the settled state
|
|
39
|
+
* unless they render a surface directly.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const mkModal = (chrome: Record<string, unknown> = {}): ThemeModal =>
|
|
43
|
+
readThemeModals([{ id: 'login', name: 'Login', chrome }])[0];
|
|
44
|
+
|
|
45
|
+
const openWith = (chrome: Record<string, unknown> = {}, isDesigner = false) => {
|
|
46
|
+
if (isDesigner) {
|
|
47
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
48
|
+
true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render(
|
|
52
|
+
<ThemeModalMount modal={mkModal(chrome)}>
|
|
53
|
+
<p>composed content</p>
|
|
54
|
+
</ThemeModalMount>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
act(() => {
|
|
58
|
+
openModal('login');
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
__resetOverlayBusForTests();
|
|
64
|
+
__resetModalPreviewForTests();
|
|
65
|
+
registerModalHost();
|
|
66
|
+
clearAutoOpenRecord('login');
|
|
67
|
+
delete (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('the loading settings', () => {
|
|
71
|
+
it('is on for a modal that never mentioned it, and off when asked', () => {
|
|
72
|
+
expect(mkModal().chrome.showLoading).toBe(true);
|
|
73
|
+
expect(mkModal({ showLoading: false }).chrome.showLoading).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps an unauthored delay absent, so the surface default stands', () => {
|
|
77
|
+
expect(mkModal().chrome.loadingDelay).toBeUndefined();
|
|
78
|
+
// Zero is a real answer — "show it at once" — and must survive as one.
|
|
79
|
+
expect(mkModal({ loadingDelay: 0 }).chrome.loadingDelay).toBe(0);
|
|
80
|
+
expect(mkModal({ loadingDelay: 400 }).chrome.loadingDelay).toBe(400);
|
|
81
|
+
// A delay longer than the wait it explains is a typo.
|
|
82
|
+
expect(mkModal({ loadingDelay: 90000 }).chrome.loadingDelay).toBe(2000);
|
|
83
|
+
expect(mkModal({ loadingDelay: -5 }).chrome.loadingDelay).toBe(0);
|
|
84
|
+
expect(
|
|
85
|
+
mkModal({ loadingDelay: 'soon' }).chrome.loadingDelay
|
|
86
|
+
).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps the label absence-preserving, since the package has no copy', () => {
|
|
90
|
+
expect(mkModal().chrome.loadingLabel).toBeUndefined();
|
|
91
|
+
expect(
|
|
92
|
+
mkModal({ loadingLabel: ' ' }).chrome.loadingLabel
|
|
93
|
+
).toBeUndefined();
|
|
94
|
+
expect(mkModal({ loadingLabel: 'Yükleniyor' }).chrome.loadingLabel).toBe(
|
|
95
|
+
'Yükleniyor'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('the layer itself', () => {
|
|
101
|
+
const renderLayer = (props: { label?: string; delay?: number } = {}) => {
|
|
102
|
+
render(
|
|
103
|
+
<LayeredOverlaySurface
|
|
104
|
+
presentation="drawer"
|
|
105
|
+
overlayProps={{ ref: undefined, 'aria-modal': 'true' }}
|
|
106
|
+
ariaLabel="Login"
|
|
107
|
+
showBackdrop
|
|
108
|
+
closeOnOverlay={false}
|
|
109
|
+
onDismiss={() => undefined}
|
|
110
|
+
width={480}
|
|
111
|
+
widthUnit="px"
|
|
112
|
+
loading={<OverlayLoadingLayer {...props} />}
|
|
113
|
+
panelBusy
|
|
114
|
+
>
|
|
115
|
+
<p>composed content</p>
|
|
116
|
+
</LayeredOverlaySurface>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// The LAST one: a test that renders twice to compare two settings stacks
|
|
120
|
+
// two containers, and the first match would answer for the previous render.
|
|
121
|
+
return Array.from(
|
|
122
|
+
document.querySelectorAll('[data-part="loading"]')
|
|
123
|
+
).pop() as HTMLElement;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
it('starts invisible and fades in only after the delay', () => {
|
|
127
|
+
const layer = renderLayer({ delay: 400 });
|
|
128
|
+
|
|
129
|
+
expect(layer.style.opacity).toBe('0');
|
|
130
|
+
expect(layer.style.animation).toContain('400ms forwards');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('falls back to the surface delay when the modal authored none', () => {
|
|
134
|
+
expect(renderLayer().style.animation).toContain('150ms forwards');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('never takes a click meant for the panel underneath', () => {
|
|
138
|
+
expect(renderLayer().style.pointerEvents).toBe('none');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('sizes itself in flow, so a scrolling panel cannot scroll it away', () => {
|
|
142
|
+
const layer = renderLayer();
|
|
143
|
+
|
|
144
|
+
expect(layer.style.position).toBe('');
|
|
145
|
+
// Fills a stretched panel's flex column; the min-height is what a centred
|
|
146
|
+
// dialog with nothing in it has to go on.
|
|
147
|
+
expect(layer.style.flex).toBe('1 1 auto');
|
|
148
|
+
expect(layer.style.minHeight).toBe('160px');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('is silent for assistive tech until a designer gives it words', () => {
|
|
152
|
+
expect(renderLayer().getAttribute('aria-hidden')).toBe('true');
|
|
153
|
+
|
|
154
|
+
const labelled = renderLayer({ label: 'Yükleniyor' });
|
|
155
|
+
|
|
156
|
+
expect(labelled.getAttribute('aria-hidden')).toBeNull();
|
|
157
|
+
expect(
|
|
158
|
+
labelled.querySelector('[data-part="loading-text"]')?.textContent
|
|
159
|
+
).toBe('Yükleniyor');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('tells assistive tech the dialog is still filling', () => {
|
|
163
|
+
renderLayer();
|
|
164
|
+
|
|
165
|
+
expect(
|
|
166
|
+
document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')
|
|
167
|
+
).toBe('true');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('a modal that is holding one', () => {
|
|
172
|
+
it('has the composition and no layer left once the transition lands', () => {
|
|
173
|
+
openWith();
|
|
174
|
+
|
|
175
|
+
expect(screen.getByText('composed content')).toBeInTheDocument();
|
|
176
|
+
// Both halves go together: nothing drawn over the content, and no
|
|
177
|
+
// attribute left claiming the dialog is still filling.
|
|
178
|
+
expect(document.querySelector('[data-part="loading"]')).toBeNull();
|
|
179
|
+
expect(
|
|
180
|
+
document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')
|
|
181
|
+
).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('draws no layer at all when the setting is off', () => {
|
|
185
|
+
openWith({ showLoading: false });
|
|
186
|
+
|
|
187
|
+
expect(screen.getByText('composed content')).toBeInTheDocument();
|
|
188
|
+
expect(document.querySelector('[data-part="loading"]')).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('shows the composition at once in the canvas, where the wait is not real', () => {
|
|
192
|
+
openWith({}, true);
|
|
193
|
+
|
|
194
|
+
expect(screen.getByText('composed content')).toBeInTheDocument();
|
|
195
|
+
expect(document.querySelector('[data-part="loading"]')).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('starts from the cheap commit again on the next open', () => {
|
|
199
|
+
openWith();
|
|
200
|
+
|
|
201
|
+
act(() => {
|
|
202
|
+
closeModal('login');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
206
|
+
|
|
207
|
+
act(() => {
|
|
208
|
+
openModal('login');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// The reopen is a fresh mount of the composition, so the reveal has to
|
|
212
|
+
// have been reset — a latched one would leave the second open with no
|
|
213
|
+
// layer to cover it.
|
|
214
|
+
expect(screen.getByText('composed content')).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('the designer styles', () => {
|
|
219
|
+
it('reach the layer, its mark and its words', () => {
|
|
220
|
+
const css = buildModalChromeCss({
|
|
221
|
+
id: 'login',
|
|
222
|
+
styles: {
|
|
223
|
+
'loading/background-color': '#ffffff',
|
|
224
|
+
'loading-mark/border-color': '#00321b',
|
|
225
|
+
'loading-text/font-size': '15px'
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(css).toContain('[data-theme-modal="login"] [data-part="loading"]');
|
|
230
|
+
expect(css).toContain('[data-part="loading-mark"]');
|
|
231
|
+
expect(css).toContain('[data-part="loading-text"]');
|
|
232
|
+
// The panel's own inline styles would otherwise outrank every rule here.
|
|
233
|
+
expect(css).toContain('!important');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -63,7 +63,11 @@ describe('readThemeModals', () => {
|
|
|
63
63
|
// surface, so an unauthored padding must stay unauthored here.
|
|
64
64
|
padding: undefined,
|
|
65
65
|
showSheetHandle: true,
|
|
66
|
-
stretchContent: false
|
|
66
|
+
stretchContent: false,
|
|
67
|
+
// On for a modal that never mentioned it, which is safe precisely
|
|
68
|
+
// because the layer waits: an unauthored delay leaves the surface's
|
|
69
|
+
// own 150ms, and nothing a small composition can outrun changes.
|
|
70
|
+
showLoading: true
|
|
67
71
|
},
|
|
68
72
|
autoTrigger: undefined,
|
|
69
73
|
visibilityRules: undefined
|
|
@@ -41,7 +41,14 @@ export const MODAL_CHROME_PARTS = [
|
|
|
41
41
|
'overlay',
|
|
42
42
|
'close',
|
|
43
43
|
'close-text',
|
|
44
|
-
'handle'
|
|
44
|
+
'handle',
|
|
45
|
+
// The layer shown while a composition is still committing, its mark, and the
|
|
46
|
+
// authored words beside it. Chrome rather than blocks for the usual reason:
|
|
47
|
+
// they are drawn by `overlay-loading.tsx` from inline styles and hang off no
|
|
48
|
+
// `[data-block-id]`, so no block part rule could ever reach them.
|
|
49
|
+
'loading',
|
|
50
|
+
'loading-mark',
|
|
51
|
+
'loading-text'
|
|
45
52
|
] as const;
|
|
46
53
|
|
|
47
54
|
export type ModalChromePart = (typeof MODAL_CHROME_PARTS)[number];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { closeModal } from './overlay-bus';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which modal the composition being rendered is INSIDE.
|
|
9
|
+
*
|
|
10
|
+
* A composed form had no way to know. The consequence a shopper met: signing
|
|
11
|
+
* in inside the auth drawer left the drawer open over the page, because the
|
|
12
|
+
* only close a form could perform was `closeAllModals()` behind an authored
|
|
13
|
+
* toggle — and that toggle defaults to off, deliberately, since "close every
|
|
14
|
+
* modal on the page" is not something a form on a plain page should ever do
|
|
15
|
+
* unasked. So the safe default and the useful one were in conflict, and the
|
|
16
|
+
* live GANT drawer's SAVED config shows exactly how that ends — measured off
|
|
17
|
+
* the storefront's own payload, not off the shipped template: the OTP form has
|
|
18
|
+
* the toggle on and the login and register forms do not, so signing in with an
|
|
19
|
+
* e-mail left the drawer covering the page while the phone path closed it. A
|
|
20
|
+
* designer had no way to know they had to tick three separate boxes.
|
|
21
|
+
*
|
|
22
|
+
* With the host's id in hand the conflict disappears: a form closes THE MODAL
|
|
23
|
+
* IT IS IN, which is never a surprise, and a form that is not in one closes
|
|
24
|
+
* nothing at all.
|
|
25
|
+
*
|
|
26
|
+
* Same shape and same reason as `AuthFlowIdContext` — a host telling its
|
|
27
|
+
* composed children which instance of itself they belong to, because they are
|
|
28
|
+
* blocks at any depth and the host has no reference to hand them.
|
|
29
|
+
*/
|
|
30
|
+
const ModalHostIdContext = React.createContext<string>('');
|
|
31
|
+
|
|
32
|
+
export const ModalHostIdProvider = ModalHostIdContext.Provider;
|
|
33
|
+
|
|
34
|
+
/** The id of the modal this block is inside, or '' when it is not in one. */
|
|
35
|
+
export const useModalHostId = (): string =>
|
|
36
|
+
React.useContext(ModalHostIdContext);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "My task is finished — close the surface I am on."
|
|
40
|
+
*
|
|
41
|
+
* One helper rather than the same three lines in every block that can finish,
|
|
42
|
+
* because the rule is the same everywhere and the mistakes would not be: a
|
|
43
|
+
* form that closes a modal it is not in, or that closes one while the shopper
|
|
44
|
+
* still has something to read.
|
|
45
|
+
*
|
|
46
|
+
* Which blocks turn it on by DEFAULT is a judgement about their success state,
|
|
47
|
+
* not about the mechanism:
|
|
48
|
+
*
|
|
49
|
+
* - a sign-in leaves nothing to read — the shopper is in — so the auth forms
|
|
50
|
+
* default to closing, and that is the bug this whole family exists to fix;
|
|
51
|
+
* - "check your inbox", "thanks for subscribing", "your password has been
|
|
52
|
+
* changed" are the point of the success state, so those forms default to
|
|
53
|
+
* staying open and let the designer decide otherwise.
|
|
54
|
+
*
|
|
55
|
+
* Returns a stable no-op when the block is not in a modal, so a caller can
|
|
56
|
+
* fire it unconditionally.
|
|
57
|
+
*/
|
|
58
|
+
export const useHostModalCloser = (enabled: boolean): (() => void) => {
|
|
59
|
+
const hostModalId = useModalHostId();
|
|
60
|
+
|
|
61
|
+
return React.useCallback(() => {
|
|
62
|
+
if (!enabled || !hostModalId) return;
|
|
63
|
+
|
|
64
|
+
closeModal(hostModalId);
|
|
65
|
+
}, [enabled, hostModalId]);
|
|
66
|
+
};
|