@kubex/zinc 1.0.25 → 1.0.99

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 (199) hide show
  1. package/.claude/project.md +556 -0
  2. package/.github/workflows/js_build_and_deploy.yaml +9 -10
  3. package/dist/custom-elements.json +13575 -8856
  4. package/dist/vscode.html-custom-data.json +583 -48
  5. package/dist/web-types.json +1441 -181
  6. package/dist/zn.d.ts +1645 -660
  7. package/dist/zn.min.css +1 -1
  8. package/dist/zn.min.js +1412 -893
  9. package/docs/_utilities/code-previews.cjs +10 -1
  10. package/docs/assets/scripts/docs.js +42 -18
  11. package/docs/data/data-table.json +229 -66
  12. package/docs/data/empty.json +6 -0
  13. package/docs/data/products-table.json +41 -193
  14. package/docs/pages/components/action-bar.md +121 -6
  15. package/docs/pages/components/alert.md +118 -15
  16. package/docs/pages/components/animated-button.md +299 -0
  17. package/docs/pages/components/audio-select.md +534 -0
  18. package/docs/pages/components/bulk-actions.md +79 -5
  19. package/docs/pages/components/button-group.md +235 -27
  20. package/docs/pages/components/button.md +139 -1
  21. package/docs/pages/components/chart.md +365 -9
  22. package/docs/pages/components/checkbox.md +387 -58
  23. package/docs/pages/components/chip.md +253 -33
  24. package/docs/pages/components/collapsible.md +393 -46
  25. package/docs/pages/components/cols.md +152 -30
  26. package/docs/pages/components/confirm.md +182 -16
  27. package/docs/pages/components/copy-button.md +169 -6
  28. package/docs/pages/components/data-select.md +194 -15
  29. package/docs/pages/components/data-table-filter.md +96 -5
  30. package/docs/pages/components/data-table-search.md +451 -0
  31. package/docs/pages/components/data-table-sort.md +66 -5
  32. package/docs/pages/components/data-table.md +727 -66
  33. package/docs/pages/components/datepicker.md +402 -7
  34. package/docs/pages/components/defined-label.md +120 -5
  35. package/docs/pages/components/dialog.md +339 -59
  36. package/docs/pages/components/dropdown.md +366 -11
  37. package/docs/pages/components/editor.md +604 -7
  38. package/docs/pages/components/empty-state.md +315 -16
  39. package/docs/pages/components/expanding-action.md +212 -28
  40. package/docs/pages/components/file.md +421 -12
  41. package/docs/pages/components/filter-container.md +158 -5
  42. package/docs/pages/components/filter-wrapper.md +167 -6
  43. package/docs/pages/components/form-group.md +383 -6
  44. package/docs/pages/components/hover-container.md +237 -6
  45. package/docs/pages/components/icon-picker.md +121 -0
  46. package/docs/pages/components/icon.md +454 -48
  47. package/docs/pages/components/inline-edit.md +787 -19
  48. package/docs/pages/components/input-group.md +207 -13
  49. package/docs/pages/components/input.md +194 -0
  50. package/docs/pages/components/item.md +18 -0
  51. package/docs/pages/components/key-container.md +115 -0
  52. package/docs/pages/components/key.md +101 -0
  53. package/docs/pages/components/menu.md +512 -6
  54. package/docs/pages/components/note.md +239 -36
  55. package/docs/pages/components/opt-group.md +107 -0
  56. package/docs/pages/components/pagination.md +332 -6
  57. package/docs/pages/components/pane.md +222 -6
  58. package/docs/pages/components/panel.md +213 -26
  59. package/docs/pages/components/popup.md +1354 -6
  60. package/docs/pages/components/priority-list.md +233 -0
  61. package/docs/pages/components/progress-bar.md +387 -6
  62. package/docs/pages/components/radio-group.md +242 -5
  63. package/docs/pages/components/radio.md +474 -6
  64. package/docs/pages/components/rating.md +331 -7
  65. package/docs/pages/components/reveal.md +524 -6
  66. package/docs/pages/components/scroll-container.md +552 -6
  67. package/docs/pages/components/select.md +762 -160
  68. package/docs/pages/components/skeleton.md +473 -30
  69. package/docs/pages/components/slideout.md +261 -5
  70. package/docs/pages/components/sp.md +99 -41
  71. package/docs/pages/components/split-button.md +347 -9
  72. package/docs/pages/components/split-pane.md +445 -6
  73. package/docs/pages/components/stat.md +549 -9
  74. package/docs/pages/components/status-indicator.md +230 -1
  75. package/docs/pages/components/stepper.md +563 -6
  76. package/docs/pages/components/tabs.md +439 -13
  77. package/docs/pages/components/textarea.md +415 -17
  78. package/docs/pages/components/tile.md +385 -26
  79. package/docs/pages/components/toggle.md +424 -6
  80. package/docs/pages/components/tooltip.md +367 -0
  81. package/docs/pages/components/translation-group.md +210 -0
  82. package/docs/pages/components/translations.md +319 -19
  83. package/docs/pages/components/vertical-stepper.md +401 -8
  84. package/docs/pages/components/well.md +300 -3
  85. package/docs/pages/getting-started/form-controls.md +162 -0
  86. package/docs/pages/index.md +116 -15
  87. package/package.json +5 -6
  88. package/scss/_global-spacing.scss +4 -0
  89. package/scss/_root.scss +2 -2
  90. package/scss/boot.scss +19 -0
  91. package/scss/shared/layout.scss +5 -0
  92. package/scss/themes/_light.scss +3 -3
  93. package/src/components/alert/alert.scss +1 -0
  94. package/src/components/animated-button/README.md +306 -0
  95. package/src/components/animated-button/animated-button.component.ts +229 -0
  96. package/src/components/animated-button/animated-button.scss +228 -0
  97. package/src/components/animated-button/animated-button.test.ts +143 -0
  98. package/src/components/animated-button/index.ts +12 -0
  99. package/src/components/button/button.component.ts +5 -0
  100. package/src/components/button-group/button-group.component.ts +25 -13
  101. package/src/components/button-group/button-group.scss +6 -0
  102. package/src/components/checkbox/checkbox.component.ts +4 -0
  103. package/src/components/checkbox/checkbox.scss +7 -4
  104. package/src/components/checkbox-group/checkbox-group.scss +7 -0
  105. package/src/components/chip/chip.component.ts +7 -2
  106. package/src/components/chip/chip.scss +16 -2
  107. package/src/components/collapsible/collapsible.component.ts +27 -20
  108. package/src/components/cols/cols.component.ts +14 -2
  109. package/src/components/cols/cols.scss +6 -12
  110. package/src/components/confirm/confirm.component.ts +7 -0
  111. package/src/components/copy-button/copy-button.component.ts +12 -11
  112. package/src/components/data-select/data-select.component.ts +215 -44
  113. package/src/components/data-select/data-select.scss +66 -6
  114. package/src/components/data-select/providers/currency-data-provider.ts +20 -6
  115. package/src/components/data-select/providers/provider.ts +1 -0
  116. package/src/components/data-select/providers/us-state-data-provider.ts +68 -0
  117. package/src/components/data-table/data-table.component.ts +81 -22
  118. package/src/components/data-table-search/data-table-search.component.ts +184 -0
  119. package/src/components/data-table-search/data-table-search.scss +17 -0
  120. package/src/components/data-table-search/data-table-search.test.ts +11 -0
  121. package/src/components/data-table-search/index.ts +12 -0
  122. package/src/components/datepicker/datepicker.component.ts +347 -13
  123. package/src/components/dialog/dialog.component.ts +1 -1
  124. package/src/components/empty-state/empty-state.scss +6 -1
  125. package/src/components/header/header.scss +3 -3
  126. package/src/components/icon/icon.component.ts +23 -1
  127. package/src/components/icon/icon.scss +23 -0
  128. package/src/components/icon-picker/brand-icons.ts +24 -0
  129. package/src/components/icon-picker/icon-picker.component.ts +407 -0
  130. package/src/components/icon-picker/icon-picker.scss +205 -0
  131. package/src/components/icon-picker/index.ts +12 -0
  132. package/src/components/icon-picker/line-icons.ts +612 -0
  133. package/src/components/icon-picker/material-icons.ts +14543 -0
  134. package/src/components/inline-edit/inline-edit.component.ts +190 -46
  135. package/src/components/inline-edit/inline-edit.scss +29 -0
  136. package/src/components/inline-edit/inline-edit.test.ts +314 -1
  137. package/src/components/input/input.component.ts +324 -5
  138. package/src/components/input/input.scss +87 -0
  139. package/src/components/input-group/input-group.component.ts +28 -7
  140. package/src/components/input-group/input-group.scss +48 -10
  141. package/src/components/item/item.component.ts +24 -3
  142. package/src/components/item/item.scss +18 -0
  143. package/src/components/key/index.ts +12 -0
  144. package/src/components/key/key.component.ts +47 -0
  145. package/src/components/key/key.scss +38 -0
  146. package/src/components/key-container/index.ts +12 -0
  147. package/src/components/key-container/key-container.component.ts +142 -0
  148. package/src/components/key-container/key-container.scss +33 -0
  149. package/src/components/menu-item/menu-item.component.ts +20 -0
  150. package/src/components/menu-item/menu-item.scss +69 -2
  151. package/src/components/navbar/navbar.component.ts +103 -11
  152. package/src/components/navbar/navbar.scss +6 -1
  153. package/src/components/opt-group/index.ts +12 -0
  154. package/src/components/opt-group/opt-group.component.ts +74 -0
  155. package/src/components/opt-group/opt-group.scss +37 -0
  156. package/src/components/opt-group/opt-group.test.ts +27 -0
  157. package/src/components/option/option.component.ts +1 -16
  158. package/src/components/option/option.scss +4 -0
  159. package/src/components/panel/panel.component.ts +10 -8
  160. package/src/components/panel/panel.scss +20 -0
  161. package/src/components/priority-list/index.ts +12 -0
  162. package/src/components/priority-list/priority-list.component.ts +611 -0
  163. package/src/components/priority-list/priority-list.scss +185 -0
  164. package/src/components/priority-list/priority-list.test.ts +139 -0
  165. package/src/components/progress-bar/progress-bar.component.ts +22 -16
  166. package/src/components/radio/radio.scss +10 -3
  167. package/src/components/select/select.component.ts +812 -72
  168. package/src/components/select/select.scss +108 -2
  169. package/src/components/select/select.test.ts +147 -2
  170. package/src/components/settings-container/settings-container.component.ts +44 -19
  171. package/src/components/settings-container/settings-container.scss +1 -1
  172. package/src/components/skeleton/skeleton.component.ts +6 -1
  173. package/src/components/slideout/slideout.scss +8 -3
  174. package/src/components/sp/sp.component.ts +3 -1
  175. package/src/components/sp/sp.scss +19 -0
  176. package/src/components/stepper/stepper.component.ts +16 -10
  177. package/src/components/textarea/textarea.scss +0 -4
  178. package/src/components/tile/tile.component.ts +12 -3
  179. package/src/components/tile/tile.scss +1 -1
  180. package/src/components/timer/timer.component.ts +15 -2
  181. package/src/components/toggle/toggle.component.ts +29 -26
  182. package/src/components/toggle/toggle.scss +13 -1
  183. package/src/components/translation-group/index.ts +12 -0
  184. package/src/components/translation-group/translation-group.component.ts +207 -0
  185. package/src/components/translation-group/translation-group.scss +8 -0
  186. package/src/components/translations/translations.component.ts +90 -44
  187. package/src/components/translations/translations.scss +4 -0
  188. package/src/components/vertical-stepper/vertical-stepper.component.ts +14 -9
  189. package/src/events/events.ts +12 -7
  190. package/src/events/zn-language-change.ts +7 -0
  191. package/src/events/zn-purchase.ts +11 -0
  192. package/src/events/zn-redirect.ts +7 -0
  193. package/src/events/zn-reorder.ts +7 -0
  194. package/src/events/zn-search-change.ts +11 -0
  195. package/src/events/zn-submit.ts +1 -1
  196. package/src/form-control.scss +4 -0
  197. package/src/internal/form-navigation.ts +510 -0
  198. package/src/internal/form.ts +6 -0
  199. package/src/zinc.ts +91 -83
@@ -14,11 +14,12 @@ import {watch} from '../../internal/watch';
14
14
  import ZincElement from '../../internal/zinc-element';
15
15
  import ZnChip from "../chip";
16
16
  import ZnIcon from "../icon";
17
+ import ZnOptGroup from "../opt-group";
18
+ import ZnOption from "../option";
17
19
  import ZnPopup from "../popup";
18
20
  import type {CSSResultGroup, PropertyValues, TemplateResult} from 'lit';
19
21
  import type {ZincFormControl} from '../../internal/zinc-element';
20
22
  import type {ZnRemoveEvent} from "../../events/zn-remove";
21
- import type ZnOption from "../option";
22
23
 
23
24
  import styles from './select.scss';
24
25
 
@@ -29,10 +30,12 @@ import styles from './select.scss';
29
30
  * @since 1.0
30
31
  *
31
32
  * @dependency zn-icon
33
+ * @dependency zn-opt-group
34
+ * @dependency zn-option
32
35
  * @dependency zn-popup
33
36
  * @dependency zn-tag
34
37
  *
35
- * @slot - The listbox options. Must be `<zn-option>` elements. You can use `<zn-divider>` to group items visually.
38
+ * @slot - The listbox options. Must be `<zn-option>` elements. You can use `<zn-opt-group>` to group options under a labeled header, or `<zn-divider>` to group items visually.
36
39
  * @slot label - The input's label. Alternatively, you can use the `label` attribute.
37
40
  * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
38
41
  * @slot context-note - Used to add contextual text that is displayed above the select, on the right. Alternatively, you can use the `context-note` attribute.
@@ -51,7 +54,11 @@ import styles from './select.scss';
51
54
  * @event zn-hide - Emitted when the select's menu closes.
52
55
  * @event zn-after-hide - Emitted after the select's menu closes and all animations are complete.
53
56
  * @event zn-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
57
+ * @event zn-load - Emitted when options have been successfully loaded from the `src` URL.
58
+ * @event zn-error - Emitted when loading options from the `src` URL fails.
54
59
  *
60
+ * @csspart search-loading - The container shown while a remote search request is in flight.
61
+ * @csspart max-results-indicator - The message shown when results are truncated by `max-results`.
55
62
  * @csspart form-control - The form control that wraps the label, input, and help text.
56
63
  * @csspart form-control-label - The label's wrapper.
57
64
  * @csspart form-control-input - The select's wrapper.
@@ -73,6 +80,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
73
80
  static styles: CSSResultGroup = unsafeCSS(styles);
74
81
  static dependencies = {
75
82
  'zn-icon': ZnIcon,
83
+ 'zn-opt-group': ZnOptGroup,
84
+ 'zn-option': ZnOption,
76
85
  'zn-popup': ZnPopup,
77
86
  'zn-tag': ZnChip
78
87
  };
@@ -100,26 +109,86 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
100
109
  @state() private valueHasChanged: boolean = false;
101
110
  @state() private inputPrefix: boolean = false;
102
111
 
112
+ /** @internal - current search/filter text when search is enabled (lowercased for matching) */
113
+ private _searchQuery = '';
114
+
115
+ /** @internal - raw display value of the search input (preserves case for the input field) */
116
+ private _searchDisplayValue = '';
117
+
118
+ /** @internal - whether the "no matching options" empty state is visible */
119
+ @state() private _noResultsVisible = false;
120
+
121
+ /** @internal */
122
+ @state() private _fetchedOptions: ({ key: string; value: string; group?: undefined } | {
123
+ group: string;
124
+ options: { key: string; value: string }[]
125
+ })[] = [];
126
+
127
+ /** @internal */
128
+ @state() private _fetchLoading = false;
129
+
130
+ /** @internal */
131
+ @state() private _fetchError = '';
132
+
133
+ /** @internal */
134
+ private _fetchAbortController: AbortController | null = null;
135
+
136
+ /** @internal - debounce timer for remote search */
137
+ private _searchDebounceTimer: number | null = null;
138
+
139
+ /** @internal - whether a remote search fetch is in progress (distinct from initial load) */
140
+ @state() private _searchLoading = false;
141
+
142
+ /** @internal - the query string of the last successful remote search */
143
+ private _lastRemoteQuery: string | null = null;
144
+
145
+ /** @internal - whether the last remote result set was exhaustive (fewer than maxResults returned) */
146
+ private _lastRemoteExhaustive = false;
147
+
148
+ /** @internal - total number of results before maxResults truncation (0 = not truncated) */
149
+ @state() private _totalResultCount = 0;
150
+
151
+ /**
152
+ * @internal - tracks selected items by key/value during remote search so they survive
153
+ * when search results replace the fetched options list.
154
+ */
155
+ private _selectedRemoteItems: { key: string; value: string }[] = [];
156
+
103
157
  /** The name of the select, submitted as a name/value pair with form data. */
104
158
  @property() name = '';
105
159
 
160
+ @property({type: Boolean, attribute: "non-removable"}) nonRemovable = false;
161
+
106
162
  private _value: string | string[] = '';
107
163
 
108
164
  get value() {
109
- return this._value
165
+ return this._value;
110
166
  }
111
167
 
112
168
  /**
113
169
  * The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
114
- * value attribute will be a space-delimited list of values based on the options selected, and the value property will
115
- * be an array. **For this reason, values must not contain spaces.**
170
+ * value property will be an array. Values may contain spaces when using JSON array syntax in the attribute,
171
+ * e.g. `value='["my value", "their value"]'`. Space-delimited strings are still supported for backward compatibility.
116
172
  */
117
173
  @state()
118
174
  set value(val: string | string[]) {
119
175
  if (this.multiple) {
120
- val = Array.isArray(val) ? val : val.split(' ');
176
+ if (Array.isArray(val)) {
177
+ // Already an array, keep as-is
178
+ } else if (val.trimStart().startsWith('[')) {
179
+ try {
180
+ const parsed = JSON.parse(val) as unknown;
181
+ val = Array.isArray(parsed) ? parsed : val.split(" ");
182
+ } catch {
183
+ if (typeof val === "string") {
184
+ val = val.split(" ");
185
+ }
186
+ }
187
+ } else {
188
+ val = val.split(" ");
189
+ }
121
190
  } else {
122
- val = Array.isArray(val) ? val.join(' ') : val;
191
+ val = Array.isArray(val) ? val.join(" ") : val;
123
192
  }
124
193
 
125
194
  if (this._value === val) {
@@ -128,6 +197,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
128
197
 
129
198
  this.valueHasChanged = true;
130
199
  this._value = val;
200
+
201
+ if (this.hasUpdated) {
202
+ this.updateDependencies();
203
+ }
131
204
  }
132
205
 
133
206
  /** The default value of the form control. Primarily used for resetting the form control. */
@@ -135,15 +208,30 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
135
208
  attribute: 'value',
136
209
  converter: {
137
210
  fromAttribute: (value) => {
138
- if (!value || Array.isArray(value)) return value
211
+ if (!value || Array.isArray(value)) return value;
212
+
213
+ // Auto-detect JSON array syntax: value='["my value", "their value"]'
214
+ if (value.trimStart().startsWith('[')) {
215
+ try {
216
+ const parsed = JSON.parse(value) as unknown;
217
+ if (Array.isArray(parsed)) return parsed as string[];
218
+ } catch {
219
+ // Not valid JSON, fall through to space-delimited parsing
220
+ }
221
+ }
222
+
139
223
  return value.includes(' ') ? value.split(' ') : value;
140
224
  },
141
225
  toAttribute: (value) => {
226
+ if (Array.isArray(value) && value.some((v: string) => v.includes(' '))) {
227
+ return JSON.stringify(value);
228
+ }
142
229
  return Array.isArray(value) ? value.join(' ') : value;
143
230
  }
144
231
  }
145
232
  }) defaultValue: string | string[] = '';
146
233
 
234
+
147
235
  /** The select's size. */
148
236
  @property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
149
237
 
@@ -183,6 +271,9 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
183
271
  /** Draws a pill-style select with rounded edges. */
184
272
  @property({type: Boolean, reflect: true}) pill = false;
185
273
 
274
+ /** Enables search/filter functionality. When enabled, the user can type into the select to filter the visible options. */
275
+ @property({type: Boolean, reflect: true}) search = false;
276
+
186
277
  /** The select's label. If you need to display HTML, use the `label` slot instead. */
187
278
  @property() label = '';
188
279
 
@@ -232,19 +323,59 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
232
323
  ?pill=${this.pill}
233
324
  size="x-small"
234
325
  type="primary"
235
- removable
236
326
  @zn-remove=${(event: ZnRemoveEvent) => this.handleTagRemove(event, option)}>
237
327
  ${option.getTextLabel()}
238
- <zn-icon slot="action" src="close" size="16"
239
- @click=${(e: any) => this.handleTagRemove(e as ZnRemoveEvent, option)}></zn-icon>
328
+ ${(!this.nonRemovable) ? html`
329
+ <zn-icon slot="action" src="close" size="16"
330
+ @click=${(e: any) => this.handleTagRemove(e as ZnRemoveEvent, option)}></zn-icon>` : nothing}
240
331
  </zn-chip>
241
332
  `;
242
333
  };
243
334
 
335
+ /** Automatically select the first option if no value is set. */
336
+ @property({attribute: 'select-first', type: Boolean}) selectFirst = false;
337
+
244
338
  @property() distinct = "";
245
339
 
246
340
  @property() conditional = "";
247
341
 
342
+ /**
343
+ * The URL to fetch options from. When set, the component fetches JSON from this URL and renders the results as
344
+ * options. The expected format is an array of objects with `key` and `value` properties:
345
+ * `[{"key": "us", "value": "United States"}, ...]`
346
+ * When not set, the component works exactly as before using slotted `<zn-option>` elements.
347
+ */
348
+ @property({attribute: 'data-uri'}) dataUri: string;
349
+
350
+ /**
351
+ * Context data to send as a header when fetching options from the URL specified by the `src` property.
352
+ */
353
+ @property({attribute: 'context-data'}) contextData: string;
354
+
355
+ /**
356
+ * The maximum number of options to display from a remote fetch response. Set to 0 for unlimited.
357
+ * Only applies when fetching from `data-uri`.
358
+ */
359
+ @property({attribute: 'max-results', type: Number}) maxResults = 0;
360
+
361
+ /**
362
+ * Debounce delay in milliseconds for remote search requests. The component waits this long after the user
363
+ * stops typing before sending a request.
364
+ */
365
+ @property({attribute: 'search-debounce', type: Number}) searchDebounce = 300;
366
+
367
+ /**
368
+ * The query parameter name appended to the `data-uri` URL for remote search requests.
369
+ * Defaults to `"q"`, e.g. `/api/items?q=search+term`. Set to a custom value like `"search"` or `"filter"` to match your API.
370
+ */
371
+ @property({attribute: 'search-param'}) searchParam = 'q';
372
+
373
+ /**
374
+ * When set alongside `search` and `data-uri`, the component will not fetch options on initial load.
375
+ * Options are only fetched when the user starts typing a search query.
376
+ */
377
+ @property({attribute: 'search-only', type: Boolean}) searchOnly = false;
378
+
248
379
  /** Gets the validity state object */
249
380
  get validity() {
250
381
  return this.valueInput?.validity;
@@ -264,13 +395,23 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
264
395
  this.open = false;
265
396
 
266
397
  if (this.cacheKey) {
267
- const cache: any = JSON.parse(localStorage.getItem('zn-linked-select-cache') || '{}');
398
+ const cache = JSON.parse(localStorage.getItem('zn-linked-select-cache') || '{}') as Record<string, string | string[]>;
268
399
  if (cache[this.cacheKey]) {
269
400
  this.value = cache[this.cacheKey];
270
401
  }
271
402
  }
272
403
  }
273
404
 
405
+ disconnectedCallback() {
406
+ super.disconnectedCallback();
407
+ this._fetchAbortController?.abort();
408
+ this._fetchAbortController = null;
409
+ if (this._searchDebounceTimer !== null) {
410
+ clearTimeout(this._searchDebounceTimer);
411
+ this._searchDebounceTimer = null;
412
+ }
413
+ }
414
+
274
415
  private updateHasInputPrefix() {
275
416
  const assigned = this.prefixSlot?.assignedElements({flatten: true}) || [];
276
417
  this.inputPrefix = assigned.length > 0;
@@ -287,7 +428,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
287
428
  this.closeWatcher = new CloseWatcher();
288
429
  this.closeWatcher.onclose = () => {
289
430
  if (this.open) {
290
- this.hide();
431
+ this.hide().then();
291
432
  this.displayInput.focus({preventScroll: true});
292
433
  }
293
434
  };
@@ -314,11 +455,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
314
455
  this.emit('zn-blur');
315
456
  }
316
457
 
317
- private handleDocumentFocusIn = (event: KeyboardEvent) => {
458
+ private handleDocumentFocusIn = (event: FocusEvent) => {
318
459
  // Close when focusing out of the select
319
460
  const path = event.composedPath();
320
461
  if (this && !path.includes(this)) {
321
- this.hide();
462
+ this.hide().then();
322
463
  }
323
464
  };
324
465
 
@@ -339,19 +480,19 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
339
480
  if ((event.key === 'Escape' || event.key === 'Tab') && this.open && !this.closeWatcher) {
340
481
  event.preventDefault();
341
482
  event.stopPropagation();
342
- this.hide();
483
+ this.hide().then();
343
484
  this.displayInput.focus({preventScroll: true});
344
485
  }
345
486
 
346
487
  // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
347
- // buffer we _don't_ close it.
348
- if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
488
+ // buffer we _don't_ close it. When search is enabled, space should type into the input, not toggle.
489
+ if (event.key === 'Enter' || (event.key === ' ' && !this.search && this.typeToSelectString === '')) {
349
490
  event.preventDefault();
350
491
  event.stopImmediatePropagation();
351
492
 
352
493
  // If it's not open, open it
353
494
  if (!this.open) {
354
- this.show();
495
+ this.show().then();
355
496
  return;
356
497
  }
357
498
 
@@ -364,6 +505,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
364
505
  this.setSelectedOptions(this.currentOption);
365
506
  }
366
507
 
508
+ // Clear search after keyboard selection
509
+ if (this.search) {
510
+ this.clearSearch();
511
+ if (this.multiple) {
512
+ this.displayInput.value = '';
513
+ }
514
+ }
515
+
367
516
  // Emit after updating
368
517
  this.updateComplete.then(() => {
369
518
  this.emit('zn-input');
@@ -371,8 +520,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
371
520
  });
372
521
 
373
522
  if (!this.multiple) {
374
- this.hide();
523
+ this.hide().then();
375
524
  this.displayInput.focus({preventScroll: true});
525
+ } else if (this.search) {
526
+ // In multi-select + search, re-focus the search input after keyboard selection
527
+ this.updateComplete.then(() => {
528
+ this.displayInput.value = '';
529
+ this.displayInput.focus({preventScroll: true});
530
+ });
376
531
  }
377
532
  }
378
533
 
@@ -381,8 +536,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
381
536
 
382
537
  // Navigate options
383
538
  if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
384
- const allOptions = this.getAllOptions();
385
- const currentIndex = allOptions.indexOf(this.currentOption);
539
+ const navOptions = this.search ? this.getVisibleOptions() : this.getAllOptions();
540
+ const currentIndex = navOptions.indexOf(this.currentOption);
386
541
  let newIndex = Math.max(0, currentIndex);
387
542
 
388
543
  // Prevent scrolling
@@ -390,7 +545,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
390
545
 
391
546
  // Open it
392
547
  if (!this.open) {
393
- this.show();
548
+ this.show().then();
394
549
 
395
550
  // If an option is already selected, stop here because we want that one to remain highlighted when the listbox
396
551
  // opens for the first time
@@ -401,17 +556,22 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
401
556
 
402
557
  if (event.key === 'ArrowDown') {
403
558
  newIndex = currentIndex + 1;
404
- if (newIndex > allOptions.length - 1) newIndex = 0;
559
+ if (newIndex > navOptions.length - 1) newIndex = 0;
405
560
  } else if (event.key === 'ArrowUp') {
406
561
  newIndex = currentIndex - 1;
407
- if (newIndex < 0) newIndex = allOptions.length - 1;
562
+ if (newIndex < 0) newIndex = navOptions.length - 1;
408
563
  } else if (event.key === 'Home') {
409
564
  newIndex = 0;
410
565
  } else if (event.key === 'End') {
411
- newIndex = allOptions.length - 1;
566
+ newIndex = navOptions.length - 1;
412
567
  }
413
568
 
414
- this.setCurrentOption(allOptions[newIndex]);
569
+ this.setCurrentOption(navOptions[newIndex]);
570
+ }
571
+
572
+ // When search is enabled, let the input handle keystrokes natively (they go through handleSearchInput)
573
+ if (this.search) {
574
+ return;
415
575
  }
416
576
 
417
577
  // All other "printable" keys trigger type to select
@@ -429,7 +589,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
429
589
  return;
430
590
  }
431
591
 
432
- this.show();
592
+ this.show().then();
433
593
  }
434
594
 
435
595
  event.stopPropagation();
@@ -459,7 +619,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
459
619
  // Close when clicking outside of the select
460
620
  const path = event.composedPath();
461
621
  if (this && !path.includes(this)) {
462
- this.hide();
622
+ this.hide().then();
463
623
  }
464
624
  };
465
625
 
@@ -475,8 +635,15 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
475
635
  const isExpandIcon = path.some(el => el instanceof Element && el.classList.contains('select__expand-icon'));
476
636
  // if click is inside the prefix area (e.g. checkbox), don't toggle the select
477
637
  const inPrefix = path.some(el => el instanceof Element && el.classList.contains('select__prefix'));
478
- // Ignore disabled controls and clicks on tags (remove buttons)
479
- if (((this.disabled || isIcon) && !isExpandIcon) || inPrefix) {
638
+ // Ignore disabled controls, fetch loading/error, and clicks on tags (remove buttons)
639
+ if (((this.disabled || this._fetchLoading || this._fetchError || isIcon) && !isExpandIcon) || inPrefix) {
640
+ return;
641
+ }
642
+
643
+ // When search is enabled, clicking the input area should open (not toggle) so the user can type
644
+ if (this.search && this.open && !isExpandIcon) {
645
+ event.preventDefault();
646
+ this.displayInput.focus({preventScroll: true});
480
647
  return;
481
648
  }
482
649
 
@@ -504,6 +671,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
504
671
  event.stopPropagation();
505
672
 
506
673
  if (this.value !== '') {
674
+ if (this.search) {
675
+ this.clearSearch();
676
+ this.displayInput.value = '';
677
+ }
678
+
507
679
  this.setSelectedOptions([]);
508
680
  this.displayInput.focus({preventScroll: true});
509
681
 
@@ -522,29 +694,157 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
522
694
  event.preventDefault();
523
695
  }
524
696
 
697
+ /** Whether the component is in remote-search mode (search + dataUri both set) */
698
+ private get _isRemoteSearch() {
699
+ return this.search && !!this.dataUri;
700
+ }
701
+
702
+ /** Handles text input on the display input for search/filter mode */
703
+ private handleSearchInput() {
704
+ if (!this.search) return;
705
+
706
+ this._searchDisplayValue = this.displayInput.value;
707
+ this._searchQuery = this._searchDisplayValue.toLowerCase();
708
+
709
+ // Open the dropdown when the user starts typing
710
+ if (!this.open) {
711
+ this.show().then();
712
+ }
713
+
714
+ if (this._isRemoteSearch) {
715
+ // Cancel any pending request when the query changes
716
+ if (this._searchDebounceTimer !== null) {
717
+ clearTimeout(this._searchDebounceTimer);
718
+ this._searchDebounceTimer = null;
719
+ }
720
+
721
+ // Empty query — just show all current options, no fetch needed
722
+ if (!this._searchQuery) {
723
+ this.filterOptions();
724
+ return;
725
+ }
726
+
727
+ // If the new query is a refinement of the last one and we know the server had no more
728
+ // results to give (i.e. it returned fewer than maxResults), we can filter locally instead
729
+ // of making another round-trip.
730
+ const canFilterLocally = this._lastRemoteExhaustive
731
+ && this._lastRemoteQuery !== null
732
+ && this._searchQuery.startsWith(this._lastRemoteQuery);
733
+
734
+ if (canFilterLocally) {
735
+ this.filterOptions();
736
+ return;
737
+ }
738
+
739
+ // Remote search: debounce and fetch from server
740
+ this._noResultsVisible = false;
741
+ this._searchDebounceTimer = window.setTimeout(() => {
742
+ this._searchDebounceTimer = null;
743
+ this.fetchOptions(this._searchQuery).then();
744
+ }, this.searchDebounce);
745
+ } else {
746
+ // Local search: filter existing options
747
+ this.filterOptions();
748
+ }
749
+ }
750
+
751
+ /** Filters visible options based on the current search query */
752
+ private filterOptions() {
753
+ const allOptions = this.getAllOptions();
754
+ const searchQuery = this._searchQuery;
755
+
756
+ if (!searchQuery) {
757
+ // Show all options and opt-groups
758
+ allOptions.forEach(option => {
759
+ option.hidden = false;
760
+ });
761
+ this.getAllOptGroups().forEach(group => {
762
+ group.hidden = false;
763
+ });
764
+ this._noResultsVisible = false;
765
+ return;
766
+ }
767
+
768
+ allOptions.forEach(option => {
769
+ // Don't un-hide selected options that are only in the DOM for value preservation
770
+ if (option.selected && option.hidden) return;
771
+
772
+ const label = option.getTextLabel().toLowerCase();
773
+ const value = option.value.toLowerCase();
774
+ const matches = label.includes(searchQuery) || value.includes(searchQuery);
775
+ option.hidden = !matches;
776
+ });
777
+
778
+ // Update opt-group visibility based on whether any child options are visible
779
+ this.getAllOptGroups().forEach(group => group.updateVisibility());
780
+
781
+ // Show empty state when no non-selected options match (selected-but-hidden items are value anchors, not results)
782
+ this._noResultsVisible = allOptions.filter(o => !o.selected).every(option => option.hidden);
783
+
784
+ // Reset current option if it's now hidden
785
+ if (this.currentOption && this.currentOption.hidden) {
786
+ const firstVisible = allOptions.find(o => !o.hidden && !o.disabled);
787
+ if (firstVisible) {
788
+ this.setCurrentOption(firstVisible);
789
+ }
790
+ }
791
+ }
792
+
793
+ /** Clears the search query and shows all options */
794
+ private clearSearch() {
795
+ this._searchQuery = '';
796
+ this._searchDisplayValue = '';
797
+ this._noResultsVisible = false;
798
+
799
+ // Cancel any pending remote search
800
+ if (this._searchDebounceTimer !== null) {
801
+ clearTimeout(this._searchDebounceTimer);
802
+ this._searchDebounceTimer = null;
803
+ }
804
+ this._searchLoading = false;
805
+
806
+ const allOptions = this.getAllOptions();
807
+ allOptions.forEach(option => {
808
+ option.hidden = false;
809
+ });
810
+ // Also unhide any opt-groups that were hidden during filtering
811
+ this.getAllOptGroups().forEach(group => {
812
+ group.hidden = false;
813
+ });
814
+ }
815
+
525
816
  private handleOptionClick(event: MouseEvent) {
526
817
  const target = event.target as HTMLElement;
527
818
  const option = target.closest('zn-option');
528
819
  const oldValue = this.value;
529
820
 
530
821
  if (option && !option.disabled) {
531
- this.valueHasChanged = true
822
+ this.valueHasChanged = true;
532
823
  if (this.multiple) {
533
- if (this.maxOptions > 0) {
534
- if (this.selectedOptions.length >= this.maxOptions && !option.selected) {
535
- return;
536
- }
537
-
538
- this.toggleOptionSelection(option);
539
- } else {
540
- this.toggleOptionSelection(option)
824
+ if (this.maxOptions > 0 && this.selectedOptions.length >= this.maxOptions && !option.selected) {
825
+ return;
541
826
  }
827
+ this.toggleOptionSelection(option);
542
828
  } else {
543
829
  this.setSelectedOptions(option);
544
830
  }
545
831
 
832
+ // Clear search after selection when in search mode
833
+ if (this.search) {
834
+ this.clearSearch();
835
+ if (this.multiple) {
836
+ // Keep the search input clear for further typing
837
+ this.displayInput.value = '';
838
+ }
839
+ }
840
+
546
841
  // Set focus after updating so the value is announced by screen readers
547
- this.updateComplete.then(() => this.displayInput.focus({preventScroll: true}));
842
+ this.updateComplete.then(() => {
843
+ if (this.multiple && this.search && this.open) {
844
+ this.displayInput.value = '';
845
+ }
846
+ this.displayInput.focus({preventScroll: true});
847
+ });
548
848
 
549
849
  if (this.value !== oldValue) {
550
850
  // Emit after updating
@@ -555,7 +855,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
555
855
  }
556
856
 
557
857
  if (!this.multiple) {
558
- this.hide();
858
+ this.hide().then();
559
859
  this.displayInput.focus({preventScroll: true});
560
860
  }
561
861
 
@@ -593,6 +893,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
593
893
  }
594
894
  }
595
895
  }
896
+
897
+ // Auto-select the first option if selectFirst is enabled and no value is set
898
+ if (this.selectFirst && this.selectedOptions.length === 0 && allOptions.length > 0) {
899
+ this.setSelectedOptions(allOptions[0]);
900
+ this.valueHasChanged = true;
901
+ this.emit('zn-input');
902
+ this.emit('zn-change');
903
+ }
596
904
  }
597
905
 
598
906
  private handleTagRemove(event: ZnRemoveEvent, option: ZnOption) {
@@ -610,9 +918,27 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
610
918
  }
611
919
  }
612
920
 
613
- // Gets an array of all <zn-option> elements
921
+ // Gets an array of all <zn-option> elements (both fetched in shadow DOM and slotted in light DOM)
614
922
  private getAllOptions() {
615
- return [...this.querySelectorAll<ZnOption>('zn-option')];
923
+ const shadowOptions = this.shadowRoot
924
+ ? [...this.shadowRoot.querySelectorAll<ZnOption>('.select__listbox zn-option')]
925
+ : [];
926
+ const slottedOptions = [...this.querySelectorAll<ZnOption>('zn-option')];
927
+ return [...shadowOptions, ...slottedOptions];
928
+ }
929
+
930
+ // Gets an array of all <zn-opt-group> elements (both in shadow DOM and slotted in light DOM)
931
+ private getAllOptGroups() {
932
+ const shadowGroups = this.shadowRoot
933
+ ? [...this.shadowRoot.querySelectorAll<ZnOptGroup>('.select__listbox zn-opt-group')]
934
+ : [];
935
+ const slottedGroups = [...this.querySelectorAll<ZnOptGroup>('zn-opt-group')];
936
+ return [...shadowGroups, ...slottedGroups];
937
+ }
938
+
939
+ // Gets an array of visible (not hidden) <zn-option> elements, used by keyboard navigation when search is active
940
+ private getVisibleOptions() {
941
+ return this.getAllOptions().filter(option => !option.hidden);
616
942
  }
617
943
 
618
944
  // Gets the first <zn-option> element
@@ -631,13 +957,16 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
631
957
  el.tabIndex = -1;
632
958
  });
633
959
 
634
-
635
960
  // Select the target option
636
961
  if (option) {
637
962
  this.currentOption = option;
638
963
  option.current = true;
639
964
  option.tabIndex = 0;
640
- option.focus();
965
+ // When search is active and the display input has focus, don't steal focus to the option.
966
+ // The user is typing in the search input and we only want to visually highlight the current option.
967
+ if (!(this.search && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
968
+ option.focus();
969
+ }
641
970
  }
642
971
  }
643
972
 
@@ -675,23 +1004,35 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
675
1004
  // Update selected options cache
676
1005
  this.selectedOptions = this.getAllOptions().filter(el => el.selected);
677
1006
 
1007
+ // In remote-search + multiple mode, track selected items so they persist across search result changes
1008
+ if (this._isRemoteSearch && this.multiple) {
1009
+ this._selectedRemoteItems = this.selectedOptions.map(el => ({
1010
+ key: el.value,
1011
+ value: el.getTextLabel()
1012
+ }));
1013
+ }
1014
+
678
1015
  // Keep a reference to the previous `valueHasChanged`. Changes made here don't count has changing the value.
679
1016
  const cachedValueHasChanged: boolean = this.valueHasChanged;
680
1017
 
681
1018
  // Update the value and display label
682
1019
  if (this.multiple) {
683
- this.getAllOptions().filter(el => el.multiple !== true).forEach(el => el.multiple = true);
1020
+ this.getAllOptions().filter(el => !el.multiple).forEach(el => {
1021
+ el.multiple = true;
1022
+ });
684
1023
 
685
1024
  this.value = this.selectedOptions.map(el => el.value);
686
1025
 
687
- if (this.placeholder && this.value.length === 0) {
1026
+ if (this.value.length === 0) {
688
1027
  // When no items are selected, keep the value empty so the placeholder shows
689
1028
  this.displayLabel = '';
690
1029
  } else {
691
1030
  this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
692
1031
  }
693
1032
  } else {
694
- this.getAllOptions().filter(el => el.multiple === true).forEach(el => el.multiple = false);
1033
+ this.getAllOptions().filter(el => el.multiple).forEach(el => {
1034
+ el.multiple = false;
1035
+ });
695
1036
  const selectedOption = this.selectedOptions[0];
696
1037
  this.value = selectedOption?.value ?? '';
697
1038
  this.displayLabel = selectedOption?.getTextLabel() ?? '';
@@ -722,6 +1063,33 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
722
1063
  });
723
1064
  }
724
1065
 
1066
+ /**
1067
+ * Renders hidden <zn-option> elements for items that are currently selected but not present in
1068
+ * the latest `_fetchedOptions` (e.g. because a remote search narrowed the results). This keeps
1069
+ * them in the DOM so `handleDefaultSlotChange` can find and re-select them.
1070
+ */
1071
+ private _renderSelectedRemoteItems() {
1072
+ if (!this._isRemoteSearch || !this.multiple || this._selectedRemoteItems.length === 0) {
1073
+ return nothing;
1074
+ }
1075
+
1076
+ // Build a set of keys present in the current fetched options
1077
+ const fetchedKeys = new Set<string>();
1078
+ for (const item of this._fetchedOptions) {
1079
+ if (item.group !== undefined) {
1080
+ for (const opt of item.options) fetchedKeys.add(opt.key);
1081
+ } else {
1082
+ fetchedKeys.add(item.key);
1083
+ }
1084
+ }
1085
+
1086
+ // Render hidden options for selected items not in the fetched results
1087
+ return this._selectedRemoteItems
1088
+ .filter(item => !fetchedKeys.has(item.key))
1089
+ .map(item => html`
1090
+ <zn-option value=${item.key} hidden>${item.value}</zn-option>`);
1091
+ }
1092
+
725
1093
  private handleInvalid(event: Event) {
726
1094
  this.formControlController.setValidity(false);
727
1095
  this.formControlController.emitInvalidEvent(event);
@@ -732,7 +1100,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
732
1100
 
733
1101
  if (this.distinct !== "") {
734
1102
  // if we are linked to another zn-data-select remove the selected values from that select from this select
735
- const linkedSelect = document.querySelector(`zn-select[id="${this.distinct}"]`) as ZnSelect;
1103
+ const linkedSelect = document.querySelector<ZnSelect>(`zn-select[id="${this.distinct}"]`)!;
736
1104
  if (linkedSelect) {
737
1105
  linkedSelect.addEventListener('zn-change', () => {
738
1106
  const linkedValues = Array.isArray(linkedSelect.value) ? linkedSelect.value : [linkedSelect.value];
@@ -753,30 +1121,269 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
753
1121
  }
754
1122
 
755
1123
  if (this.conditional !== "") {
756
- const conditionalSelectList = deepQuerySelectorAll(`#${this.conditional}`, document.documentElement, '') as ZnSelect[];
757
- conditionalSelectList.forEach((conditionalSelect) => {
758
- // disable if the other has any options selected
759
- conditionalSelect.addEventListener('zn-change', () => {
760
- let linkedValues = Array.isArray(conditionalSelect.value) ? conditionalSelect.value : [conditionalSelect.value];
1124
+ const ids = this.conditional.split(',').map(id => id.trim());
1125
+ const conditionals: ZnSelect[] = [];
1126
+
1127
+ ids.forEach(id => {
1128
+ const elements = deepQuerySelectorAll(`#${id}`, document.documentElement, '') as ZnSelect[];
1129
+ conditionals.push(...elements);
1130
+ });
1131
+
1132
+ const initiallyDisabled = this.disabled;
1133
+ const checkConditionals = () => {
1134
+ const shouldDisable = conditionals.some(select => {
1135
+ let linkedValues = Array.isArray(select.value) ? select.value : [select.value];
761
1136
  linkedValues = linkedValues.filter(v => v !== '');
762
- console.log('linkedValues', linkedValues);
763
- this.disabled = linkedValues.length > 0;
1137
+ return linkedValues.length > 0;
764
1138
  });
1139
+ this.disabled = initiallyDisabled || shouldDisable;
1140
+ };
765
1141
 
766
- // trigger the event once to initialize
767
- conditionalSelect.dispatchEvent(new Event('zn-input'));
1142
+ conditionals.forEach(select => {
1143
+ select.addEventListener('zn-change', checkConditionals);
1144
+ select.addEventListener('zn-input', checkConditionals);
768
1145
  });
1146
+
1147
+ // trigger the check once to initialize
1148
+ checkConditionals();
1149
+ }
1150
+
1151
+ if (this.dataUri && !this.searchOnly) {
1152
+ this.fetchOptions().then();
769
1153
  }
770
1154
 
771
1155
  this.updateHasInputPrefix();
1156
+ this.updateDependencies();
772
1157
  }
773
1158
 
774
1159
  @watch('disabled', {waitUntilFirstUpdate: true})
775
1160
  handleDisabledChange() {
776
- // Close the listbox when the control is disabled
777
1161
  if (this.disabled) {
1162
+ // Close the listbox and abort any in-progress fetch
778
1163
  this.open = false;
779
- this.handleOpenChange();
1164
+ this.handleOpenChange().then();
1165
+ this._fetchAbortController?.abort();
1166
+ this._fetchAbortController = null;
1167
+ this._fetchLoading = false;
1168
+ this._fetchError = '';
1169
+ } else if (this.dataUri && !this.searchOnly) {
1170
+ // Fetch options when enabled
1171
+ this.fetchOptions().then();
1172
+ }
1173
+ }
1174
+
1175
+ @watch(['dataUri', 'contextData'], {waitUntilFirstUpdate: true})
1176
+ handleSrcChange() {
1177
+ if (this.dataUri && !this.searchOnly) {
1178
+ this.fetchOptions().then();
1179
+ } else {
1180
+ this._fetchedOptions = [];
1181
+ this._fetchLoading = false;
1182
+ this._fetchError = '';
1183
+ this.handleDefaultSlotChange();
1184
+ }
1185
+ }
1186
+
1187
+ private _handleFetchError(message: string) {
1188
+ this._fetchError = message;
1189
+ this._fetchLoading = false;
1190
+ this._fetchAbortController = null;
1191
+ this.emit('zn-error', {detail: {status: 400}});
1192
+ }
1193
+
1194
+ /** Builds the localStorage cache key for a given search query */
1195
+ private _buildCacheKey(searchQuery?: string): string {
1196
+ const base = this.cacheKey || this.dataUri;
1197
+ return searchQuery ? `zn-select-search:${base}:${searchQuery}` : `zn-select-search:${base}`;
1198
+ }
1199
+
1200
+ /** Reads cached results from localStorage */
1201
+ private _readCache(searchQuery?: string): typeof this._fetchedOptions | null {
1202
+ if (!this.cacheKey) return null;
1203
+ try {
1204
+ const raw = localStorage.getItem(this._buildCacheKey(searchQuery));
1205
+ if (raw) return JSON.parse(raw) as typeof this._fetchedOptions;
1206
+ } catch {
1207
+ // Ignore corrupt cache
1208
+ }
1209
+ return null;
1210
+ }
1211
+
1212
+ /** Writes results to localStorage cache */
1213
+ private _writeCache(searchQuery: string | undefined, data: typeof this._fetchedOptions) {
1214
+ if (!this.cacheKey) return;
1215
+ try {
1216
+ localStorage.setItem(this._buildCacheKey(searchQuery), JSON.stringify(data));
1217
+ } catch {
1218
+ // Storage full or unavailable — silently ignore
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Parses raw JSON data into the internal fetched-options format.
1224
+ * Applies `maxResults` limiting to flat option arrays.
1225
+ */
1226
+ private _parseOptions(data: unknown): typeof this._fetchedOptions | null {
1227
+ type FetchedOption = { key: string; value: string; group?: undefined };
1228
+ type FetchedGroup = { group: string; options: { key: string; value: string }[] };
1229
+ let options: (FetchedOption | FetchedGroup)[];
1230
+
1231
+ if (Array.isArray(data)) {
1232
+ const isGrouped = data.length > 0 && data.some(
1233
+ (item: unknown) => item && typeof item === 'object' && 'group' in (item as Record<string, unknown>)
1234
+ );
1235
+
1236
+ if (isGrouped) {
1237
+ options = (data as Record<string, unknown>[]).map(item => {
1238
+ if ('group' in item && Array.isArray(item.options)) {
1239
+ return {
1240
+ group: String(item.group),
1241
+ options: item.options as { key: string; value: string }[]
1242
+ } as FetchedGroup;
1243
+ }
1244
+ return item as unknown as FetchedOption;
1245
+ });
1246
+ } else {
1247
+ options = data as FetchedOption[];
1248
+ }
1249
+ } else if (data && typeof data === 'object') {
1250
+ options = Object.entries(data as Record<string, unknown>).map(([key, value]) => ({
1251
+ key,
1252
+ value: String(value)
1253
+ }));
1254
+ } else {
1255
+ return null;
1256
+ }
1257
+
1258
+ // Apply max-results limit to flat (non-grouped) options
1259
+ if (this.maxResults > 0) {
1260
+ let count = 0;
1261
+ const limited: (FetchedOption | FetchedGroup)[] = [];
1262
+ for (const item of options) {
1263
+ if (count >= this.maxResults) break;
1264
+ if (item.group !== undefined) {
1265
+ const remaining = this.maxResults - count;
1266
+ const slicedOpts = item.options.slice(0, remaining);
1267
+ if (slicedOpts.length > 0) {
1268
+ limited.push({group: item.group, options: slicedOpts});
1269
+ count += slicedOpts.length;
1270
+ }
1271
+ } else {
1272
+ limited.push(item);
1273
+ count++;
1274
+ }
1275
+ }
1276
+ options = limited;
1277
+ }
1278
+
1279
+ return options;
1280
+ }
1281
+
1282
+ /** Fetches options from the URL specified by the `data-uri` property. Optionally appends a search query. */
1283
+ async fetchOptions(searchQuery?: string) {
1284
+ if (!this.dataUri || this.disabled) return;
1285
+
1286
+ const isRemoteSearch = !!searchQuery;
1287
+
1288
+ // Check cache first
1289
+ if (this.cacheKey) {
1290
+ const cached = this._readCache(searchQuery);
1291
+ if (cached) {
1292
+ this._fetchedOptions = cached;
1293
+ this._searchLoading = false;
1294
+ this._fetchLoading = false;
1295
+ this._totalResultCount = 0;
1296
+ this._noResultsVisible = cached.length === 0 && isRemoteSearch;
1297
+ this.emit('zn-load');
1298
+ this.updateComplete.then(() => this.handleDefaultSlotChange());
1299
+ return;
1300
+ }
1301
+ }
1302
+
1303
+ this._fetchAbortController?.abort();
1304
+ this._fetchAbortController = new AbortController();
1305
+
1306
+ if (isRemoteSearch) {
1307
+ this._searchLoading = true;
1308
+ } else {
1309
+ this._fetchLoading = true;
1310
+ }
1311
+ this._fetchError = '';
1312
+
1313
+ try {
1314
+ // Build the URL — append search query as a parameter for remote search
1315
+ let url = this.dataUri;
1316
+ if (searchQuery) {
1317
+ const separator = url.includes('?') ? '&' : '?';
1318
+ url = `${url}${separator}${encodeURIComponent(this.searchParam)}=${encodeURIComponent(searchQuery)}`;
1319
+ }
1320
+
1321
+ const response = await fetch(url, {
1322
+ signal: this._fetchAbortController.signal,
1323
+ credentials: 'same-origin',
1324
+ headers: {
1325
+ 'x-kx-fetch-style': 'zn-select-options',
1326
+ 'x-kx-context-data': this.contextData || ''
1327
+ },
1328
+ });
1329
+
1330
+ if (!response.ok) {
1331
+ this._searchLoading = false;
1332
+ this._handleFetchError(response.statusText || `HTTP ${response.status}`);
1333
+ return;
1334
+ }
1335
+
1336
+ const data: unknown = await response.json();
1337
+
1338
+ // Count raw results before maxResults limiting to determine exhaustiveness
1339
+ const rawCount = Array.isArray(data)
1340
+ ? data.reduce((n: number, item: unknown) => {
1341
+ if (item && typeof item === 'object' && 'group' in (item as Record<string, unknown>)) {
1342
+ const group = item as Record<string, unknown>;
1343
+ return n + (Array.isArray(group.options) ? group.options.length : 0);
1344
+ }
1345
+ return n + 1;
1346
+ }, 0)
1347
+ : (data && typeof data === 'object') ? Object.keys(data as object).length : 0;
1348
+
1349
+ const options = this._parseOptions(data);
1350
+
1351
+ if (!options) {
1352
+ this._searchLoading = false;
1353
+ this._handleFetchError('Expected JSON array or object of options');
1354
+ return;
1355
+ }
1356
+
1357
+ this._fetchedOptions = options;
1358
+ this._fetchLoading = false;
1359
+ this._searchLoading = false;
1360
+ this._fetchAbortController = null;
1361
+ this._noResultsVisible = options.length === 0 && isRemoteSearch;
1362
+
1363
+ // Track total vs displayed count for truncation indicator
1364
+ this._totalResultCount = this.maxResults > 0 && rawCount > this.maxResults ? rawCount : 0;
1365
+
1366
+ // Track whether the server's result set was exhaustive (returned fewer than the limit).
1367
+ // If so, further refinements of this query can be filtered client-side.
1368
+ // For the initial load (no search query), this tells us if we already have all results.
1369
+ this._lastRemoteQuery = searchQuery ?? '';
1370
+ this._lastRemoteExhaustive = this.maxResults > 0 ? rawCount < this.maxResults : true;
1371
+
1372
+ // Cache the results
1373
+ this._writeCache(searchQuery, options);
1374
+
1375
+ this.emit('zn-load');
1376
+
1377
+ this.updateComplete.then(() => {
1378
+ this.handleDefaultSlotChange();
1379
+ });
1380
+ } catch (error: unknown) {
1381
+ if (error instanceof DOMException && error.name === 'AbortError') {
1382
+ return;
1383
+ }
1384
+
1385
+ this._searchLoading = false;
1386
+ this._handleFetchError(error instanceof Error ? error.message : 'Failed to load options');
780
1387
  }
781
1388
  }
782
1389
 
@@ -806,6 +1413,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
806
1413
 
807
1414
  // Select only the options that match the new value
808
1415
  this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
1416
+ this.updateDependencies();
809
1417
  }
810
1418
 
811
1419
  @watch('open', {waitUntilFirstUpdate: true})
@@ -814,6 +1422,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
814
1422
  // Reset the current option
815
1423
  this.setCurrentOption(null);
816
1424
 
1425
+ // When search is enabled, clear the display input to allow typing and show all options
1426
+ if (this.search) {
1427
+ this.clearSearch();
1428
+ this.displayInput.value = '';
1429
+ this.displayInput.readOnly = false;
1430
+ }
1431
+
817
1432
  // Show
818
1433
  this.emit('zn-show');
819
1434
  this.addOpenListeners();
@@ -827,6 +1442,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
827
1442
  this.setCurrentOption(this.currentOption);
828
1443
  });
829
1444
 
1445
+ // Focus the search input so the user can start typing immediately
1446
+ if (this.search) {
1447
+ requestAnimationFrame(() => {
1448
+ this.displayInput.focus({preventScroll: true});
1449
+ });
1450
+ }
1451
+
830
1452
  const {keyframes, options} = getAnimation(this, 'select.show', {dir: this.localize.dir()});
831
1453
  await animateTo(this.popup.popup, keyframes, options);
832
1454
 
@@ -841,6 +1463,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
841
1463
  this.emit('zn-hide');
842
1464
  this.removeOpenListeners();
843
1465
 
1466
+ // When search is enabled, restore the display label and clear the search
1467
+ if (this.search) {
1468
+ this.clearSearch();
1469
+ this.displayInput.readOnly = true;
1470
+ // In multiple mode, tags show the selection so keep the input empty
1471
+ this.displayInput.value = this.multiple ? '' : this.displayLabel;
1472
+ }
1473
+
844
1474
  await stopAnimations(this);
845
1475
  const {keyframes, options} = getAnimation(this, 'select.hide', {dir: this.localize.dir()});
846
1476
  await animateTo(this.popup.popup, keyframes, options);
@@ -904,6 +1534,67 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
904
1534
  this.displayInput.blur();
905
1535
  }
906
1536
 
1537
+ private updateDependencies() {
1538
+ const root = this.getRootNode() as Document | ShadowRoot;
1539
+ if (!root?.querySelectorAll) return;
1540
+
1541
+ const dependentElements = root.querySelectorAll('[data-disable-on]');
1542
+ dependentElements.forEach(el => {
1543
+ const selector = el.getAttribute('data-disable-on');
1544
+ if (!selector) return;
1545
+
1546
+ let trigger: Element | null = null;
1547
+ try {
1548
+ trigger = root.querySelector(selector);
1549
+ } catch (e) {
1550
+ // no-op
1551
+ }
1552
+
1553
+ if (!trigger) {
1554
+ const idSelector = selector.startsWith('#') ? selector : `#${selector}`;
1555
+ try {
1556
+ trigger = root.querySelector(idSelector);
1557
+ } catch (e) {
1558
+ // no-op
1559
+ }
1560
+ }
1561
+
1562
+ if (trigger === this) {
1563
+ this.updateDependencyState(el as HTMLInputElement);
1564
+ }
1565
+ });
1566
+ }
1567
+
1568
+ private updateDependencyState(target: HTMLInputElement) {
1569
+ const disableValue = target.getAttribute('data-disable-value');
1570
+ if (disableValue !== null) {
1571
+ const valuesToCheck = disableValue.split(',').map(v => v.trim());
1572
+ let shouldDisable: boolean;
1573
+
1574
+ if (Array.isArray(this.value)) {
1575
+ shouldDisable = this.value.some(v => valuesToCheck.includes(v));
1576
+ } else {
1577
+ shouldDisable = valuesToCheck.includes(String(this.value));
1578
+ }
1579
+
1580
+ this.toggleDisabled(target, shouldDisable);
1581
+ }
1582
+ }
1583
+
1584
+ private toggleDisabled(target: HTMLInputElement, disabled: boolean) {
1585
+ const reset = () => {
1586
+ if ('defaultValue' in target && typeof target.defaultValue !== 'undefined') {
1587
+ target.value = target.defaultValue;
1588
+ }
1589
+ }
1590
+
1591
+ target.disabled = disabled;
1592
+
1593
+ if (disabled) {
1594
+ reset();
1595
+ }
1596
+ }
1597
+
907
1598
  render() {
908
1599
  const hasLabelSlot = this.hasSlotController.test('label');
909
1600
  const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
@@ -915,6 +1606,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
915
1606
  const hasHelpText = this.helpText ? true : hasHelpTextSlot;
916
1607
  const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
917
1608
  const isPlaceholderVisible = this.placeholder && this.value.length === 0;
1609
+ const isFetchDisabled = this._fetchLoading || !!this._fetchError;
1610
+ const isDisabled = this.disabled || isFetchDisabled;
918
1611
 
919
1612
  return html`
920
1613
  <div
@@ -958,7 +1651,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
958
1651
  'select--standard': true,
959
1652
  'select--pill': this.pill,
960
1653
  'select--open': this.open,
961
- 'select--disabled': this.disabled,
1654
+ 'select--disabled': isDisabled,
962
1655
  'select--multiple': this.multiple,
963
1656
  'select--focused': this.hasFocus,
964
1657
  'select--placeholder-visible': isPlaceholderVisible,
@@ -967,7 +1660,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
967
1660
  'select--small': this.size === 'small',
968
1661
  'select--medium': this.size === 'medium',
969
1662
  'select--large': this.size === 'large',
970
- 'select--has-input-prefix': this.inputPrefix
1663
+ 'select--has-input-prefix': this.inputPrefix,
1664
+ 'select--search': this.search
971
1665
  })}
972
1666
  placement=${this.placement}
973
1667
  strategy=${this.hoist ? 'fixed' : 'absolute'}
@@ -991,23 +1685,25 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
991
1685
  part="display-input"
992
1686
  class="select__display-input"
993
1687
  type="text"
994
- placeholder=${this.placeholder}
995
- .disabled=${this.disabled}
996
- .value=${this.displayLabel}
1688
+ placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this.search && this.open ? (this.displayLabel || this.placeholder || 'Search...') : this.placeholder)}
1689
+ .disabled=${isDisabled}
1690
+ .value=${isFetchDisabled ? '' : (this.search && this.open) ? this._searchDisplayValue : (this.search && this.multiple) ? '' : this.displayLabel}
997
1691
  autocomplete="off"
998
1692
  spellcheck="false"
999
1693
  autocapitalize="off"
1000
- readonly
1694
+ ?readonly=${!(this.search && this.open)}
1001
1695
  aria-controls="listbox"
1002
1696
  aria-expanded=${this.open ? 'true' : 'false'}
1003
1697
  aria-haspopup="listbox"
1004
1698
  aria-labelledby="label"
1005
- aria-disabled=${this.disabled ? 'true' : 'false'}
1699
+ aria-disabled=${isDisabled ? 'true' : 'false'}
1006
1700
  aria-describedby="help-text"
1701
+ aria-autocomplete=${this.search ? 'list' : 'none'}
1007
1702
  role="combobox"
1008
1703
  tabindex="0"
1009
1704
  @focus=${this.handleFocus}
1010
1705
  @blur=${this.handleBlur}
1706
+ @input=${this.handleSearchInput}
1011
1707
  />
1012
1708
 
1013
1709
  ${this.multiple ? html`
@@ -1016,7 +1712,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1016
1712
  <input
1017
1713
  class="select__value-input"
1018
1714
  type="text"
1019
- disabled=${this.disabled || nothing}
1715
+ disabled=${isDisabled || nothing}
1020
1716
  required=${this.required || nothing}
1021
1717
  value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
1022
1718
  tabindex="-1"
@@ -1041,9 +1737,17 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1041
1737
  </button>`
1042
1738
  : ''}
1043
1739
 
1044
- <slot name="expand-icon" part="expand-icon" class="select__expand-icon">
1045
- <zn-icon src="keyboard_arrow_down"></zn-icon>
1046
- </slot>
1740
+ ${this._fetchLoading
1741
+ ? html`
1742
+ <zn-icon src="progress_activity" class="select__expand-icon select__spinner"></zn-icon>`
1743
+ : this._fetchError
1744
+ ? html`
1745
+ <zn-icon src="error" class="select__expand-icon select__fetch-error-icon"></zn-icon>`
1746
+ : html`
1747
+ <slot name="expand-icon" part="expand-icon" class="select__expand-icon">
1748
+ <zn-icon src="keyboard_arrow_down"></zn-icon>
1749
+ </slot>`
1750
+ }
1047
1751
  </div>
1048
1752
 
1049
1753
  <div
@@ -1058,6 +1762,42 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1058
1762
  @mouseup=${this.handleOptionClick}
1059
1763
  @slotchange=${this.handleDefaultSlotChange}>
1060
1764
  <slot></slot>
1765
+ ${this._renderSelectedRemoteItems()}
1766
+ ${this._fetchedOptions.map(item =>
1767
+ item.group !== undefined
1768
+ ? html`
1769
+ <zn-opt-group label=${item.group}>
1770
+ ${item.options.map(
1771
+ option => html`
1772
+ <zn-option value=${option.key}>${option.value}</zn-option>`
1773
+ )}
1774
+ </zn-opt-group>`
1775
+ : html`
1776
+ <zn-option value=${item.key}>${item.value}</zn-option>`
1777
+ )}
1778
+ ${this._totalResultCount > 0
1779
+ ? html`
1780
+ <div part="max-results-indicator" class="select__max-results-indicator">
1781
+ Showing ${this.maxResults} of ${this._totalResultCount} results — refine your search for more
1782
+ </div>`
1783
+ : nothing
1784
+ }
1785
+ ${this._searchLoading
1786
+ ? html`
1787
+ <div part="search-loading" class="select__search-loading">
1788
+ <zn-icon src="progress_activity" class="select__spinner"></zn-icon>
1789
+ Searching...
1790
+ </div>`
1791
+ : this.searchOnly && this._fetchedOptions.length === 0 && !this._searchQuery
1792
+ ? html`
1793
+ <div part="empty-state" class="select__empty-state">
1794
+ Type to search...
1795
+ </div>`
1796
+ : html`
1797
+ <div part="empty-state" class="select__empty-state" ?hidden=${!this._noResultsVisible}>
1798
+ No matching options
1799
+ </div>`
1800
+ }
1061
1801
  </div>
1062
1802
  </zn-popup>
1063
1803
  </div>