@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.
@@ -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) / 2) * -1);
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-base-gap);
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' : undefined),
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
- @property({ attribute: 'fallback' }) fallbackValue: string = '';
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"