@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
@@ -1,6 +1,8 @@
1
1
  import {type CSSResultGroup, html, unsafeCSS, PropertyValues} from 'lit';
2
- import ZincElement from '../../internal/zinc-element';
2
+ import {MutationController} from '@lit-labs/observers/mutation-controller.js';
3
3
  import {property, query} from "lit/decorators.js";
4
+ import {ResizeController} from '@lit-labs/observers/resize-controller.js';
5
+ import ZincElement from '../../internal/zinc-element';
4
6
 
5
7
  import styles from './scroll-container.scss';
6
8
 
@@ -43,33 +45,32 @@ export default class ZnScrollContainer extends ZincElement {
43
45
  this.container.scrollTop = this.container.scrollHeight;
44
46
  }
45
47
 
46
- private _footerResizeObserver?: ResizeObserver;
48
+ private readonly _footerResizeObserver = new ResizeController(this, {
49
+ target: null,
50
+ callback: () => {
51
+ this.style.setProperty('--zn-scroll-footer-height', `${this.footer?.clientHeight ?? 0}px`);
52
+ },
53
+ });
54
+
55
+ private readonly _domObserver = new MutationController(this, {
56
+ target: null,
57
+ config: {childList: true, subtree: true},
58
+ callback: () => {
59
+ setTimeout(() => this.scrollEnd(), 100);
60
+ if (this.footer) {
61
+ this.style.setProperty('--zn-scroll-footer-height', `${this.footer.clientHeight}px`);
62
+ this._footerResizeObserver.observe(this.footer);
63
+ }
64
+ },
65
+ });
47
66
 
48
67
  connectedCallback() {
49
68
  super.connectedCallback();
50
69
  if (this.startScrolled) {
51
- const observer = new MutationObserver(() => {
52
- setTimeout(this.scrollEnd.bind(this), 100);
53
- if (this.footer) {
54
- // Initialize height immediately
55
- this.style.setProperty('--zn-scroll-footer-height', `${this.footer.clientHeight}px`);
56
- // Attach ResizeObserver to watch for size changes
57
- this._footerResizeObserver = new ResizeObserver(() => {
58
- this.style.setProperty('--zn-scroll-footer-height', `${this.footer?.clientHeight ?? 0}px`);
59
- });
60
- this._footerResizeObserver.observe(this.footer);
61
- }
62
- });
63
- observer.observe(this, {childList: true, subtree: true});
70
+ this._domObserver.observe(this);
64
71
  }
65
72
  }
66
73
 
67
- disconnectedCallback() {
68
- super.disconnectedCallback();
69
- this._footerResizeObserver?.disconnect();
70
- this._footerResizeObserver = undefined;
71
- }
72
-
73
74
  render() {
74
75
  return html`
75
76
  <div class="container">
@@ -113,12 +113,16 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
113
113
  /** @internal - current search/filter text when search is enabled (lowercased for matching) */
114
114
  private _searchQuery = '';
115
115
 
116
- /** @internal - raw display value of the search input (preserves case for the input field) */
117
- private _searchDisplayValue = '';
116
+ /** @internal - raw display value of the search input (preserves case for the input field). Reactive so the
117
+ * free-text "Add" row re-renders on every keystroke, not just when the match/no-match state changes. */
118
+ @state() private _searchDisplayValue = '';
118
119
 
119
120
  /** @internal - whether the "no matching options" empty state is visible */
120
121
  @state() private _noResultsVisible = false;
121
122
 
123
+ /** @internal - whether the free-text "Add" row is the active keyboard-navigation target */
124
+ @state() private _addOptionActive = false;
125
+
122
126
  /** @internal */
123
127
  @state() private _fetchedOptions: ({ key: string; value: string; group?: undefined } | {
124
128
  group: string;
@@ -275,6 +279,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
275
279
  /** Enables search/filter functionality. When enabled, the user can type into the select to filter the visible options. */
276
280
  @property({type: Boolean, reflect: true}) search = false;
277
281
 
282
+ /**
283
+ * Allows the user to enter values that are not in the options list ("free text"). Implies the editable,
284
+ * filtering input behavior of `search`. A typed value that doesn't match an existing option is committed
285
+ * by pressing Enter, clicking the "Add" row, or blurring the field, and becomes a selected option — a tag
286
+ * when `multiple` is enabled. The committed value is both the option's value and its label.
287
+ */
288
+ @property({type: Boolean, reflect: true, attribute: 'free-text'}) freeText = false;
289
+
278
290
  /** The select's label. If you need to display HTML, use the `label` slot instead. */
279
291
  @property() label = '';
280
292
 
@@ -477,6 +489,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
477
489
  return;
478
490
  }
479
491
 
492
+ // Escape cancels uncommitted free-text rather than letting the close path commit it. Done here (before
493
+ // either the CloseWatcher or the branch below closes the dropdown) so it applies on every browser.
494
+ if (event.key === 'Escape' && this.open && this.freeText && this._searchDisplayValue.trim()) {
495
+ this.clearSearch();
496
+ this.displayInput.value = '';
497
+ }
498
+
480
499
  // Close when pressing escape
481
500
  if ((event.key === 'Escape' || event.key === 'Tab') && this.open && !this.closeWatcher) {
482
501
  event.preventDefault();
@@ -494,7 +513,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
494
513
 
495
514
  // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
496
515
  // buffer we _don't_ close it. When search is enabled, space should type into the input, not toggle.
497
- if (event.key === 'Enter' || (event.key === ' ' && !this.search && this.typeToSelectString === '')) {
516
+ if (event.key === 'Enter' || (event.key === ' ' && !this._isTypeable && this.typeToSelectString === '')) {
498
517
  event.preventDefault();
499
518
  event.stopImmediatePropagation();
500
519
 
@@ -504,6 +523,15 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
504
523
  return;
505
524
  }
506
525
 
526
+ // Free-text: commit when the Add row is the active target, or when no real option is highlighted
527
+ if (this.freeText) {
528
+ const addActive = this._addOptionActive && this._freeTextAddValue !== null;
529
+ const usableCurrent = !addActive && this.currentOption && !this.currentOption.disabled && !this.currentOption.hidden;
530
+ if (!usableCurrent && this.commitFreeText()) {
531
+ return;
532
+ }
533
+ }
534
+
507
535
  // If it is open, update the value based on the current selection and close it
508
536
  if (this.currentOption && !this.currentOption.disabled) {
509
537
  this.valueHasChanged = true;
@@ -514,7 +542,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
514
542
  }
515
543
 
516
544
  // Clear search after keyboard selection
517
- if (this.search) {
545
+ if (this._isTypeable) {
518
546
  this.clearSearch();
519
547
  if (this.multiple) {
520
548
  this.displayInput.value = '';
@@ -530,8 +558,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
530
558
  if (!this.multiple) {
531
559
  this.hide().then();
532
560
  this.displayInput.focus({preventScroll: true});
533
- } else if (this.search) {
534
- // In multi-select + search, re-focus the search input after keyboard selection
561
+ } else if (this._isTypeable) {
562
+ // In multi-select + typeable, re-focus the search input after keyboard selection
535
563
  this.updateComplete.then(() => {
536
564
  this.displayInput.value = '';
537
565
  this.displayInput.focus({preventScroll: true});
@@ -542,12 +570,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
542
570
  return;
543
571
  }
544
572
 
545
- // Navigate options
573
+ // Navigate options (and the free-text "Add" row, when shown)
546
574
  if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
547
- const navOptions = this.search ? this.getVisibleOptions() : this.getAllOptions();
548
- const currentIndex = navOptions.indexOf(this.currentOption);
549
- let newIndex = Math.max(0, currentIndex);
550
-
551
575
  // Prevent scrolling
552
576
  event.preventDefault();
553
577
 
@@ -562,23 +586,43 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
562
586
  }
563
587
  }
564
588
 
589
+ const navOptions = this._isTypeable ? this.getVisibleOptions() : this.getAllOptions();
590
+ const hasAdd = this.freeText && this._freeTextAddValue !== null;
591
+
592
+ // The Add row, when shown, sits at the top of the listbox and is the first navigable target.
593
+ const ADD = 'add';
594
+ const targets: (ZnOption | typeof ADD)[] = hasAdd ? [ADD, ...navOptions] : [...navOptions];
595
+ if (targets.length === 0) return;
596
+
597
+ const currentTarget: ZnOption | typeof ADD | null =
598
+ this._addOptionActive && hasAdd ? ADD : (this.currentOption ?? null);
599
+ const currentIndex = currentTarget === null ? -1 : targets.indexOf(currentTarget);
600
+ let newIndex = Math.max(0, currentIndex);
601
+
565
602
  if (event.key === 'ArrowDown') {
566
603
  newIndex = currentIndex + 1;
567
- if (newIndex > navOptions.length - 1) newIndex = 0;
604
+ if (newIndex > targets.length - 1) newIndex = 0;
568
605
  } else if (event.key === 'ArrowUp') {
569
606
  newIndex = currentIndex - 1;
570
- if (newIndex < 0) newIndex = navOptions.length - 1;
607
+ if (newIndex < 0) newIndex = targets.length - 1;
571
608
  } else if (event.key === 'Home') {
572
609
  newIndex = 0;
573
610
  } else if (event.key === 'End') {
574
- newIndex = navOptions.length - 1;
611
+ newIndex = targets.length - 1;
575
612
  }
576
613
 
577
- this.setCurrentOption(navOptions[newIndex]);
614
+ const next = targets[newIndex];
615
+ if (next === ADD) {
616
+ this._addOptionActive = true;
617
+ this.setCurrentOption(null);
618
+ } else {
619
+ this._addOptionActive = false;
620
+ this.setCurrentOption(next);
621
+ }
578
622
  }
579
623
 
580
- // When search is enabled, let the input handle keystrokes natively (they go through handleSearchInput)
581
- if (this.search) {
624
+ // When typeable, let the input handle keystrokes natively (they go through handleSearchInput)
625
+ if (this._isTypeable) {
582
626
  return;
583
627
  }
584
628
 
@@ -648,8 +692,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
648
692
  return;
649
693
  }
650
694
 
651
- // When search is enabled, clicking the input area should open (not toggle) so the user can type
652
- if (this.search && this.open && !isExpandIcon) {
695
+ // When typeable, clicking the input area should open (not toggle) so the user can type
696
+ if (this._isTypeable && this.open && !isExpandIcon) {
653
697
  event.preventDefault();
654
698
  this.displayInput.focus({preventScroll: true});
655
699
  return;
@@ -678,8 +722,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
678
722
  private handleClearClick(event: MouseEvent) {
679
723
  event.stopPropagation();
680
724
 
681
- if (this.value !== '') {
682
- if (this.search) {
725
+ if (this.value.length > 0) {
726
+ this.valueHasChanged = true;
727
+
728
+ if (this._isTypeable) {
683
729
  this.clearSearch();
684
730
  this.displayInput.value = '';
685
731
  }
@@ -711,9 +757,17 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
711
757
  return this.search && !!this.dataUri;
712
758
  }
713
759
 
760
+ /** Whether the display input is editable and filters options (search or free-text). */
761
+ private get _isTypeable() {
762
+ return this.search || this.freeText;
763
+ }
764
+
714
765
  /** Handles text input on the display input for search/filter mode */
715
766
  private handleSearchInput() {
716
- if (!this.search) return;
767
+ if (!this._isTypeable) return;
768
+
769
+ // A new keystroke resets the Add-row keyboard highlight
770
+ this._addOptionActive = false;
717
771
 
718
772
  this._searchDisplayValue = this.displayInput.value;
719
773
  this._searchQuery = this._searchDisplayValue.toLowerCase();
@@ -807,6 +861,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
807
861
  this._searchQuery = '';
808
862
  this._searchDisplayValue = '';
809
863
  this._noResultsVisible = false;
864
+ this._addOptionActive = false;
810
865
 
811
866
  // Cancel any pending remote search
812
867
  if (this._searchDebounceTimer !== null) {
@@ -825,8 +880,126 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
825
880
  });
826
881
  }
827
882
 
883
+ /**
884
+ * Commits the current typed text as a selected value when `free-text` is enabled. A selected `<zn-option>`
885
+ * (value = label = trimmed text) is created in the light DOM so the value flows through the existing
886
+ * tag/value/selection machinery and appears as a deselectable row in the listbox. Returns `true` when
887
+ * something was committed, `false` when there was nothing to commit.
888
+ */
889
+ private commitFreeText(refocus = true): boolean {
890
+ if (!this.freeText) return false;
891
+
892
+ const text = (this.displayInput?.value ?? '').trim();
893
+ if (!text) return false;
894
+
895
+ this.valueHasChanged = true;
896
+
897
+ // If the text matches an existing option (label or value, case-insensitive), select it instead of
898
+ // creating a duplicate free-text option.
899
+ const lower = text.toLowerCase();
900
+ const existing = this.getAllOptions().find(
901
+ o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
902
+ );
903
+
904
+ const option = existing ?? this._createFreeTextOption(text);
905
+
906
+ if (this.multiple) {
907
+ // Add to the existing selection and keep the dropdown open for more entry
908
+ this.toggleOptionSelection(option, true);
909
+ } else {
910
+ this.setSelectedOptions(option);
911
+ }
912
+
913
+ this.clearSearch();
914
+
915
+ this.updateComplete.then(() => {
916
+ this.emit('zn-input');
917
+ this.emit('zn-change');
918
+ });
919
+
920
+ // When committed via blur/close, the dropdown is already closing and focus has left — don't fight it.
921
+ if (!refocus) {
922
+ return true;
923
+ }
924
+
925
+ if (this.multiple) {
926
+ // Clear the input so the user can type the next value
927
+ this.displayInput.value = '';
928
+ this.displayInput.focus({preventScroll: true});
929
+ } else {
930
+ this.hide().then();
931
+ this.displayInput.focus({preventScroll: true});
932
+ }
933
+
934
+ return true;
935
+ }
936
+
937
+ /**
938
+ * The trimmed pending text to offer as a free-text "Add" row, or `null` when no Add row should be shown
939
+ * (free-text disabled, dropdown closed, input empty, or the text exactly matches an existing option).
940
+ */
941
+ private get _freeTextAddValue(): string | null {
942
+ if (!this.freeText || !this.open) return null;
943
+
944
+ const text = this._searchDisplayValue.trim();
945
+ if (!text) return null;
946
+
947
+ const lower = text.toLowerCase();
948
+ const exact = this.getAllOptions().some(
949
+ o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
950
+ );
951
+
952
+ return exact ? null : text;
953
+ }
954
+
955
+ /** Commits the typed value when the "Add" row is pressed, keeping focus on the input. */
956
+ private handleAddOptionMouseDown(event: MouseEvent) {
957
+ // Keep focus on the input but DON'T commit here. Committing on mousedown would clear the search and
958
+ // collapse the listbox, so the following mouseup would land on (and select) whichever option shifted
959
+ // under the cursor. Instead the commit happens on mouseup in handleOptionClick.
960
+ event.preventDefault();
961
+ }
962
+
963
+ /**
964
+ * In free-text mode, creates a hidden `<zn-option>` for any value that has no matching option, so values
965
+ * set via the `value`/`defaultValue` attribute (e.g. previously-saved custom entries) render as tags or
966
+ * the display value on load. No-op when free-text is disabled.
967
+ */
968
+ private _materialiseFreeTextValues(values: string[]) {
969
+ if (!this.freeText) return;
970
+
971
+ const existingValues = new Set(this.getAllOptions().map(o => o.value));
972
+ values.forEach(value => {
973
+ if (value === '' || existingValues.has(value)) return;
974
+ this._createFreeTextOption(value);
975
+ existingValues.add(value);
976
+ });
977
+ }
978
+
979
+ /**
980
+ * Creates a free-text-marked `<zn-option>` in the light DOM (value = label) and returns it. The option is
981
+ * a normal, visible, selectable row so the user can deselect a custom value from the dropdown like any
982
+ * other option; the `data-free-text` marker lets us drop it from the DOM once it's no longer selected.
983
+ */
984
+ private _createFreeTextOption(value: string): ZnOption {
985
+ const option = document.createElement('zn-option') as ZnOption;
986
+ option.value = value;
987
+ option.textContent = value;
988
+ option.setAttribute('data-free-text', '');
989
+ this.appendChild(option);
990
+ return option;
991
+ }
992
+
828
993
  private handleOptionClick(event: MouseEvent) {
829
994
  const target = event.target as HTMLElement;
995
+
996
+ // The free-text "Add" row lives in the listbox and commits on this same mouseup, so it can never
997
+ // also trigger an option selection.
998
+ if (target.closest('.select__add-option')) {
999
+ this.commitFreeText();
1000
+ return;
1001
+ }
1002
+
830
1003
  const option = target.closest('zn-option');
831
1004
  const oldValue = this.value;
832
1005
 
@@ -841,8 +1014,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
841
1014
  this.setSelectedOptions(option);
842
1015
  }
843
1016
 
844
- // Clear search after selection when in search mode
845
- if (this.search) {
1017
+ // Clear search after selection when in search/free-text mode
1018
+ if (this._isTypeable) {
846
1019
  this.clearSearch();
847
1020
  if (this.multiple) {
848
1021
  // Keep the search input clear for further typing
@@ -852,7 +1025,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
852
1025
 
853
1026
  // Set focus after updating so the value is announced by screen readers
854
1027
  this.updateComplete.then(() => {
855
- if (this.multiple && this.search && this.open) {
1028
+ if (this.multiple && this._isTypeable && this.open) {
856
1029
  this.displayInput.value = '';
857
1030
  }
858
1031
  this.displayInput.focus({preventScroll: true});
@@ -891,8 +1064,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
891
1064
  // Check for duplicate values in menu items
892
1065
  allOptions.forEach(option => values.push(option.value));
893
1066
 
894
- // Select only the options that match the new value
895
- this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
1067
+ // In free-text mode, materialise any value with no matching option so it renders as a tag/value
1068
+ this._materialiseFreeTextValues(value);
1069
+
1070
+ // Select only the options that match the new value (re-query in case options were materialised)
1071
+ this.setSelectedOptions(this.getAllOptions().filter(el => value.includes(el.value)));
896
1072
 
897
1073
  // of check if an option has selected attribute set initially
898
1074
  if (!this.valueHasChanged) {
@@ -920,6 +1096,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
920
1096
  event.stopImmediatePropagation();
921
1097
 
922
1098
  if (!this.disabled) {
1099
+ // Deselecting drops the backing element for free-text values via selectionChanged's cleanup.
923
1100
  this.toggleOptionSelection(option, false);
924
1101
 
925
1102
  // Emit after updating
@@ -976,7 +1153,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
976
1153
  option.tabIndex = 0;
977
1154
  // When search is active and the display input has focus, don't steal focus to the option.
978
1155
  // The user is typing in the search input and we only want to visually highlight the current option.
979
- if (!(this.search && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
1156
+ if (!(this._isTypeable && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
980
1157
  option.focus();
981
1158
  }
982
1159
  }
@@ -1016,6 +1193,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1016
1193
  // Update selected options cache
1017
1194
  this.selectedOptions = this.getAllOptions().filter(el => el.selected);
1018
1195
 
1196
+ // Free-text options exist only to anchor a selected value. Drop any that are no longer selected — e.g.
1197
+ // when a single-select value is replaced or the selection is cleared, the previous custom entry should
1198
+ // disappear entirely rather than linger as an orphaned option.
1199
+ this.querySelectorAll<ZnOption>('[data-free-text]').forEach(option => {
1200
+ if (!option.selected) option.remove();
1201
+ });
1202
+
1019
1203
  // In remote-search + multiple mode, track selected items so they persist across search result changes
1020
1204
  if (this._isRemoteSearch && this.multiple) {
1021
1205
  this._selectedRemoteItems = this.selectedOptions.map(el => ({
@@ -1408,8 +1592,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1408
1592
  // Reset the current option
1409
1593
  this.setCurrentOption(null);
1410
1594
 
1411
- // When search is enabled, clear the display input to allow typing and show all options
1412
- if (this.search) {
1595
+ // When typeable, clear the display input to allow typing and show all options
1596
+ if (this._isTypeable) {
1413
1597
  this.clearSearch();
1414
1598
  this.displayInput.value = '';
1415
1599
  this.displayInput.readOnly = false;
@@ -1429,7 +1613,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1429
1613
  });
1430
1614
 
1431
1615
  // Focus the search input so the user can start typing immediately
1432
- if (this.search) {
1616
+ if (this._isTypeable) {
1433
1617
  requestAnimationFrame(() => {
1434
1618
  this.displayInput.focus({preventScroll: true});
1435
1619
  });
@@ -1449,8 +1633,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1449
1633
  this.emit('zn-hide');
1450
1634
  this.removeOpenListeners();
1451
1635
 
1452
- // When search is enabled, restore the display label and clear the search
1453
- if (this.search) {
1636
+ // When typeable, restore the display label and clear the search
1637
+ if (this._isTypeable) {
1638
+ // Commit any pending free-text before clearing it (blur/close-to-commit). Don't refocus —
1639
+ // the dropdown is closing because focus left the control.
1640
+ if (this.freeText && this._searchDisplayValue.trim()) {
1641
+ this.commitFreeText(false);
1642
+ }
1454
1643
  this.clearSearch();
1455
1644
  this.displayInput.readOnly = true;
1456
1645
  // In multiple mode, tags show the selection so keep the input empty
@@ -1647,7 +1836,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1647
1836
  'select--medium': this.size === 'medium',
1648
1837
  'select--large': this.size === 'large',
1649
1838
  'select--has-input-prefix': this.inputPrefix,
1650
- 'select--search': this.search
1839
+ 'select--search': this._isTypeable
1651
1840
  })}
1652
1841
  placement=${this.placement}
1653
1842
  strategy=${this.hoist ? 'fixed' : 'absolute'}
@@ -1671,20 +1860,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1671
1860
  part="display-input"
1672
1861
  class="select__display-input"
1673
1862
  type="text"
1674
- placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this.search && this.open ? (this.displayLabel || this.placeholder || 'Search...') : this.placeholder)}
1863
+ placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this._isTypeable && this.open ? (this.multiple ? (this.selectedOptions.length ? '' : (this.placeholder || 'Search...')) : (this.displayLabel || this.placeholder || 'Search...')) : this.placeholder)}
1675
1864
  .disabled=${isDisabled}
1676
- .value=${isFetchDisabled ? '' : (this.search && this.open) ? this._searchDisplayValue : (this.search && this.multiple) ? '' : this.displayLabel}
1865
+ .value=${isFetchDisabled ? '' : (this._isTypeable && this.open) ? this._searchDisplayValue : (this._isTypeable && this.multiple) ? '' : this.displayLabel}
1677
1866
  autocomplete="off"
1678
1867
  spellcheck="false"
1679
1868
  autocapitalize="off"
1680
- ?readonly=${!(this.search && this.open)}
1869
+ ?readonly=${!(this._isTypeable && this.open)}
1681
1870
  aria-controls="listbox"
1682
1871
  aria-expanded=${this.open ? 'true' : 'false'}
1683
1872
  aria-haspopup="listbox"
1684
1873
  aria-labelledby="label"
1685
1874
  aria-disabled=${isDisabled ? 'true' : 'false'}
1686
1875
  aria-describedby="help-text"
1687
- aria-autocomplete=${this.search ? 'list' : 'none'}
1876
+ aria-autocomplete=${this._isTypeable ? 'list' : 'none'}
1688
1877
  role="combobox"
1689
1878
  tabindex="0"
1690
1879
  @focus=${this.handleFocus}
@@ -1747,6 +1936,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1747
1936
  tabindex="-1"
1748
1937
  @mouseup=${this.handleOptionClick}
1749
1938
  @slotchange=${this.handleDefaultSlotChange}>
1939
+ ${this._freeTextAddValue !== null
1940
+ ? html`
1941
+ <div
1942
+ part="add-option"
1943
+ class=${classMap({
1944
+ 'select__add-option': true,
1945
+ 'select__add-option--current': this._addOptionActive
1946
+ })}
1947
+ role="option"
1948
+ aria-selected=${this._addOptionActive ? 'true' : 'false'}
1949
+ @mousedown=${this.handleAddOptionMouseDown}>
1950
+ Add "${this._freeTextAddValue}"
1951
+ </div>`
1952
+ : nothing}
1750
1953
  <slot></slot>
1751
1954
  ${this._renderSelectedRemoteItems()}
1752
1955
  ${this._fetchedOptions.map(item =>
@@ -1780,7 +1983,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1780
1983
  Type to search...
1781
1984
  </div>`
1782
1985
  : html`
1783
- <div part="empty-state" class="select__empty-state" ?hidden=${!this._noResultsVisible}>
1986
+ <div part="empty-state" class="select__empty-state"
1987
+ ?hidden=${!this._noResultsVisible || this._freeTextAddValue !== null}>
1784
1988
  No matching options
1785
1989
  </div>`
1786
1990
  }
@@ -127,6 +127,15 @@
127
127
  flex-wrap: wrap;
128
128
  gap: var(--zn-spacing-2x-small);
129
129
  padding-inline-start: 0;
130
+ /* Allow the control to grow with the wrapped tag rows instead of clipping them, and pack the rows
131
+ from the top with a little vertical padding. align-self stops the popup from stretching the
132
+ combobox to a fixed height, so it sizes to its (wrapped) content. */
133
+ overflow: visible;
134
+ align-content: flex-start;
135
+ align-items: center;
136
+ align-self: flex-start;
137
+ height: auto;
138
+ padding-block: var(--zn-spacing-2x-small);
130
139
  }
131
140
 
132
141
  .select__value-input {
@@ -149,13 +158,21 @@
149
158
  margin-inline-start: var(--zn-spacing-2x-small);
150
159
  }
151
160
 
152
- .select__tags::slotted(zn-chip) {
153
- cursor: pointer !important;
161
+ /* The tags are rendered into the shadow (not slotted), so target them as descendants. Only the remove
162
+ "X" is clickable (pointer); the chip body isn't, so give it a default cursor rather than letting it
163
+ inherit the combobox's text cursor in search/free-text mode. */
164
+ .select__tags zn-chip {
165
+ cursor: default;
166
+ }
167
+
168
+ .select__tags zn-icon[slot="action"] {
169
+ cursor: pointer;
154
170
  }
155
171
 
156
172
  .select--disabled .select__tags,
157
- .select--disabled .select__tags::slotted(zn-chip) {
158
- cursor: not-allowed !important;
173
+ .select--disabled .select__tags zn-chip,
174
+ .select--disabled .select__tags zn-icon[slot="action"] {
175
+ cursor: not-allowed;
159
176
  }
160
177
 
161
178
  /* Standard selects */
@@ -374,7 +391,6 @@
374
391
  border: solid var(--zn-panel-border-width) var(--zn-panel-border-color);
375
392
  border-radius: var(--zn-border-radius-medium);
376
393
  margin: var(--zn-spacing-x-small) 0;
377
- padding-block: var(--zn-spacing-x-small);
378
394
  padding-inline: 0;
379
395
  overflow: auto;
380
396
  overscroll-behavior: none;
@@ -442,6 +458,21 @@
442
458
  }
443
459
  }
444
460
 
461
+ /* Free-text "Add" row */
462
+ .select__add-option {
463
+ display: flex;
464
+ align-items: center;
465
+ padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
466
+ color: var(--zn-input-color);
467
+ font-size: var(--zn-font-size-small);
468
+ cursor: pointer;
469
+
470
+ &:hover,
471
+ &.select__add-option--current {
472
+ background-color: var(--zn-color-primary-100);
473
+ }
474
+ }
475
+
445
476
  /* Search mode */
446
477
  .select--search .select__combobox {
447
478
  cursor: text;