@kubex/zinc 1.1.140 → 1.1.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.140",
3
+ "version": "1.1.142",
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",
@@ -102,8 +102,18 @@
102
102
  }
103
103
  }
104
104
 
105
+ // The 70px is the gutter between the label column and the fields. Once zn-cols
106
+ // wraps them into one column it is dead space above the fields instead, so it
107
+ // only applies at the width the two columns actually fit — the same 670px the
108
+ // sticky label uses.
105
109
  zn-cols::part(base) {
106
- gap: 70px;
110
+ gap: var(--zn-gap);
111
+ }
112
+
113
+ @container (min-width: 670px) {
114
+ zn-cols::part(base) {
115
+ gap: 70px;
116
+ }
107
117
  }
108
118
 
109
119
 
@@ -126,4 +126,20 @@ describe('<zn-form-group>', () => {
126
126
  expect(after).to.be.closeTo(before, 4);
127
127
  });
128
128
  });
129
+
130
+ it('should gutter the label column only while it sits beside the fields', async () => {
131
+ const wide = await fixture<HTMLElement>(html`
132
+ <div style="width: 900px; container-type: inline-size;">
133
+ <zn-form-group label="Wide"><div>Field</div></zn-form-group>
134
+ </div>`);
135
+ const wideCols = wide.querySelector('zn-form-group')!.shadowRoot!.querySelector('zn-cols')!;
136
+ expect(getComputedStyle(wideCols.shadowRoot!.querySelector('.cols')!).columnGap).to.equal('70px');
137
+
138
+ const narrow = await fixture<HTMLElement>(html`
139
+ <div style="width: 400px; container-type: inline-size;">
140
+ <zn-form-group label="Narrow"><div>Field</div></zn-form-group>
141
+ </div>`);
142
+ const narrowCols = narrow.querySelector('zn-form-group')!.shadowRoot!.querySelector('zn-cols')!;
143
+ expect(getComputedStyle(narrowCols.shadowRoot!.querySelector('.cols')!).rowGap).to.not.equal('70px');
144
+ });
129
145
  });
@@ -39,10 +39,10 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
39
39
  static styles: CSSResultGroup = unsafeCSS(styles);
40
40
 
41
41
  @property() name: string = "";
42
- @property() value: string;
42
+ @property() value: string | string[] = '';
43
43
 
44
44
  /** The default value of the form control. Primarily used for resetting the form control. */
45
- @defaultValue() defaultValue: string = '';
45
+ @defaultValue() defaultValue: string | string[] = '';
46
46
 
47
47
  @property({type: Boolean, reflect: true}) checked = false;
48
48
  @property({type: Array}) options: linkedSelectOptions;
@@ -54,6 +54,14 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
54
54
  /** Automatically select the first option of the linked group when no value is set. */
55
55
  @property({attribute: 'select-first', type: Boolean}) selectFirst = false;
56
56
 
57
+ /** Allows more than one option of the linked group to be selected. */
58
+ @property({type: Boolean, reflect: true}) multiple = false;
59
+
60
+ @property() placeholder = '';
61
+ @property({type: Boolean}) clearable = false;
62
+ @property({type: Boolean}) search = false;
63
+ @property({attribute: 'help-text'}) helpText = '';
64
+
57
65
  @query('zn-select') input: ZnSelect;
58
66
 
59
67
  private linkedSelectElement: HTMLSelectElement | ZnSelect;
@@ -100,6 +108,15 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
100
108
  }
101
109
  }
102
110
 
111
+ protected willUpdate(_changedProperties: PropertyValues) {
112
+ super.willUpdate(_changedProperties);
113
+ // An unset value is the empty string, which the inner select would read as a
114
+ // single selection of "".
115
+ if (this.multiple && !Array.isArray(this.value)) {
116
+ this.value = this.value ? this.value.split(' ') : [];
117
+ }
118
+ }
119
+
103
120
  protected firstUpdated(_changedProperties: PropertyValues) {
104
121
  this.linkedSelectElement?.addEventListener('zn-change', this.handleLinkedSelectChange);
105
122
  this.input.addEventListener('zn-change', this.handleChange);
@@ -131,21 +148,31 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
131
148
  }
132
149
 
133
150
  public handleLinkedSelectChange = () => {
151
+ const previous = this.serialisedValue();
134
152
  // The inner select only auto-selects on slotchange, which lit skips when it
135
153
  // can reuse the option elements of the outgoing group, so the first option of
136
154
  // the new group is resolved here rather than left to the select.
137
- this.value = this.selectFirst ? Object.keys(this.currentOptions())[0] ?? "" : "";
155
+ this.value = this.multiple ? [] : this.selectFirst ? Object.keys(this.currentOptions())[0] ?? "" : "";
138
156
  this.requestUpdate();
139
157
  this.formControlController.updateValidity();
158
+ // A host reading the value off change events would otherwise keep the
159
+ // selection made against the previous group.
160
+ if (previous !== this.serialisedValue()) {
161
+ this.emit('zn-change');
162
+ }
140
163
  };
141
164
 
165
+ private serialisedValue(): string {
166
+ return Array.isArray(this.value) ? this.value.join(' ') : this.value;
167
+ }
168
+
142
169
  public handleChange(e: Event) {
143
170
  this.value = (e.target as HTMLSelectElement).value;
144
171
  this.formControlController.updateValidity();
145
172
  }
146
173
 
147
174
  handleSelectChange = (e: ZnSelectEvent) => {
148
- this.value = (e.target as ZnSelect).value as string;
175
+ this.value = (e.target as ZnSelect).value;
149
176
  }
150
177
 
151
178
  /** The options of the group the linked select currently points at. */
@@ -171,7 +198,12 @@ export default class ZnLinkedSelect extends ZincElement implements ZincFormContr
171
198
  id="main-input"
172
199
  cache-key="${this.cacheKey}"
173
200
  ?select-first="${this.selectFirst}"
174
- value="${this.value}"
201
+ ?multiple="${this.multiple}"
202
+ ?clearable="${this.clearable}"
203
+ ?search="${this.search}"
204
+ placeholder="${this.placeholder}"
205
+ help-text="${this.helpText}"
206
+ .value="${this.value}"
175
207
  @zn-change=${this.handleSelectChange}
176
208
  label="${this.label}">
177
209
  ${options && Object.entries(options).map(([key, value]) => html`
@@ -1,17 +1,103 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
3
+ import type ZnLinkedSelect from './linked-select.component';
4
+ import type ZnSelect from '../select';
5
+
6
+ const options = {
7
+ '': {},
8
+ 'cat-1': { 'cat-1-a': 'Disputes', 'cat-1-b': 'Refunds' },
9
+ 'cat-2': { 'cat-2-a': 'Delivery' },
10
+ };
11
+
12
+ async function group(multiple = false) {
13
+ const wrapper = await fixture<HTMLDivElement>(html`
14
+ <div>
15
+ <zn-select id="parent" name="parent">
16
+ <zn-option value="cat-1">Billing</zn-option>
17
+ <zn-option value="cat-2">Orders</zn-option>
18
+ </zn-select>
19
+ <zn-linked-select linked-select="parent" name="child"
20
+ ?multiple=${multiple} .options=${options}></zn-linked-select>
21
+ </div>`);
22
+
23
+ return {
24
+ parent: wrapper.querySelector<ZnSelect>('zn-select')!,
25
+ child: wrapper.querySelector<ZnLinkedSelect>('zn-linked-select')!,
26
+ };
27
+ }
28
+
29
+ function childOptions(child: ZnLinkedSelect): string[] {
30
+ return [...child.shadowRoot!.querySelectorAll('zn-option')].map(option => option.getAttribute('value')!);
31
+ }
3
32
 
4
33
  describe('<zn-linked-select>', () => {
5
34
  it('should render a component', async () => {
6
- const el = await fixture(html`
7
- <zn-select name="linked-select" id="select-1">
8
- <option value="option1">Option 1</option>
9
- </zn-select>
35
+ const { child } = await group();
36
+ expect(child).to.exist;
37
+ });
38
+
39
+ it('should only offer the options of the linked select group', async () => {
40
+ const { parent, child } = await group();
41
+
42
+ parent.value = 'cat-1';
43
+ parent.emit('zn-change');
44
+ await waitUntil(() => childOptions(child).length === 2);
45
+
46
+ expect(childOptions(child)).to.deep.equal(['cat-1-a', 'cat-1-b']);
47
+
48
+ parent.value = 'cat-2';
49
+ parent.emit('zn-change');
50
+ await waitUntil(() => childOptions(child).length === 1);
51
+
52
+ expect(childOptions(child)).to.deep.equal(['cat-2-a']);
53
+ });
54
+
55
+ it('should offer no options until the linked select has a value', async () => {
56
+ const { child } = await group();
57
+ expect(childOptions(child)).to.deep.equal([]);
58
+ });
59
+
60
+ it('should keep an array value when multiple', async () => {
61
+ const { parent, child } = await group(true);
62
+ parent.value = 'cat-1';
63
+ parent.emit('zn-change');
64
+ await waitUntil(() => childOptions(child).length === 2);
65
+
66
+ child.value = ['cat-1-a', 'cat-1-b'];
67
+ await child.updateComplete;
68
+
69
+ expect(child.input.multiple).to.be.true;
70
+ expect(child.input.value).to.deep.equal(['cat-1-a', 'cat-1-b']);
71
+ });
72
+
73
+ it('should start a multiple select with no selection', async () => {
74
+ const { child } = await group(true);
75
+ expect(child.value).to.deep.equal([]);
76
+ expect(child.input.value).to.deep.equal([]);
77
+ });
78
+
79
+ it('should clear a multiple selection when the linked select changes', async () => {
80
+ const { parent, child } = await group(true);
81
+ child.value = ['cat-1-a'];
82
+ await child.updateComplete;
83
+
84
+ parent.value = 'cat-2';
85
+ const changed = oneEvent(child, 'zn-change');
86
+ parent.emit('zn-change');
87
+ await changed;
88
+
89
+ expect(child.value).to.deep.equal([]);
90
+ });
91
+
92
+ it('should not announce a change when the value was already empty', async () => {
93
+ const { parent, child } = await group(true);
94
+ let changes = 0;
95
+ child.addEventListener('zn-change', () => changes++);
10
96
 
11
- <zn-linked-select linked-select="select-1"
12
- options="{&quot;-&quot;:{&quot;-&quot;:&quot;Select Department&quot;},&quot;FID:DEP:1677585809:MZxvTiWlzBlSG&quot;:{&quot;-&quot;:&quot;Select Queue&quot;,&quot;FID:TKT:QUE:1677585809:DFP20NM0Q&quot;:&quot;Generic&quot;,&quot;FID:TKT:QUE:1678099064:8nGJ5OQfp&quot;:&quot;Money Back&quot;},&quot;FID:DEP:1677585809:Kd83L0aIhsGpB&quot;:{&quot;-&quot;:&quot;Select Queue&quot;,&quot;FID:TKT:QUE:1677585809:ma9I3ORGu&quot;:&quot;Generic&quot;},&quot;FID:DEP:1677585809:3mBpgQnrkzLSO&quot;:{&quot;-&quot;:&quot;Select Queue&quot;,&quot;FID:TKT:QUE:1677585809:cFvFFL8o6&quot;:&quot;Generic&quot;}}">
13
- </zn-linked-select> `);
97
+ parent.value = 'cat-2';
98
+ parent.emit('zn-change');
99
+ await child.updateComplete;
14
100
 
15
- expect(el).to.exist;
101
+ expect(changes).to.equal(0);
16
102
  });
17
103
  });
@@ -47,6 +47,25 @@ export default class ZnOptGroup extends ZincElement {
47
47
  this.hidden = options.length > 0 && options.every(option => option.hidden);
48
48
  }
49
49
 
50
+ /**
51
+ * @internal - Marks the group as the first visible one so it doesn't draw a separator against the top of the
52
+ * listbox. Structural `:first-child` can't see that earlier siblings were hidden by a search filter.
53
+ */
54
+ updateSeparator() {
55
+ let previous = this.previousElementSibling;
56
+ while (previous && !ZnOptGroup.isRendered(previous)) {
57
+ previous = previous.previousElementSibling;
58
+ }
59
+ this.toggleAttribute('data-first-visible', previous === null);
60
+ }
61
+
62
+ private static isRendered(el: Element): boolean {
63
+ if (el instanceof HTMLSlotElement) {
64
+ return el.assignedElements().some(assigned => ZnOptGroup.isRendered(assigned));
65
+ }
66
+ return !(el instanceof HTMLElement) || !el.hidden;
67
+ }
68
+
50
69
  render() {
51
70
  return html`
52
71
  <div part="base" class="opt-group">
@@ -8,7 +8,7 @@
8
8
  display: none !important;
9
9
  }
10
10
 
11
- :host(:not(:first-child)) {
11
+ :host(:not(:first-child):not([data-first-visible])) {
12
12
  border-block-start: solid var(--zn-panel-border-width) var(--zn-panel-border-color);
13
13
  margin-block-start: var(--zn-spacing-2x-small);
14
14
  }
@@ -1,4 +1,4 @@
1
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
1
+ import { type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS } from 'lit';
2
2
  import {
3
3
  emptyDragImage,
4
4
  emptyPageState,
@@ -35,6 +35,12 @@ const MAX_SECTIONS = 500;
35
35
  /** Builder width below which the palette auto-collapses — keep in sync with the @container query in page-builder.scss. */
36
36
  const NARROW_WIDTH = 768;
37
37
 
38
+ // 300 matches the narrow-container floor in the stylesheet: below it the
39
+ // inspector's toggle rows wrap their switch under the label.
40
+ const MIN_INSPECTOR_WIDTH = 300;
41
+ const MIN_CANVAS_WIDTH = 200;
42
+ const RESIZE_STEP = 20;
43
+
38
44
  const AUTO_SAVE_DEFAULT_MINUTES = 5;
39
45
  const AUTO_SAVE_TTL_MS = 24 * 60 * 60 * 1000;
40
46
 
@@ -158,6 +164,8 @@ export default class ZnPageBuilder extends ZincElement {
158
164
  @state() private _slotPicker: { containerId: string; index: number } | null = null;
159
165
  /** The stamped config form for the selected section; rebuilt on selection change. */
160
166
  @state() private _form: HTMLDivElement | null = null;
167
+ @state() private _inspectorWidth: number | null = null;
168
+ @state() private _inspectorWide = false;
161
169
 
162
170
  private readonly _hasSlot = new HasSlotController(this, 'header-left', 'header-right');
163
171
 
@@ -1301,6 +1309,7 @@ export default class ZnPageBuilder extends ZincElement {
1301
1309
  // A content-free template stamps no controls — leave the form null so the
1302
1310
  // inspector shows its "no settings" hint rather than a blank gap.
1303
1311
  this._form = form.childElementCount ? form : null;
1312
+ this._inspectorWide = Boolean(this._form?.querySelector('zn-remarkd-editor, [input-type="remarkd"]'));
1304
1313
  }
1305
1314
 
1306
1315
  private _isBooleanControl(control: HTMLElement, value: unknown): boolean {
@@ -1336,6 +1345,41 @@ export default class ZnPageBuilder extends ZincElement {
1336
1345
  this._commit({ sections: this._patchSection(id, s => ({ ...s, label: label || undefined })) });
1337
1346
  }
1338
1347
 
1348
+ /** Width the inspector may be dragged to, against the builder's own width. */
1349
+ private _clampInspector(width: number): number {
1350
+ const room = this.getBoundingClientRect().width - MIN_CANVAS_WIDTH;
1351
+ return Math.round(Math.min(Math.max(width, MIN_INSPECTOR_WIDTH), Math.max(room, MIN_INSPECTOR_WIDTH)));
1352
+ }
1353
+
1354
+ private _inspectorRect(): number {
1355
+ return this.shadowRoot?.querySelector('.inspector')?.getBoundingClientRect().width ?? MIN_INSPECTOR_WIDTH;
1356
+ }
1357
+
1358
+ private _onResizeStart = (e: PointerEvent) => {
1359
+ e.preventDefault();
1360
+ const startX = e.clientX;
1361
+ const startWidth = this._inspectorWidth ?? this._inspectorRect();
1362
+ // The handle is on the inspector's left edge, so dragging left widens it.
1363
+ const move = (ev: PointerEvent) => {
1364
+ this._inspectorWidth = this._clampInspector(startWidth + startX - ev.clientX);
1365
+ };
1366
+ const stop = () => {
1367
+ window.removeEventListener('pointermove', move);
1368
+ window.removeEventListener('pointerup', stop);
1369
+ window.removeEventListener('pointercancel', stop);
1370
+ };
1371
+ window.addEventListener('pointermove', move);
1372
+ window.addEventListener('pointerup', stop);
1373
+ window.addEventListener('pointercancel', stop);
1374
+ };
1375
+
1376
+ private _onResizeKey = (e: KeyboardEvent) => {
1377
+ const step = e.key === 'ArrowLeft' ? RESIZE_STEP : e.key === 'ArrowRight' ? -RESIZE_STEP : 0;
1378
+ if (!step) return;
1379
+ e.preventDefault();
1380
+ this._inspectorWidth = this._clampInspector((this._inspectorWidth ?? this._inspectorRect()) + step);
1381
+ };
1382
+
1339
1383
  private _renderInspector() {
1340
1384
  const section = this._selectedSection();
1341
1385
  if (!section) return html``;
@@ -1345,6 +1389,15 @@ export default class ZnPageBuilder extends ZincElement {
1345
1389
  const typeLabel = type?.label ?? section.type;
1346
1390
  return html`
1347
1391
  <aside part="inspector" class="inspector">
1392
+ <div
1393
+ part="inspector-resize"
1394
+ class="inspector-resize"
1395
+ role="separator"
1396
+ aria-orientation="vertical"
1397
+ aria-label="Resize section settings"
1398
+ tabindex="0"
1399
+ @pointerdown="${this._onResizeStart}"
1400
+ @keydown="${this._onResizeKey}"></div>
1348
1401
  <div part="inspector-header" class="inspector-head">
1349
1402
  <span
1350
1403
  class="inspector-head__icon"
@@ -1401,7 +1454,8 @@ export default class ZnPageBuilder extends ZincElement {
1401
1454
  return html`
1402
1455
  <div
1403
1456
  part="base"
1404
- class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''}"
1457
+ class="builder ${this._selectedId ? 'builder--inspecting' : ''} ${this.paletteCollapsed ? 'builder--palette-collapsed' : ''} ${this.inspectorCollapsed ? 'builder--inspector-collapsed' : ''} ${this._inspectorWide ? 'builder--inspector-wide' : ''}"
1458
+ style="${this._inspectorWidth === null || this.inspectorCollapsed ? nothing : `--inspector-col: ${this._inspectorWidth}px`}"
1405
1459
  @dragend="${() => {
1406
1460
  this._dragOverIndex = null;
1407
1461
  this._slotDragOver = null;
@@ -39,6 +39,13 @@
39
39
 
40
40
  // Side panels tuck away via the canvas-edge chevrons (flow-builder pattern).
41
41
  // Their border would floor the width above zero, so it collapses too.
42
+ // A section whose settings are mostly a remarkd editor needs a writing
43
+ // measure, not a form column. Declared before the collapsed rules so tucking
44
+ // the panel away still wins.
45
+ &--inspector-wide {
46
+ --inspector-col: 520px;
47
+ }
48
+
42
49
  &--palette-collapsed {
43
50
  --palette-col: 0px;
44
51
 
@@ -254,6 +261,7 @@ zn-page-palette-item {
254
261
  // Right column — anatomy shared with flow-builder's inspector (icon tile +
255
262
  // title/type head over a scrolling body), so the two builders read alike.
256
263
  .inspector {
264
+ position: relative;
257
265
  display: flex;
258
266
  flex-direction: column;
259
267
  border-left: 1px solid rgb(var(--zn-border-color));
@@ -328,6 +336,10 @@ zn-page-palette-item {
328
336
  flex: 1;
329
337
  min-height: 0;
330
338
  overflow-y: auto;
339
+ // Fields inside a narrow panel have to answer to its width, not the whole
340
+ // builder's — the host is the only container otherwise, and a form group
341
+ // would lay out as if it had the canvas to spread into.
342
+ container-type: inline-size;
331
343
  // Extra bottom padding so the last field isn't flush against the scroll end.
332
344
  padding: 18px 16px 24px;
333
345
  gap: var(--pb-field-gap);
@@ -391,6 +403,35 @@ zn-page-palette-item {
391
403
  }
392
404
  }
393
405
 
406
+ // Straddles the inspector's left border so the grab area is wider than the 1px
407
+ // line it appears to be.
408
+ .inspector-resize {
409
+ position: absolute;
410
+ top: 0;
411
+ bottom: 0;
412
+ left: -3px;
413
+ z-index: 5;
414
+ width: 7px;
415
+ cursor: col-resize;
416
+
417
+ &::after {
418
+ content: '';
419
+ position: absolute;
420
+ inset: 0 3px;
421
+ background: transparent;
422
+ transition: background 0.15s ease;
423
+ }
424
+
425
+ &:hover::after,
426
+ &:focus-visible::after {
427
+ background: rgb(var(--zn-color-primary));
428
+ }
429
+
430
+ &:focus-visible {
431
+ outline: none;
432
+ }
433
+ }
434
+
394
435
  .inspector-close {
395
436
  display: flex;
396
437
  align-items: center;