@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
@@ -192,7 +192,7 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
192
192
  /** Determines whether or no the password is currently visible. Only applies to password types **/
193
193
  @property({attribute: 'password-visible', type: Boolean}) passwordVisible: boolean = false;
194
194
 
195
- /** Hides the browsers built-in increment/decrement spin buttons for number inputs **/
195
+ /** Hides the increment/decrement buttons on number inputs **/
196
196
  @property({attribute: 'no-spin-buttons', type: Boolean}) noSpinButtons: boolean = false;
197
197
 
198
198
  /**
@@ -505,6 +505,27 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
505
505
  }
506
506
  }
507
507
 
508
+ /** Whether the value has reached `limit`; `direction` 1 checks a maximum, -1 a minimum. */
509
+ private isAtLimit(limit: number | string | undefined, direction: 1 | -1): boolean {
510
+ const value = parseFloat(String(this.value));
511
+ const bound = parseFloat(String(limit));
512
+ return !isNaN(value) && !isNaN(bound) && (value - bound) * direction >= 0;
513
+ }
514
+
515
+ private handleStep(direction: 'up' | 'down') {
516
+ const previous: unknown = this.value;
517
+ if (direction === 'up') {
518
+ this.stepUp();
519
+ } else {
520
+ this.stepDown();
521
+ }
522
+ if (this.value === previous) return;
523
+ // stepUp()/stepDown() don't fire events, unlike the native spinner they replace
524
+ this.formControlController.updateValidity();
525
+ this.emit('zn-input');
526
+ this.emit('zn-change');
527
+ }
528
+
508
529
  private handlePasswordToggle() {
509
530
  this.passwordVisible = !this.passwordVisible;
510
531
  }
@@ -861,7 +882,6 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
861
882
  'input--focused': this.hasFocus,
862
883
  'input--empty': !this.value,
863
884
  'input--range': isRange,
864
- 'input--no-spin-buttons': this.noSpinButtons && this.type !== 'currency',
865
885
  'input--color': this.type === 'color',
866
886
  'input--color-invalid': this.colorInvalid,
867
887
  })}>
@@ -973,6 +993,22 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
973
993
  </button>`
974
994
  : ''}
975
995
 
996
+ ${this.type === 'currency' || (this.type === 'number' && !this.noSpinButtons)
997
+ ? html`
998
+ <span part="stepper" class="input__stepper">
999
+ <button class="input__stepper-button" type="button" tabindex="-1" aria-label="Decrement"
1000
+ ?disabled=${this.disabled || this.readonly || this.isAtLimit(this.min, -1)}
1001
+ @click=${() => this.handleStep('down')}>
1002
+ <zn-icon src="minus@lu" size="16"></zn-icon>
1003
+ </button>
1004
+ <button class="input__stepper-button" type="button" tabindex="-1" aria-label="Increment"
1005
+ ?disabled=${this.disabled || this.readonly || this.isAtLimit(this.max, 1)}
1006
+ @click=${() => this.handleStep('up')}>
1007
+ <zn-icon src="plus@lu" size="16"></zn-icon>
1008
+ </button>
1009
+ </span>`
1010
+ : ''}
1011
+
976
1012
  <span part="suffix" class="input__suffix">
977
1013
  <slot name="suffix"></slot>
978
1014
  </span>
@@ -162,6 +162,29 @@ input[type='text'].input__control[data-color-input] {
162
162
  }
163
163
  }
164
164
 
165
+ // The same gap right of a suffix as above and below one 24px tall (an icon or copy button). Built-in controls such as
166
+ // the clear button and stepper keep their own spacing.
167
+ .input__suffix ::slotted(*) {
168
+ margin-inline-end: max(2px, calc((var(--input-height) - var(--zn-input-border-width) * 2 - 24px) / 2));
169
+ }
170
+
171
+ // Slotted buttons are held to the small button height, shrunk to fit smaller inputs, with the same gap all round.
172
+ // Read from the density token: redefining --zn-button-height-small from itself would be a cycle.
173
+ .input__suffix ::slotted(zn-button) {
174
+ --input-suffix-button-height: min(var(--zn-density-control-height-sm, 28px), calc(var(--input-height) - var(--zn-input-border-width) * 2 - 4px));
175
+ --zn-button-height: var(--input-suffix-button-height);
176
+ --zn-button-height-small: var(--input-suffix-button-height);
177
+ margin-inline-end: calc((var(--input-height) - var(--zn-input-border-width) * 2 - var(--input-suffix-button-height)) / 2);
178
+ }
179
+
180
+ .input__suffix ::slotted(zn-checkbox) {
181
+ margin-inline-end: 0;
182
+ }
183
+
184
+ .input__suffix-default {
185
+ margin-inline-end: var(--zn-spacing-small);
186
+ }
187
+
165
188
  .input__prefix ::slotted(zn-icon),
166
189
  .input__suffix ::slotted(zn-icon) {
167
190
  --icon-size: var(--zn-font-size-medium);
@@ -174,6 +197,7 @@ input[type='text'].input__control[data-color-input] {
174
197
  */
175
198
 
176
199
  .input--x-small {
200
+ --input-height: var(--zn-input-height-x-small);
177
201
  border-radius: var(--zn-input-border-radius-x-small);
178
202
  font-size: var(--zn-input-font-size-small);
179
203
  height: var(--zn-input-height-x-small);
@@ -194,20 +218,14 @@ input[type='text'].input__control[data-color-input] {
194
218
  margin-inline-start: var(--zn-spacing-x-small);
195
219
  }
196
220
 
197
- .input--x-small .input__suffix ::slotted(*),
198
- .input__suffix-default {
199
- margin-inline-end: var(--zn-input-spacing-x-small);
200
- }
201
-
202
- .input--x-small .input__suffix ::slotted(zn-button),
203
221
  .input--x-small .input__prefix ::slotted(zn-button),
204
- .input--x-small .input__suffix ::slotted(zn-checkbox),
205
- .input--x-small .input__prefix ::slotted(zn-checkbox), {
222
+ .input--x-small .input__prefix ::slotted(zn-checkbox) {
206
223
  margin-inline-end: 0;
207
224
  margin-inline-start: 0;
208
225
  }
209
226
 
210
227
  .input--small {
228
+ --input-height: var(--zn-input-height-small);
211
229
  border-radius: var(--zn-input-border-radius-small);
212
230
  font-size: var(--zn-input-font-size-small);
213
231
  height: var(--zn-input-height-small);
@@ -228,20 +246,14 @@ input[type='text'].input__control[data-color-input] {
228
246
  margin-inline-start: var(--zn-input-spacing-small);
229
247
  }
230
248
 
231
- .input--small .input__suffix ::slotted(*),
232
- .input__suffix-default {
233
- margin-inline-end: var(--zn-input-spacing-small);
234
- }
235
-
236
- .input--small .input__suffix ::slotted(zn-button),
237
249
  .input--small .input__prefix ::slotted(zn-button),
238
- .input--small .input__suffix ::slotted(zn-checkbox),
239
250
  .input--small .input__prefix ::slotted(zn-checkbox) {
240
251
  margin-inline-end: 0;
241
252
  margin-inline-start: 0;
242
253
  }
243
254
 
244
255
  .input--medium {
256
+ --input-height: var(--zn-input-height-medium);
245
257
  border-radius: var(--zn-input-border-radius-medium);
246
258
  font-size: var(--zn-input-font-size-medium);
247
259
  height: var(--zn-input-height-medium);
@@ -262,34 +274,23 @@ input[type='text'].input__control[data-color-input] {
262
274
  margin-inline-start: var(--zn-spacing-small);
263
275
  }
264
276
 
265
- .input--medium .input__suffix ::slotted(*),
266
- .input__suffix-default {
267
- margin-inline-end: var(--zn-spacing-small);
268
- }
269
-
270
- .input--medium .input__suffix ::slotted(zn-button),
271
277
  .input--medium .input__prefix ::slotted(zn-button),
272
- .input--medium .input__suffix ::slotted(zn-checkbox),
273
278
  .input--medium .input__prefix ::slotted(zn-checkbox) {
274
279
  margin-inline-end: 0;
275
280
  margin-inline-start: 0;
276
281
  }
277
282
 
278
- .input__suffix ::slotted(zn-button),
279
283
  .input__prefix ::slotted(zn-button) {
280
284
  --zn-button-border-color: rgb(var(--zn-color-border));
281
285
  --zn-button-border-width: 0;
282
286
  }
283
287
 
284
- .input__suffix ::slotted(zn-button) {
285
- --zn-button-border-left-width: 1px;
286
- }
287
-
288
288
  .input__prefix ::slotted(zn-button) {
289
289
  --zn-button-border-right-width: 1px;
290
290
  }
291
291
 
292
292
  .input--large {
293
+ --input-height: var(--zn-input-height-large);
293
294
  border-radius: var(--zn-input-border-radius-large);
294
295
  font-size: var(--zn-input-font-size-large);
295
296
  height: var(--zn-input-height-large);
@@ -305,9 +306,7 @@ input[type='text'].input__control[data-color-input] {
305
306
  width: calc(1em + var(--zn-input-spacing-large) * 2);
306
307
  }
307
308
 
308
- .input--large .input__suffix ::slotted(zn-button),
309
309
  .input--large .input__prefix ::slotted(zn-button),
310
- .input--large .input__suffix ::slotted(zn-checkbox),
311
310
  .input--large .input__prefix ::slotted(zn-checkbox) {
312
311
  margin-inline-end: 0;
313
312
  margin-inline-start: 0;
@@ -386,17 +385,81 @@ input[type='text'].input__control[data-color-input] {
386
385
  display: none;
387
386
  }
388
387
 
389
- /* Hide the built-in number spinner */
390
- .input--no-spin-buttons input[type='number']::-webkit-outer-spin-button,
391
- .input--no-spin-buttons input[type='number']::-webkit-inner-spin-button {
388
+ /* The built-in spinner is replaced by .input__stepper */
389
+ input[type='number']::-webkit-outer-spin-button,
390
+ input[type='number']::-webkit-inner-spin-button {
392
391
  -webkit-appearance: none;
393
392
  display: none;
394
393
  }
395
394
 
396
- .input--no-spin-buttons input[type='number'] {
395
+ input[type='number'] {
397
396
  -moz-appearance: textfield;
398
397
  }
399
398
 
399
+ .input__stepper {
400
+ --stepper-button-size: calc(var(--zn-input-height-medium) - 10px);
401
+ display: flex;
402
+ flex: 0 0 auto;
403
+ align-items: center;
404
+ gap: 4px;
405
+ padding-inline: 4px 5px;
406
+ }
407
+
408
+ .input--x-small .input__stepper {
409
+ --stepper-button-size: calc(var(--zn-input-height-x-small) - 6px);
410
+ gap: 2px;
411
+ padding-inline: 2px 3px;
412
+ }
413
+
414
+ .input--small .input__stepper {
415
+ --stepper-button-size: calc(var(--zn-input-height-small) - 8px);
416
+ }
417
+
418
+ .input--large .input__stepper {
419
+ --stepper-button-size: calc(var(--zn-input-height-large) - 10px);
420
+ }
421
+
422
+ .input__stepper-button {
423
+ display: grid;
424
+ place-content: center;
425
+ flex: 0 0 auto;
426
+ width: var(--stepper-button-size);
427
+ height: var(--stepper-button-size);
428
+ padding: 0;
429
+ border: none;
430
+ border-radius: var(--zn-border-radius-small);
431
+ background: none;
432
+ cursor: pointer;
433
+ transition: var(--zn-transition-fast) background-color,
434
+ transform 160ms var(--zn-ease-out, cubic-bezier(0.23, 1, 0.32, 1));
435
+
436
+ zn-icon {
437
+ --icon-color: var(--zn-input-icon-color);
438
+ }
439
+
440
+ &:hover:not(:disabled) {
441
+ background-color: var(--zn-color-neutral-100);
442
+
443
+ zn-icon {
444
+ --icon-color: var(--zn-input-icon-color-hover);
445
+ }
446
+ }
447
+
448
+ // Deeper than zn-button's 0.97, which is imperceptible at this size
449
+ &:active:not(:disabled) {
450
+ background-color: var(--zn-color-neutral-200);
451
+ transform: scale(0.9);
452
+ }
453
+
454
+ &:disabled {
455
+ cursor: not-allowed;
456
+
457
+ zn-icon {
458
+ --icon-color: var(--zn-input-border-color);
459
+ }
460
+ }
461
+ }
462
+
400
463
  /* Replace the -webkit-calendar-picker icon */
401
464
  input[type='date']::-webkit-calendar-picker-indicator {
402
465
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="14" width="12.25" viewBox="0 0 448 512"><path fill="%236d7176" d="M128 0c13.3 0 24 10.7 24 24V64H296V24c0-13.3 10.7-24 24-24s24 10.7 24 24V64h40c35.3 0 64 28.7 64 64v16 48V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V192 144 128C0 92.7 28.7 64 64 64h40V24c0-13.3 10.7-24 24-24zM400 192H48V448c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V192zM112 256h96c8.8 0 16 7.2 16 16v96c0 8.8-7.2 16-16 16H112c-8.8 0-16-7.2-16-16V272c0-8.8 7.2-16 16-16z"/></svg>');
@@ -1,6 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
- import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
2
  import {clearFormStoreValues} from '../../utilities/form';
3
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
4
4
  import type ZnInput from './input.component';
5
5
  import type ZnSlashMenu from '../slash-menu/slash-menu.component';
6
6
 
@@ -1,9 +1,11 @@
1
1
  import {classMap} from 'lit/directives/class-map.js';
2
2
  import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
3
+ import {defaultValue} from '../../internal/default-value';
3
4
  import {FormControlController} from '../../internal/form';
4
5
  import {HasSlotController} from '../../internal/slot';
5
6
  import {property, state} from 'lit/decorators.js';
6
7
  import ZincElement, {type ZincFormControl} from '../../internal/zinc-element';
8
+ import type ZnInput from '../input';
7
9
 
8
10
  import formControlStyles from '../../form-control.scss';
9
11
  import styles from './key-value.scss';
@@ -13,6 +15,8 @@ interface KeyValueRow {
13
15
  value: string;
14
16
  }
15
17
 
18
+ const EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';
19
+
16
20
  /**
17
21
  * @summary Edits a list of name and value pairs, submitted as a JSON object.
18
22
  * @documentation https://zinc.style/components/key-value
@@ -20,6 +24,8 @@ interface KeyValueRow {
20
24
  * @since 1.0
21
25
  *
22
26
  * @dependency zn-input
27
+ * @dependency zn-select
28
+ * @dependency zn-option
23
29
  * @dependency zn-button
24
30
  *
25
31
  * @event zn-change - Emitted when a pair is added, removed or edited.
@@ -41,9 +47,15 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
41
47
 
42
48
  @property() name = '';
43
49
 
44
- /** The pairs as a JSON object, such as `{"1": "{{recipient.firstName}}"}`. */
50
+ /**
51
+ * The pairs as a JSON object, such as `{"1": "{{recipient.firstName}}"}`. With `list`, the names as a JSON array,
52
+ * such as `["10.0.0.0/24"]`.
53
+ */
45
54
  @property() value = '{}';
46
55
 
56
+ /** The value a form reset restores, taken from the initial `value` attribute. */
57
+ @defaultValue() defaultValue = '{}';
58
+
47
59
  @property() label = '';
48
60
 
49
61
  @property({attribute: 'help-text'}) helpText = '';
@@ -57,6 +69,15 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
57
69
  /** Passed to each value input, so its slash menu can insert merge fields. */
58
70
  @property({attribute: 'slash-items'}) slashItems = '';
59
71
 
72
+ /**
73
+ * Makes each value a choice instead of free text: a JSON object of option values to labels, such as
74
+ * `{"default": "System default", "auto": "Auto approve"}`. New rows take the first option.
75
+ */
76
+ @property({attribute: 'value-options', type: Object}) valueOptions: Record<string, string> | null = null;
77
+
78
+ /** Edits a plain list of names with no values, submitted as a JSON array. */
79
+ @property({type: Boolean, reflect: true}) list = false;
80
+
60
81
  @property({reflect: true}) form = '';
61
82
 
62
83
  @property({type: Boolean, reflect: true}) disabled = false;
@@ -87,15 +108,35 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
87
108
  }
88
109
 
89
110
  protected willUpdate(changed: PropertyValues) {
111
+ if (this.list && this.value === '{}') {
112
+ this.value = '[]';
113
+ }
90
114
  if (changed.has('value') && this.value !== this._serialise()) {
91
115
  this._rows = this._parse(this.value);
92
116
  }
117
+ this._rows = this._withSpareRow(this._rows);
118
+ }
119
+
120
+ private get _defaultValue(): string {
121
+ return this.valueOptions ? Object.keys(this.valueOptions)[0] ?? '' : '';
122
+ }
123
+
124
+ private _emptyRow(): KeyValueRow {
125
+ return {key: '', value: this._defaultValue};
126
+ }
127
+
128
+ // There's always a row to type into, so an empty list never leaves the add button on its own
129
+ private _withSpareRow(rows: KeyValueRow[]): KeyValueRow[] {
130
+ return rows.length ? rows : [this._emptyRow()];
93
131
  }
94
132
 
95
133
  private _parse(value: string): KeyValueRow[] {
96
134
  try {
97
- const parsed: unknown = JSON.parse(value || '{}');
98
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
135
+ const parsed: unknown = JSON.parse(value || (this.list ? '[]' : '{}'));
136
+ if (Array.isArray(parsed)) {
137
+ return parsed.map(key => ({key: String(key ?? ''), value: this._defaultValue}));
138
+ }
139
+ if (parsed && typeof parsed === 'object') {
99
140
  return Object.entries(parsed as Record<string, unknown>).map(([key, v]) => ({key, value: String(v ?? '')}));
100
141
  }
101
142
  } catch {
@@ -105,8 +146,12 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
105
146
  }
106
147
 
107
148
  private _serialise(): string {
149
+ const named = this._rows.filter(row => row.key.trim());
150
+ if (this.list) {
151
+ return JSON.stringify(named.map(row => row.key.trim()));
152
+ }
108
153
  const pairs: Record<string, string> = {};
109
- this._rows.filter(row => row.key.trim()).forEach(row => pairs[row.key.trim()] = row.value);
154
+ named.forEach(row => pairs[row.key.trim()] = row.value);
110
155
  return JSON.stringify(pairs);
111
156
  }
112
157
 
@@ -122,9 +167,70 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
122
167
  this._update(rows);
123
168
  }
124
169
 
170
+ private get _reduceMotion(): boolean {
171
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
172
+ }
173
+
174
+ private _easing(): string {
175
+ return getComputedStyle(this).getPropertyValue('--zn-ease-out').trim() || EASE_OUT;
176
+ }
177
+
178
+ private async _add() {
179
+ this._update([...this._rows, this._emptyRow()]);
180
+ await this.updateComplete;
181
+ const rows = this.shadowRoot!.querySelectorAll<HTMLElement>('.key-value__row');
182
+ const row = rows[rows.length - 1];
183
+ if (!row) return;
184
+ // Opacity and transform only: the new inputs render for the first time during these frames
185
+ row.animate(
186
+ this._reduceMotion
187
+ ? [{opacity: 0}, {opacity: 1}]
188
+ : [{opacity: 0, transform: 'translateY(-4px)'}, {opacity: 1, transform: 'translateY(0)'}],
189
+ {duration: this._reduceMotion ? 150 : 180, easing: this._easing()});
190
+ const input = row.querySelector<ZnInput>('.key-value__key');
191
+ await input?.updateComplete;
192
+ input?.focus();
193
+ }
194
+
195
+ private async _remove(row: KeyValueRow, rowElement: HTMLElement) {
196
+ if (this._rows.length === 1) {
197
+ this._update([this._emptyRow()]);
198
+ return;
199
+ }
200
+ rowElement.style.pointerEvents = 'none';
201
+ rowElement.style.overflow = 'hidden';
202
+ const height = rowElement.getBoundingClientRect().height;
203
+ await rowElement.animate(
204
+ this._reduceMotion
205
+ ? [{opacity: 1}, {opacity: 0}]
206
+ : [{opacity: 1, transform: 'scale(1)', height: `${height}px`}, {opacity: 0, transform: 'scale(0.98)', height: '0px'}],
207
+ {duration: this._reduceMotion ? 150 : 220, easing: this._easing()}).finished.catch(() => undefined);
208
+ rowElement.style.pointerEvents = '';
209
+ rowElement.style.overflow = '';
210
+ this._update(this._rows.filter(r => r !== row));
211
+ }
212
+
213
+ private _renderValue(row: KeyValueRow, index: number) {
214
+ if (this.list) return null;
215
+ if (this.valueOptions) {
216
+ return html`
217
+ <zn-select class="key-value__value" .value=${row.value} ?disabled=${this.disabled} aria-label=${this.valueLabel}
218
+ @zn-change=${(e: Event) => this._edit(index, 'value', e)}>
219
+ ${Object.entries(this.valueOptions).map(([value, label]) => html`
220
+ <zn-option value=${value}>${label}</zn-option>`)}
221
+ </zn-select>`;
222
+ }
223
+ return html`
224
+ <zn-input class="key-value__value" placeholder=${this.valueLabel} .value=${row.value}
225
+ slash-items=${this.slashItems} ?disabled=${this.disabled}
226
+ @zn-input=${(e: Event) => this._edit(index, 'value', e)}></zn-input>`;
227
+ }
228
+
125
229
  render() {
126
230
  const hasLabel = this.label || this.hasSlotController.test('label');
127
231
  const hasHelpText = this.helpText || this.hasSlotController.test('help-text');
232
+ // A lone empty row has nothing to remove; hidden rather than dropped so its inputs don't change width
233
+ const lonelyEmpty = this._rows.length === 1 && !this._rows[0].key.trim();
128
234
 
129
235
  return html`
130
236
  <div part="base" class=${classMap({
@@ -137,19 +243,24 @@ export default class ZnKeyValue extends ZincElement implements ZincFormControl {
137
243
  <slot name="label">${this.label}</slot>
138
244
  </label>
139
245
 
140
- <div class="key-value__rows">
246
+ <div class=${classMap({
247
+ 'key-value__rows': true,
248
+ 'key-value__rows--list': this.list,
249
+ 'key-value__rows--options': !!this.valueOptions && !this.list,
250
+ })}>
141
251
  ${this._rows.map((row, index) => html`
142
252
  <div part="row" class="key-value__row">
143
253
  <zn-input class="key-value__key" placeholder=${this.keyLabel} .value=${row.key}
144
- ?disabled=${this.disabled} @zn-input=${(e: Event) => this._edit(index, 'key', e)}></zn-input>
145
- <zn-input class="key-value__value" placeholder=${this.valueLabel} .value=${row.value}
146
- slash-items=${this.slashItems} ?disabled=${this.disabled}
147
- @zn-input=${(e: Event) => this._edit(index, 'value', e)}></zn-input>
148
- <zn-button icon="x@lu" color="transparent" size="small" title="Remove" icon-button="small" plain ?disabled=${this.disabled}
149
- @click=${() => this._update(this._rows.filter((_, i) => i !== index))}></zn-button>
254
+ aria-label=${this.keyLabel} ?disabled=${this.disabled}
255
+ @zn-input=${(e: Event) => this._edit(index, 'key', e)}></zn-input>
256
+ ${this._renderValue(row, index)}
257
+ <zn-button class=${classMap({'key-value__remove': true, 'key-value__remove--hidden': lonelyEmpty})}
258
+ icon="x@lu" color="transparent" size="small" title="Remove" icon-button="small" plain
259
+ ?disabled=${this.disabled}
260
+ @click=${(e: Event) => this._remove(row, (e.currentTarget as HTMLElement).closest('.key-value__row')!)}></zn-button>
150
261
  </div>`)}
151
262
  <zn-button class="key-value__add" icon="plus@lu" color="secondary" size="small" ?disabled=${this.disabled}
152
- @click=${() => this._update([...this._rows, {key: '', value: ''}])}>${this.addText}</zn-button>
263
+ @click=${() => this._add()}>${this.addText}</zn-button>
153
264
  </div>
154
265
 
155
266
  <div class="form-control__help-text" aria-hidden=${hasHelpText ? 'false' : 'true'}>
@@ -18,3 +18,16 @@
18
18
  gap: var(--zn-spacing-x-small);
19
19
  width: 100%;
20
20
  }
21
+
22
+ .key-value__rows--list .key-value__row {
23
+ grid-template-columns: minmax(0, 1fr) auto;
24
+ }
25
+
26
+ .key-value__rows--options .key-value__row {
27
+ grid-template-columns: minmax(0, 1fr) var(--zn-key-value-option-width, 13rem) auto;
28
+ }
29
+
30
+ // Keeps its space so the inputs don't change width when it appears
31
+ .key-value__remove--hidden {
32
+ visibility: hidden;
33
+ }
@@ -2,11 +2,15 @@ import '../../../dist/zn.min.js';
2
2
  import {expect, fixture, html} from '@open-wc/testing';
3
3
  import type ZnKeyValue from './key-value.component';
4
4
 
5
+ const rows = (el: ZnKeyValue) => el.shadowRoot!.querySelectorAll<HTMLElement>('.key-value__row');
6
+ // zn-button.click() skips the click event that the row buttons listen for, so press them as a user would
7
+ const press = (button: Element) => button.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
8
+
5
9
  describe('<zn-key-value>', () => {
6
10
  it('should show a row per pair in its value', async () => {
7
11
  const el = await fixture<ZnKeyValue>(html`<zn-key-value value='{"1":"{{recipient.firstName}}","2":"x"}'></zn-key-value>`);
8
12
 
9
- expect(el.shadowRoot!.querySelectorAll('.key-value__row').length).to.equal(2);
13
+ expect(rows(el).length).to.equal(2);
10
14
  });
11
15
 
12
16
  it('should submit the pairs as a JSON object, leaving out unnamed rows', async () => {
@@ -14,9 +18,68 @@ describe('<zn-key-value>', () => {
14
18
  <form><zn-key-value name="pairs" value='{"1":"a"}'></zn-key-value></form>`);
15
19
  const el = form.querySelector<ZnKeyValue>('zn-key-value')!;
16
20
 
17
- el.shadowRoot!.querySelector<HTMLElement>('.key-value__add')!.click();
21
+ press(el.shadowRoot!.querySelector('.key-value__add')!);
18
22
  await el.updateComplete;
19
23
 
20
24
  expect(new FormData(form).get('pairs')).to.equal('{"1":"a"}');
21
25
  });
26
+
27
+ it('keeps one empty row to type into, with nothing to remove', async () => {
28
+ const el = await fixture<ZnKeyValue>(html`<zn-key-value></zn-key-value>`);
29
+
30
+ expect(rows(el).length).to.equal(1);
31
+ expect(rows(el)[0].querySelector('.key-value__remove--hidden')).to.exist;
32
+ });
33
+
34
+ it('clears the last row instead of removing it', async () => {
35
+ const el = await fixture<ZnKeyValue>(html`<zn-key-value value='{"a":"1"}'></zn-key-value>`);
36
+
37
+ press(rows(el)[0].querySelector('.key-value__remove')!);
38
+ await el.updateComplete;
39
+
40
+ expect(rows(el).length).to.equal(1);
41
+ expect(el.value).to.equal('{}');
42
+ });
43
+
44
+ it('submits a list as a JSON array of names', async () => {
45
+ const form = await fixture<HTMLFormElement>(html`
46
+ <form><zn-key-value name="entries" list value='["10.0.0.1","10.0.0.0/24"]'></zn-key-value></form>`);
47
+ const el = form.querySelector<ZnKeyValue>('zn-key-value')!;
48
+
49
+ expect(rows(el).length).to.equal(2);
50
+ expect(rows(el)[0].querySelector('.key-value__value')).to.not.exist;
51
+ expect(new FormData(form).get('entries')).to.equal('["10.0.0.1","10.0.0.0/24"]');
52
+ });
53
+
54
+ it('renders value options as a select, defaulting new rows to the first option', async () => {
55
+ const options = JSON.stringify({default: 'System default', auto: 'Auto approve'});
56
+ const el = await fixture<ZnKeyValue>(html`
57
+ <zn-key-value value-options="${options}" value='{"a.com":"auto"}'></zn-key-value>`);
58
+
59
+ expect(rows(el)[0].querySelector('zn-select.key-value__value')).to.exist;
60
+
61
+ press(el.shadowRoot!.querySelector('.key-value__add')!);
62
+ await el.updateComplete;
63
+ const key = rows(el)[1].querySelector<HTMLElement & {value: string}>('.key-value__key')!;
64
+ key.value = 'b.com';
65
+ key.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
66
+ await el.updateComplete;
67
+
68
+ expect(JSON.parse(el.value)).to.deep.equal({'a.com': 'auto', 'b.com': 'default'});
69
+ });
70
+
71
+ it('restores its rows on form reset', async () => {
72
+ const form = await fixture<HTMLFormElement>(html`
73
+ <form><zn-key-value name="pairs" value='{"a":"1"}'></zn-key-value></form>`);
74
+ const el = form.querySelector<ZnKeyValue>('zn-key-value')!;
75
+
76
+ press(rows(el)[0].querySelector('.key-value__remove')!);
77
+ await el.updateComplete;
78
+ expect(el.value).to.equal('{}');
79
+
80
+ form.reset();
81
+ await el.updateComplete;
82
+ expect(el.value).to.equal('{"a":"1"}');
83
+ expect(rows(el).length).to.equal(1);
84
+ });
22
85
  });
@@ -1,6 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import {aTimeout, expect, fixture, html} from '@open-wc/testing';
3
- import {centre, drop, dragTo, startDrag} from '../../internal/test-drag';
3
+ import {centre, dragTo, drop, startDrag} from '../../internal/test-drag';
4
4
  import type ZnNavItem from '../nav-item';
5
5
  import type ZnNavList from './nav-list.component';
6
6