@kubex/zinc 1.1.61 → 1.1.63
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 +337 -6
- package/dist/vscode.html-custom-data.json +55 -3
- package/dist/web-types.json +116 -6
- package/dist/zn.d.ts +83 -0
- package/dist/zn.min.js +529 -518
- package/docs/data/preview-frame-payload-error.json +4 -0
- package/docs/data/preview-frame-payload.json +6 -0
- package/docs/pages/components/form-group.md +12 -0
- package/docs/pages/components/preview-frame-demo.njk +138 -0
- package/docs/pages/components/preview-frame.md +105 -0
- package/docs/pages/components/toggle.md +2 -2
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +24 -12
- package/src/components/form-group/form-group.scss +10 -0
- package/src/components/form-group/form-group.test.ts +19 -0
- package/src/components/preview-frame/index.ts +12 -0
- package/src/components/preview-frame/preview-frame.component.ts +246 -0
- package/src/components/preview-frame/preview-frame.scss +27 -0
- package/src/components/preview-frame/preview-frame.test.ts +268 -0
- package/src/components/query-builder/query-builder.component.ts +1 -1
- package/src/components/select/select.component.ts +4 -0
- package/src/components/select/select.test.ts +29 -0
- package/src/components/sp/sp.scss +5 -1
- package/src/components/tile/tile.scss +5 -1
- package/src/components/toggle/toggle.component.ts +3 -12
- package/src/components/toggle/toggle.test.ts +29 -0
- package/src/events/zn-error.ts +1 -1
- package/src/zinc.ts +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {type CSSResultGroup, html, unsafeCSS} from 'lit';
|
|
2
|
+
import {MutationController} from '@lit-labs/observers/mutation-controller.js';
|
|
3
|
+
import {property, query, state} from 'lit/decorators.js';
|
|
4
|
+
import {styleMap} from 'lit/directives/style-map.js';
|
|
5
|
+
import ZincElement from '../../internal/zinc-element';
|
|
6
|
+
|
|
7
|
+
import styles from './preview-frame.scss';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @summary Embeds a live preview iframe and drives the hp-preview postMessage
|
|
11
|
+
* protocol: answers the frame's ready handshake with a config payload fetched
|
|
12
|
+
* from data-uri, auto-saves watched forms on change, and refreshes the
|
|
13
|
+
* preview after each save.
|
|
14
|
+
* @documentation https://zinc.style/components/preview-frame
|
|
15
|
+
* @status experimental
|
|
16
|
+
* @since 1.0
|
|
17
|
+
*
|
|
18
|
+
* @event zn-error - Emitted when the preview reports a render error or a save fails.
|
|
19
|
+
*
|
|
20
|
+
* @csspart base - The component's base wrapper.
|
|
21
|
+
* @csspart iframe - The preview iframe.
|
|
22
|
+
* @csspart error - The error overlay.
|
|
23
|
+
*/
|
|
24
|
+
export default class ZnPreviewFrame extends ZincElement {
|
|
25
|
+
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
26
|
+
|
|
27
|
+
/** URL of the preview shell page (tokened embed URL). */
|
|
28
|
+
@property() src = '';
|
|
29
|
+
|
|
30
|
+
/** Expected origin of the iframe; all postMessage traffic is checked against it. */
|
|
31
|
+
@property({attribute: 'frame-origin'}) frameOrigin = '';
|
|
32
|
+
|
|
33
|
+
/** Endpoint returning the hp-preview:config payload JSON. The console proxy rewrites this attribute to an app-prefixed path for proper fetch resolution. */
|
|
34
|
+
@property({attribute: 'data-uri'}) dataUri = '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Selector (resolved against the component's root node) for the forms to watch.
|
|
38
|
+
* Defaults to only forms explicitly opted in via a `data-auto-save` attribute —
|
|
39
|
+
* unmarked forms keep normal submit behavior and are never intercepted,
|
|
40
|
+
* auto-saved, or used to trigger a preview refresh. Override to widen the scope.
|
|
41
|
+
*/
|
|
42
|
+
@property() watch = 'form[data-auto-save]';
|
|
43
|
+
|
|
44
|
+
/** Debounce in ms between a form change and its auto-save. */
|
|
45
|
+
@property({type: Number}) debounce = 400;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Zooms the previewed page out (0–1]. The frame always fills the panel;
|
|
49
|
+
* zoom shrinks the content browser-style, so 0.4 shows the page at 40%
|
|
50
|
+
* size with correspondingly more of it visible. 1 = natural size.
|
|
51
|
+
*/
|
|
52
|
+
@property({type: Number}) zoom = 1;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The visible height (in CSS pixels) of the preview panel. Fixed rather
|
|
56
|
+
* than measured, because a measured height would feed back into the
|
|
57
|
+
* scaled iframe's layout box and grow unbounded.
|
|
58
|
+
*/
|
|
59
|
+
@property({type: Number, attribute: 'min-height'}) minHeight = 480;
|
|
60
|
+
|
|
61
|
+
@query('iframe') frame: HTMLIFrameElement;
|
|
62
|
+
|
|
63
|
+
@state() private error = '';
|
|
64
|
+
|
|
65
|
+
private _generation = 0;
|
|
66
|
+
|
|
67
|
+
private readonly _watchedForms = new Set<HTMLFormElement>();
|
|
68
|
+
private readonly _debounceTimers = new Map<HTMLFormElement, number>();
|
|
69
|
+
|
|
70
|
+
// Forms are siblings in light DOM and get replaced when other content
|
|
71
|
+
// re-renders; re-resolve them whenever the surrounding DOM changes.
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
73
|
+
private readonly _formObserver = new MutationController(this, {
|
|
74
|
+
target: null,
|
|
75
|
+
config: {subtree: true, childList: true},
|
|
76
|
+
callback: () => this._attachForms(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
connectedCallback() {
|
|
80
|
+
super.connectedCallback();
|
|
81
|
+
window.addEventListener('message', this._onMessage);
|
|
82
|
+
this._attachForms();
|
|
83
|
+
const root = this.getRootNode();
|
|
84
|
+
if (root instanceof Document) {
|
|
85
|
+
this._formObserver.observe(root.body);
|
|
86
|
+
} else if (root instanceof ShadowRoot) {
|
|
87
|
+
// MutationController's type only accepts Element, but MutationObserver.observe()
|
|
88
|
+
// accepts any Node at runtime, including a ShadowRoot.
|
|
89
|
+
this._formObserver.observe(root as unknown as Element);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
disconnectedCallback() {
|
|
94
|
+
super.disconnectedCallback();
|
|
95
|
+
window.removeEventListener('message', this._onMessage);
|
|
96
|
+
this._watchedForms.forEach(form => this._detachForm(form));
|
|
97
|
+
this._watchedForms.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private readonly _onMessage = (e: MessageEvent) => {
|
|
101
|
+
// Fail closed: with frame-origin unset, every message is rejected.
|
|
102
|
+
if (e.origin !== this.frameOrigin) return;
|
|
103
|
+
if (!this.frame || e.source !== this.frame.contentWindow) return;
|
|
104
|
+
|
|
105
|
+
const data = e.data as {type?: string; message?: string} | undefined;
|
|
106
|
+
switch (data?.type) {
|
|
107
|
+
case 'hp-preview:ready':
|
|
108
|
+
void this._sendConfig();
|
|
109
|
+
break;
|
|
110
|
+
case 'hp-preview:rendered':
|
|
111
|
+
this.error = '';
|
|
112
|
+
break;
|
|
113
|
+
case 'hp-preview:error':
|
|
114
|
+
this._fail(String(data.message ?? 'Preview failed to render'));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Re-fetches the payload and pushes a fresh config to the preview. */
|
|
120
|
+
refresh() {
|
|
121
|
+
return this._sendConfig();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async _sendConfig() {
|
|
125
|
+
const generation = ++this._generation;
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(this.dataUri, {
|
|
128
|
+
credentials: 'same-origin',
|
|
129
|
+
// The console proxy pagelet-wraps app responses; 'download' streams
|
|
130
|
+
// the endpoint's raw JSON through verbatim.
|
|
131
|
+
headers: {'x-kx-fetch-style': 'download'},
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(await response.text() || response.statusText);
|
|
135
|
+
}
|
|
136
|
+
const payload = await response.json() as Record<string, unknown>;
|
|
137
|
+
if (generation !== this._generation) return; // a newer refresh is in flight
|
|
138
|
+
this.frame?.contentWindow?.postMessage(
|
|
139
|
+
{type: 'hp-preview:config', ...payload},
|
|
140
|
+
this.frameOrigin
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (generation === this._generation) {
|
|
144
|
+
this._fail(err instanceof Error ? err.message : String(err));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private _attachForms() {
|
|
150
|
+
const root = this.getRootNode() as Document | ShadowRoot;
|
|
151
|
+
const matched = new Set<HTMLFormElement>();
|
|
152
|
+
root.querySelectorAll(this.watch).forEach(node => {
|
|
153
|
+
if (node instanceof HTMLFormElement) matched.add(node);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this._watchedForms.forEach(form => {
|
|
157
|
+
if (!matched.has(form)) {
|
|
158
|
+
this._detachForm(form);
|
|
159
|
+
this._watchedForms.delete(form);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
matched.forEach(form => {
|
|
164
|
+
if (this._watchedForms.has(form)) return;
|
|
165
|
+
this._watchedForms.add(form);
|
|
166
|
+
form.addEventListener('submit', this._onSubmit, {capture: true});
|
|
167
|
+
form.addEventListener('zn-change', this._onChange);
|
|
168
|
+
form.addEventListener('zn-input', this._onChange);
|
|
169
|
+
form.addEventListener('change', this._onChange);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private _detachForm(form: HTMLFormElement) {
|
|
174
|
+
form.removeEventListener('submit', this._onSubmit, {capture: true});
|
|
175
|
+
form.removeEventListener('zn-change', this._onChange);
|
|
176
|
+
form.removeEventListener('zn-input', this._onChange);
|
|
177
|
+
form.removeEventListener('change', this._onChange);
|
|
178
|
+
const timer = this._debounceTimers.get(form);
|
|
179
|
+
if (timer) window.clearTimeout(timer);
|
|
180
|
+
this._debounceTimers.delete(form);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private readonly _onChange = (e: Event) => {
|
|
184
|
+
const form = e.currentTarget as HTMLFormElement;
|
|
185
|
+
const existing = this._debounceTimers.get(form);
|
|
186
|
+
if (existing) window.clearTimeout(existing);
|
|
187
|
+
this._debounceTimers.set(form, window.setTimeout(() => {
|
|
188
|
+
this._debounceTimers.delete(form);
|
|
189
|
+
if (!form.isConnected) return;
|
|
190
|
+
form.requestSubmit();
|
|
191
|
+
}, this.debounce));
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
private readonly _onSubmit = (e: SubmitEvent) => {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
e.stopImmediatePropagation();
|
|
197
|
+
void this._save(e.currentTarget as HTMLFormElement);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
private async _save(form: HTMLFormElement) {
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(form.getAttribute('action') || '', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
credentials: 'same-origin',
|
|
205
|
+
body: new FormData(form),
|
|
206
|
+
});
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(await response.text() || response.statusText);
|
|
209
|
+
}
|
|
210
|
+
await this._sendConfig();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
this._fail(err instanceof Error ? err.message : String(err));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _fail(message: string) {
|
|
217
|
+
this.error = message;
|
|
218
|
+
this.emit('zn-error', {detail: {message}});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
render() {
|
|
222
|
+
const zoom = this.zoom > 0 && this.zoom <= 1 ? this.zoom : 1;
|
|
223
|
+
// Browser-style zoom-out: the iframe lays out oversized (1/zoom) and is
|
|
224
|
+
// transformed back down, so the frame fills the panel while the content
|
|
225
|
+
// renders smaller and more of the page is visible. Percentage width means
|
|
226
|
+
// nothing is measured — no layout feedback loop.
|
|
227
|
+
const iframeStyles = {
|
|
228
|
+
width: `${100 / zoom}%`,
|
|
229
|
+
height: `${this.minHeight / zoom}px`,
|
|
230
|
+
transform: `scale(${zoom})`,
|
|
231
|
+
transformOrigin: '0 0',
|
|
232
|
+
};
|
|
233
|
+
const containerStyles = {height: `${this.minHeight}px`};
|
|
234
|
+
|
|
235
|
+
return html`
|
|
236
|
+
<div part="base" class="preview" style="${styleMap(containerStyles)}">
|
|
237
|
+
<iframe part="iframe"
|
|
238
|
+
src="${this.src}"
|
|
239
|
+
title="Payment form preview"
|
|
240
|
+
allow="local-network-access"
|
|
241
|
+
style="${styleMap(iframeStyles)}"></iframe>
|
|
242
|
+
${this.error ? html`
|
|
243
|
+
<div part="error" class="preview__error">${this.error}</div>` : ''}
|
|
244
|
+
</div>`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.preview {
|
|
6
|
+
position: relative;
|
|
7
|
+
width: 100%;
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
iframe {
|
|
12
|
+
display: block;
|
|
13
|
+
width: 100%;
|
|
14
|
+
border: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.preview__error {
|
|
18
|
+
position: absolute;
|
|
19
|
+
inset: 0;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
padding: 1rem;
|
|
24
|
+
text-align: center;
|
|
25
|
+
background: rgba(0, 0, 0, 0.65);
|
|
26
|
+
color: #fff;
|
|
27
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import '../../../dist/zn.min.js';
|
|
2
|
+
import {expect, fixture, html, waitUntil} from '@open-wc/testing';
|
|
3
|
+
|
|
4
|
+
// The container's ResizeObserver can emit a benign "loop completed with undelivered
|
|
5
|
+
// notifications" warning when the panel resizes during layout-measuring tests. It's
|
|
6
|
+
// not a real error — ignore it so the test runner doesn't treat it as an uncaught
|
|
7
|
+
// exception (capture phase runs before the runner's).
|
|
8
|
+
window.addEventListener('error', (e: ErrorEvent) => {
|
|
9
|
+
if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
|
|
10
|
+
e.stopImmediatePropagation();
|
|
11
|
+
e.preventDefault();
|
|
12
|
+
}
|
|
13
|
+
}, true);
|
|
14
|
+
|
|
15
|
+
describe('<zn-preview-frame>', () => {
|
|
16
|
+
it('renders an iframe pointing at src', async () => {
|
|
17
|
+
const el = await fixture(html`
|
|
18
|
+
<zn-preview-frame
|
|
19
|
+
src="https://site.example/embed/preview?t=x"
|
|
20
|
+
frame-origin="https://site.example"
|
|
21
|
+
data-uri="/payload"></zn-preview-frame>`);
|
|
22
|
+
|
|
23
|
+
const iframe = el.shadowRoot!.querySelector('iframe');
|
|
24
|
+
expect(iframe).to.exist;
|
|
25
|
+
expect(iframe!.getAttribute('src')).to.equal('https://site.example/embed/preview?t=x');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const FIXTURE = html`
|
|
29
|
+
<zn-preview-frame
|
|
30
|
+
src="about:blank"
|
|
31
|
+
frame-origin="https://site.example"
|
|
32
|
+
data-uri="/payload"></zn-preview-frame>`;
|
|
33
|
+
|
|
34
|
+
const PAYLOAD = {pageType: 'payment.subscribe', page: {Name: 'Page'}, config: {BaseProduct: 'prod'}};
|
|
35
|
+
|
|
36
|
+
let fetchCalls: {uri: string; init?: RequestInit}[];
|
|
37
|
+
const realFetch = window.fetch;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
fetchCalls = [];
|
|
41
|
+
window.fetch = (uri: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
+
fetchCalls.push({uri: String(uri), init});
|
|
43
|
+
return Promise.resolve(new Response(JSON.stringify(PAYLOAD), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: {'Content-Type': 'application/json'}
|
|
46
|
+
}));
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
window.fetch = realFetch;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function ready(el: Element, origin = 'https://site.example') {
|
|
55
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
56
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
57
|
+
data: {type: 'hp-preview:ready'},
|
|
58
|
+
origin,
|
|
59
|
+
source: iframe.contentWindow
|
|
60
|
+
}));
|
|
61
|
+
return iframe;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('answers hp-preview:ready by fetching the payload and posting hp-preview:config', async () => {
|
|
65
|
+
const el = await fixture(FIXTURE);
|
|
66
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
67
|
+
const posted: {msg: Record<string, unknown>; origin: string}[] = [];
|
|
68
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>, origin: string) => void}).postMessage =
|
|
69
|
+
(msg: Record<string, unknown>, origin: string) => posted.push({msg, origin});
|
|
70
|
+
|
|
71
|
+
ready(el);
|
|
72
|
+
|
|
73
|
+
await waitUntil(() => posted.length === 1);
|
|
74
|
+
expect(fetchCalls[0].uri).to.equal('/payload');
|
|
75
|
+
expect((fetchCalls[0].init?.headers as Record<string, string>)['x-kx-fetch-style']).to.equal('download');
|
|
76
|
+
expect(posted[0].origin).to.equal('https://site.example');
|
|
77
|
+
expect(posted[0].msg['type']).to.equal('hp-preview:config');
|
|
78
|
+
expect(posted[0].msg['pageType']).to.equal('payment.subscribe');
|
|
79
|
+
expect(posted[0].msg['config']).to.deep.equal({BaseProduct: 'prod'});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('refresh() re-fetches the payload and posts a fresh config', async () => {
|
|
83
|
+
const el = await fixture(FIXTURE);
|
|
84
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
85
|
+
const posted: Record<string, unknown>[] = [];
|
|
86
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
|
|
87
|
+
|
|
88
|
+
await (el as HTMLElement & {refresh: () => Promise<void>}).refresh();
|
|
89
|
+
|
|
90
|
+
expect(fetchCalls[0].uri).to.equal('/payload');
|
|
91
|
+
expect(posted[0]['type']).to.equal('hp-preview:config');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores messages from other origins', async () => {
|
|
95
|
+
const el = await fixture(FIXTURE);
|
|
96
|
+
ready(el, 'https://evil.example');
|
|
97
|
+
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
99
|
+
expect(fetchCalls.length).to.equal(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects all messages when frame-origin is unset', async () => {
|
|
103
|
+
const el = await fixture(html`
|
|
104
|
+
<zn-preview-frame src="about:blank" data-uri="/payload"></zn-preview-frame>`);
|
|
105
|
+
ready(el, 'https://site.example');
|
|
106
|
+
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
108
|
+
expect(fetchCalls.length).to.equal(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('auto-saves a watched form on change, then refreshes the preview', async () => {
|
|
112
|
+
const wrapper = await fixture(html`
|
|
113
|
+
<div>
|
|
114
|
+
<form action="/save" method="post" data-auto-save>
|
|
115
|
+
<input name="caption" value="hello">
|
|
116
|
+
</form>
|
|
117
|
+
<zn-preview-frame
|
|
118
|
+
src="about:blank"
|
|
119
|
+
frame-origin="https://site.example"
|
|
120
|
+
data-uri="/payload"
|
|
121
|
+
debounce="10"></zn-preview-frame>
|
|
122
|
+
</div>`);
|
|
123
|
+
|
|
124
|
+
const el = wrapper.querySelector('zn-preview-frame')!;
|
|
125
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
126
|
+
const posted: Record<string, unknown>[] = [];
|
|
127
|
+
(iframe.contentWindow as {postMessage: (msg: Record<string, unknown>) => void}).postMessage = (msg: Record<string, unknown>) => posted.push(msg);
|
|
128
|
+
|
|
129
|
+
const form = wrapper.querySelector('form')!;
|
|
130
|
+
form.querySelector('input')!.dispatchEvent(
|
|
131
|
+
new Event('change', {bubbles: true}));
|
|
132
|
+
|
|
133
|
+
await waitUntil(() => fetchCalls.length === 2);
|
|
134
|
+
expect(fetchCalls[0].uri).to.contain('/save');
|
|
135
|
+
expect(fetchCalls[0].init?.method).to.equal('POST');
|
|
136
|
+
expect(fetchCalls[0].init?.body).to.be.instanceOf(FormData);
|
|
137
|
+
expect((fetchCalls[0].init!.body as FormData).get('caption')).to.equal('hello');
|
|
138
|
+
expect(fetchCalls[1].uri).to.equal('/payload');
|
|
139
|
+
await waitUntil(() => posted.length === 1);
|
|
140
|
+
expect(posted[0]['type']).to.equal('hp-preview:config');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('intercepts watched form submits so they never bubble to the shell', async () => {
|
|
144
|
+
const wrapper = await fixture(html`
|
|
145
|
+
<div>
|
|
146
|
+
<form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
|
|
147
|
+
<zn-preview-frame
|
|
148
|
+
src="about:blank"
|
|
149
|
+
frame-origin="https://site.example"
|
|
150
|
+
data-uri="/payload"></zn-preview-frame>
|
|
151
|
+
</div>`);
|
|
152
|
+
|
|
153
|
+
let bubbled = false;
|
|
154
|
+
wrapper.addEventListener('submit', () => { bubbled = true; });
|
|
155
|
+
|
|
156
|
+
wrapper.querySelector('form')!.requestSubmit();
|
|
157
|
+
|
|
158
|
+
await waitUntil(() => fetchCalls.length >= 1);
|
|
159
|
+
expect(bubbled).to.equal(false);
|
|
160
|
+
expect(fetchCalls[0].uri).to.contain('/save');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not save a form removed from the DOM mid-debounce', async () => {
|
|
164
|
+
const wrapper = await fixture(html`
|
|
165
|
+
<div>
|
|
166
|
+
<form action="/save" method="post" data-auto-save><input name="a" value="1"></form>
|
|
167
|
+
<zn-preview-frame
|
|
168
|
+
src="about:blank"
|
|
169
|
+
frame-origin="https://site.example"
|
|
170
|
+
data-uri="/payload"
|
|
171
|
+
debounce="10"></zn-preview-frame>
|
|
172
|
+
</div>`);
|
|
173
|
+
|
|
174
|
+
const form = wrapper.querySelector('form')!;
|
|
175
|
+
form.querySelector('input')!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
176
|
+
form.remove();
|
|
177
|
+
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
179
|
+
expect(fetchCalls.length).to.equal(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('leaves forms without data-auto-save alone', async () => {
|
|
183
|
+
const wrapper = await fixture(html`
|
|
184
|
+
<div>
|
|
185
|
+
<form action="/save" method="post"><input name="a" value="1"></form>
|
|
186
|
+
<zn-preview-frame
|
|
187
|
+
src="about:blank"
|
|
188
|
+
frame-origin="https://site.example"
|
|
189
|
+
data-uri="/payload"
|
|
190
|
+
debounce="10"></zn-preview-frame>
|
|
191
|
+
</div>`);
|
|
192
|
+
|
|
193
|
+
let bubbled = false;
|
|
194
|
+
wrapper.addEventListener('submit', e => { bubbled = true; e.preventDefault(); });
|
|
195
|
+
|
|
196
|
+
const form = wrapper.querySelector('form')!;
|
|
197
|
+
form.querySelector('input')!.dispatchEvent(new Event('change', {bubbles: true}));
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
199
|
+
expect(fetchCalls.length).to.equal(0);
|
|
200
|
+
|
|
201
|
+
form.requestSubmit();
|
|
202
|
+
expect(bubbled).to.equal(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('shows hp-preview:error messages in the overlay and clears on rendered', async () => {
|
|
206
|
+
const el = await fixture(FIXTURE);
|
|
207
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
208
|
+
let znError: CustomEvent<{message?: string}> | null = null;
|
|
209
|
+
el.addEventListener('zn-error', (e: Event) => { znError = e as CustomEvent<{message?: string}>; });
|
|
210
|
+
|
|
211
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
212
|
+
data: {type: 'hp-preview:error', message: 'bad config'},
|
|
213
|
+
origin: 'https://site.example',
|
|
214
|
+
source: iframe.contentWindow
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
|
|
218
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('bad config');
|
|
219
|
+
expect(znError).to.exist;
|
|
220
|
+
expect(znError!.detail.message).to.equal('bad config');
|
|
221
|
+
|
|
222
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
223
|
+
data: {type: 'hp-preview:rendered'},
|
|
224
|
+
origin: 'https://site.example',
|
|
225
|
+
source: iframe.contentWindow
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
await waitUntil(() => !el.shadowRoot!.querySelector('[part="error"]'));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('shows a payload fetch failure in the overlay', async () => {
|
|
232
|
+
window.fetch = () => Promise.resolve(new Response('payload exploded', {status: 500}));
|
|
233
|
+
const el = await fixture(FIXTURE);
|
|
234
|
+
ready(el);
|
|
235
|
+
|
|
236
|
+
await waitUntil(() => el.shadowRoot!.querySelector('[part="error"]'));
|
|
237
|
+
expect(el.shadowRoot!.querySelector('[part="error"]')!.textContent).to.contain('payload exploded');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('zooms the content out: oversized layout scaled back into the frame', async () => {
|
|
241
|
+
const el = await fixture(html`
|
|
242
|
+
<zn-preview-frame
|
|
243
|
+
src="about:blank"
|
|
244
|
+
frame-origin="https://site.example"
|
|
245
|
+
data-uri="/payload"
|
|
246
|
+
zoom="0.4"
|
|
247
|
+
min-height="600"></zn-preview-frame>`);
|
|
248
|
+
|
|
249
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
250
|
+
await waitUntil(() => iframe.style.transform === 'scale(0.4)');
|
|
251
|
+
// 1/zoom oversize, transformed back down — the frame itself fills the
|
|
252
|
+
// panel while the page renders at 40%.
|
|
253
|
+
expect(iframe.style.width).to.equal('250%');
|
|
254
|
+
expect(iframe.style.height).to.equal('1500px');
|
|
255
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
256
|
+
expect(container.style.height).to.equal('600px');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('renders at natural size by default', async () => {
|
|
260
|
+
const el = await fixture(FIXTURE);
|
|
261
|
+
const iframe = el.shadowRoot!.querySelector('iframe')!;
|
|
262
|
+
await waitUntil(() => iframe.style.transform === 'scale(1)');
|
|
263
|
+
expect(iframe.style.width).to.equal('100%');
|
|
264
|
+
expect(iframe.style.height).to.equal('480px');
|
|
265
|
+
const container = el.shadowRoot!.querySelector<HTMLDivElement>('.preview')!;
|
|
266
|
+
expect(container.style.height).to.equal('480px');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -398,7 +398,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
|
|
|
398
398
|
<zn-datepicker
|
|
399
399
|
name="${uniqueId}"
|
|
400
400
|
format-time="HH:mm"
|
|
401
|
-
time-picker="${hasTime}"
|
|
401
|
+
?time-picker="${hasTime}"
|
|
402
402
|
class="query-builder__value"
|
|
403
403
|
@zn-change="${(e: ZnChangeEvent) => this._updateDateValue(uniqueId, e, submitFormat)}"
|
|
404
404
|
>
|
|
@@ -1109,6 +1109,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1109
1109
|
this.updateComplete.then(() => {
|
|
1110
1110
|
this.emit('zn-input');
|
|
1111
1111
|
this.emit('zn-change');
|
|
1112
|
+
|
|
1113
|
+
if (this.triggerSubmit) {
|
|
1114
|
+
this.formControlController.submit();
|
|
1115
|
+
}
|
|
1112
1116
|
});
|
|
1113
1117
|
}
|
|
1114
1118
|
}
|
|
@@ -260,6 +260,35 @@ describe('<zn-select>', () => {
|
|
|
260
260
|
});
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
describe('trigger-submit', () => {
|
|
264
|
+
it('submits the form when a tag is removed', async () => {
|
|
265
|
+
const form = await fixture<HTMLFormElement>(html`
|
|
266
|
+
<form>
|
|
267
|
+
<zn-select multiple trigger-submit value="a b">
|
|
268
|
+
<zn-option value="a">Option 1</zn-option>
|
|
269
|
+
<zn-option value="b">Option 2</zn-option>
|
|
270
|
+
</zn-select>
|
|
271
|
+
</form>
|
|
272
|
+
`);
|
|
273
|
+
const el = form.querySelector<ZnSelect>('zn-select')!;
|
|
274
|
+
await el.updateComplete;
|
|
275
|
+
|
|
276
|
+
let submitted = false;
|
|
277
|
+
form.addEventListener('submit', e => {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
submitted = true;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const removeIcon = el.shadowRoot!.querySelector<HTMLElement>('.select__tags zn-icon[slot="action"]')!;
|
|
283
|
+
removeIcon.click();
|
|
284
|
+
await el.updateComplete;
|
|
285
|
+
await el.updateComplete;
|
|
286
|
+
|
|
287
|
+
expect(el.value).to.deep.equal(['b']);
|
|
288
|
+
expect(submitted).to.be.true;
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
263
292
|
describe('free-text', () => {
|
|
264
293
|
const type = (el: ZnSelect, text: string) => {
|
|
265
294
|
const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
|
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
height: 100%;
|
|
37
37
|
margin: var(--zn-default-margin, 0 auto);
|
|
38
38
|
|
|
39
|
+
&--divide {
|
|
40
|
+
gap: calc(var(--zn-sp-gap) * 2);
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
&--divide.sp--no-gap {
|
|
40
44
|
::slotted(*:not(:last-child)) {
|
|
41
45
|
border-bottom: 1px solid rgb(var(--zn-border-color)) !important;
|
|
@@ -54,7 +58,7 @@
|
|
|
54
58
|
left: 0;
|
|
55
59
|
width: 100%;
|
|
56
60
|
max-width: 100%;
|
|
57
|
-
bottom: calc((var(--zn-sp-gap) /
|
|
61
|
+
bottom: calc((var(--zn-sp-gap) / 1) * -1);
|
|
58
62
|
background-color: rgb(var(--zn-border-color));
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
padding-inline: var(--zn-base-gap);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
&--has-description#{&}--has-caption {
|
|
29
|
+
padding-block: var(--zn-spacing-small);
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
&--has-image {
|
|
29
|
-
padding: var(--zn-
|
|
33
|
+
padding: var(--zn-spacing-small);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
&--has-href {
|
|
@@ -36,7 +36,7 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
|
|
|
36
36
|
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'description');
|
|
37
37
|
|
|
38
38
|
private readonly formControlController = new FormControlController(this, {
|
|
39
|
-
value: (control: ZnToggle) => (control.checked ? control.value || 'on' :
|
|
39
|
+
value: (control: ZnToggle) => (control.checked ? control.value || 'on' : control.fallbackValue),
|
|
40
40
|
defaultValue: (control: ZnToggle) => control.defaultChecked,
|
|
41
41
|
setValue: (control: ZnToggle, checked: boolean) => (control.checked = checked)
|
|
42
42
|
});
|
|
@@ -51,7 +51,8 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
|
|
|
51
51
|
|
|
52
52
|
@property() value: string;
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/** The value submitted when the toggle is unchecked, so the toggle always submits a value. */
|
|
55
|
+
@property({ attribute: 'fallback' }) fallbackValue: string = 'off';
|
|
55
56
|
|
|
56
57
|
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
|
|
57
58
|
|
|
@@ -167,15 +168,6 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
|
|
|
167
168
|
// End Public API methods
|
|
168
169
|
|
|
169
170
|
render() {
|
|
170
|
-
let fallback = html``;
|
|
171
|
-
if (this.fallbackValue !== '') {
|
|
172
|
-
fallback = html`
|
|
173
|
-
<input type="hidden"
|
|
174
|
-
name=${this.name}
|
|
175
|
-
value=${this.fallbackValue}
|
|
176
|
-
/>`;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
171
|
const tooltipContent = this.checked ? this.onText : this.offText;
|
|
180
172
|
const showTooltip = !!tooltipContent;
|
|
181
173
|
const hasHelpText = this.helpText ? true : this.hasSlotController.test('help-text');
|
|
@@ -183,7 +175,6 @@ export default class ZnToggle extends ZincElement implements ZincFormControl {
|
|
|
183
175
|
|
|
184
176
|
const toggle = html`
|
|
185
177
|
<div class="switch__input-wrapper" part="base">
|
|
186
|
-
${fallback}
|
|
187
178
|
<input
|
|
188
179
|
class="switch__input"
|
|
189
180
|
type="checkbox"
|