@kubex/zinc 1.1.68 → 1.1.70
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/dist/custom-elements.json +1719 -282
- package/dist/vscode.html-custom-data.json +160 -29
- package/dist/web-types.json +395 -70
- package/dist/zn.d.ts +395 -16
- package/dist/zn.min.js +580 -408
- package/docs/pages/components/collapsible.md +6 -2
- package/docs/pages/components/icon-picker.md +14 -0
- package/docs/pages/components/input.md +19 -1
- package/docs/pages/components/preview-frame-demo.njk +33 -4
- package/docs/pages/components/preview-frame.md +20 -0
- package/docs/pages/components/theme-editor.md +303 -0
- package/docs/superpowers/plans/2026-08-03-theme-editor.md +1536 -0
- package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +327 -0
- package/package.json +1 -1
- package/src/components/collapsible/collapsible.component.ts +21 -23
- package/src/components/dropdown/dropdown.component.ts +9 -0
- package/src/components/editor/editor.component.ts +20 -21
- package/src/components/file/file.component.ts +15 -0
- package/src/components/icon-picker/icon-picker.component.ts +150 -16
- package/src/components/icon-picker/icon-picker.scss +32 -0
- package/src/components/input/input.component.ts +44 -21
- package/src/components/input/input.scss +134 -42
- package/src/components/input/input.test.ts +45 -0
- package/src/components/preview-frame/preview-frame.component.ts +126 -16
- package/src/components/preview-frame/preview-frame.scss +27 -0
- package/src/components/preview-frame/preview-frame.test.ts +253 -0
- package/src/components/theme-editor/index.ts +12 -0
- package/src/components/theme-editor/theme-editor.component.ts +918 -0
- package/src/components/theme-editor/theme-editor.scss +309 -0
- package/src/components/theme-editor/theme-editor.test.ts +1752 -0
- package/src/events/events.ts +2 -0
- package/src/events/zn-theme-change.ts +13 -0
- package/src/events/zn-theme-submit.ts +9 -0
- package/src/types/web-test-runner-commands.d.ts +6 -0
- package/src/zinc.ts +1 -0
- package/tsconfig.json +7 -1
- package/web-test-runner.config.js +3 -1
|
@@ -172,4 +172,49 @@ describe('<zn-input>', () => {
|
|
|
172
172
|
expect(secondInput.value).to.equal('');
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
describe('type="range"', () => {
|
|
177
|
+
const contextNote = (el: ZnInput) =>
|
|
178
|
+
el.shadowRoot!.querySelector('.form-control__label-context-note')!.textContent!.trim();
|
|
179
|
+
|
|
180
|
+
it('adopts the native midpoint when no value is set', async () => {
|
|
181
|
+
const el = await fixture<ZnInput>(html`<zn-input type="range" min="0" max="10"></zn-input>`);
|
|
182
|
+
await el.updateComplete;
|
|
183
|
+
|
|
184
|
+
expect(el.value).to.equal('5');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('displays the value with its suffix in the context note', async () => {
|
|
188
|
+
const el = await fixture<ZnInput>(
|
|
189
|
+
html`<zn-input type="range" label="Radius" min="0" max="2" step="0.5" value="0.5" value-suffix="rem"></zn-input>`
|
|
190
|
+
);
|
|
191
|
+
await el.updateComplete;
|
|
192
|
+
|
|
193
|
+
expect(contextNote(el)).to.equal('0.5rem');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('updates the context note as the value changes', async () => {
|
|
197
|
+
const el = await fixture<ZnInput>(
|
|
198
|
+
html`<zn-input type="range" label="Radius" min="0" max="10" value="4" value-suffix="px"></zn-input>`
|
|
199
|
+
);
|
|
200
|
+
await el.updateComplete;
|
|
201
|
+
|
|
202
|
+
const input = el.shadowRoot!.querySelector('.input__control') as HTMLInputElement;
|
|
203
|
+
input.value = '7';
|
|
204
|
+
input.dispatchEvent(new Event('input'));
|
|
205
|
+
await el.updateComplete;
|
|
206
|
+
|
|
207
|
+
expect(el.value).to.equal('7');
|
|
208
|
+
expect(contextNote(el)).to.equal('7px');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('prefers an explicit context-note over the value', async () => {
|
|
212
|
+
const el = await fixture<ZnInput>(
|
|
213
|
+
html`<zn-input type="range" label="Radius" context-note="Custom" value="4"></zn-input>`
|
|
214
|
+
);
|
|
215
|
+
await el.updateComplete;
|
|
216
|
+
|
|
217
|
+
expect(contextNote(el)).to.equal('Custom');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
175
220
|
});
|
|
@@ -6,11 +6,21 @@ import ZincElement from '../../internal/zinc-element';
|
|
|
6
6
|
|
|
7
7
|
import styles from './preview-frame.scss';
|
|
8
8
|
|
|
9
|
+
const DEVICE_WIDTHS = {
|
|
10
|
+
desktop: '100%',
|
|
11
|
+
tablet: '768px',
|
|
12
|
+
mobile: '390px',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type PreviewFrameDevice = keyof typeof DEVICE_WIDTHS;
|
|
16
|
+
|
|
9
17
|
/**
|
|
10
18
|
* @summary Embeds a live preview iframe and drives the hp-preview postMessage
|
|
11
19
|
* protocol: answers the frame's ready handshake with a config payload fetched
|
|
12
|
-
* from data-uri, auto-saves watched forms on change,
|
|
13
|
-
*
|
|
20
|
+
* from data-uri, auto-saves watched forms on change, refreshes the preview
|
|
21
|
+
* after each save (its own, or a shell-driven save of a `refresh-on` form),
|
|
22
|
+
* and accepts a theme payload via setTheme() that is retained and replayed
|
|
23
|
+
* after every ready handshake.
|
|
14
24
|
* @documentation https://zinc.style/components/preview-frame
|
|
15
25
|
* @status experimental
|
|
16
26
|
* @since 1.0
|
|
@@ -18,8 +28,12 @@ import styles from './preview-frame.scss';
|
|
|
18
28
|
* @event zn-error - Emitted when the preview reports a render error or a save fails.
|
|
19
29
|
*
|
|
20
30
|
* @csspart base - The component's base wrapper.
|
|
31
|
+
* @csspart stage - The device-width wrapper around the iframe.
|
|
21
32
|
* @csspart iframe - The preview iframe.
|
|
22
33
|
* @csspart error - The error overlay.
|
|
34
|
+
*
|
|
35
|
+
* @cssproperty --zn-preview-frame-dot-spacing - Spacing of the backdrop dot grid (`backdrop="dots"`). Defaults to 20px.
|
|
36
|
+
* @cssproperty --zn-preview-frame-dot-opacity - Opacity of the backdrop dots (`backdrop="dots"`). Defaults to 0.08.
|
|
23
37
|
*/
|
|
24
38
|
export default class ZnPreviewFrame extends ZincElement {
|
|
25
39
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
@@ -41,6 +55,15 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
41
55
|
*/
|
|
42
56
|
@property() watch = 'form[data-auto-save]';
|
|
43
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Selector for forms whose saves are left to the shell but should still
|
|
60
|
+
* refresh the preview. These are never intercepted: the shell submits them
|
|
61
|
+
* (so its own response handling — alerts, refreshes — runs as normal) and the
|
|
62
|
+
* preview re-fetches its config once the shell reports the save complete.
|
|
63
|
+
* Set empty to disable.
|
|
64
|
+
*/
|
|
65
|
+
@property({attribute: 'refresh-on'}) refreshOn = 'form';
|
|
66
|
+
|
|
44
67
|
/** Debounce in ms between a form change and its auto-save. */
|
|
45
68
|
@property({type: Number}) debounce = 400;
|
|
46
69
|
|
|
@@ -48,23 +71,46 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
48
71
|
* Zooms the previewed page out (0–1]. The frame always fills the panel;
|
|
49
72
|
* zoom shrinks the content browser-style, so 0.4 shows the page at 40%
|
|
50
73
|
* size with correspondingly more of it visible. 1 = natural size.
|
|
74
|
+
* Ignored when `fill` is set.
|
|
51
75
|
*/
|
|
52
76
|
@property({type: Number}) zoom = 1;
|
|
53
77
|
|
|
54
78
|
/**
|
|
55
79
|
* The visible height (in CSS pixels) of the preview panel. Fixed rather
|
|
56
80
|
* than measured, because a measured height would feed back into the
|
|
57
|
-
* scaled iframe's layout box and grow unbounded.
|
|
81
|
+
* scaled iframe's layout box and grow unbounded. With `fill` set, this
|
|
82
|
+
* becomes a `min-height` floor instead of the height.
|
|
58
83
|
*/
|
|
59
84
|
@property({type: Number, attribute: 'min-height'}) minHeight = 480;
|
|
60
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Fills the panel's own column height instead of using a fixed
|
|
88
|
+
* `min-height` pixel height — for hosts (like zn-theme-editor) whose
|
|
89
|
+
* layout already stretches the column to match a taller sibling.
|
|
90
|
+
* `zoom` is ignored when set: its oversize maths depends on a known
|
|
91
|
+
* pixel height, which `fill` deliberately doesn't have.
|
|
92
|
+
*/
|
|
93
|
+
@property({type: Boolean}) fill = false;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Constrains and centres the preview to a device width: `desktop` (100%),
|
|
97
|
+
* `tablet` (768px) or `mobile` (390px). The iframe element itself is
|
|
98
|
+
* narrowed, so the embedded page's own media queries fire.
|
|
99
|
+
*/
|
|
100
|
+
@property({reflect: true}) device: PreviewFrameDevice = 'desktop';
|
|
101
|
+
|
|
102
|
+
/** Backdrop behind the stage: `dots` (default) is the canvas dot grid; `panel` is a plain `rgb(var(--zn-panel))` fill. */
|
|
103
|
+
@property({reflect: true}) backdrop: 'dots' | 'panel' = 'dots';
|
|
104
|
+
|
|
61
105
|
@query('iframe') frame: HTMLIFrameElement;
|
|
62
106
|
|
|
63
107
|
@state() private error = '';
|
|
64
108
|
|
|
65
109
|
private _generation = 0;
|
|
110
|
+
private _theme: Record<string, unknown> | undefined;
|
|
66
111
|
|
|
67
112
|
private readonly _watchedForms = new Set<HTMLFormElement>();
|
|
113
|
+
private readonly _refreshForms = new Set<HTMLFormElement>();
|
|
68
114
|
private readonly _debounceTimers = new Map<HTMLFormElement, number>();
|
|
69
115
|
|
|
70
116
|
// Forms are siblings in light DOM and get replaced when other content
|
|
@@ -95,6 +141,8 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
95
141
|
window.removeEventListener('message', this._onMessage);
|
|
96
142
|
this._watchedForms.forEach(form => this._detachForm(form));
|
|
97
143
|
this._watchedForms.clear();
|
|
144
|
+
this._refreshForms.forEach(form => form.removeEventListener('complete', this._onShellSave));
|
|
145
|
+
this._refreshForms.clear();
|
|
98
146
|
}
|
|
99
147
|
|
|
100
148
|
private readonly _onMessage = (e: MessageEvent) => {
|
|
@@ -105,7 +153,8 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
105
153
|
const data = e.data as {type?: string; message?: string} | undefined;
|
|
106
154
|
switch (data?.type) {
|
|
107
155
|
case 'hp-preview:ready':
|
|
108
|
-
|
|
156
|
+
// config first: the embed applies the theme on top of a rendered page
|
|
157
|
+
void this._sendConfig().then(() => this._postTheme());
|
|
109
158
|
break;
|
|
110
159
|
case 'hp-preview:rendered':
|
|
111
160
|
this.error = '';
|
|
@@ -121,7 +170,29 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
121
170
|
return this._sendConfig();
|
|
122
171
|
}
|
|
123
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Pushes a theme payload into the preview. The payload is retained and
|
|
175
|
+
* re-posted after every ready handshake, so a frame reload doesn't drop an
|
|
176
|
+
* in-progress theme.
|
|
177
|
+
*/
|
|
178
|
+
setTheme(theme: Record<string, unknown>) {
|
|
179
|
+
this._theme = theme;
|
|
180
|
+
this._postTheme();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private _postTheme() {
|
|
184
|
+
if (!this._theme || !this.frameOrigin) return;
|
|
185
|
+
this.frame?.contentWindow?.postMessage(
|
|
186
|
+
{type: 'hp-preview:theme', ...this._theme},
|
|
187
|
+
this.frameOrigin
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
124
191
|
private async _sendConfig() {
|
|
192
|
+
// A theme-editor-only setup has no config endpoint; fetch('') would return
|
|
193
|
+
// the host page's HTML and fail JSON parsing into the error overlay.
|
|
194
|
+
if (!this.dataUri || !this.frameOrigin) return;
|
|
195
|
+
|
|
125
196
|
const generation = ++this._generation;
|
|
126
197
|
try {
|
|
127
198
|
const response = await fetch(this.dataUri, {
|
|
@@ -168,8 +239,33 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
168
239
|
form.addEventListener('zn-input', this._onChange);
|
|
169
240
|
form.addEventListener('change', this._onChange);
|
|
170
241
|
});
|
|
242
|
+
|
|
243
|
+
// Shell-saved forms: the shell fires 'complete' on the form it submitted.
|
|
244
|
+
const refreshMatched = new Set<HTMLFormElement>();
|
|
245
|
+
if (this.refreshOn) {
|
|
246
|
+
root.querySelectorAll(this.refreshOn).forEach(node => {
|
|
247
|
+
if (node instanceof HTMLFormElement && !matched.has(node)) refreshMatched.add(node);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this._refreshForms.forEach(form => {
|
|
252
|
+
if (!refreshMatched.has(form)) {
|
|
253
|
+
form.removeEventListener('complete', this._onShellSave);
|
|
254
|
+
this._refreshForms.delete(form);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
refreshMatched.forEach(form => {
|
|
259
|
+
if (this._refreshForms.has(form)) return;
|
|
260
|
+
this._refreshForms.add(form);
|
|
261
|
+
form.addEventListener('complete', this._onShellSave);
|
|
262
|
+
});
|
|
171
263
|
}
|
|
172
264
|
|
|
265
|
+
private readonly _onShellSave = () => {
|
|
266
|
+
void this._sendConfig();
|
|
267
|
+
};
|
|
268
|
+
|
|
173
269
|
private _detachForm(form: HTMLFormElement) {
|
|
174
270
|
form.removeEventListener('submit', this._onSubmit, {capture: true});
|
|
175
271
|
form.removeEventListener('zn-change', this._onChange);
|
|
@@ -202,8 +298,15 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
202
298
|
const response = await fetch(form.getAttribute('action') || '', {
|
|
203
299
|
method: 'POST',
|
|
204
300
|
credentials: 'same-origin',
|
|
301
|
+
// 'download' streams the app's own status and headers through verbatim;
|
|
302
|
+
// without it the console proxy pagelet-wraps the response and a failed
|
|
303
|
+
// save is indistinguishable from a successful one.
|
|
304
|
+
headers: {'x-kx-fetch-style': 'download'},
|
|
205
305
|
body: new FormData(form),
|
|
206
306
|
});
|
|
307
|
+
// Apps report a failed save as 204 plus an alert header, which is `ok`.
|
|
308
|
+
const alert = response.headers.get('x-kubex-alert-danger');
|
|
309
|
+
if (alert) throw new Error(alert);
|
|
207
310
|
if (!response.ok) {
|
|
208
311
|
throw new Error(await response.text() || response.statusText);
|
|
209
312
|
}
|
|
@@ -224,21 +327,28 @@ export default class ZnPreviewFrame extends ZincElement {
|
|
|
224
327
|
// transformed back down, so the frame fills the panel while the content
|
|
225
328
|
// renders smaller and more of the page is visible. Percentage width means
|
|
226
329
|
// nothing is measured — no layout feedback loop.
|
|
227
|
-
const iframeStyles =
|
|
228
|
-
width:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
330
|
+
const iframeStyles = this.fill
|
|
331
|
+
? {width: '100%', height: '100%'}
|
|
332
|
+
: {
|
|
333
|
+
width: `${100 / zoom}%`,
|
|
334
|
+
height: `${this.minHeight / zoom}px`,
|
|
335
|
+
transform: `scale(${zoom})`,
|
|
336
|
+
transformOrigin: '0 0',
|
|
337
|
+
};
|
|
338
|
+
const containerStyles = this.fill
|
|
339
|
+
? {height: '100%', minHeight: `${this.minHeight}px`}
|
|
340
|
+
: {height: `${this.minHeight}px`};
|
|
234
341
|
|
|
235
342
|
return html`
|
|
236
343
|
<div part="base" class="preview" style="${styleMap(containerStyles)}">
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
344
|
+
<div part="stage" class="preview__stage"
|
|
345
|
+
style="${styleMap({width: DEVICE_WIDTHS[this.device] ?? DEVICE_WIDTHS.desktop})}">
|
|
346
|
+
<iframe part="iframe"
|
|
347
|
+
src="${this.src}"
|
|
348
|
+
title="Payment form preview"
|
|
349
|
+
allow="local-network-access"
|
|
350
|
+
style="${styleMap(iframeStyles)}"></iframe>
|
|
351
|
+
</div>
|
|
242
352
|
${this.error ? html`
|
|
243
353
|
<div part="error" class="preview__error">${this.error}</div>` : ''}
|
|
244
354
|
</div>`;
|
|
@@ -2,16 +2,43 @@
|
|
|
2
2
|
display: block;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
// So `fill` works without the consumer also having to grow the host.
|
|
6
|
+
:host([fill]) {
|
|
7
|
+
flex: 1 1 auto;
|
|
8
|
+
min-height: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
.preview {
|
|
6
12
|
position: relative;
|
|
7
13
|
width: 100%;
|
|
8
14
|
overflow: hidden;
|
|
15
|
+
|
|
16
|
+
// Matches page-builder's canvas.
|
|
17
|
+
background:
|
|
18
|
+
radial-gradient(circle, rgba(var(--zn-text), var(--zn-preview-frame-dot-opacity, 0.08)) 1px, transparent 1px)
|
|
19
|
+
0 0 / var(--zn-preview-frame-dot-spacing, 20px) var(--zn-preview-frame-dot-spacing, 20px),
|
|
20
|
+
rgb(var(--zn-body));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Higher specificity than .preview alone, so it wins without !important.
|
|
24
|
+
:host([backdrop="panel"]) .preview {
|
|
25
|
+
background: rgb(var(--zn-panel));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.preview__stage {
|
|
29
|
+
height: 100%;
|
|
30
|
+
max-width: 100%;
|
|
31
|
+
margin: 0 auto;
|
|
32
|
+
overflow: hidden;
|
|
9
33
|
}
|
|
10
34
|
|
|
11
35
|
iframe {
|
|
12
36
|
display: block;
|
|
13
37
|
width: 100%;
|
|
14
38
|
border: 0;
|
|
39
|
+
// Iframes are transparent by default, so without this the dot grid would show
|
|
40
|
+
// through any embed that doesn't set its own background.
|
|
41
|
+
background: rgb(var(--zn-body));
|
|
15
42
|
}
|
|
16
43
|
|
|
17
44
|
.preview__error {
|
|
@@ -160,6 +160,41 @@ describe('<zn-preview-frame>', () => {
|
|
|
160
160
|
expect(fetchCalls[0].uri).to.contain('/save');
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
it('treats a 204 carrying an alert-danger header as a failed save', async () => {
|
|
164
|
+
window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
|
|
165
|
+
fetchCalls.push({uri: String(uri), init});
|
|
166
|
+
if (String(uri).includes('/save')) {
|
|
167
|
+
return Promise.resolve(new Response(null, {
|
|
168
|
+
status: 204,
|
|
169
|
+
headers: {'x-kubex-alert-danger': 'Unable to update page'}
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
return Promise.resolve(new Response(JSON.stringify(PAYLOAD), {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: {'Content-Type': 'application/json'}
|
|
175
|
+
}));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const wrapper = await fixture(html`
|
|
179
|
+
<div>
|
|
180
|
+
<form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
|
|
181
|
+
<zn-preview-frame
|
|
182
|
+
src="about:blank"
|
|
183
|
+
frame-origin="https://site.example"
|
|
184
|
+
data-uri="/payload"></zn-preview-frame>
|
|
185
|
+
</div>`);
|
|
186
|
+
|
|
187
|
+
const el = wrapper.querySelector('zn-preview-frame')!;
|
|
188
|
+
wrapper.querySelector('form')!.requestSubmit();
|
|
189
|
+
|
|
190
|
+
await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
|
|
191
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent)
|
|
192
|
+
.to.contain('Unable to update page');
|
|
193
|
+
// the preview must not be refreshed as though the save succeeded
|
|
194
|
+
expect(fetchCalls.filter(c => c.uri === '/payload').length).to.equal(0);
|
|
195
|
+
expect(fetchCalls[0].init?.headers).to.deep.equal({'x-kx-fetch-style': 'download'});
|
|
196
|
+
});
|
|
197
|
+
|
|
163
198
|
it('does not save a form removed from the DOM mid-debounce', async () => {
|
|
164
199
|
const wrapper = await fixture(html`
|
|
165
200
|
<div>
|
|
@@ -179,6 +214,52 @@ describe('<zn-preview-frame>', () => {
|
|
|
179
214
|
expect(fetchCalls.length).to.equal(0);
|
|
180
215
|
});
|
|
181
216
|
|
|
217
|
+
it('refreshes the preview when the shell reports a form save complete', async () => {
|
|
218
|
+
const wrapper = await fixture(html`
|
|
219
|
+
<div>
|
|
220
|
+
<form action="/save" method="post"><input name="a" value="1"></form>
|
|
221
|
+
<zn-preview-frame
|
|
222
|
+
src="about:blank"
|
|
223
|
+
frame-origin="https://site.example"
|
|
224
|
+
data-uri="/payload"></zn-preview-frame>
|
|
225
|
+
</div>`);
|
|
226
|
+
|
|
227
|
+
const el = wrapper.querySelector('zn-preview-frame')!;
|
|
228
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
229
|
+
const posted: Record<string, unknown>[] = [];
|
|
230
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
|
|
231
|
+
|
|
232
|
+
// the shell submitted the form itself and fires 'complete' on it
|
|
233
|
+
wrapper.querySelector('form')!.dispatchEvent(
|
|
234
|
+
new CustomEvent('complete', {bubbles: true, detail: {}}));
|
|
235
|
+
|
|
236
|
+
await waitUntil(() => fetchCalls.length === 1);
|
|
237
|
+
expect(fetchCalls[0].uri).to.equal('/payload');
|
|
238
|
+
// the save itself stays with the shell — the component must not post it
|
|
239
|
+
expect(fetchCalls.some(c => c.uri.includes('/save'))).to.equal(false);
|
|
240
|
+
await waitUntil(() => posted.length === 1);
|
|
241
|
+
expect(posted[0]['type']).to.equal('hp-preview:config');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not intercept the submit of a refresh-on form', async () => {
|
|
245
|
+
const wrapper = await fixture(html`
|
|
246
|
+
<div>
|
|
247
|
+
<form action="/save" method="post"><input name="a" value="1"></form>
|
|
248
|
+
<zn-preview-frame
|
|
249
|
+
src="about:blank"
|
|
250
|
+
frame-origin="https://site.example"
|
|
251
|
+
data-uri="/payload"></zn-preview-frame>
|
|
252
|
+
</div>`);
|
|
253
|
+
|
|
254
|
+
let bubbled = false;
|
|
255
|
+
wrapper.addEventListener('submit', e => { bubbled = true; e.preventDefault(); });
|
|
256
|
+
|
|
257
|
+
wrapper.querySelector('form')!.requestSubmit();
|
|
258
|
+
|
|
259
|
+
expect(bubbled).to.equal(true);
|
|
260
|
+
expect(fetchCalls.length).to.equal(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
182
263
|
it('leaves forms without data-auto-save alone', async () => {
|
|
183
264
|
const wrapper = await fixture(html`
|
|
184
265
|
<div>
|
|
@@ -256,6 +337,45 @@ describe('<zn-preview-frame>', () => {
|
|
|
256
337
|
expect(container.style.height).to.equal('600px');
|
|
257
338
|
});
|
|
258
339
|
|
|
340
|
+
describe('fill', () => {
|
|
341
|
+
it('fills the container instead of a fixed pixel height, with no zoom transform', async () => {
|
|
342
|
+
const el = await fixture(html`
|
|
343
|
+
<zn-preview-frame
|
|
344
|
+
src="about:blank"
|
|
345
|
+
frame-origin="https://site.example"
|
|
346
|
+
data-uri="/payload"
|
|
347
|
+
min-height="480"
|
|
348
|
+
fill></zn-preview-frame>`);
|
|
349
|
+
|
|
350
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
351
|
+
expect(container.style.height).to.equal('100%');
|
|
352
|
+
expect(container.style.minHeight).to.equal('480px');
|
|
353
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
354
|
+
expect(iframe.style.width).to.equal('100%');
|
|
355
|
+
expect(iframe.style.height).to.equal('100%');
|
|
356
|
+
expect(iframe.style.transform).to.equal('');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('ignores zoom when fill is set', async () => {
|
|
360
|
+
const el = await fixture(html`
|
|
361
|
+
<zn-preview-frame
|
|
362
|
+
src="about:blank"
|
|
363
|
+
frame-origin="https://site.example"
|
|
364
|
+
data-uri="/payload"
|
|
365
|
+
zoom="0.4"
|
|
366
|
+
min-height="600"
|
|
367
|
+
fill></zn-preview-frame>`);
|
|
368
|
+
|
|
369
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
370
|
+
expect(iframe.style.transform).to.equal('');
|
|
371
|
+
expect(iframe.style.width).to.equal('100%');
|
|
372
|
+
expect(iframe.style.height).to.equal('100%');
|
|
373
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
374
|
+
expect(container.style.height).to.equal('100%');
|
|
375
|
+
expect(container.style.minHeight).to.equal('600px');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
259
379
|
it('renders at natural size by default', async () => {
|
|
260
380
|
const el = await fixture(FIXTURE);
|
|
261
381
|
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
@@ -265,4 +385,137 @@ describe('<zn-preview-frame>', () => {
|
|
|
265
385
|
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
266
386
|
expect(container.style.height).to.equal('480px');
|
|
267
387
|
});
|
|
388
|
+
|
|
389
|
+
it('defaults to a full-width desktop stage', async () => {
|
|
390
|
+
const el = await fixture(FIXTURE);
|
|
391
|
+
const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
|
|
392
|
+
expect(stage.style.width).to.equal('100%');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('constrains the stage to the tablet width', async () => {
|
|
396
|
+
const el = await fixture(html`
|
|
397
|
+
<zn-preview-frame
|
|
398
|
+
src="about:blank"
|
|
399
|
+
frame-origin="https://site.example"
|
|
400
|
+
data-uri="/payload"
|
|
401
|
+
device="tablet"></zn-preview-frame>`);
|
|
402
|
+
|
|
403
|
+
const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
|
|
404
|
+
expect(stage.style.width).to.equal('768px');
|
|
405
|
+
// the iframe still sizes off the stage, so zoom maths is unaffected
|
|
406
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
407
|
+
expect(iframe.style.width).to.equal('100%');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('constrains the stage to the mobile width', async () => {
|
|
411
|
+
const el = await fixture(html`
|
|
412
|
+
<zn-preview-frame
|
|
413
|
+
src="about:blank"
|
|
414
|
+
frame-origin="https://site.example"
|
|
415
|
+
data-uri="/payload"
|
|
416
|
+
device="mobile"></zn-preview-frame>`);
|
|
417
|
+
|
|
418
|
+
const stage = el.shadowRoot!.querySelector<HTMLDivElement>('.preview__stage')!;
|
|
419
|
+
expect(stage.style.width).to.equal('390px');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('setTheme() posts an hp-preview:theme message', async () => {
|
|
423
|
+
const el = await fixture(FIXTURE);
|
|
424
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
425
|
+
const posted: {msg: Record<string, unknown>; origin: string}[] = [];
|
|
426
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>, origin: string) => void}).postMessage =
|
|
427
|
+
(msg: Record<string, unknown>, origin: string) => posted.push({msg, origin});
|
|
428
|
+
|
|
429
|
+
(el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
|
|
430
|
+
.setTheme({mode: 'dark', values: {background: '#101014'}});
|
|
431
|
+
|
|
432
|
+
await waitUntil(() => posted.length === 1);
|
|
433
|
+
expect(posted[0].origin).to.equal('https://site.example');
|
|
434
|
+
expect(posted[0].msg['type']).to.equal('hp-preview:theme');
|
|
435
|
+
expect(posted[0].msg['mode']).to.equal('dark');
|
|
436
|
+
expect(posted[0].msg['values']).to.deep.equal({background: '#101014'});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('replays the stored theme after a ready handshake', async () => {
|
|
440
|
+
const el = await fixture(FIXTURE);
|
|
441
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
442
|
+
const posted: Record<string, unknown>[] = [];
|
|
443
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
|
|
444
|
+
(msg: Record<string, unknown>) => posted.push(msg);
|
|
445
|
+
|
|
446
|
+
(el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
|
|
447
|
+
.setTheme({mode: 'light', values: {background: '#ffffff'}});
|
|
448
|
+
await waitUntil(() => posted.length === 1);
|
|
449
|
+
|
|
450
|
+
// a frame reload re-announces readiness; the theme must survive it
|
|
451
|
+
ready(el);
|
|
452
|
+
|
|
453
|
+
await waitUntil(() => posted.length === 3);
|
|
454
|
+
expect(posted[1]['type']).to.equal('hp-preview:config');
|
|
455
|
+
expect(posted[2]['type']).to.equal('hp-preview:theme');
|
|
456
|
+
expect(posted[2]['values']).to.deep.equal({background: '#ffffff'});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('setTheme() does not throw and posts nothing when frame-origin is unset', async () => {
|
|
460
|
+
const el = await fixture(html`
|
|
461
|
+
<zn-preview-frame src="about:blank" data-uri="/payload"></zn-preview-frame>`);
|
|
462
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
463
|
+
// Forward to the real postMessage (not mocked) so an unguarded '' target
|
|
464
|
+
// origin actually throws its SyntaxError, same as it would in production.
|
|
465
|
+
const realPostMessage = iframe.contentWindow!.postMessage.bind(iframe.contentWindow);
|
|
466
|
+
let calls = 0;
|
|
467
|
+
(iframe.contentWindow as {postMessage: (...args: unknown[]) => void}).postMessage =
|
|
468
|
+
(...args: unknown[]) => { calls++; return (realPostMessage as (...a: unknown[]) => void)(...args); };
|
|
469
|
+
|
|
470
|
+
expect(() => (el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void})
|
|
471
|
+
.setTheme({mode: 'light', values: {}})).to.not.throw();
|
|
472
|
+
|
|
473
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
474
|
+
expect(calls).to.equal(0);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// The docs site's theme stylesheet (--zn-text/--zn-body/--zn-panel) isn't
|
|
478
|
+
// loaded in this test shell, so an undefined var() makes the whole
|
|
479
|
+
// declaration invalid - set them inline to get a real computed background.
|
|
480
|
+
it('renders a dot grid backdrop by default', async () => {
|
|
481
|
+
const el = await fixture(FIXTURE) as HTMLElement;
|
|
482
|
+
el.style.setProperty('--zn-text', '0, 0, 0');
|
|
483
|
+
el.style.setProperty('--zn-body', '255, 255, 255');
|
|
484
|
+
|
|
485
|
+
expect(el.getAttribute('backdrop')).to.equal('dots');
|
|
486
|
+
const preview = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
487
|
+
expect(getComputedStyle(preview).backgroundImage).to.not.equal('none');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('renders a plain panel backdrop when backdrop="panel"', async () => {
|
|
491
|
+
const el = await fixture(html`
|
|
492
|
+
<zn-preview-frame
|
|
493
|
+
src="about:blank"
|
|
494
|
+
frame-origin="https://site.example"
|
|
495
|
+
data-uri="/payload"
|
|
496
|
+
backdrop="panel"></zn-preview-frame>`) as HTMLElement;
|
|
497
|
+
el.style.setProperty('--zn-panel', '240, 240, 240');
|
|
498
|
+
|
|
499
|
+
expect(el.getAttribute('backdrop')).to.equal('panel');
|
|
500
|
+
const preview = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
501
|
+
expect(getComputedStyle(preview).backgroundImage).to.equal('none');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('skips the config fetch when data-uri is empty', async () => {
|
|
505
|
+
const el = await fixture(html`
|
|
506
|
+
<zn-preview-frame src="about:blank" frame-origin="https://site.example"></zn-preview-frame>`);
|
|
507
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
508
|
+
const posted: Record<string, unknown>[] = [];
|
|
509
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage =
|
|
510
|
+
(msg: Record<string, unknown>) => posted.push(msg);
|
|
511
|
+
|
|
512
|
+
(el as HTMLElement & {setTheme: (t: Record<string, unknown>) => void}).setTheme({mode: 'light', values: {}});
|
|
513
|
+
ready(el);
|
|
514
|
+
|
|
515
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
516
|
+
expect(fetchCalls.length).to.equal(0);
|
|
517
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')).to.not.exist;
|
|
518
|
+
// theme still replays even with no config to send
|
|
519
|
+
expect(posted.filter(m => m['type'] === 'hp-preview:theme').length).to.equal(2);
|
|
520
|
+
});
|
|
268
521
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ZnThemeEditor from './theme-editor.component';
|
|
2
|
+
|
|
3
|
+
export * from './theme-editor.component';
|
|
4
|
+
export default ZnThemeEditor;
|
|
5
|
+
|
|
6
|
+
ZnThemeEditor.define('zn-theme-editor');
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface HTMLElementTagNameMap {
|
|
10
|
+
'zn-theme-editor': ZnThemeEditor;
|
|
11
|
+
}
|
|
12
|
+
}
|