@kubex/zinc 1.1.62 → 1.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/custom-elements.json +3141 -1885
  2. package/dist/vscode.html-custom-data.json +102 -3
  3. package/dist/web-types.json +224 -6
  4. package/dist/zn.d.ts +235 -0
  5. package/dist/zn.min.js +795 -679
  6. package/docs/data/preview-frame-payload-error.json +4 -0
  7. package/docs/data/preview-frame-payload.json +6 -0
  8. package/docs/pages/components/form-group.md +12 -0
  9. package/docs/pages/components/preview-frame-demo.njk +138 -0
  10. package/docs/pages/components/preview-frame.md +105 -0
  11. package/docs/pages/components/remarkd-editor.md +103 -0
  12. package/docs/pages/components/toggle.md +2 -2
  13. package/package.json +3 -2
  14. package/src/components/file/file.component.ts +16 -1
  15. package/src/components/form-group/form-group.component.ts +24 -12
  16. package/src/components/form-group/form-group.scss +10 -0
  17. package/src/components/form-group/form-group.test.ts +19 -0
  18. package/src/components/preview-frame/index.ts +12 -0
  19. package/src/components/preview-frame/preview-frame.component.ts +246 -0
  20. package/src/components/preview-frame/preview-frame.scss +27 -0
  21. package/src/components/preview-frame/preview-frame.test.ts +268 -0
  22. package/src/components/query-builder/query-builder.component.ts +14 -6
  23. package/src/components/remarkd-editor/index.ts +12 -0
  24. package/src/components/remarkd-editor/remarkd-editor.component.ts +752 -0
  25. package/src/components/remarkd-editor/remarkd-editor.scss +241 -0
  26. package/src/components/remarkd-editor/remarkd-editor.test.ts +147 -0
  27. package/src/components/select/select.component.ts +4 -0
  28. package/src/components/select/select.test.ts +29 -0
  29. package/src/components/sp/sp.scss +5 -1
  30. package/src/components/tabs/tabs.component.ts +9 -4
  31. package/src/components/tabs/tabs.test.ts +42 -1
  32. package/src/components/tile/tile.scss +5 -1
  33. package/src/components/toggle/toggle.component.ts +3 -12
  34. package/src/components/toggle/toggle.test.ts +29 -0
  35. package/src/events/zn-error.ts +1 -1
  36. package/src/zinc.ts +2 -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
+ });
@@ -167,6 +167,10 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
167
167
  }) showValues: string[] = [];
168
168
 
169
169
 
170
+ private get _usedFilterIds(): Set<string> {
171
+ return new Set([...this._selectedRules.values()].map(rule => rule.id));
172
+ }
173
+
170
174
  get validationMessage(): string {
171
175
  return '';
172
176
  }
@@ -196,7 +200,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
196
200
  size="medium"
197
201
  placeholder="Select Filter"
198
202
  @zn-change="${this._addRule}">
199
- ${this.filters && this.filters.map(item => html`
203
+ ${this.filters && this.filters.filter(item => !this._usedFilterIds.has(item.id)).map(item => html`
200
204
  <zn-option value="${item.id}">
201
205
  ${item.name.charAt(0).toUpperCase() + item.name.slice(1)}
202
206
  </zn-option>`)}
@@ -227,6 +231,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
227
231
 
228
232
  const filter: QueryBuilderItem | undefined = this.filters.find(item => item.id === id);
229
233
  if (filter === undefined) return;
234
+ if (this._usedFilterIds.has(filter.id)) return;
230
235
 
231
236
  const uniqueId = Math.random().toString(36).substring(7);
232
237
 
@@ -237,11 +242,12 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
237
242
  value: ''
238
243
  });
239
244
 
245
+ const availableFilters = this.filters.filter(item => item.id === filter.id || !this._usedFilterIds.has(item.id));
240
246
  const select = html`
241
247
  <zn-select class="query-builder__key"
242
248
  @zn-change="${(e: ZnChangeEvent) => this._changeRule(uniqueId, e)}"
243
249
  value="${filter?.id}">
244
- ${this.filters.map((item: QueryBuilderItem) => {
250
+ ${availableFilters.map((item: QueryBuilderItem) => {
245
251
  return html`
246
252
  <zn-option value="${item.id}">
247
253
  ${item.name.charAt(0).toUpperCase() + item.name.slice(1)}
@@ -338,7 +344,7 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
338
344
  break;
339
345
  }
340
346
  case 'dateTime': {
341
- input = this._createDateInput(uniqueId, true , filter.dateSubmitFormat);
347
+ input = this._createDateInput(uniqueId, true, filter.dateSubmitFormat);
342
348
  break;
343
349
  }
344
350
  default: {
@@ -401,9 +407,9 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
401
407
  ?time-picker="${hasTime}"
402
408
  class="query-builder__value"
403
409
  @zn-change="${(e: ZnChangeEvent) => this._updateDateValue(uniqueId, e, submitFormat)}"
404
- >
410
+ >
405
411
  </zn-datepicker>
406
- `;
412
+ `;
407
413
  return litToHTML<ZnDatepicker>(input);
408
414
  }
409
415
 
@@ -451,7 +457,9 @@ export default class ZnQueryBuilder extends ZincElement implements ZincFormContr
451
457
  this._handleChange();
452
458
  }
453
459
 
454
- private _updateDateValue(id: string, event: Event | { target: ZnDatepicker | HTMLDivElement }, submitFormat: QueryBuilderDateSubmitFormat = 'legacy') {
460
+ private _updateDateValue(id: string, event: Event | {
461
+ target: ZnDatepicker | HTMLDivElement
462
+ }, submitFormat: QueryBuilderDateSubmitFormat = 'legacy') {
455
463
  const filter = this._selectedRules.get(id);
456
464
  if (!filter) return;
457
465
 
@@ -0,0 +1,12 @@
1
+ import ZnRemarkdEditor from './remarkd-editor.component';
2
+
3
+ export * from './remarkd-editor.component';
4
+ export default ZnRemarkdEditor;
5
+
6
+ ZnRemarkdEditor.define('zn-remarkd-editor');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-remarkd-editor': ZnRemarkdEditor;
11
+ }
12
+ }