@kubex/zinc 1.0.113 → 1.0.115

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 (101) hide show
  1. package/.github/workflows/eleventy_build.yml +5 -5
  2. package/.github/workflows/js_build_and_deploy.yaml +1 -1
  3. package/dist/custom-elements.json +2422 -190
  4. package/dist/vscode.html-custom-data.json +86 -5
  5. package/dist/web-types.json +251 -9
  6. package/dist/zn.d.ts +518 -340
  7. package/dist/zn.min.css +1 -1
  8. package/dist/zn.min.js +960 -882
  9. package/docs/eleventy.config.cjs +4 -2
  10. package/docs/pages/components/header.md +13 -0
  11. package/docs/pages/components/inline-edit.md +20 -0
  12. package/docs/pages/components/item.md +12 -1
  13. package/docs/pages/components/page.md +149 -0
  14. package/docs/pages/components/progress-bar.md +16 -0
  15. package/docs/pages/components/select.md +25 -0
  16. package/docs/pages/components/split-pane.md +6 -7
  17. package/docs/pages/components/tabs.md +59 -9
  18. package/package.json +8 -5
  19. package/scripts/build.js +40 -19
  20. package/scss/_global-spacing.scss +12 -1
  21. package/scss/_root.scss +34 -2
  22. package/scss/mixins/_spacing-mixins.scss +1 -1
  23. package/scss/shared/layout.scss +15 -2
  24. package/scss/themes/_light.scss +1 -1
  25. package/src/components/absolute-container/absolute-container.component.ts +9 -25
  26. package/src/components/animated-button/animated-button.test.ts +8 -8
  27. package/src/components/button/button.component.ts +3 -1
  28. package/src/components/button/button.scss +5 -0
  29. package/src/components/button-menu/button-menu.component.ts +36 -21
  30. package/src/components/button-menu/button-menu.test.ts +55 -1
  31. package/src/components/chart/chart.component.ts +7 -7
  32. package/src/components/collapsible/collapsible.component.ts +7 -14
  33. package/src/components/cols/cols.scss +1 -1
  34. package/src/components/content-block/content-block.component.ts +11 -33
  35. package/src/components/data-select/data-select.component.ts +7 -11
  36. package/src/components/data-select/data-select.scss +1 -0
  37. package/src/components/data-table/data-table.component.ts +10 -14
  38. package/src/components/dialog/dialog.scss +4 -0
  39. package/src/components/editor/modules/toolbar/toolbar.component.ts +5 -9
  40. package/src/components/expanding-action/expanding-action.component.ts +37 -43
  41. package/src/components/form-group/form-group.component.ts +32 -27
  42. package/src/components/form-group/form-group.scss +1 -0
  43. package/src/components/header/header.component.ts +6 -5
  44. package/src/components/header/header.scss +28 -26
  45. package/src/components/inline-edit/inline-edit.component.ts +13 -1
  46. package/src/components/inline-edit/inline-edit.test.ts +50 -0
  47. package/src/components/input/input.test.ts +77 -0
  48. package/src/components/input-group/input-group.test.ts +2 -10
  49. package/src/components/item/item.component.ts +12 -6
  50. package/src/components/item/item.scss +1 -1
  51. package/src/components/item/item.test.ts +8 -0
  52. package/src/components/navbar/navbar.component.ts +222 -48
  53. package/src/components/navbar/navbar.scss +134 -21
  54. package/src/components/navbar/navbar.test.ts +114 -9
  55. package/src/components/option/option.scss +5 -7
  56. package/src/components/option/option.test.ts +30 -0
  57. package/src/components/page/index.ts +13 -0
  58. package/src/components/page/page.component.ts +406 -0
  59. package/src/components/page/page.scss +325 -0
  60. package/src/components/page/page.test.ts +397 -0
  61. package/src/components/page-nav/page-nav.scss +7 -5
  62. package/src/components/pane/pane.component.ts +2 -2
  63. package/src/components/pane/pane.scss +0 -1
  64. package/src/components/pane/pane.test.ts +31 -0
  65. package/src/components/panel/panel.scss +5 -16
  66. package/src/components/progress-bar/progress-bar.component.ts +5 -2
  67. package/src/components/progress-bar/progress-bar.scss +17 -1
  68. package/src/components/progress-bar/progress-bar.test.ts +12 -0
  69. package/src/components/scroll-container/scroll-container.component.ts +22 -21
  70. package/src/components/select/select.component.ts +243 -39
  71. package/src/components/select/select.scss +36 -5
  72. package/src/components/select/select.test.ts +533 -0
  73. package/src/components/settings-container/settings-container.component.ts +15 -19
  74. package/src/components/settings-container/settings-container.scss +8 -8
  75. package/src/components/sidebar/sidebar.component.ts +11 -11
  76. package/src/components/simple-chart/simple-chart.component.ts +6 -7
  77. package/src/components/sp/sp.scss +46 -15
  78. package/src/components/sp/sp.test.ts +15 -0
  79. package/src/components/split-pane/split-pane.component.ts +153 -26
  80. package/src/components/split-pane/split-pane.scss +89 -13
  81. package/src/components/split-pane/split-pane.test.ts +187 -0
  82. package/src/components/style/style.component.ts +8 -0
  83. package/src/components/tab/index.ts +13 -0
  84. package/src/components/tab/tab.component.ts +25 -0
  85. package/src/components/tab/tab.scss +7 -0
  86. package/src/components/table/table.component.ts +7 -2
  87. package/src/components/table/table.scss +1 -0
  88. package/src/components/tabs/tabs.component.ts +75 -70
  89. package/src/components/tabs/tabs.scss +1 -1
  90. package/src/components/textarea/textarea.component.ts +5 -8
  91. package/src/components/tile/tile.scss +2 -2
  92. package/src/components/translation-group/translation-group.component.ts +15 -14
  93. package/src/components/translations/translations.component.ts +18 -17
  94. package/src/components/translations/translations.scss +1 -0
  95. package/src/components/translations/translations.test.ts +3 -2
  96. package/src/internal/form.ts +234 -0
  97. package/src/internal/theme.ts +26 -46
  98. package/src/internal/zinc-element.ts +12 -7
  99. package/src/utilities/form.ts +1 -0
  100. package/src/wc.scss +1 -1
  101. package/src/zinc.ts +3 -0
@@ -3,6 +3,16 @@ import {expect, fixture, html} from '@open-wc/testing';
3
3
  import type ZnOption from '../option/option.component';
4
4
  import type ZnSelect from './select.component';
5
5
 
6
+ // The popup's ResizeObserver can emit a benign "loop completed with undelivered notifications"
7
+ // warning when the dropdown opens during layout-measuring tests. It's not a real error — ignore it
8
+ // so the test runner doesn't treat it as an uncaught exception (capture phase runs before the runner's).
9
+ window.addEventListener('error', (e: ErrorEvent) => {
10
+ if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
11
+ e.stopImmediatePropagation();
12
+ e.preventDefault();
13
+ }
14
+ }, true);
15
+
6
16
  describe('<zn-select>', () => {
7
17
  it('should render a component', async () => {
8
18
  const el = await fixture(html`
@@ -176,4 +186,527 @@ describe('<zn-select>', () => {
176
186
  });
177
187
 
178
188
  });
189
+
190
+ describe('multiple + search layout', () => {
191
+ const openWithTags = async (width: number) => {
192
+ const el = await fixture<ZnSelect>(html`
193
+ <zn-select search multiple max-options-visible="0" style="width: ${width}px;">
194
+ <zn-option value="a" selected>Urgent</zn-option>
195
+ <zn-option value="b" selected>Backend</zn-option>
196
+ <zn-option value="c" selected>Frontend</zn-option>
197
+ <zn-option value="d" selected>stetset</zn-option>
198
+ <zn-option value="e" selected>another</zn-option>
199
+ </zn-select>
200
+ `);
201
+ await el.updateComplete;
202
+ await el.show();
203
+ await el.updateComplete;
204
+ return el.shadowRoot!.querySelector<HTMLElement>('.select__combobox')!;
205
+ };
206
+
207
+ it('grows to show all tags instead of clipping them when they wrap', async () => {
208
+ const cb = await openWithTags(360);
209
+ // scrollHeight > clientHeight would mean wrapped rows are clipped by the box.
210
+ expect(cb.scrollHeight).to.equal(cb.clientHeight);
211
+ });
212
+
213
+ it('does not clip tags at very narrow widths', async () => {
214
+ const cb = await openWithTags(180);
215
+ expect(cb.scrollHeight).to.equal(cb.clientHeight);
216
+ });
217
+
218
+ it('shows a pointer cursor on a tag remove icon (even in search mode)', async () => {
219
+ // In search/free-text mode the combobox uses cursor:text, which the chips would otherwise inherit.
220
+ const el = await fixture<ZnSelect>(html`
221
+ <zn-select multiple search>
222
+ <zn-option value="a" selected>Apple</zn-option>
223
+ </zn-select>
224
+ `);
225
+ await el.updateComplete;
226
+ const removeIcon = el.shadowRoot!.querySelector<HTMLElement>('.select__tags zn-icon[slot="action"]');
227
+ expect(removeIcon, 'remove icon should exist').to.exist;
228
+ // Only the remove X is clickable, so only it gets a pointer cursor...
229
+ expect(getComputedStyle(removeIcon!).cursor).to.equal('pointer');
230
+ // ...the chip body is not clickable, so it should not show a pointer.
231
+ const chip = el.shadowRoot!.querySelector<HTMLElement>('.select__tags zn-chip')!;
232
+ expect(getComputedStyle(chip).cursor).to.equal('default');
233
+ });
234
+ });
235
+
236
+ describe('free-text', () => {
237
+ const type = (el: ZnSelect, text: string) => {
238
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
239
+ displayInput.value = text;
240
+ displayInput.dispatchEvent(new Event('input'));
241
+ };
242
+
243
+ const pressEnter = (el: ZnSelect) => {
244
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
245
+ displayInput.dispatchEvent(new KeyboardEvent('keydown', {
246
+ key: 'Enter',
247
+ bubbles: true,
248
+ composed: true,
249
+ cancelable: true
250
+ }));
251
+ };
252
+
253
+ it('commits typed text not in the list as the value on Enter', async () => {
254
+ const el = await fixture<ZnSelect>(html`
255
+ <zn-select free-text>
256
+ <zn-option value="apple">Apple</zn-option>
257
+ </zn-select>
258
+ `);
259
+ await el.updateComplete;
260
+ await el.show();
261
+ await el.updateComplete;
262
+
263
+ type(el, 'kiwi');
264
+ await el.updateComplete;
265
+ pressEnter(el);
266
+ await el.updateComplete;
267
+
268
+ expect(el.value).to.equal('kiwi');
269
+ });
270
+
271
+ it('adds typed values as tags in multiple mode, keeping the dropdown open', async () => {
272
+ const el = await fixture<ZnSelect>(html`
273
+ <zn-select free-text multiple></zn-select>
274
+ `);
275
+ await el.updateComplete;
276
+ await el.show();
277
+ await el.updateComplete;
278
+
279
+ type(el, 'kiwi');
280
+ await el.updateComplete;
281
+ pressEnter(el);
282
+ await el.updateComplete;
283
+
284
+ type(el, 'mango');
285
+ await el.updateComplete;
286
+ pressEnter(el);
287
+ await el.updateComplete;
288
+
289
+ expect(el.value).to.deep.equal(['kiwi', 'mango']);
290
+ expect(el.open).to.be.true;
291
+ });
292
+
293
+ it('selects the existing option instead of creating a duplicate on exact match', async () => {
294
+ const el = await fixture<ZnSelect>(html`
295
+ <zn-select free-text>
296
+ <zn-option value="apple">Apple</zn-option>
297
+ </zn-select>
298
+ `);
299
+ await el.updateComplete;
300
+ await el.show();
301
+ await el.updateComplete;
302
+
303
+ type(el, 'Apple'); // case-insensitive match of the existing label
304
+ await el.updateComplete;
305
+ pressEnter(el);
306
+ await el.updateComplete;
307
+
308
+ expect(el.value).to.equal('apple');
309
+ expect(el.querySelectorAll('zn-option').length).to.equal(1);
310
+ expect(el.querySelector('[data-free-text]')).to.be.null;
311
+ });
312
+
313
+ it('shows an Add row for non-matching text and commits it on click', async () => {
314
+ const el = await fixture<ZnSelect>(html`
315
+ <zn-select free-text>
316
+ <zn-option value="apple">Apple</zn-option>
317
+ </zn-select>
318
+ `);
319
+ await el.updateComplete;
320
+ await el.show();
321
+ await el.updateComplete;
322
+
323
+ type(el, 'kiwi');
324
+ await el.updateComplete;
325
+
326
+ const addRow = el.shadowRoot!.querySelector<HTMLElement>('.select__add-option');
327
+ expect(addRow, 'Add row should be visible for non-matching text').to.exist;
328
+
329
+ addRow!.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true, cancelable: true}));
330
+ addRow!.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, composed: true}));
331
+ await el.updateComplete;
332
+
333
+ expect(el.value).to.equal('kiwi');
334
+ });
335
+
336
+ it('commits the Add row on click without also selecting an option', async () => {
337
+ const el = await fixture<ZnSelect>(html`
338
+ <zn-select free-text multiple>
339
+ <zn-option value="apple">Apple</zn-option>
340
+ <zn-option value="banana">Banana</zn-option>
341
+ </zn-select>
342
+ `);
343
+ await el.updateComplete;
344
+ await el.show();
345
+ await el.updateComplete;
346
+
347
+ // "ap" filters to Apple (still visible) and offers Add "ap" at the top of the listbox
348
+ type(el, 'ap');
349
+ await el.updateComplete;
350
+
351
+ const addRow = el.shadowRoot!.querySelector<HTMLElement>('.select__add-option')!;
352
+ addRow.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true, cancelable: true}));
353
+ await el.updateComplete;
354
+
355
+ // The mouseup hits whatever now sits under the cursor. If committing on mousedown removed the Add
356
+ // row (the bug), the first option shifts up into that spot and would be wrongly selected on mouseup.
357
+ const underCursor = el.shadowRoot!.querySelector<HTMLElement>('.select__add-option')
358
+ ?? el.querySelector<HTMLElement>('zn-option');
359
+ underCursor!.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, composed: true}));
360
+ await el.updateComplete;
361
+
362
+ // Only the typed value is committed — the first option ("apple") must not be selected too
363
+ expect(el.value).to.deep.equal(['ap']);
364
+ });
365
+
366
+ it('keeps the Add row text in sync as the user keeps typing', async () => {
367
+ const el = await fixture<ZnSelect>(html`
368
+ <zn-select free-text>
369
+ <zn-option value="apple">Apple</zn-option>
370
+ </zn-select>
371
+ `);
372
+ await el.updateComplete;
373
+ await el.show();
374
+ await el.updateComplete;
375
+
376
+ type(el, 'te');
377
+ await el.updateComplete;
378
+ expect(el.shadowRoot!.querySelector('.select__add-option')!.textContent!.trim())
379
+ .to.equal('Add "te"');
380
+
381
+ // Continue typing without crossing the match/no-match boundary
382
+ type(el, 'testssdfs');
383
+ await el.updateComplete;
384
+ expect(el.shadowRoot!.querySelector('.select__add-option')!.textContent!.trim())
385
+ .to.equal('Add "testssdfs"');
386
+ });
387
+
388
+ it('does not show the Add row when the text exactly matches an option', async () => {
389
+ const el = await fixture<ZnSelect>(html`
390
+ <zn-select free-text>
391
+ <zn-option value="apple">Apple</zn-option>
392
+ </zn-select>
393
+ `);
394
+ await el.updateComplete;
395
+ await el.show();
396
+ await el.updateComplete;
397
+
398
+ type(el, 'Apple');
399
+ await el.updateComplete;
400
+
401
+ expect(el.shadowRoot!.querySelector('.select__add-option')).to.be.null;
402
+ });
403
+
404
+ it('commits pending typed text when the dropdown closes', async () => {
405
+ const el = await fixture<ZnSelect>(html`<zn-select free-text></zn-select>`);
406
+ await el.updateComplete;
407
+ await el.show();
408
+ await el.updateComplete;
409
+
410
+ type(el, 'kiwi');
411
+ await el.updateComplete;
412
+
413
+ await el.hide();
414
+ await el.updateComplete;
415
+
416
+ expect(el.value).to.equal('kiwi');
417
+ });
418
+
419
+ it('removes the backing option from the DOM when a custom tag is removed', async () => {
420
+ const el = await fixture<ZnSelect>(html`<zn-select free-text multiple></zn-select>`);
421
+ await el.updateComplete;
422
+ await el.show();
423
+ await el.updateComplete;
424
+
425
+ type(el, 'kiwi');
426
+ await el.updateComplete;
427
+ pressEnter(el);
428
+ await el.updateComplete;
429
+
430
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(1);
431
+
432
+ const chip = el.shadowRoot!.querySelector('.select__tags zn-chip')!;
433
+ chip.dispatchEvent(new CustomEvent('zn-remove', {bubbles: true, composed: true}));
434
+ await el.updateComplete;
435
+
436
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(0);
437
+ expect(el.value).to.deep.equal([]);
438
+ });
439
+
440
+ it('materialises an unmatched initial value as a custom option', async () => {
441
+ const el = await fixture<ZnSelect>(html`
442
+ <zn-select free-text value="custom-thing">
443
+ <zn-option value="apple">Apple</zn-option>
444
+ </zn-select>
445
+ `);
446
+ await el.updateComplete;
447
+
448
+ expect(el.value).to.equal('custom-thing');
449
+ const created = el.querySelector('[data-free-text]');
450
+ expect(created, 'a backing option should be created').to.exist;
451
+ expect(created!.getAttribute('value')).to.equal('custom-thing');
452
+ expect(el.displayLabel).to.equal('custom-thing');
453
+ });
454
+
455
+ it('does not add a duplicate when the same custom value is committed twice', async () => {
456
+ const el = await fixture<ZnSelect>(html`<zn-select free-text multiple></zn-select>`);
457
+ await el.updateComplete;
458
+ await el.show();
459
+ await el.updateComplete;
460
+
461
+ type(el, 'kiwi');
462
+ await el.updateComplete;
463
+ pressEnter(el);
464
+ await el.updateComplete;
465
+
466
+ type(el, 'kiwi');
467
+ await el.updateComplete;
468
+ pressEnter(el);
469
+ await el.updateComplete;
470
+
471
+ expect(el.value).to.deep.equal(['kiwi']);
472
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(1);
473
+ });
474
+
475
+ it('enables an editable, filtering input on its own (without search)', async () => {
476
+ const el = await fixture<ZnSelect>(html`
477
+ <zn-select free-text>
478
+ <zn-option value="apple">Apple</zn-option>
479
+ <zn-option value="banana">Banana</zn-option>
480
+ </zn-select>
481
+ `);
482
+ await el.updateComplete;
483
+ await el.show();
484
+ await el.updateComplete;
485
+
486
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
487
+ expect(displayInput.readOnly).to.be.false;
488
+
489
+ type(el, 'app');
490
+ await el.updateComplete;
491
+
492
+ const visible = [...el.querySelectorAll<ZnOption>('zn-option')].filter(o => !o.hidden);
493
+ expect(visible.map(o => o.value)).to.deep.equal(['apple']);
494
+ });
495
+
496
+ it('submits committed free-text values as form data', async () => {
497
+ const form = await fixture<HTMLFormElement>(html`
498
+ <form>
499
+ <zn-select name="fruits" free-text multiple></zn-select>
500
+ </form>
501
+ `);
502
+ const el = form.querySelector<ZnSelect>('zn-select')!;
503
+ await el.updateComplete;
504
+ await el.show();
505
+ await el.updateComplete;
506
+
507
+ type(el, 'kiwi');
508
+ await el.updateComplete;
509
+ pressEnter(el);
510
+ await el.updateComplete;
511
+
512
+ type(el, 'mango');
513
+ await el.updateComplete;
514
+ pressEnter(el);
515
+ await el.updateComplete;
516
+
517
+ const formData = new FormData(form);
518
+ expect(formData.getAll('fruits')).to.deep.equal(['kiwi', 'mango']);
519
+ });
520
+
521
+ it('cancels uncommitted free-text on Escape instead of committing it', async () => {
522
+ const el = await fixture<ZnSelect>(html`<zn-select free-text></zn-select>`);
523
+ await el.updateComplete;
524
+ await el.show();
525
+ await el.updateComplete;
526
+
527
+ type(el, 'kiwi');
528
+ await el.updateComplete;
529
+
530
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
531
+ displayInput.dispatchEvent(new KeyboardEvent('keydown', {
532
+ key: 'Escape',
533
+ bubbles: true,
534
+ composed: true,
535
+ cancelable: true
536
+ }));
537
+ await el.updateComplete;
538
+
539
+ expect(el.value).to.equal('');
540
+ });
541
+
542
+ it('removes a previous custom entry when a different option is selected (single)', async () => {
543
+ const el = await fixture<ZnSelect>(html`
544
+ <zn-select free-text>
545
+ <zn-option value="apple">Apple</zn-option>
546
+ </zn-select>
547
+ `);
548
+ await el.updateComplete;
549
+ await el.show();
550
+ await el.updateComplete;
551
+
552
+ type(el, 'kiwi');
553
+ await el.updateComplete;
554
+ pressEnter(el);
555
+ await el.updateComplete;
556
+ expect(el.value).to.equal('kiwi');
557
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(1);
558
+
559
+ // Reopen and pick a real option
560
+ await el.show();
561
+ await el.updateComplete;
562
+ const apple = el.querySelector<ZnOption>('zn-option[value="apple"]')!;
563
+ apple.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, composed: true}));
564
+ await el.updateComplete;
565
+
566
+ expect(el.value).to.equal('apple');
567
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(0);
568
+ });
569
+
570
+ it('shows a committed custom option as a selected row in the listbox', async () => {
571
+ const el = await fixture<ZnSelect>(html`
572
+ <zn-select free-text>
573
+ <zn-option value="apple">Apple</zn-option>
574
+ </zn-select>
575
+ `);
576
+ await el.updateComplete;
577
+ await el.show();
578
+ await el.updateComplete;
579
+
580
+ type(el, 'kiwi');
581
+ await el.updateComplete;
582
+ pressEnter(el);
583
+ await el.updateComplete;
584
+
585
+ // Reopen the dropdown
586
+ await el.show();
587
+ await el.updateComplete;
588
+
589
+ const custom = el.querySelector<ZnOption>('[data-free-text]')!;
590
+ expect(custom, 'custom option should exist').to.exist;
591
+ expect(custom.hidden, 'custom option should be visible so it can be deselected').to.be.false;
592
+ expect(custom.selected, 'custom option should be selected').to.be.true;
593
+ });
594
+
595
+ it('removes a committed custom option when it is deselected from the dropdown', async () => {
596
+ const el = await fixture<ZnSelect>(html`<zn-select free-text multiple></zn-select>`);
597
+ await el.updateComplete;
598
+ await el.show();
599
+ await el.updateComplete;
600
+
601
+ type(el, 'kiwi');
602
+ await el.updateComplete;
603
+ pressEnter(el);
604
+ await el.updateComplete;
605
+ expect(el.value).to.deep.equal(['kiwi']);
606
+
607
+ // Click the custom option's row in the listbox to deselect it
608
+ const custom = el.querySelector<ZnOption>('[data-free-text]')!;
609
+ custom.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, composed: true}));
610
+ await el.updateComplete;
611
+
612
+ expect(el.value).to.deep.equal([]);
613
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(0);
614
+ });
615
+
616
+ it('can re-add a free-text value after it was removed', async () => {
617
+ const el = await fixture<ZnSelect>(html`<zn-select free-text multiple></zn-select>`);
618
+ await el.updateComplete;
619
+ await el.show();
620
+ await el.updateComplete;
621
+
622
+ // Add "foo"
623
+ type(el, 'foo');
624
+ await el.updateComplete;
625
+ pressEnter(el);
626
+ await el.updateComplete;
627
+ expect(el.value).to.deep.equal(['foo']);
628
+
629
+ // Remove it via the tag's remove button
630
+ const chip = el.shadowRoot!.querySelector('.select__tags zn-chip')!;
631
+ chip.dispatchEvent(new CustomEvent('zn-remove', {bubbles: true, composed: true}));
632
+ await el.updateComplete;
633
+ expect(el.value).to.deep.equal([]);
634
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(0);
635
+
636
+ // Add the same value again
637
+ type(el, 'foo');
638
+ await el.updateComplete;
639
+ pressEnter(el);
640
+ await el.updateComplete;
641
+ expect(el.value).to.deep.equal(['foo']);
642
+ expect(el.querySelectorAll('[data-free-text]').length).to.equal(1);
643
+ });
644
+
645
+ it('does not put the "N selected" summary in the input placeholder in multiple mode', async () => {
646
+ const el = await fixture<ZnSelect>(html`
647
+ <zn-select free-text multiple>
648
+ <zn-option value="apple">Apple</zn-option>
649
+ </zn-select>
650
+ `);
651
+ await el.updateComplete;
652
+ await el.show();
653
+ await el.updateComplete;
654
+
655
+ // Select an option — in multiple+search the dropdown stays open
656
+ const apple = el.querySelector<ZnOption>('zn-option[value="apple"]')!;
657
+ apple.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, composed: true}));
658
+ await el.updateComplete;
659
+
660
+ const input = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
661
+ // The tags convey the selection; the input placeholder must not also show "1 option selected"
662
+ expect(input.placeholder).to.equal('');
663
+ });
664
+
665
+ it('lets keyboard navigation reach the Add row over a closer-matching option', async () => {
666
+ const el = await fixture<ZnSelect>(html`
667
+ <zn-select free-text>
668
+ <zn-option value="apple">Apple</zn-option>
669
+ </zn-select>
670
+ `);
671
+ await el.updateComplete;
672
+ await el.show();
673
+ await el.updateComplete;
674
+
675
+ type(el, 'app'); // matches Apple AND offers Add "app"
676
+ await el.updateComplete;
677
+
678
+ // ArrowDown highlights the Add row (it sits at the top of the list)
679
+ const input = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
680
+ input.dispatchEvent(new KeyboardEvent('keydown', {
681
+ key: 'ArrowDown',
682
+ bubbles: true,
683
+ composed: true,
684
+ cancelable: true
685
+ }));
686
+ await el.updateComplete;
687
+
688
+ // Enter commits the typed value, not the matching option
689
+ pressEnter(el);
690
+ await el.updateComplete;
691
+
692
+ expect(el.value).to.equal('app');
693
+ });
694
+
695
+ it('renders the Add row text at the small option font size', async () => {
696
+ const el = await fixture<ZnSelect>(html`
697
+ <zn-select free-text style="--zn-font-size-small: 11px; --zn-font-size-medium: 99px;">
698
+ <zn-option value="apple">Apple</zn-option>
699
+ </zn-select>
700
+ `);
701
+ await el.updateComplete;
702
+ await el.show();
703
+ await el.updateComplete;
704
+
705
+ type(el, 'kiwi');
706
+ await el.updateComplete;
707
+
708
+ const addRow = el.shadowRoot!.querySelector<HTMLElement>('.select__add-option')!;
709
+ expect(getComputedStyle(addRow).fontSize).to.equal('11px');
710
+ });
711
+ });
179
712
  });
@@ -1,6 +1,7 @@
1
1
  import {classMap} from "lit/directives/class-map.js";
2
2
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
3
3
  import {deepQuerySelectorAll} from "../../utilities/query";
4
+ import {MutationController} from '@lit-labs/observers/mutation-controller.js';
4
5
  import {property, state} from "lit/decorators.js";
5
6
  import {Store} from "../../internal/storage";
6
7
  import {type ZnChangeEvent} from "../../events/zn-change";
@@ -43,7 +44,19 @@ export default class ZnSettingsContainer extends ZincElement {
43
44
  @property({attribute: 'store-key'}) storeKey: string;
44
45
  @property({attribute: 'no-scroll'}) noScroll: boolean;
45
46
 
46
- private _mutationObserver: MutationObserver | null = null;
47
+ private readonly _mutationObserver = new MutationController(this, {
48
+ target: null,
49
+ config: {childList: true, subtree: true, attributes: true},
50
+ callback: mutations => {
51
+ for (const mutation of mutations) {
52
+ if (mutation.type === 'attributes' && mutation.attributeName === 'hidden') {
53
+ continue;
54
+ }
55
+ this.scheduleUpdateFilters();
56
+ break;
57
+ }
58
+ },
59
+ });
47
60
 
48
61
  private _updateFiltersScheduled = false;
49
62
 
@@ -72,24 +85,7 @@ export default class ZnSettingsContainer extends ZincElement {
72
85
 
73
86
  this.updateFilters();
74
87
 
75
- this._mutationObserver = new MutationObserver(mutations => {
76
- for (const mutation of mutations) {
77
- if (mutation.type === 'attributes' && mutation.attributeName === 'hidden') {
78
- continue;
79
- }
80
- this.scheduleUpdateFilters();
81
- break;
82
- }
83
- });
84
- this._mutationObserver.observe(this, {childList: true, subtree: true, attributes: true});
85
- }
86
-
87
- disconnectedCallback() {
88
- super.disconnectedCallback();
89
- if (this._mutationObserver) {
90
- this._mutationObserver.disconnect();
91
- this._mutationObserver = null;
92
- }
88
+ this._mutationObserver.observe(this);
93
89
  }
94
90
 
95
91
  // Debounced scheduling to avoid excessive updates on rapid DOM mutations
@@ -40,27 +40,27 @@
40
40
  }
41
41
 
42
42
  &--top-end {
43
- top: var(--zn-spacing-medium);
44
- right: var(--zn-spacing-medium);
43
+ top: var(--zn-base-gap);
44
+ right: calc(var(--zn-base-gap) + var(--zn-gutter));
45
45
  bottom: auto;
46
46
  }
47
47
 
48
48
  &--top-start {
49
- top: var(--zn-spacing-medium);
49
+ top: var(--zn-base-gap);
50
50
  right: auto;
51
- left: var(--zn-spacing-medium);
51
+ left: calc(var(--zn-base-gap) + var(--zn-gutter));
52
52
  bottom: auto;
53
53
  }
54
54
 
55
55
  &--bottom-end {
56
- bottom: calc(var(--zn-spacing-medium) + var(--zn-scroll-footer-height, 0px));
57
- right: var(--zn-spacing-medium);
56
+ bottom: calc(var(--zn-base-gap) + var(--zn-scroll-footer-height, 0px));
57
+ right: calc(var(--zn-base-gap) + var(--zn-gutter));
58
58
  top: auto;
59
59
  }
60
60
 
61
61
  &--bottom-start {
62
- bottom: calc(var(--zn-spacing-medium) + var(--zn-scroll-footer-height, 0px));
63
- left: var(--zn-spacing-medium);
62
+ bottom: calc(var(--zn-base-gap) + var(--zn-scroll-footer-height, 0px));
63
+ left: calc(var(--zn-base-gap) + var(--zn-gutter));
64
64
  top: auto;
65
65
  }
66
66
  }
@@ -1,3 +1,4 @@
1
+ import {MutationController} from '@lit-labs/observers/mutation-controller.js';
1
2
  import {property} from 'lit/decorators.js';
2
3
  import {type CSSResultGroup, html, unsafeCSS} from 'lit';
3
4
  import ZincElement from '../../internal/zinc-element';
@@ -30,30 +31,29 @@ export default class ZnSidebar extends ZincElement {
30
31
  @property({attribute: 'start-scrolled', type: Boolean, reflect: true}) startScrolled: boolean = false;
31
32
  @property({type: Boolean}) wide: boolean = false;
32
33
 
34
+ private domObserver = new MutationController(this, {
35
+ target: null,
36
+ config: {childList: true, subtree: true},
37
+ callback: () => {
38
+ setTimeout(() => this.scrollBottom(), 10);
39
+ },
40
+ });
41
+
33
42
  constructor() {
34
43
  super();
35
44
  this.addEventListener('scroll-to-bottom', () => {
36
- setTimeout(this.scrollBottom.bind(this), 200);
45
+ setTimeout(() => this.scrollBottom(), 200);
37
46
  });
38
47
  }
39
48
 
40
49
  connectedCallback() {
41
50
  super.connectedCallback();
42
51
  if (this.startScrolled) {
43
- this.observerDom();
52
+ this.domObserver.observe(this);
44
53
  this.scrollBottom();
45
54
  }
46
55
  }
47
56
 
48
- observerDom() {
49
- // observe the DOM for changes
50
- const observer = new MutationObserver((_) => {
51
- setTimeout(this.scrollBottom.bind(this), 10);
52
- });
53
-
54
- observer.observe(this, {childList: true, subtree: true});
55
- }
56
-
57
57
  scrollBottom() {
58
58
  const container = this.shadowRoot?.getElementById('primary-content');
59
59
  if (container) {