@kubex/zinc 2.0.2 → 2.0.4

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 (38) hide show
  1. package/dist/custom-elements.json +356 -115
  2. package/dist/vscode.html-custom-data.json +29 -14
  3. package/dist/web-types.json +67 -45
  4. package/dist/zn.d.ts +47 -21
  5. package/dist/zn.min.css +1 -1
  6. package/dist/zn.min.js +333 -309
  7. package/docs/pages/components/form-actions.md +11 -0
  8. package/package.json +1 -1
  9. package/scss/_global-spacing.scss +7 -0
  10. package/scss/_root.scss +1 -5
  11. package/src/components/button/button.component.ts +2 -2
  12. package/src/components/button/button.test.ts +7 -0
  13. package/src/components/chart/chart.component.ts +1 -1
  14. package/src/components/editor/modules/dialog/dialog.test.ts +1 -1
  15. package/src/components/expanding-action/expanding-action.component.ts +1 -1
  16. package/src/components/filter-wrapper/filter-wrapper.component.ts +1 -1
  17. package/src/components/form-actions/form-actions.component.ts +112 -14
  18. package/src/components/form-actions/form-actions.scss +37 -1
  19. package/src/components/form-actions/form-actions.test.ts +137 -0
  20. package/src/components/inline-edit/inline-edit.component.ts +1 -2
  21. package/src/components/inline-edit/inline-edit.scss +20 -20
  22. package/src/components/input/input.component.ts +38 -2
  23. package/src/components/input/input.scss +96 -33
  24. package/src/components/input/input.test.ts +1 -1
  25. package/src/components/key-value/key-value.component.ts +123 -12
  26. package/src/components/key-value/key-value.scss +13 -0
  27. package/src/components/key-value/key-value.test.ts +65 -2
  28. package/src/components/nav-list/nav-list.test.ts +1 -1
  29. package/src/components/navbar/navbar.component.ts +14 -8
  30. package/src/components/panel/panel.component.ts +1 -1
  31. package/src/components/priority-list/priority-list.test.ts +1 -1
  32. package/src/components/select/select.test.ts +24 -0
  33. package/src/components/split-pane/split-pane.component.ts +1 -1
  34. package/src/components/table/table.component.ts +5 -2
  35. package/src/components/table/table.scss +4 -0
  36. package/src/components/table/table.test.ts +13 -0
  37. package/src/components/tabs/tabs.component.ts +2 -2
  38. package/src/internal/conditional.ts +9 -0
@@ -39,6 +39,17 @@ Use the `with-reset` attribute to add a button that resets the form's fields.
39
39
  </form>
40
40
  ```
41
41
 
42
+ ### Hide Until Changed
43
+
44
+ Use the `hide-until-changed` attribute to keep the row hidden until a field in the form changes. It hides again once the form is back to its original values — undone by hand, reset, or saved.
45
+
46
+ ```html:preview
47
+ <form>
48
+ <zn-input name="title" label="Title" value="Edit me"></zn-input>
49
+ <zn-form-actions hide-until-changed with-reset></zn-form-actions>
50
+ </form>
51
+ ```
52
+
42
53
  ### Custom Text and Icons
43
54
 
44
55
  Each button's text and icon can be overridden with `submit-text` / `submit-icon`, `cancel-text` / `cancel-icon` and `reset-text` / `reset-icon`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -89,6 +89,13 @@ zn-sp[divide] > zn-form-group ~ zn-form-group {
89
89
  border-top: 0;
90
90
  }
91
91
 
92
+ // Divided rows of fields keep the plain field spacing, half each side of the line, where divided sections keep the
93
+ // full gap either side.
94
+ zn-sp[divide]:has(> zn-toggle, > zn-input, > zn-select, > zn-checkbox, > zn-textarea, > zn-datepicker,
95
+ > zn-radio-group, > zn-checkbox-group) {
96
+ --zn-sp-gap: calc(var(--zn-form-row-gap, var(--zn-spacing-medium)) / 2);
97
+ }
98
+
92
99
 
93
100
  .form-spacing > * {
94
101
  grid-column: span 6 / span 6;
package/scss/_root.scss CHANGED
@@ -14,6 +14,7 @@
14
14
  --zn-page-closer-size: 36px;
15
15
  --zn-page-closer-gap: var(--zn-spacing-x-small);
16
16
  --zn-gutter: 2px;
17
+ // zn-page centres its content box, so containers inside it start at its left edge, in line with the header
17
18
  --zn-default-margin: 0 auto 0 0;
18
19
  --zn-icon-size: 20px;
19
20
  --zn-button-gutter: 2px;
@@ -75,11 +76,6 @@
75
76
  }
76
77
  }
77
78
 
78
- @media (min-width: 2500px) {
79
- :root {
80
- --zn-default-margin: 0 auto;
81
- }
82
- }
83
79
 
84
80
 
85
81
  :root {
@@ -12,8 +12,8 @@ import ZnDropdown from "../dropdown";
12
12
  import ZnIcon from "../icon";
13
13
  import ZnTooltip from "../tooltip";
14
14
  import type {IconColor, IconLibrary} from "../icon";
15
- import type ZnMenu from "../menu";
16
15
  import type {ZincFormControl} from '../../internal/zinc-element';
16
+ import type ZnMenu from "../menu";
17
17
 
18
18
  import sharedStyles from '../../../scss/shared/index.scss';
19
19
  import styles from './button.scss';
@@ -641,7 +641,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
641
641
  'button--icon-button-small': this.iconButton === 'small',
642
642
  'button--icon-button-round': this.iconButton === 'round',
643
643
  'button--grow': this.grow,
644
- 'button--standard': !this.outline && !this.text,
644
+ 'button--standard': !this.outline && (!this.text || this.solid),
645
645
  'button--with-icon': this.icon,
646
646
  'button--icon-left': this.iconPosition === 'left',
647
647
  'button--icon-right': this.iconPosition === 'right',
@@ -66,6 +66,13 @@ describe('<zn-button>', () => {
66
66
  expect(base.classList.contains('button--large')).to.be.true;
67
67
  });
68
68
 
69
+ it('keeps the filled style when solid overrides text', async () => {
70
+ const el = await fixture<ZnButton>(html`<zn-button text solid color="primary">Create</zn-button>`);
71
+ const base = el.shadowRoot!.querySelector('[part~="base"]')!;
72
+ expect(base.classList.contains('button--standard')).to.be.true;
73
+ expect(base.classList.contains('button--text')).to.be.false;
74
+ });
75
+
69
76
  it('applies the ai color', async () => {
70
77
  const el = await fixture<ZnButton>(html`<zn-button color="ai">Summarise</zn-button>`);
71
78
  const base = el.shadowRoot!.querySelector('[part~="base"]')!;
@@ -16,8 +16,8 @@ import {
16
16
  type RadarIndicator,
17
17
  type SeriesItem,
18
18
  } from './builders';
19
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
20
19
  import { classMap } from 'lit/directives/class-map.js';
20
+ import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
21
21
  import { type EChartsModule, loadECharts } from './echarts-loader';
22
22
  import { LiveTransport, LiveWindow } from './live';
23
23
  import { property, state } from 'lit/decorators.js';
@@ -1,7 +1,7 @@
1
1
  import '../../../../../dist/zn.min.js';
2
2
  import {expect, fixture, html, oneEvent} from '@open-wc/testing';
3
- import type DialogComponent from './dialog.component';
4
3
  import type {ZnEditorInsertEvent} from '../events/zn-editor-insert';
4
+ import type DialogComponent from './dialog.component';
5
5
 
6
6
  describe('<zn-editor-dialog> content inserts', () => {
7
7
  const dialog = () => fixture<DialogComponent>(html`
@@ -3,10 +3,10 @@ import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from
3
3
  import {deepQuerySelectorAll} from "../../utilities/query";
4
4
  import {md5} from "../../utilities/md5";
5
5
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
6
+ import {onHoverIntent} from '../../internal/hover-intent';
6
7
  import {property} from 'lit/decorators.js';
7
8
  import {styleMap} from "lit/directives/style-map.js";
8
9
  import ZincElement from '../../internal/zinc-element';
9
- import {onHoverIntent} from '../../internal/hover-intent';
10
10
 
11
11
  import styles from './expanding-action.scss';
12
12
 
@@ -3,8 +3,8 @@ import {property, state} from "lit/decorators.js";
3
3
  import {trimControlValue} from '../../internal/form';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
 
6
- import type ZnDataSelect from "../data-select";
7
6
  import type ZnButton from "../button";
7
+ import type ZnDataSelect from "../data-select";
8
8
  import type ZnDataTable from "../data-table";
9
9
  import type ZnInput from "../input";
10
10
  import type ZnQueryBuilder from "../query-builder";
@@ -1,4 +1,5 @@
1
1
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
2
+ import {formCollections} from '../../internal/form';
2
3
  import {idSelector} from "../../utilities/query";
3
4
  import {property, state} from 'lit/decorators.js';
4
5
  import ZincElement from '../../internal/zinc-element';
@@ -21,10 +22,20 @@ import styles from './form-actions.scss';
21
22
  * number can sit on either side. Write them in the order they should be read — the sides are set by CSS ordering, so
22
23
  * markup order is what a keyboard follows.
23
24
  *
25
+ * @csspart base - The row holding the actions.
24
26
  * @csspart cancel-button - The cancel button.
25
27
  * @csspart reset-button - The reset button.
26
28
  * @csspart submit-button - The submit button.
27
29
  */
30
+ const CHANGE_EVENTS = ['input', 'change', 'zn-input', 'zn-change'];
31
+
32
+ // An empty multi-select reports '', [''] or [] depending on whether it was loaded, reset or cleared by hand
33
+ const normaliseValue = (value: unknown): unknown =>
34
+ typeof value === 'string' || Array.isArray(value)
35
+ ? (Array.isArray(value) ? value : [value]).filter(v => v !== '')
36
+ : value;
37
+ const INTERACTION_EVENTS = ['pointerdown', 'keydown'];
38
+
28
39
  export default class ZnFormActions extends ZincElement {
29
40
  static styles: CSSResultGroup = unsafeCSS(styles);
30
41
  static dependencies = {
@@ -64,9 +75,18 @@ export default class ZnFormActions extends ZincElement {
64
75
  */
65
76
  @property({attribute: 'loading-on-submit', type: Boolean}) loadingOnSubmit = false;
66
77
 
78
+ /**
79
+ * Hides the row until the form's values differ from when it loaded or was last saved, and hides it again once
80
+ * they match.
81
+ */
82
+ @property({attribute: 'hide-until-changed', type: Boolean, reflect: true}) hideUntilChanged = false;
83
+
67
84
  @state() private submitting = false;
68
85
 
69
86
  private watchedForm: HTMLFormElement | null = null;
87
+ private changeForm: HTMLFormElement | null = null;
88
+ private baseline = '';
89
+ private interacted = false;
70
90
 
71
91
  connectedCallback() {
72
92
  super.connectedCallback();
@@ -76,6 +96,17 @@ export default class ZnFormActions extends ZincElement {
76
96
  this.watchedForm?.addEventListener('requested', this.handleRequested);
77
97
  ['complete', 'error', 'cancelled'].forEach(e => this.watchedForm?.addEventListener(e, this.handleDone));
78
98
  }
99
+
100
+ this.changeForm = this.hideUntilChanged ? this.getForm() : null;
101
+ if (this.changeForm) {
102
+ CHANGE_EVENTS.forEach(e => this.changeForm!.addEventListener(e, this.checkForChanges));
103
+ INTERACTION_EVENTS.forEach(e => this.changeForm!.addEventListener(e, this.refreshBaseline, {capture: true}));
104
+ this.changeForm.addEventListener('reset', this.handleFormReset);
105
+ this.changeForm.addEventListener('complete', this.markSaved);
106
+ this.toggleAttribute('data-unchanged', true);
107
+ // Controls later in the form may not have upgraded or set their values yet
108
+ requestAnimationFrame(this.markSaved);
109
+ }
79
110
  }
80
111
 
81
112
  disconnectedCallback() {
@@ -83,9 +114,74 @@ export default class ZnFormActions extends ZincElement {
83
114
  this.watchedForm?.removeEventListener('requested', this.handleRequested);
84
115
  ['complete', 'error', 'cancelled'].forEach(e => this.watchedForm?.removeEventListener(e, this.handleDone));
85
116
  this.watchedForm = null;
117
+
118
+ CHANGE_EVENTS.forEach(e => this.changeForm?.removeEventListener(e, this.checkForChanges));
119
+ INTERACTION_EVENTS.forEach(e => this.changeForm?.removeEventListener(e, this.refreshBaseline, {capture: true}));
120
+ this.interacted = false;
121
+ this.changeForm?.removeEventListener('reset', this.handleFormReset);
122
+ this.changeForm?.removeEventListener('complete', this.markSaved);
123
+ this.changeForm = null;
86
124
  super.disconnectedCallback();
87
125
  }
88
126
 
127
+ /**
128
+ * The form's values, compared against the baseline. Read from the controls directly, since building FormData runs
129
+ * zinc controls' formdata handlers, which trim the value being typed.
130
+ */
131
+ private snapshot(): string {
132
+ const form = this.changeForm;
133
+ if (!form) return '';
134
+
135
+ const entries: unknown[] = [];
136
+ for (const el of Array.from(form.elements)) {
137
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)
138
+ || !el.name) continue;
139
+ if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
140
+ entries.push([el.name, el.value, el.checked]);
141
+ } else if (el instanceof HTMLInputElement && el.type === 'file') {
142
+ entries.push([el.name, Array.from(el.files ?? [], f => f.name)]);
143
+ } else if (el instanceof HTMLSelectElement) {
144
+ entries.push([el.name, Array.from(el.selectedOptions, o => o.value)]);
145
+ } else {
146
+ entries.push([el.name, el.value]);
147
+ }
148
+ }
149
+ formCollections.get(form)?.forEach(control => {
150
+ // Disabled controls still count: a conditional control disabling is a side effect of another change
151
+ if (!control.name) return;
152
+ // A toggle's value only appears once it's first clicked, so on/off controls compare by checked alone
153
+ const checked = (control as {checked?: boolean}).checked;
154
+ entries.push(typeof checked === 'boolean' ? [control.name, checked] : [control.name, normaliseValue(control.value)]);
155
+ });
156
+
157
+ return JSON.stringify(entries);
158
+ }
159
+
160
+ private checkForChanges = () => {
161
+ // Before the user touches the form, changes come from controls settling (options loading, select-first)
162
+ if (!this.interacted) {
163
+ this.baseline = this.snapshot();
164
+ return;
165
+ }
166
+ this.toggleAttribute('data-unchanged', this.snapshot() === this.baseline);
167
+ };
168
+
169
+ private markSaved = () => {
170
+ this.baseline = this.snapshot();
171
+ this.toggleAttribute('data-unchanged', true);
172
+ };
173
+
174
+ // Values set by code between edits (remote options, late data) aren't user changes, so re-read them while unchanged
175
+ private refreshBaseline = () => {
176
+ this.interacted = true;
177
+ if (this.hasAttribute('data-unchanged')) this.baseline = this.snapshot();
178
+ };
179
+
180
+ // Controls reset their values after the reset event
181
+ private handleFormReset = () => {
182
+ setTimeout(this.checkForChanges);
183
+ };
184
+
89
185
  // Only the original submit is blocked; the console re-dispatches it as a composed event
90
186
  private handleFormSubmit = (e: Event) => {
91
187
  if (this.submitting && !e.composed) {
@@ -127,19 +223,21 @@ export default class ZnFormActions extends ZincElement {
127
223
 
128
224
  render() {
129
225
  return html`
130
- <slot></slot>
131
- <span class="form-actions__spacer"></span>
132
- ${this.withCancel ? html`
133
- <zn-button part="cancel-button" panel-bg modal-closer icon="${this.cancelIcon}" @click="${this.handleCancel}">
134
- ${this.cancelText}
135
- </zn-button>` : ''}
136
- ${this.withReset ? html`
137
- <zn-button part="reset-button" panel-bg icon="${this.resetIcon}" @click="${this.handleReset}">
138
- ${this.resetText}
139
- </zn-button>` : ''}
140
- <zn-button part="submit-button" color="primary" icon="${this.submitIcon}" @click="${this.handleSubmit}"
141
- ?loading=${this.submitting} ?disabled=${this.submitting} loading-text="${this.submitText}">
142
- ${this.submitText}
143
- </zn-button>`;
226
+ <div part="base" class="form-actions">
227
+ <slot></slot>
228
+ <span class="form-actions__spacer"></span>
229
+ ${this.withCancel ? html`
230
+ <zn-button part="cancel-button" panel-bg modal-closer icon="${this.cancelIcon}" @click="${this.handleCancel}">
231
+ ${this.cancelText}
232
+ </zn-button>` : ''}
233
+ ${this.withReset ? html`
234
+ <zn-button part="reset-button" panel-bg icon="${this.resetIcon}" @click="${this.handleReset}">
235
+ ${this.resetText}
236
+ </zn-button>` : ''}
237
+ <zn-button part="submit-button" color="primary" icon="${this.submitIcon}" @click="${this.handleSubmit}"
238
+ ?loading=${this.submitting} ?disabled=${this.submitting} loading-text="${this.submitText}">
239
+ ${this.submitText}
240
+ </zn-button>
241
+ </div>`;
144
242
  }
145
243
  }
@@ -1,11 +1,22 @@
1
1
  @use "../../wc";
2
2
 
3
- // Keep in sync with the global .form-actions layout in scss/_global-spacing.scss.
3
+ // One grid row, so hide-until-changed can collapse the height by animating the track between 1fr and 0fr.
4
4
  :host {
5
+ --form-actions-ease: var(--zn-ease-out, cubic-bezier(0.23, 1, 0.32, 1));
6
+ display: grid;
7
+ grid-template-rows: 1fr;
8
+ transition: grid-template-rows 200ms var(--form-actions-ease),
9
+ opacity 200ms var(--form-actions-ease),
10
+ display 200ms allow-discrete;
11
+ }
12
+
13
+ // Keep in sync with the global .form-actions layout in scss/_global-spacing.scss.
14
+ .form-actions {
5
15
  display: flex;
6
16
  justify-content: flex-end;
7
17
  align-items: center;
8
18
  gap: var(--zn-form-actions-gap, var(--zn-spacing-small));
19
+ min-height: 0;
9
20
  }
10
21
 
11
22
  // The spacer, not an auto margin, is what splits the two sides: an auto margin on every left-hand action would open
@@ -24,3 +35,28 @@ zn-button {
24
35
  ::slotted([align='start']) {
25
36
  order: 0;
26
37
  }
38
+
39
+ // display: none lands once the collapse finishes, dropping the parent's gap around the row as well
40
+ :host([data-unchanged]) {
41
+ display: none;
42
+ grid-template-rows: 0fr;
43
+ opacity: 0;
44
+ transition-duration: var(--zn-transition-fast, 150ms);
45
+
46
+ .form-actions {
47
+ overflow: hidden;
48
+ }
49
+ }
50
+
51
+ @starting-style {
52
+ :host([hide-until-changed]:not([data-unchanged])) {
53
+ grid-template-rows: 0fr;
54
+ opacity: 0;
55
+ }
56
+ }
57
+
58
+ @media (prefers-reduced-motion: reduce) {
59
+ :host {
60
+ transition-property: opacity, display;
61
+ }
62
+ }
@@ -45,6 +45,143 @@ describe('<zn-form-actions>', () => {
45
45
  expect(submit.left).to.be.closeTo(cancel.right + 16, 1);
46
46
  });
47
47
 
48
+ it('shows only while the form differs from its loaded values', async () => {
49
+ const form = await fixture<HTMLFormElement>(html`
50
+ <form>
51
+ <input name="title" value="a">
52
+ <zn-form-actions hide-until-changed></zn-form-actions>
53
+ </form>
54
+ `);
55
+ const actions = form.querySelector('zn-form-actions')!;
56
+ const input = form.querySelector('input')!;
57
+ const visible = async () => {
58
+ await Promise.all(actions.getAnimations().map(animation => animation.finished));
59
+ return getComputedStyle(actions).display !== 'none';
60
+ };
61
+ const type = (value: string) => {
62
+ input.dispatchEvent(new Event('keydown', { bubbles: true }));
63
+ input.value = value;
64
+ input.dispatchEvent(new Event('input', { bubbles: true }));
65
+ };
66
+ await new Promise(requestAnimationFrame);
67
+
68
+ expect(await visible()).to.be.false;
69
+
70
+ type('b');
71
+ expect(await visible()).to.be.true;
72
+
73
+ type('a');
74
+ expect(await visible()).to.be.false;
75
+
76
+ type('b');
77
+ form.reset();
78
+ await new Promise(resolve => setTimeout(resolve));
79
+ expect(await visible()).to.be.false;
80
+ });
81
+
82
+ it('hides again when a toggle is switched back', async () => {
83
+ const form = await fixture<HTMLFormElement>(html`
84
+ <form>
85
+ <zn-toggle name="auto"></zn-toggle>
86
+ <zn-form-actions hide-until-changed></zn-form-actions>
87
+ </form>
88
+ `);
89
+ const actions = form.querySelector('zn-form-actions')!;
90
+ const toggle = form.querySelector('zn-toggle')!;
91
+ await new Promise(requestAnimationFrame);
92
+ const flip = async () => {
93
+ toggle.dispatchEvent(new Event('pointerdown', { bubbles: true }));
94
+ toggle.shadowRoot!.querySelector('input')!.click();
95
+ await (toggle as HTMLElement & { updateComplete: Promise<unknown> }).updateComplete;
96
+ };
97
+
98
+ await flip();
99
+ expect(actions.hasAttribute('data-unchanged')).to.be.false;
100
+
101
+ await flip();
102
+ expect(actions.hasAttribute('data-unchanged')).to.be.true;
103
+ });
104
+
105
+ it('hides again when an empty multi-select is changed then reset', async () => {
106
+ const form = await fixture<HTMLFormElement>(html`
107
+ <form>
108
+ <zn-select name="groups" multiple>
109
+ <zn-option value="a">A</zn-option>
110
+ <zn-option value="b">B</zn-option>
111
+ </zn-select>
112
+ <zn-form-actions hide-until-changed></zn-form-actions>
113
+ </form>
114
+ `);
115
+ const actions = form.querySelector('zn-form-actions')!;
116
+ const select = form.querySelector<HTMLElement & { value: string[] }>('zn-select')!;
117
+ await new Promise(requestAnimationFrame);
118
+
119
+ select.dispatchEvent(new Event('pointerdown', { bubbles: true }));
120
+ select.value = ['a'];
121
+ select.dispatchEvent(new Event('zn-change', { bubbles: true }));
122
+ expect(actions.hasAttribute('data-unchanged')).to.be.false;
123
+
124
+ form.reset();
125
+ await new Promise(resolve => setTimeout(resolve));
126
+ expect(actions.hasAttribute('data-unchanged')).to.be.true;
127
+ });
128
+
129
+ it('hides again when a reset re-enables a conditional select', async () => {
130
+ const form = await fixture<HTMLFormElement>(html`
131
+ <form>
132
+ <zn-select id="allowed" name="allowed" multiple value="" provider="conditional" conditional="blocked">
133
+ <zn-option value="a">A</zn-option>
134
+ </zn-select>
135
+ <zn-select id="blocked" name="blocked" multiple value="" provider="conditional" conditional="allowed">
136
+ <zn-option value="a">A</zn-option>
137
+ </zn-select>
138
+ <zn-form-actions hide-until-changed></zn-form-actions>
139
+ </form>
140
+ `);
141
+ const actions = form.querySelector('zn-form-actions')!;
142
+ const blocked = form.querySelector<HTMLElement & { value: string[] }>('#blocked')!;
143
+ await new Promise(requestAnimationFrame);
144
+
145
+ blocked.dispatchEvent(new Event('pointerdown', { bubbles: true }));
146
+ blocked.value = ['a'];
147
+ blocked.dispatchEvent(new Event('zn-change', { bubbles: true }));
148
+ expect(actions.hasAttribute('data-unchanged')).to.be.false;
149
+
150
+ form.reset();
151
+ await new Promise(resolve => setTimeout(resolve, 10));
152
+ expect(actions.hasAttribute('data-unchanged')).to.be.true;
153
+ });
154
+
155
+ it('treats changes before any interaction as the starting values', async () => {
156
+ const form = await fixture<HTMLFormElement>(html`
157
+ <form>
158
+ <input name="type" value="">
159
+ <zn-form-actions hide-until-changed></zn-form-actions>
160
+ </form>
161
+ `);
162
+ const actions = form.querySelector('zn-form-actions')!;
163
+ const input = form.querySelector('input')!;
164
+ await new Promise(requestAnimationFrame);
165
+
166
+ // As zn-select does when its options load or select-first picks one
167
+ input.value = 'team';
168
+ input.dispatchEvent(new Event('change', { bubbles: true }));
169
+
170
+ expect(actions.hasAttribute('data-unchanged')).to.be.true;
171
+ });
172
+
173
+ it('stays visible in an unchanged form without hide-until-changed', async () => {
174
+ const form = await fixture<HTMLFormElement>(html`
175
+ <form>
176
+ <input name="title" value="a">
177
+ <zn-form-actions></zn-form-actions>
178
+ </form>
179
+ `);
180
+ await new Promise(requestAnimationFrame);
181
+
182
+ expect(getComputedStyle(form.querySelector('zn-form-actions')!).display).to.not.equal('none');
183
+ });
184
+
48
185
  it('should close a containing dialog when the cancel button is clicked', async () => {
49
186
  const dialog = await fixture<HTMLElement & { open: boolean }>(html`
50
187
  <zn-dialog open label="Test">
@@ -86,7 +86,7 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
86
86
 
87
87
  @property({ type: Boolean }) inline: boolean
88
88
 
89
- @property({ type: Boolean }) padded: boolean
89
+ @property({ type: Boolean, reflect: true }) padded: boolean
90
90
 
91
91
  @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
92
92
 
@@ -436,7 +436,6 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
436
436
  'ai--focused': this.hasFocus,
437
437
  'ai--disabled': this.disabled,
438
438
  'ai--inline': this.inline,
439
- 'ai--padded': this.padded,
440
439
  'ai--masked': !!this.displayValue,
441
440
  })}" dir="ltr">
442
441
 
@@ -1,30 +1,34 @@
1
1
  @use "../../wc";
2
2
 
3
3
  :host {
4
+ --ai-input-height: var(--zn-input-height-medium);
5
+
4
6
  display: block;
7
+ box-sizing: border-box;
5
8
  container-type: inline-size;
6
9
  width: 100%;
7
10
  }
8
11
 
12
+ // On the host rather than the row, so the hover fill and edit actions stay hugging the field
13
+ :host([padded]) {
14
+ padding: var(--zn-spacing-large);
15
+ }
16
+
9
17
  .ai {
10
18
  --bleed: var(--zn-inline-edit-bleed, 10px);
11
19
  display: flex;
12
- width: 100%;
20
+ box-sizing: border-box;
21
+ // The hover fill hangs out past the value's left edge, so the text stays aligned with the column
22
+ width: calc(100% + var(--bleed));
23
+ margin-inline-start: calc(-1 * var(--bleed));
24
+ padding-inline-start: var(--bleed);
13
25
  flex-direction: row;
14
26
  gap: 10px;
15
27
  border-radius: var(--zn-border-radius-medium);
16
28
  transition: background-color var(--zn-transition-fast) ease;
17
29
 
18
- // The hover fill hangs out past the value's left edge, so the text stays aligned with the column
19
- &:not(.ai--padded) {
20
- box-sizing: border-box;
21
- width: calc(100% + var(--bleed));
22
- margin-inline-start: calc(-1 * var(--bleed));
23
- padding-inline-start: var(--bleed);
24
- }
25
-
26
30
  // The input's own padding takes over from the bleed, keeping its text roughly where the value was
27
- &--editing:not(.ai--padded) {
31
+ &--editing {
28
32
  padding-inline-start: 0;
29
33
  }
30
34
 
@@ -116,10 +120,6 @@
116
120
  }
117
121
  }
118
122
 
119
- &--padded {
120
- padding: var(--zn-spacing-large);
121
- }
122
-
123
123
  &--inline {
124
124
  display: flex;
125
125
  flex-direction: row;
@@ -160,7 +160,7 @@
160
160
  position: absolute;
161
161
  inset-block-start: 0;
162
162
  inset-inline-end: var(--action-inset);
163
- height: var(--zn-input-height-medium);
163
+ height: var(--ai-input-height);
164
164
  gap: 2px;
165
165
  }
166
166
 
@@ -238,7 +238,7 @@
238
238
 
239
239
  &__reveal {
240
240
  // Matches the height of the input it stands in for, so entering edit mode doesn't resize the row
241
- line-height: var(--zn-input-height-medium);
241
+ line-height: var(--ai-input-height);
242
242
  color: rgb(var(--zn-text));
243
243
 
244
244
  // Fill the row so hovering anywhere over the field reveals the value
@@ -312,10 +312,10 @@ zn-textarea::part(textarea) {
312
312
  resize: none;
313
313
  }
314
314
 
315
- :host([size="small"]) .ai__reveal {
316
- line-height: var(--zn-input-height-small);
315
+ :host([size="small"]) {
316
+ --ai-input-height: var(--zn-input-height-small);
317
317
  }
318
318
 
319
- :host([size="large"]) .ai__reveal {
320
- line-height: var(--zn-input-height-large);
319
+ :host([size="large"]) {
320
+ --ai-input-height: var(--zn-input-height-large);
321
321
  }