@akinon/pz-theme 2.0.84-rc.0 → 2.0.85-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 +17 -33
- package/package.json +4 -4
- package/src/blocks/__tests__/video-block.test.tsx +42 -0
- package/src/blocks/video-block.tsx +27 -4
- package/src/overlays/__tests__/modal-chrome-styles.test.tsx +168 -0
- package/src/overlays/__tests__/modal-close-text.test.tsx +118 -0
- package/src/overlays/__tests__/modal-preview-registers-region.test.tsx +117 -0
- package/src/overlays/modal-chrome-css.ts +95 -0
- package/src/overlays/modal-preview-host.tsx +12 -0
- package/src/overlays/modal-registry.ts +39 -1
- package/src/overlays/overlay-surface.tsx +45 -1
- package/src/overlays/theme-modal-mount.tsx +17 -0
- package/src/utils/__tests__/inner-root-ownership.test.tsx +334 -0
- package/src/utils/__tests__/inner-root-styles.test.tsx +6 -1
- package/src/utils/__tests__/part-ownership-guard.test.tsx +6 -0
- package/src/utils/__tests__/scrolled-state-styles.test.ts +5 -1
- package/src/utils/index.ts +31 -8
- package/src/utils/part-styles.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,22 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.84-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.84-rc.0
|
|
3
|
+
## 2.0.85-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.85-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.85-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.84
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- cef43540: ZERO-4848: Add close text and styling options for modals
|
|
16
|
+
- cef43540: Composed modal chrome takes the designer's own styles and a visible close label
|
|
17
|
+
- cef43540: Video player inherits the block's corner radius, so a rounded video block rounds its iframe
|
|
18
|
+
- @akinon/next@2.0.84
|
|
19
|
+
- @akinon/theme-contract@2.0.84
|
|
36
20
|
|
|
37
21
|
## 2.0.83
|
|
38
22
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.85-beta.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.85-beta.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.85-beta.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"tailwind-merge": "^2.5.4",
|
|
20
20
|
"zod": "4.4.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.
|
|
23
|
+
"@akinon/next": "2.0.85-beta.0",
|
|
24
24
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
25
|
"@testing-library/react": "16.3.2",
|
|
26
26
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -13,6 +13,48 @@ const renderBlock = (value: any, properties?: any, styles?: any) =>
|
|
|
13
13
|
);
|
|
14
14
|
|
|
15
15
|
describe('VideoBlock', () => {
|
|
16
|
+
/**
|
|
17
|
+
* A designer's Corner Radius lands on the block's `[data-block-id]` box, and
|
|
18
|
+
* the player is its direct child. An iframe paints its own square corners
|
|
19
|
+
* straight over a rounded parent, so the radius was written, saved, shown in
|
|
20
|
+
* the panel and visibly did nothing — the only way to get it was to also
|
|
21
|
+
* find `overflow: hidden` in the All CSS families. Inheriting is what makes
|
|
22
|
+
* the one control the designer reached for enough on its own.
|
|
23
|
+
*/
|
|
24
|
+
describe('the corner radius of the player itself', () => {
|
|
25
|
+
it('is inherited by the iframe, so a rounded block rounds the player', () => {
|
|
26
|
+
const { container } = renderBlock(
|
|
27
|
+
'https://www.youtube.com/watch?v=abc123'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(
|
|
31
|
+
(container.querySelector('iframe') as HTMLIFrameElement).style
|
|
32
|
+
.borderRadius
|
|
33
|
+
).toBe('inherit');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('is inherited by a direct video file too', () => {
|
|
37
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4');
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
(container.querySelector('video') as HTMLVideoElement).style
|
|
41
|
+
.borderRadius
|
|
42
|
+
).toBe('inherit');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('needs no clipping context, so full screen still escapes the box', () => {
|
|
46
|
+
// `overflow: hidden` on the wrapper would have been the other fix, and
|
|
47
|
+
// it imposes a clipping context on a player that legitimately overflows.
|
|
48
|
+
const { container } = renderBlock(
|
|
49
|
+
'https://www.youtube.com/watch?v=abc123'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
(container.querySelector('iframe') as HTMLIFrameElement).style.overflow
|
|
54
|
+
).toBe('');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
16
58
|
describe('YouTube URLs -> iframe', () => {
|
|
17
59
|
it('renders an iframe for a youtube.com watch URL', () => {
|
|
18
60
|
const { container } = renderBlock(
|
|
@@ -208,6 +208,18 @@ const VideoBlock = ({
|
|
|
208
208
|
);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `borderRadius: 'inherit'` on the player, not on the wrapper.
|
|
213
|
+
*
|
|
214
|
+
* A designer's Corner Radius lands on the block's `[data-block-id]` box, and
|
|
215
|
+
* the player is its direct child — an `<iframe>` for YouTube and Vimeo. An
|
|
216
|
+
* iframe paints its own square corners straight over a rounded parent, so
|
|
217
|
+
* the radius was written, saved, shown in the panel and visibly did nothing;
|
|
218
|
+
* the only way to get it was to also find `overflow: hidden` in the All CSS
|
|
219
|
+
* families. Inheriting costs nothing when no radius is set (it resolves to
|
|
220
|
+
* 0) and needs no clipping context, which `overflow: hidden` would impose on
|
|
221
|
+
* a player that legitimately overflows in full screen.
|
|
222
|
+
*/
|
|
211
223
|
if (parsed.kind === 'embed') {
|
|
212
224
|
return (
|
|
213
225
|
<iframe
|
|
@@ -221,9 +233,15 @@ const VideoBlock = ({
|
|
|
221
233
|
width: '100%',
|
|
222
234
|
height: 'auto',
|
|
223
235
|
aspectRatio: ratio,
|
|
224
|
-
border: 'none'
|
|
236
|
+
border: 'none',
|
|
237
|
+
borderRadius: 'inherit'
|
|
238
|
+
}
|
|
239
|
+
: {
|
|
240
|
+
width: '100%',
|
|
241
|
+
height: '400px',
|
|
242
|
+
border: 'none',
|
|
243
|
+
borderRadius: 'inherit'
|
|
225
244
|
}
|
|
226
|
-
: { width: '100%', height: '400px', border: 'none' }
|
|
227
245
|
}
|
|
228
246
|
allow="autoplay; fullscreen; picture-in-picture"
|
|
229
247
|
allowFullScreen
|
|
@@ -246,8 +264,13 @@ const VideoBlock = ({
|
|
|
246
264
|
playsInline={properties.playsInline !== false}
|
|
247
265
|
style={
|
|
248
266
|
ratio
|
|
249
|
-
? {
|
|
250
|
-
|
|
267
|
+
? {
|
|
268
|
+
width: '100%',
|
|
269
|
+
aspectRatio: ratio,
|
|
270
|
+
objectFit: 'cover',
|
|
271
|
+
borderRadius: 'inherit'
|
|
272
|
+
}
|
|
273
|
+
: { width: '100%', height: 'auto', borderRadius: 'inherit' }
|
|
251
274
|
}
|
|
252
275
|
/>
|
|
253
276
|
);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { act, render } 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 { __resetEditorBridgeForTests } from '../../editor-bridge';
|
|
18
|
+
import { clearAutoOpenRecord } from '../modal-frequency';
|
|
19
|
+
import { readThemeModals, ThemeModal } from '../modal-registry';
|
|
20
|
+
import ModalPreviewHost from '../modal-preview-host';
|
|
21
|
+
import {
|
|
22
|
+
__resetOverlayBusForTests,
|
|
23
|
+
openModal,
|
|
24
|
+
registerModalHost
|
|
25
|
+
} from '../overlay-bus';
|
|
26
|
+
import ThemeModalMount from '../theme-modal-mount';
|
|
27
|
+
import { __resetEditorModalsForTests } from '../use-editor-modals';
|
|
28
|
+
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The chrome a designer dresses: the panel, the backdrop, the ✕ and its text.
|
|
32
|
+
*
|
|
33
|
+
* Everything these assert used to be unreachable. The panel's radius,
|
|
34
|
+
* background and shadow are INLINE styles on an element no part rule could
|
|
35
|
+
* name — a part selector hangs off a `[data-block-id]` and chrome has none —
|
|
36
|
+
* so the fix had to both invent a selector for it and beat an inline style.
|
|
37
|
+
* That is why every case here checks the `!important`: without it a control
|
|
38
|
+
* would be written, saved, shown in the panel and quietly do nothing, which is
|
|
39
|
+
* strictly worse than having no control at all.
|
|
40
|
+
*
|
|
41
|
+
* Each case runs through BOTH hosts. Nothing else pins that the storefront
|
|
42
|
+
* mount and the canvas preview agree, and that gap has already shipped a bug
|
|
43
|
+
* once (see the comment in `modal-preview-host.tsx`).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const EDITOR_ORIGIN = 'http://localhost:5174';
|
|
47
|
+
|
|
48
|
+
const mkModal = (
|
|
49
|
+
overrides: Record<string, unknown> = {},
|
|
50
|
+
chrome: Record<string, unknown> = {}
|
|
51
|
+
): ThemeModal =>
|
|
52
|
+
readThemeModals([
|
|
53
|
+
{ id: 'dressed', name: 'Dressed', chrome, ...overrides }
|
|
54
|
+
])[0];
|
|
55
|
+
|
|
56
|
+
/** The storefront path: a real trigger opens a saved modal. */
|
|
57
|
+
const openOnStorefront = (modal: ThemeModal) => {
|
|
58
|
+
render(
|
|
59
|
+
<ThemeModalMount modal={modal}>
|
|
60
|
+
<p>composed content</p>
|
|
61
|
+
</ThemeModalMount>
|
|
62
|
+
);
|
|
63
|
+
act(() => {
|
|
64
|
+
openModal(modal.id);
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The canvas path: the editor holds the modal open over the page. */
|
|
69
|
+
const holdInCanvas = (modal: ThemeModal) => {
|
|
70
|
+
render(<ModalPreviewHost />);
|
|
71
|
+
act(() => {
|
|
72
|
+
window.dispatchEvent(
|
|
73
|
+
new MessageEvent('message', {
|
|
74
|
+
origin: EDITOR_ORIGIN,
|
|
75
|
+
data: {
|
|
76
|
+
type: 'PREVIEW_OPEN_MODAL',
|
|
77
|
+
data: { modalId: modal.id, modal }
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const HOSTS: Array<[string, (modal: ThemeModal) => void]> = [
|
|
85
|
+
['the storefront mount', openOnStorefront],
|
|
86
|
+
['the canvas preview', holdInCanvas]
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** Every rule the surface emitted, as one string. */
|
|
90
|
+
const emittedCss = () =>
|
|
91
|
+
Array.from(document.querySelectorAll('style'))
|
|
92
|
+
.map(tag => tag.innerHTML)
|
|
93
|
+
.join('\n');
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
__resetOverlayBusForTests();
|
|
97
|
+
__resetModalPreviewForTests();
|
|
98
|
+
__resetEditorModalsForTests();
|
|
99
|
+
__resetEditorBridgeForTests();
|
|
100
|
+
registerModalHost();
|
|
101
|
+
clearAutoOpenRecord('dressed');
|
|
102
|
+
document.body.style.overflow = '';
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe.each(HOSTS)('chrome styles through %s', (_name, open) => {
|
|
106
|
+
it('sends a bare css key to the panel, and wins against its inline default', () => {
|
|
107
|
+
open(mkModal({ styles: { 'border-radius': { desktop: '0px' } } }));
|
|
108
|
+
|
|
109
|
+
const css = emittedCss();
|
|
110
|
+
// Scoped to this modal's panel — not to the overlay root, and not to
|
|
111
|
+
// every modal on the page.
|
|
112
|
+
expect(css).toContain('[data-theme-modal="dressed"]');
|
|
113
|
+
expect(css).toContain('[data-part="modal"]');
|
|
114
|
+
expect(css).toMatch(/border-radius:\s*0px\s*!important/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('sends a namespaced key to the part it names', () => {
|
|
118
|
+
open(
|
|
119
|
+
mkModal({
|
|
120
|
+
styles: { 'overlay/background-color': { desktop: 'rgb(1, 2, 3)' } }
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Note the alpha: the colour pipeline normalises every paint to rgba(),
|
|
125
|
+
// so the assertion pins what actually reaches the sheet.
|
|
126
|
+
expect(emittedCss()).toMatch(
|
|
127
|
+
/\[data-part="overlay"\][\s\S]*background-color:\s*rgba\(1, 2, 3, 1\)\s*!important/
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('carries a part state through as its own rule', () => {
|
|
132
|
+
open(mkModal({ styles: { 'close/hover/color': { desktop: 'red' } } }));
|
|
133
|
+
|
|
134
|
+
expect(emittedCss()).toMatch(/\[data-part="close"\][^{]*:hover/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('emits nothing at all when the designer has styled nothing', () => {
|
|
138
|
+
open(mkModal());
|
|
139
|
+
|
|
140
|
+
// Not an empty <style> either: the legacy golden pins this markup.
|
|
141
|
+
expect(emittedCss()).not.toContain('[data-theme-modal="dressed"]');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps the custom-CSS hatch out of the declaration stream', () => {
|
|
145
|
+
open(
|
|
146
|
+
mkModal({
|
|
147
|
+
styles: { customCss: { desktop: '.promo { color: red; }' } }
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const css = emittedCss();
|
|
152
|
+
expect(css).toContain('.promo');
|
|
153
|
+
// A multi-line body inside a declaration would produce a broken rule.
|
|
154
|
+
expect(css).not.toMatch(/customCss\s*:/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('the ownership guard', () => {
|
|
159
|
+
it('does not dress a panel block nested inside the modal', () => {
|
|
160
|
+
openOnStorefront(
|
|
161
|
+
mkModal({ styles: { 'border-radius': { desktop: '20px' } } })
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// The guard is what keeps `[data-part="modal"]` from matching a nested
|
|
165
|
+
// block's own part of the same name.
|
|
166
|
+
expect(emittedCss()).toContain(':not(');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
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 { readThemeModals, ThemeModal } from '../modal-registry';
|
|
19
|
+
import {
|
|
20
|
+
__resetOverlayBusForTests,
|
|
21
|
+
openModal,
|
|
22
|
+
registerModalHost
|
|
23
|
+
} from '../overlay-bus';
|
|
24
|
+
import ThemeModalMount from '../theme-modal-mount';
|
|
25
|
+
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A visible word beside the ✕.
|
|
29
|
+
*
|
|
30
|
+
* `closeLabel` could never do this: it reaches only `aria-label`, so a designer
|
|
31
|
+
* asking for "✕ Close" got a screen-reader-only string and an icon. No CSS
|
|
32
|
+
* control could supply it either — the `<span>` did not exist. Hence a chrome
|
|
33
|
+
* field rather than a style, and hence the last case here: the button without
|
|
34
|
+
* one has to stay byte-identical, because the legacy size-guide panel renders
|
|
35
|
+
* this same component and live storefronts style its markup.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const mkModal = (chrome: Record<string, unknown> = {}): ThemeModal =>
|
|
39
|
+
readThemeModals([{ id: 'newsletter', name: 'Newsletter', chrome }])[0];
|
|
40
|
+
|
|
41
|
+
const openWith = (chrome: Record<string, unknown> = {}) => {
|
|
42
|
+
render(
|
|
43
|
+
<ThemeModalMount modal={mkModal(chrome)}>
|
|
44
|
+
<p>composed content</p>
|
|
45
|
+
</ThemeModalMount>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
openModal('newsletter');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The LAST one: a test that opens twice to compare two settings stacks two
|
|
53
|
+
// containers, and the first match would answer for the previous render.
|
|
54
|
+
return Array.from(
|
|
55
|
+
document.querySelectorAll('[data-part="close"]')
|
|
56
|
+
).pop() as HTMLElement;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
__resetOverlayBusForTests();
|
|
61
|
+
__resetModalPreviewForTests();
|
|
62
|
+
registerModalHost();
|
|
63
|
+
clearAutoOpenRecord('newsletter');
|
|
64
|
+
document.body.style.overflow = '';
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('the text beside the close button', () => {
|
|
68
|
+
it('survives the config parser, and stays absent when never authored', () => {
|
|
69
|
+
expect(mkModal({ closeText: 'Kapat' }).chrome.closeText).toBe('Kapat');
|
|
70
|
+
expect(mkModal().chrome.closeText).toBeUndefined();
|
|
71
|
+
// An empty string is an absence, not "a text that is blank".
|
|
72
|
+
expect(mkModal({ closeText: ' ' }).chrome.closeText).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders inside the button, as its own styleable part', () => {
|
|
76
|
+
const button = openWith({ closeText: 'Kapat' });
|
|
77
|
+
|
|
78
|
+
expect(screen.getByText('Kapat')).toBeInTheDocument();
|
|
79
|
+
expect(
|
|
80
|
+
button.querySelector('[data-part="close-text"]')?.textContent
|
|
81
|
+
).toBe('Kapat');
|
|
82
|
+
// Still a real button, so the text is inside it rather than beside it.
|
|
83
|
+
expect(button.tagName).toBe('BUTTON');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('leaves the accessible name to closeLabel, which may differ', () => {
|
|
87
|
+
const button = openWith({
|
|
88
|
+
closeText: 'Şimdi değil',
|
|
89
|
+
closeLabel: 'Close the newsletter dialog'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(button.getAttribute('aria-label')).toBe(
|
|
93
|
+
'Close the newsletter dialog'
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('puts the text where the designer asked', () => {
|
|
98
|
+
expect(openWith({ closeText: 'Kapat' }).style.flexDirection).toBe('row');
|
|
99
|
+
expect(
|
|
100
|
+
openWith({ closeText: 'Kapat', closeTextSide: 'before' }).style
|
|
101
|
+
.flexDirection
|
|
102
|
+
).toBe('row-reverse');
|
|
103
|
+
// Only 'before' flips it; anything else is the historical order.
|
|
104
|
+
expect(mkModal({ closeTextSide: 'sideways' }).chrome.closeTextSide).toBe(
|
|
105
|
+
undefined
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('changes nothing about the button when there is no text', () => {
|
|
110
|
+
const button = openWith();
|
|
111
|
+
|
|
112
|
+
expect(button.querySelector('[data-part="close-text"]')).toBeNull();
|
|
113
|
+
// The golden fixture pins exactly these, and nothing else.
|
|
114
|
+
expect(button.style.display).toBe('');
|
|
115
|
+
expect(button.style.gap).toBe('');
|
|
116
|
+
expect(button.style.flexDirection).toBe('');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { act, fireEvent, render } 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 { __resetEditorBridgeForTests } from '../../editor-bridge';
|
|
18
|
+
import { __resetPlaceholderRegistryForTests } from '../../placeholder-registry';
|
|
19
|
+
import ModalPreviewHost from '../modal-preview-host';
|
|
20
|
+
import { readThemeModals, ThemeModal } from '../modal-registry';
|
|
21
|
+
import { __resetOverlayBusForTests } from '../overlay-bus';
|
|
22
|
+
import { __resetEditorModalsForTests } from '../use-editor-modals';
|
|
23
|
+
import { __resetModalPreviewForTests } from '../use-modal-preview';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The seam between the two repos, and the one `modal-preview-host.test.tsx`
|
|
27
|
+
* cannot pin: it stands the placeholder wrapper in for a stub, which is
|
|
28
|
+
* precisely the component whose mount ANNOUNCES the modal's slug.
|
|
29
|
+
*
|
|
30
|
+
* That announce is the editor's only channel for learning a modal exists:
|
|
31
|
+
* its left-hand tree lists the regions the frame reported, so a preview host
|
|
32
|
+
* that drew the surface without registering its region would open a modal the
|
|
33
|
+
* designer could look at and never fill. Rendered here with the real wrapper.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const EDITOR_ORIGIN = 'http://localhost:5174';
|
|
37
|
+
|
|
38
|
+
const postMessage = jest.fn();
|
|
39
|
+
|
|
40
|
+
const framesOfType = (type: string) =>
|
|
41
|
+
postMessage.mock.calls
|
|
42
|
+
.map(([message]) => message)
|
|
43
|
+
.filter(message => message?.type === type);
|
|
44
|
+
|
|
45
|
+
const mkModal = (): ThemeModal =>
|
|
46
|
+
readThemeModals([{ id: 'newsletter', name: 'Newsletter' }])[0];
|
|
47
|
+
|
|
48
|
+
const hold = (modal: ThemeModal) =>
|
|
49
|
+
act(() => {
|
|
50
|
+
fireEvent(
|
|
51
|
+
window,
|
|
52
|
+
new MessageEvent('message', {
|
|
53
|
+
origin: EDITOR_ORIGIN,
|
|
54
|
+
data: {
|
|
55
|
+
type: 'PREVIEW_OPEN_MODAL',
|
|
56
|
+
data: { modalId: modal.id, modal }
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('the region a held modal announces', () => {
|
|
63
|
+
beforeAll(() => {
|
|
64
|
+
// `postToEditor` drops everything unless the document is framed.
|
|
65
|
+
Object.defineProperty(window, 'top', {
|
|
66
|
+
configurable: true,
|
|
67
|
+
value: { name: 'designer' }
|
|
68
|
+
});
|
|
69
|
+
Object.defineProperty(window, 'parent', {
|
|
70
|
+
configurable: true,
|
|
71
|
+
value: { postMessage }
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
jest.useFakeTimers();
|
|
77
|
+
postMessage.mockClear();
|
|
78
|
+
delete (window as { __iframeReadySent?: boolean }).__iframeReadySent;
|
|
79
|
+
__resetModalPreviewForTests();
|
|
80
|
+
__resetEditorModalsForTests();
|
|
81
|
+
__resetOverlayBusForTests();
|
|
82
|
+
__resetEditorBridgeForTests();
|
|
83
|
+
__resetPlaceholderRegistryForTests();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
__resetPlaceholderRegistryForTests();
|
|
88
|
+
jest.useRealTimers();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('reports the modal slug to the editor, so its region can be listed', () => {
|
|
92
|
+
render(<ModalPreviewHost />);
|
|
93
|
+
|
|
94
|
+
// Nothing is held yet: no modal region exists to report.
|
|
95
|
+
act(() => {
|
|
96
|
+
jest.advanceTimersByTime(500);
|
|
97
|
+
});
|
|
98
|
+
expect(
|
|
99
|
+
framesOfType('PLACEHOLDERS_AVAILABLE').flatMap(
|
|
100
|
+
frame => frame.data?.placeholders ?? []
|
|
101
|
+
)
|
|
102
|
+
).not.toContain('modal-newsletter');
|
|
103
|
+
|
|
104
|
+
hold(mkModal());
|
|
105
|
+
act(() => {
|
|
106
|
+
jest.advanceTimersByTime(500);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const announced = framesOfType('PLACEHOLDERS_AVAILABLE').flatMap(
|
|
110
|
+
frame => frame.data?.placeholders ?? []
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// The whole set travels on every frame, never a delta — the editor
|
|
114
|
+
// replaces what it holds.
|
|
115
|
+
expect(announced).toContain('modal-newsletter');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCustomCss,
|
|
3
|
+
buildPartCustomCss,
|
|
4
|
+
PART_CUSTOM_CSS_PREFIX
|
|
5
|
+
} from '../utils/custom-css';
|
|
6
|
+
import {
|
|
7
|
+
escapeCssAttributeValue,
|
|
8
|
+
neutralizeStyleTagBreakout
|
|
9
|
+
} from '../utils/css-safety';
|
|
10
|
+
import { generatePartCSS } from '../utils';
|
|
11
|
+
import {
|
|
12
|
+
buildPartScopeSelector,
|
|
13
|
+
buildPartStyleKey,
|
|
14
|
+
isPartStyleKey
|
|
15
|
+
} from '../utils/part-styles';
|
|
16
|
+
import type { ThemeModal } from './modal-registry';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The designer's styles for a modal's CHROME, as CSS.
|
|
20
|
+
*
|
|
21
|
+
* Chrome is the one surface a part rule could not already reach: every part
|
|
22
|
+
* selector hangs off a `[data-block-id]`, and the panel, the backdrop and the
|
|
23
|
+
* close button belong to no block — they are drawn by `overlay-surface.tsx`
|
|
24
|
+
* from inline styles. Inline styles outrank any stylesheet, which is why the
|
|
25
|
+
* emitter here is `generatePartCSS` and not a hand-rolled one: it is the path
|
|
26
|
+
* that already rewrites every declaration `!important`, so "no radius at all"
|
|
27
|
+
* beats the renderer's historical `4px` instead of being written, saved, shown
|
|
28
|
+
* in the panel and quietly doing nothing.
|
|
29
|
+
*
|
|
30
|
+
* NEVER import this from `modal-registry.ts`. That module is a server read
|
|
31
|
+
* path, policed by `__tests__/server-read-path-client-free.test.ts`, and
|
|
32
|
+
* `../utils` pulls React in.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The parts a modal's chrome offers. `modal` is the panel itself, which is
|
|
37
|
+
* also what a BARE css key means — see the key grammar on `ThemeModal.styles`.
|
|
38
|
+
*/
|
|
39
|
+
export const MODAL_CHROME_PARTS = [
|
|
40
|
+
'modal',
|
|
41
|
+
'overlay',
|
|
42
|
+
'close',
|
|
43
|
+
'close-text',
|
|
44
|
+
'handle'
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type ModalChromePart = (typeof MODAL_CHROME_PARTS)[number];
|
|
48
|
+
|
|
49
|
+
export const buildModalChromeCss = (
|
|
50
|
+
modal: Pick<ThemeModal, 'id' | 'styles'>,
|
|
51
|
+
targetBreakpoint?: string
|
|
52
|
+
): string => {
|
|
53
|
+
const styles = modal.styles;
|
|
54
|
+
if (!styles || typeof styles !== 'object' || Array.isArray(styles)) return '';
|
|
55
|
+
|
|
56
|
+
const entries = Object.entries(styles as Record<string, unknown>);
|
|
57
|
+
if (entries.length === 0) return '';
|
|
58
|
+
|
|
59
|
+
// The modal's own root carries this attribute in both hosts, so one anchor
|
|
60
|
+
// serves the canvas and the storefront identically.
|
|
61
|
+
const anchor = `[data-theme-modal="${escapeCssAttributeValue(modal.id)}"]`;
|
|
62
|
+
|
|
63
|
+
const hatch: Record<string, unknown> = {};
|
|
64
|
+
const parted: Record<string, unknown> = {};
|
|
65
|
+
|
|
66
|
+
entries.forEach(([key, value]) => {
|
|
67
|
+
// Split BEFORE the part emitter sees them: `customCss/close` parses as the
|
|
68
|
+
// part "customCss" with the property "close", and a multi-line CSS body
|
|
69
|
+
// inside a declaration produces a broken rule.
|
|
70
|
+
if (
|
|
71
|
+
key === 'customCss' ||
|
|
72
|
+
key === 'custom-css' ||
|
|
73
|
+
key.startsWith(PART_CUSTOM_CSS_PREFIX)
|
|
74
|
+
) {
|
|
75
|
+
hatch[key] = value;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A bare key is the panel. Namespacing it here — rather than emitting the
|
|
80
|
+
// root bucket against the anchor as well — is what makes double
|
|
81
|
+
// application structurally impossible.
|
|
82
|
+
parted[isPartStyleKey(key) ? key : buildPartStyleKey('modal', key)] = value;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let css = generatePartCSS(anchor, parted, targetBreakpoint);
|
|
86
|
+
|
|
87
|
+
css += buildCustomCss(
|
|
88
|
+
{ customCss: hatch.customCss ?? hatch['custom-css'] },
|
|
89
|
+
buildPartScopeSelector(anchor, 'modal') ?? anchor,
|
|
90
|
+
targetBreakpoint
|
|
91
|
+
);
|
|
92
|
+
css += buildPartCustomCss(hatch, anchor, targetBreakpoint);
|
|
93
|
+
|
|
94
|
+
return neutralizeStyleTagBreakout(css);
|
|
95
|
+
};
|