@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -33
  2. package/package.json +4 -4
  3. package/src/__tests__/auth-captcha.test.tsx +261 -0
  4. package/src/__tests__/auth-commerce-events.test.tsx +5 -1
  5. package/src/__tests__/auth-destination.test.tsx +348 -0
  6. package/src/__tests__/auth-error-reading.test.ts +232 -0
  7. package/src/__tests__/auth-flow-scope.test.tsx +162 -0
  8. package/src/__tests__/auth-form-error-block.test.tsx +2 -0
  9. package/src/__tests__/auth-form-success-block.test.tsx +429 -0
  10. package/src/__tests__/auth-form-validation.test.tsx +2 -0
  11. package/src/__tests__/auth-slot-coverage.test.ts +9 -1
  12. package/src/__tests__/auth-step-title.test.tsx +2 -0
  13. package/src/__tests__/block-action-audience.test.ts +154 -0
  14. package/src/__tests__/detached-submit-state.test.tsx +80 -2
  15. package/src/__tests__/forgot-password-composed.test.tsx +2 -0
  16. package/src/__tests__/form-state-composition.test.tsx +484 -0
  17. package/src/__tests__/modal-login-close.test.tsx +366 -0
  18. package/src/__tests__/otp-form-composed.test.tsx +2 -0
  19. package/src/__tests__/password-reset-step.test.tsx +2 -0
  20. package/src/__tests__/register-form-composed.test.tsx +253 -4
  21. package/src/__tests__/registry-parity.test.ts +12 -0
  22. package/src/__tests__/reset-password-composed.test.tsx +385 -0
  23. package/src/auth/auth-captcha.tsx +125 -0
  24. package/src/auth/auth-destination.ts +168 -0
  25. package/src/auth/auth-field-errors.tsx +31 -0
  26. package/src/auth/form-state-context.tsx +170 -0
  27. package/src/auth/form-success-context.tsx +56 -0
  28. package/src/auth/read-auth-error.ts +332 -0
  29. package/src/blocks/__tests__/slider-block.test.tsx +265 -1
  30. package/src/blocks/account-button-block.tsx +25 -0
  31. package/src/blocks/add-to-cart-block.tsx +15 -0
  32. package/src/blocks/auth-field-block.tsx +13 -3
  33. package/src/blocks/auth-flow-block.tsx +45 -3
  34. package/src/blocks/auth-form-block.tsx +337 -54
  35. package/src/blocks/auth-success-block.tsx +85 -0
  36. package/src/blocks/block-renderer-registry.tsx +8 -0
  37. package/src/blocks/button-block.tsx +43 -13
  38. package/src/blocks/captcha-block.tsx +61 -0
  39. package/src/blocks/change-password-form-block.tsx +15 -0
  40. package/src/blocks/forgot-password-block.tsx +132 -9
  41. package/src/blocks/form-state-block.tsx +84 -0
  42. package/src/blocks/newsletter-form-block.tsx +16 -0
  43. package/src/blocks/otp-form-block.tsx +161 -67
  44. package/src/blocks/profile-form-block.tsx +13 -0
  45. package/src/blocks/register-form-block.tsx +362 -110
  46. package/src/blocks/reset-password-block.tsx +717 -0
  47. package/src/blocks/slider-block.tsx +170 -4
  48. package/src/chrome/__tests__/theme-config-source.test.ts +2 -1
  49. package/src/chrome/placeholder-slugs.ts +3 -0
  50. package/src/chrome/route-slug.ts +11 -0
  51. package/src/index.ts +10 -0
  52. package/src/overlays/__tests__/modal-loading.test.tsx +235 -0
  53. package/src/overlays/__tests__/modal-registry.test.ts +5 -1
  54. package/src/overlays/modal-chrome-css.ts +8 -1
  55. package/src/overlays/modal-host-context.tsx +66 -0
  56. package/src/overlays/modal-preview-host.tsx +7 -2
  57. package/src/overlays/modal-registry.ts +38 -1
  58. package/src/overlays/overlay-loading.tsx +144 -0
  59. package/src/overlays/overlay-surface.tsx +23 -0
  60. package/src/overlays/overlay-tokens.ts +12 -0
  61. package/src/overlays/theme-modal-mount.tsx +85 -6
  62. 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
  />
@@ -93,7 +93,8 @@ describe('readThemeConfig', () => {
93
93
  lockScroll: true,
94
94
  padding: undefined,
95
95
  showSheetHandle: true,
96
- stretchContent: false
96
+ stretchContent: false,
97
+ showLoading: true
97
98
  },
98
99
  autoTrigger: undefined,
99
100
  visibilityRules: undefined
@@ -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
@@ -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
+ };