@kubex/zinc 1.0.113 → 1.0.114

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 (95) 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 +2636 -421
  4. package/dist/vscode.html-custom-data.json +96 -16
  5. package/dist/web-types.json +268 -31
  6. package/dist/zn.d.ts +517 -340
  7. package/dist/zn.min.css +1 -1
  8. package/dist/zn.min.js +942 -863
  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/shared/layout.scss +15 -2
  23. package/scss/themes/_light.scss +1 -1
  24. package/src/components/absolute-container/absolute-container.component.ts +9 -25
  25. package/src/components/animated-button/animated-button.test.ts +8 -8
  26. package/src/components/button/button.component.ts +3 -1
  27. package/src/components/button/button.scss +5 -0
  28. package/src/components/button-menu/button-menu.component.ts +36 -21
  29. package/src/components/button-menu/button-menu.test.ts +55 -1
  30. package/src/components/chart/chart.component.ts +7 -7
  31. package/src/components/collapsible/collapsible.component.ts +7 -14
  32. package/src/components/cols/cols.scss +1 -1
  33. package/src/components/content-block/content-block.component.ts +11 -33
  34. package/src/components/data-select/data-select.component.ts +7 -11
  35. package/src/components/data-table/data-table.component.ts +10 -14
  36. package/src/components/editor/modules/toolbar/toolbar.component.ts +5 -9
  37. package/src/components/expanding-action/expanding-action.component.ts +37 -43
  38. package/src/components/form-group/form-group.component.ts +8 -3
  39. package/src/components/header/header.component.ts +6 -5
  40. package/src/components/header/header.scss +31 -25
  41. package/src/components/inline-edit/inline-edit.component.ts +13 -1
  42. package/src/components/inline-edit/inline-edit.test.ts +50 -0
  43. package/src/components/input/input.test.ts +77 -0
  44. package/src/components/input-group/input-group.test.ts +2 -10
  45. package/src/components/item/item.component.ts +12 -6
  46. package/src/components/item/item.scss +1 -1
  47. package/src/components/item/item.test.ts +8 -0
  48. package/src/components/navbar/navbar.component.ts +222 -48
  49. package/src/components/navbar/navbar.scss +134 -21
  50. package/src/components/navbar/navbar.test.ts +114 -9
  51. package/src/components/option/option.scss +5 -7
  52. package/src/components/option/option.test.ts +30 -0
  53. package/src/components/page/index.ts +13 -0
  54. package/src/components/page/page.component.ts +406 -0
  55. package/src/components/page/page.scss +325 -0
  56. package/src/components/page/page.test.ts +397 -0
  57. package/src/components/page-nav/page-nav.scss +7 -5
  58. package/src/components/pane/pane.component.ts +2 -2
  59. package/src/components/pane/pane.scss +0 -1
  60. package/src/components/pane/pane.test.ts +31 -0
  61. package/src/components/panel/panel.scss +1 -0
  62. package/src/components/progress-bar/progress-bar.component.ts +5 -2
  63. package/src/components/progress-bar/progress-bar.scss +17 -1
  64. package/src/components/progress-bar/progress-bar.test.ts +12 -0
  65. package/src/components/scroll-container/scroll-container.component.ts +22 -21
  66. package/src/components/select/select.component.ts +240 -38
  67. package/src/components/select/select.scss +15 -0
  68. package/src/components/select/select.test.ts +477 -0
  69. package/src/components/settings-container/settings-container.component.ts +15 -19
  70. package/src/components/settings-container/settings-container.scss +8 -8
  71. package/src/components/sidebar/sidebar.component.ts +11 -11
  72. package/src/components/simple-chart/simple-chart.component.ts +6 -7
  73. package/src/components/sp/sp.scss +46 -15
  74. package/src/components/sp/sp.test.ts +15 -0
  75. package/src/components/split-pane/split-pane.component.ts +153 -26
  76. package/src/components/split-pane/split-pane.scss +89 -13
  77. package/src/components/split-pane/split-pane.test.ts +187 -0
  78. package/src/components/style/style.component.ts +8 -0
  79. package/src/components/tab/index.ts +13 -0
  80. package/src/components/tab/tab.component.ts +25 -0
  81. package/src/components/tab/tab.scss +7 -0
  82. package/src/components/table/table.component.ts +7 -2
  83. package/src/components/tabs/tabs.component.ts +75 -70
  84. package/src/components/tabs/tabs.scss +1 -1
  85. package/src/components/textarea/textarea.component.ts +5 -8
  86. package/src/components/translation-group/translation-group.component.ts +15 -14
  87. package/src/components/translations/translations.component.ts +18 -17
  88. package/src/components/translations/translations.scss +1 -0
  89. package/src/components/translations/translations.test.ts +3 -2
  90. package/src/internal/form.ts +234 -0
  91. package/src/internal/theme.ts +26 -46
  92. package/src/internal/zinc-element.ts +12 -7
  93. package/src/utilities/form.ts +1 -0
  94. package/src/wc.scss +1 -1
  95. package/src/zinc.ts +3 -0
@@ -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;
@@ -679,7 +723,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
679
723
  event.stopPropagation();
680
724
 
681
725
  if (this.value !== '') {
682
- if (this.search) {
726
+ if (this._isTypeable) {
683
727
  this.clearSearch();
684
728
  this.displayInput.value = '';
685
729
  }
@@ -711,9 +755,17 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
711
755
  return this.search && !!this.dataUri;
712
756
  }
713
757
 
758
+ /** Whether the display input is editable and filters options (search or free-text). */
759
+ private get _isTypeable() {
760
+ return this.search || this.freeText;
761
+ }
762
+
714
763
  /** Handles text input on the display input for search/filter mode */
715
764
  private handleSearchInput() {
716
- if (!this.search) return;
765
+ if (!this._isTypeable) return;
766
+
767
+ // A new keystroke resets the Add-row keyboard highlight
768
+ this._addOptionActive = false;
717
769
 
718
770
  this._searchDisplayValue = this.displayInput.value;
719
771
  this._searchQuery = this._searchDisplayValue.toLowerCase();
@@ -807,6 +859,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
807
859
  this._searchQuery = '';
808
860
  this._searchDisplayValue = '';
809
861
  this._noResultsVisible = false;
862
+ this._addOptionActive = false;
810
863
 
811
864
  // Cancel any pending remote search
812
865
  if (this._searchDebounceTimer !== null) {
@@ -825,8 +878,126 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
825
878
  });
826
879
  }
827
880
 
881
+ /**
882
+ * Commits the current typed text as a selected value when `free-text` is enabled. A selected `<zn-option>`
883
+ * (value = label = trimmed text) is created in the light DOM so the value flows through the existing
884
+ * tag/value/selection machinery and appears as a deselectable row in the listbox. Returns `true` when
885
+ * something was committed, `false` when there was nothing to commit.
886
+ */
887
+ private commitFreeText(refocus = true): boolean {
888
+ if (!this.freeText) return false;
889
+
890
+ const text = (this.displayInput?.value ?? '').trim();
891
+ if (!text) return false;
892
+
893
+ this.valueHasChanged = true;
894
+
895
+ // If the text matches an existing option (label or value, case-insensitive), select it instead of
896
+ // creating a duplicate free-text option.
897
+ const lower = text.toLowerCase();
898
+ const existing = this.getAllOptions().find(
899
+ o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
900
+ );
901
+
902
+ const option = existing ?? this._createFreeTextOption(text);
903
+
904
+ if (this.multiple) {
905
+ // Add to the existing selection and keep the dropdown open for more entry
906
+ this.toggleOptionSelection(option, true);
907
+ } else {
908
+ this.setSelectedOptions(option);
909
+ }
910
+
911
+ this.clearSearch();
912
+
913
+ this.updateComplete.then(() => {
914
+ this.emit('zn-input');
915
+ this.emit('zn-change');
916
+ });
917
+
918
+ // When committed via blur/close, the dropdown is already closing and focus has left — don't fight it.
919
+ if (!refocus) {
920
+ return true;
921
+ }
922
+
923
+ if (this.multiple) {
924
+ // Clear the input so the user can type the next value
925
+ this.displayInput.value = '';
926
+ this.displayInput.focus({preventScroll: true});
927
+ } else {
928
+ this.hide().then();
929
+ this.displayInput.focus({preventScroll: true});
930
+ }
931
+
932
+ return true;
933
+ }
934
+
935
+ /**
936
+ * The trimmed pending text to offer as a free-text "Add" row, or `null` when no Add row should be shown
937
+ * (free-text disabled, dropdown closed, input empty, or the text exactly matches an existing option).
938
+ */
939
+ private get _freeTextAddValue(): string | null {
940
+ if (!this.freeText || !this.open) return null;
941
+
942
+ const text = this._searchDisplayValue.trim();
943
+ if (!text) return null;
944
+
945
+ const lower = text.toLowerCase();
946
+ const exact = this.getAllOptions().some(
947
+ o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
948
+ );
949
+
950
+ return exact ? null : text;
951
+ }
952
+
953
+ /** Commits the typed value when the "Add" row is pressed, keeping focus on the input. */
954
+ private handleAddOptionMouseDown(event: MouseEvent) {
955
+ // Keep focus on the input but DON'T commit here. Committing on mousedown would clear the search and
956
+ // collapse the listbox, so the following mouseup would land on (and select) whichever option shifted
957
+ // under the cursor. Instead the commit happens on mouseup in handleOptionClick.
958
+ event.preventDefault();
959
+ }
960
+
961
+ /**
962
+ * In free-text mode, creates a hidden `<zn-option>` for any value that has no matching option, so values
963
+ * set via the `value`/`defaultValue` attribute (e.g. previously-saved custom entries) render as tags or
964
+ * the display value on load. No-op when free-text is disabled.
965
+ */
966
+ private _materialiseFreeTextValues(values: string[]) {
967
+ if (!this.freeText) return;
968
+
969
+ const existingValues = new Set(this.getAllOptions().map(o => o.value));
970
+ values.forEach(value => {
971
+ if (value === '' || existingValues.has(value)) return;
972
+ this._createFreeTextOption(value);
973
+ existingValues.add(value);
974
+ });
975
+ }
976
+
977
+ /**
978
+ * Creates a free-text-marked `<zn-option>` in the light DOM (value = label) and returns it. The option is
979
+ * a normal, visible, selectable row so the user can deselect a custom value from the dropdown like any
980
+ * other option; the `data-free-text` marker lets us drop it from the DOM once it's no longer selected.
981
+ */
982
+ private _createFreeTextOption(value: string): ZnOption {
983
+ const option = document.createElement('zn-option') as ZnOption;
984
+ option.value = value;
985
+ option.textContent = value;
986
+ option.setAttribute('data-free-text', '');
987
+ this.appendChild(option);
988
+ return option;
989
+ }
990
+
828
991
  private handleOptionClick(event: MouseEvent) {
829
992
  const target = event.target as HTMLElement;
993
+
994
+ // The free-text "Add" row lives in the listbox and commits on this same mouseup, so it can never
995
+ // also trigger an option selection.
996
+ if (target.closest('.select__add-option')) {
997
+ this.commitFreeText();
998
+ return;
999
+ }
1000
+
830
1001
  const option = target.closest('zn-option');
831
1002
  const oldValue = this.value;
832
1003
 
@@ -841,8 +1012,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
841
1012
  this.setSelectedOptions(option);
842
1013
  }
843
1014
 
844
- // Clear search after selection when in search mode
845
- if (this.search) {
1015
+ // Clear search after selection when in search/free-text mode
1016
+ if (this._isTypeable) {
846
1017
  this.clearSearch();
847
1018
  if (this.multiple) {
848
1019
  // Keep the search input clear for further typing
@@ -852,7 +1023,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
852
1023
 
853
1024
  // Set focus after updating so the value is announced by screen readers
854
1025
  this.updateComplete.then(() => {
855
- if (this.multiple && this.search && this.open) {
1026
+ if (this.multiple && this._isTypeable && this.open) {
856
1027
  this.displayInput.value = '';
857
1028
  }
858
1029
  this.displayInput.focus({preventScroll: true});
@@ -891,8 +1062,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
891
1062
  // Check for duplicate values in menu items
892
1063
  allOptions.forEach(option => values.push(option.value));
893
1064
 
894
- // Select only the options that match the new value
895
- this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
1065
+ // In free-text mode, materialise any value with no matching option so it renders as a tag/value
1066
+ this._materialiseFreeTextValues(value);
1067
+
1068
+ // Select only the options that match the new value (re-query in case options were materialised)
1069
+ this.setSelectedOptions(this.getAllOptions().filter(el => value.includes(el.value)));
896
1070
 
897
1071
  // of check if an option has selected attribute set initially
898
1072
  if (!this.valueHasChanged) {
@@ -920,6 +1094,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
920
1094
  event.stopImmediatePropagation();
921
1095
 
922
1096
  if (!this.disabled) {
1097
+ // Deselecting drops the backing element for free-text values via selectionChanged's cleanup.
923
1098
  this.toggleOptionSelection(option, false);
924
1099
 
925
1100
  // Emit after updating
@@ -976,7 +1151,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
976
1151
  option.tabIndex = 0;
977
1152
  // When search is active and the display input has focus, don't steal focus to the option.
978
1153
  // 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)) {
1154
+ if (!(this._isTypeable && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
980
1155
  option.focus();
981
1156
  }
982
1157
  }
@@ -1016,6 +1191,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1016
1191
  // Update selected options cache
1017
1192
  this.selectedOptions = this.getAllOptions().filter(el => el.selected);
1018
1193
 
1194
+ // Free-text options exist only to anchor a selected value. Drop any that are no longer selected — e.g.
1195
+ // when a single-select value is replaced or the selection is cleared, the previous custom entry should
1196
+ // disappear entirely rather than linger as an orphaned option.
1197
+ this.querySelectorAll<ZnOption>('[data-free-text]').forEach(option => {
1198
+ if (!option.selected) option.remove();
1199
+ });
1200
+
1019
1201
  // In remote-search + multiple mode, track selected items so they persist across search result changes
1020
1202
  if (this._isRemoteSearch && this.multiple) {
1021
1203
  this._selectedRemoteItems = this.selectedOptions.map(el => ({
@@ -1408,8 +1590,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1408
1590
  // Reset the current option
1409
1591
  this.setCurrentOption(null);
1410
1592
 
1411
- // When search is enabled, clear the display input to allow typing and show all options
1412
- if (this.search) {
1593
+ // When typeable, clear the display input to allow typing and show all options
1594
+ if (this._isTypeable) {
1413
1595
  this.clearSearch();
1414
1596
  this.displayInput.value = '';
1415
1597
  this.displayInput.readOnly = false;
@@ -1429,7 +1611,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1429
1611
  });
1430
1612
 
1431
1613
  // Focus the search input so the user can start typing immediately
1432
- if (this.search) {
1614
+ if (this._isTypeable) {
1433
1615
  requestAnimationFrame(() => {
1434
1616
  this.displayInput.focus({preventScroll: true});
1435
1617
  });
@@ -1449,8 +1631,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1449
1631
  this.emit('zn-hide');
1450
1632
  this.removeOpenListeners();
1451
1633
 
1452
- // When search is enabled, restore the display label and clear the search
1453
- if (this.search) {
1634
+ // When typeable, restore the display label and clear the search
1635
+ if (this._isTypeable) {
1636
+ // Commit any pending free-text before clearing it (blur/close-to-commit). Don't refocus —
1637
+ // the dropdown is closing because focus left the control.
1638
+ if (this.freeText && this._searchDisplayValue.trim()) {
1639
+ this.commitFreeText(false);
1640
+ }
1454
1641
  this.clearSearch();
1455
1642
  this.displayInput.readOnly = true;
1456
1643
  // In multiple mode, tags show the selection so keep the input empty
@@ -1647,7 +1834,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1647
1834
  'select--medium': this.size === 'medium',
1648
1835
  'select--large': this.size === 'large',
1649
1836
  'select--has-input-prefix': this.inputPrefix,
1650
- 'select--search': this.search
1837
+ 'select--search': this._isTypeable
1651
1838
  })}
1652
1839
  placement=${this.placement}
1653
1840
  strategy=${this.hoist ? 'fixed' : 'absolute'}
@@ -1671,20 +1858,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1671
1858
  part="display-input"
1672
1859
  class="select__display-input"
1673
1860
  type="text"
1674
- placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this.search && this.open ? (this.displayLabel || this.placeholder || 'Search...') : this.placeholder)}
1861
+ 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
1862
  .disabled=${isDisabled}
1676
- .value=${isFetchDisabled ? '' : (this.search && this.open) ? this._searchDisplayValue : (this.search && this.multiple) ? '' : this.displayLabel}
1863
+ .value=${isFetchDisabled ? '' : (this._isTypeable && this.open) ? this._searchDisplayValue : (this._isTypeable && this.multiple) ? '' : this.displayLabel}
1677
1864
  autocomplete="off"
1678
1865
  spellcheck="false"
1679
1866
  autocapitalize="off"
1680
- ?readonly=${!(this.search && this.open)}
1867
+ ?readonly=${!(this._isTypeable && this.open)}
1681
1868
  aria-controls="listbox"
1682
1869
  aria-expanded=${this.open ? 'true' : 'false'}
1683
1870
  aria-haspopup="listbox"
1684
1871
  aria-labelledby="label"
1685
1872
  aria-disabled=${isDisabled ? 'true' : 'false'}
1686
1873
  aria-describedby="help-text"
1687
- aria-autocomplete=${this.search ? 'list' : 'none'}
1874
+ aria-autocomplete=${this._isTypeable ? 'list' : 'none'}
1688
1875
  role="combobox"
1689
1876
  tabindex="0"
1690
1877
  @focus=${this.handleFocus}
@@ -1747,6 +1934,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1747
1934
  tabindex="-1"
1748
1935
  @mouseup=${this.handleOptionClick}
1749
1936
  @slotchange=${this.handleDefaultSlotChange}>
1937
+ ${this._freeTextAddValue !== null
1938
+ ? html`
1939
+ <div
1940
+ part="add-option"
1941
+ class=${classMap({
1942
+ 'select__add-option': true,
1943
+ 'select__add-option--current': this._addOptionActive
1944
+ })}
1945
+ role="option"
1946
+ aria-selected=${this._addOptionActive ? 'true' : 'false'}
1947
+ @mousedown=${this.handleAddOptionMouseDown}>
1948
+ Add "${this._freeTextAddValue}"
1949
+ </div>`
1950
+ : nothing}
1750
1951
  <slot></slot>
1751
1952
  ${this._renderSelectedRemoteItems()}
1752
1953
  ${this._fetchedOptions.map(item =>
@@ -1780,7 +1981,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1780
1981
  Type to search...
1781
1982
  </div>`
1782
1983
  : html`
1783
- <div part="empty-state" class="select__empty-state" ?hidden=${!this._noResultsVisible}>
1984
+ <div part="empty-state" class="select__empty-state"
1985
+ ?hidden=${!this._noResultsVisible || this._freeTextAddValue !== null}>
1784
1986
  No matching options
1785
1987
  </div>`
1786
1988
  }
@@ -442,6 +442,21 @@
442
442
  }
443
443
  }
444
444
 
445
+ /* Free-text "Add" row */
446
+ .select__add-option {
447
+ display: flex;
448
+ align-items: center;
449
+ padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
450
+ color: var(--zn-input-color);
451
+ font-size: var(--zn-font-size-small);
452
+ cursor: pointer;
453
+
454
+ &:hover,
455
+ &.select__add-option--current {
456
+ background-color: var(--zn-color-primary-100);
457
+ }
458
+ }
459
+
445
460
  /* Search mode */
446
461
  .select--search .select__combobox {
447
462
  cursor: text;