tyrell-components 1.0.0-RC7 → 1.0.0-RC9

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 (77) hide show
  1. package/dist/tyrell.js +1 -1
  2. package/lib/components/button.d.ts +9 -0
  3. package/lib/components/button.d.ts.map +1 -1
  4. package/lib/components/button.js +34 -1
  5. package/lib/components/button.js.map +1 -1
  6. package/lib/components/copy.js +1 -1
  7. package/lib/components/date-picker.js +1 -1
  8. package/lib/components/dropdown.d.ts +35 -13
  9. package/lib/components/dropdown.d.ts.map +1 -1
  10. package/lib/components/dropdown.js +130 -42
  11. package/lib/components/dropdown.js.map +1 -1
  12. package/lib/components/file-upload.d.ts +121 -0
  13. package/lib/components/file-upload.d.ts.map +1 -0
  14. package/lib/components/file-upload.js +441 -0
  15. package/lib/components/file-upload.js.map +1 -0
  16. package/lib/components/input.js +3 -3
  17. package/lib/components/input.js.map +1 -1
  18. package/lib/components/multiselect.d.ts +22 -22
  19. package/lib/components/multiselect.d.ts.map +1 -1
  20. package/lib/components/multiselect.js +123 -108
  21. package/lib/components/multiselect.js.map +1 -1
  22. package/lib/components/textarea.js +1 -1
  23. package/lib/components/wizard.js +9 -9
  24. package/lib/components/wizard.js.map +1 -1
  25. package/lib/index.d.ts +8 -0
  26. package/lib/index.d.ts.map +1 -1
  27. package/lib/index.js +3 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/styles/button.d.ts +1 -1
  30. package/lib/styles/button.d.ts.map +1 -1
  31. package/lib/styles/button.js +41 -0
  32. package/lib/styles/button.js.map +1 -1
  33. package/lib/styles/checkbox.d.ts +1 -1
  34. package/lib/styles/checkbox.d.ts.map +1 -1
  35. package/lib/styles/date-picker.d.ts +1 -1
  36. package/lib/styles/date-picker.d.ts.map +1 -1
  37. package/lib/styles/date-picker.js +7 -4
  38. package/lib/styles/date-picker.js.map +1 -1
  39. package/lib/styles/dropdown.d.ts +1 -1
  40. package/lib/styles/dropdown.d.ts.map +1 -1
  41. package/lib/styles/dropdown.js +104 -6
  42. package/lib/styles/dropdown.js.map +1 -1
  43. package/lib/styles/file-upload.d.ts +2 -0
  44. package/lib/styles/file-upload.d.ts.map +1 -0
  45. package/lib/styles/file-upload.js +241 -0
  46. package/lib/styles/file-upload.js.map +1 -0
  47. package/lib/styles/input.d.ts +1 -1
  48. package/lib/styles/input.d.ts.map +1 -1
  49. package/lib/styles/input.js +2 -2
  50. package/lib/styles/multiselect.d.ts +1 -1
  51. package/lib/styles/multiselect.d.ts.map +1 -1
  52. package/lib/styles/multiselect.js +147 -96
  53. package/lib/styles/multiselect.js.map +1 -1
  54. package/lib/styles/radio.d.ts +1 -1
  55. package/lib/styles/radio.d.ts.map +1 -1
  56. package/lib/styles/step.d.ts +1 -1
  57. package/lib/styles/step.d.ts.map +1 -1
  58. package/lib/styles/step.js +3 -3
  59. package/lib/styles/switch.d.ts +1 -1
  60. package/lib/styles/switch.d.ts.map +1 -1
  61. package/lib/styles/tag.d.ts +1 -1
  62. package/lib/styles/tag.d.ts.map +1 -1
  63. package/lib/styles/tag.js +1 -12
  64. package/lib/styles/tag.js.map +1 -1
  65. package/lib/styles/textarea.d.ts +1 -1
  66. package/lib/styles/textarea.js +1 -1
  67. package/lib/styles/wizard.d.ts +10 -15
  68. package/lib/styles/wizard.d.ts.map +1 -1
  69. package/lib/styles/wizard.js +149 -98
  70. package/lib/styles/wizard.js.map +1 -1
  71. package/lib/utils/loader-registry.d.ts +35 -0
  72. package/lib/utils/loader-registry.d.ts.map +1 -0
  73. package/lib/utils/loader-registry.js +43 -0
  74. package/lib/utils/loader-registry.js.map +1 -0
  75. package/lib/version.d.ts +1 -1
  76. package/lib/version.js +1 -1
  77. package/package.json +5 -1
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { ensureStyles } from '../utils/styles.js';
44
44
  import { multiselectStyles } from '../styles/multiselect.js';
45
+ import { getLoaderSvg } from '../utils/loader-registry.js';
45
46
  import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
46
47
  import { isMobileTouch } from '../utils/mobile.js';
47
48
  import { TyComponent } from '../base/ty-component.js';
@@ -90,19 +91,11 @@ const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
90
91
  * Ty Multiselect Component
91
92
  */
92
93
  export class TyMultiselect extends TyComponent {
93
- // Debug: Log when expandedSection changes
94
- set expandedSection(value) {
95
- this._state.expandedSection = value;
96
- }
97
- get expandedSection() {
98
- return this._state.expandedSection;
99
- }
100
94
  constructor() {
101
95
  super(); // TyComponent handles attachInternals() and attachShadow()
102
96
  // ============================================================================
103
97
  // INTERNAL STATE
104
98
  // ============================================================================
105
- this._value = '';
106
99
  this._name = '';
107
100
  this._placeholder = 'Select options...';
108
101
  this._label = '';
@@ -110,12 +103,11 @@ export class TyMultiselect extends TyComponent {
110
103
  this._readonly = false;
111
104
  this._required = false;
112
105
  this._externalSearch = false;
106
+ this._loading = false;
113
107
  this._scrollLockId = null;
114
108
  this._size = 'md';
115
- this._flavor = 'neutral';
116
109
  this._selectedLabel = 'Selected';
117
110
  this._availableLabel = 'Available';
118
- this._noSelectionMessage = 'No items selected';
119
111
  this._noOptionsMessage = 'No options available';
120
112
  // Component state
121
113
  this._state = {
@@ -124,12 +116,10 @@ export class TyMultiselect extends TyComponent {
124
116
  highlightedIndex: -1,
125
117
  filteredTags: [],
126
118
  selectedValues: [],
127
- mode: 'desktop', // Updated dynamically on render via syncMode()
128
- expandedSection: 'selected' // Will be corrected by toggleSection call
119
+ mode: 'desktop' // Updated dynamically on render via syncMode()
129
120
  };
130
121
  // Event handler references for cleanup
131
122
  this._stubClickHandler = null;
132
- this._outsideClickHandler = null;
133
123
  this._tagClickHandler = null;
134
124
  this._tagDismissHandler = null;
135
125
  this._searchInputHandler = null;
@@ -140,6 +130,9 @@ export class TyMultiselect extends TyComponent {
140
130
  this._searchDebounceTimer = null;
141
131
  // Custom scrollbar for options list
142
132
  this._optionsScrollbar = null;
133
+ // MutationObserver for light-DOM children — re-syncs selected tags' visual
134
+ // state when consumers swap tag children (external-search refresh pattern).
135
+ this._childObserver = null;
143
136
  const shadow = this.shadowRoot;
144
137
  ensureStyles(shadow, { css: multiselectStyles, id: 'ty-multiselect' });
145
138
  // DON'T render here - wait for onConnect() to initialize values first
@@ -166,6 +159,16 @@ export class TyMultiselect extends TyComponent {
166
159
  this.initializeState();
167
160
  // Visual updates happen automatically via onPropertiesChanged
168
161
  });
162
+ // Observe light-DOM children — re-sync selected state when consumers swap
163
+ // tag children (external-search refresh). syncSelectedTags is idempotent
164
+ // (only acts on tags whose desired-vs-actual selected state differs), so
165
+ // spurious firings caused by our own re-slot work are no-ops.
166
+ this._childObserver = new MutationObserver(() => {
167
+ if (this._state.selectedValues.length > 0) {
168
+ this.syncSelectedTags(this._state.selectedValues);
169
+ }
170
+ });
171
+ this._childObserver.observe(this, { childList: true });
169
172
  }
170
173
  /**
171
174
  * Called when component is disconnected from DOM
@@ -192,6 +195,11 @@ export class TyMultiselect extends TyComponent {
192
195
  }
193
196
  // Cleanup custom scrollbar
194
197
  this._destroyOptionsScrollbar();
198
+ // Disconnect children observer
199
+ if (this._childObserver) {
200
+ this._childObserver.disconnect();
201
+ this._childObserver = null;
202
+ }
195
203
  }
196
204
  /**
197
205
  * Called when properties change
@@ -201,7 +209,6 @@ export class TyMultiselect extends TyComponent {
201
209
  for (const { name, newValue } of changes) {
202
210
  switch (name) {
203
211
  case 'value':
204
- this._value = newValue || '';
205
212
  const selectedValues = this.parseValue(newValue);
206
213
  this._state.selectedValues = selectedValues;
207
214
  // CRITICAL: Only sync tags if we're connected and tags exist
@@ -240,9 +247,6 @@ export class TyMultiselect extends TyComponent {
240
247
  case 'size':
241
248
  this._size = newValue;
242
249
  break;
243
- case 'flavor':
244
- this._flavor = newValue;
245
- break;
246
250
  case 'debounce':
247
251
  this._debounce = newValue;
248
252
  break;
@@ -252,15 +256,40 @@ export class TyMultiselect extends TyComponent {
252
256
  case 'available-label':
253
257
  this._availableLabel = newValue || 'Available';
254
258
  break;
255
- case 'no-selection-message':
256
- this._noSelectionMessage = newValue || 'No items selected';
257
- break;
258
259
  case 'no-options-message':
259
260
  this._noOptionsMessage = newValue || 'No options available';
260
261
  break;
262
+ case 'loading':
263
+ this._loading = newValue;
264
+ this.applyLoadingState();
265
+ break;
261
266
  }
262
267
  }
263
268
  }
269
+ /**
270
+ * Toggle the loading visual state on the open popup.
271
+ * Replaces the available-options area with a centered spinner; search input stays usable.
272
+ * Pulls the latest registered loader SVG on each call so registry changes
273
+ * take effect on the next loading toggle.
274
+ */
275
+ applyLoadingState() {
276
+ const shadow = this.shadowRoot;
277
+ if (!shadow)
278
+ return;
279
+ const svg = this._loading ? getLoaderSvg() : null;
280
+ shadow.querySelectorAll('.dropdown-options-wrapper').forEach((wrapper) => {
281
+ wrapper.classList.toggle('loading', this._loading);
282
+ if (this._loading) {
283
+ wrapper.setAttribute('aria-busy', 'true');
284
+ const spinner = wrapper.querySelector('.dropdown-loading-spinner');
285
+ if (spinner && svg)
286
+ spinner.innerHTML = svg;
287
+ }
288
+ else {
289
+ wrapper.removeAttribute('aria-busy');
290
+ }
291
+ });
292
+ }
264
293
  /**
265
294
  * Get the form value for this component
266
295
  * Returns FormData with multiple entries (HTMX standard)
@@ -419,7 +448,6 @@ export class TyMultiselect extends TyComponent {
419
448
  const valueStr = newValues.join(',');
420
449
  // Only update if changed
421
450
  if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
422
- const currentPropertyValue = this.getProperty('value');
423
451
  // Use TyComponent's property system - this will trigger:
424
452
  // 1. onPropertiesChanged() → syncs tags via syncSelectedTags()
425
453
  // 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
@@ -561,6 +589,8 @@ export class TyMultiselect extends TyComponent {
561
589
  if (searchInput) {
562
590
  setTimeout(() => searchInput.focus(), 100);
563
591
  }
592
+ // Lifecycle event (also fires empty search if external-search)
593
+ this.fireOpenEvent();
564
594
  }
565
595
  /**
566
596
  * Close dropdown dialog (desktop mode)
@@ -619,6 +649,8 @@ export class TyMultiselect extends TyComponent {
619
649
  this.updateTagVisibility(allTags, allTags);
620
650
  this.clearHighlights(allTags);
621
651
  }
652
+ // Lifecycle event
653
+ this.fireCloseEvent();
622
654
  }
623
655
  /**
624
656
  * Open mobile modal (mobile mode)
@@ -651,13 +683,12 @@ export class TyMultiselect extends TyComponent {
651
683
  // Small delay to ensure dialog is ready
652
684
  setTimeout(() => searchInput.focus(), 100);
653
685
  }
654
- // Initialize sections (available expanded by default)
655
- this._state.expandedSection = 'available';
656
- this.syncSectionStates();
657
686
  // Update state after slots are ready
658
687
  requestAnimationFrame(() => {
659
688
  this.updateMobileSelectedState();
660
689
  });
690
+ // Lifecycle event (also fires empty search if external-search)
691
+ this.fireOpenEvent();
661
692
  }
662
693
  /**
663
694
  * Close mobile modal (mobile mode)
@@ -706,6 +737,8 @@ export class TyMultiselect extends TyComponent {
706
737
  const allTags = this.getTagElements();
707
738
  allTags.forEach(el => el.removeAttribute('hidden'));
708
739
  }
740
+ // Lifecycle event
741
+ this.fireCloseEvent();
709
742
  }
710
743
  // ============================================================================
711
744
  // EVENT HANDLERS (Phase 5 & 6)
@@ -722,18 +755,6 @@ export class TyMultiselect extends TyComponent {
722
755
  }
723
756
  this.openDropdown();
724
757
  }
725
- handleOutsideClick(e) {
726
- if (!this._state.open)
727
- return;
728
- const target = e.target;
729
- // Check if click is inside this component (handles shadow DOM retargeting)
730
- // Also check composedPath for clicks that originated inside shadow DOM
731
- const path = e.composedPath();
732
- const clickedInside = path.includes(this);
733
- if (!clickedInside) {
734
- this.closeDropdown();
735
- }
736
- }
737
758
  handleTagClick(e) {
738
759
  const target = e.target;
739
760
  // Find the ty-tag element
@@ -799,8 +820,10 @@ export class TyMultiselect extends TyComponent {
799
820
  this._state.highlightedIndex = -1;
800
821
  // Update visibility
801
822
  this.updateTagVisibility(filtered, allTags);
802
- // Hide options area if no results
823
+ // Hide options area if no results (desktop)
803
824
  this.updateOptionsVisibility(filtered.length > 0);
825
+ // Refresh mobile count + empty-state to reflect filtered visibility
826
+ this.updateMobileSelectedState();
804
827
  // Clear highlights
805
828
  this.clearHighlights(allTags);
806
829
  }
@@ -978,6 +1001,28 @@ export class TyMultiselect extends TyComponent {
978
1001
  composed: true
979
1002
  }));
980
1003
  }
1004
+ /**
1005
+ * Dispatch lifecycle events for popup open/close.
1006
+ * On open with external-search, also fire a `search` event with an empty
1007
+ * query so consumers have a clean hook to reset/refetch the option list.
1008
+ */
1009
+ fireOpenEvent() {
1010
+ this.dispatchEvent(new CustomEvent('open', {
1011
+ detail: { mode: this._state.mode, element: this },
1012
+ bubbles: true,
1013
+ composed: true
1014
+ }));
1015
+ if (this._externalSearch) {
1016
+ this.fireSearchEvent('');
1017
+ }
1018
+ }
1019
+ fireCloseEvent() {
1020
+ this.dispatchEvent(new CustomEvent('close', {
1021
+ detail: { mode: this._state.mode, element: this },
1022
+ bubbles: true,
1023
+ composed: true
1024
+ }));
1025
+ }
981
1026
  // ============================================================================
982
1027
  // CHANGE EVENT DISPATCHING (Phase 5)
983
1028
  // ============================================================================
@@ -1007,6 +1052,8 @@ export class TyMultiselect extends TyComponent {
1007
1052
  else {
1008
1053
  this.renderDesktop();
1009
1054
  }
1055
+ // Loading wrapper is rendered dynamically — re-apply each render
1056
+ this.applyLoadingState();
1010
1057
  }
1011
1058
  /**
1012
1059
  * Setup event listeners
@@ -1068,14 +1115,14 @@ export class TyMultiselect extends TyComponent {
1068
1115
  if (!shadow.querySelector('.multiselect-container')) {
1069
1116
  const stubClasses = this.buildStubClasses();
1070
1117
  const labelHtml = this._label ? `
1071
- <label class="dropdown-label">
1118
+ <label class="ty-field-label">
1072
1119
  ${this._label}
1073
1120
  ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1074
1121
  </label>
1075
1122
  ` : '';
1076
1123
  const searchPlaceholder = 'Search...';
1077
1124
  shadow.innerHTML = `
1078
- <div class="multiselect-container dropdown-mode-mobile">
1125
+ <div class="multiselect-container dropdown-mode-desktop">
1079
1126
  ${labelHtml}
1080
1127
  <div class="dropdown-wrapper">
1081
1128
  <div class="dropdown-stub multiselect-stub ${stubClasses}"
@@ -1105,6 +1152,12 @@ export class TyMultiselect extends TyComponent {
1105
1152
  <div class="dropdown-options">
1106
1153
  <slot id="options-slot"></slot>
1107
1154
  </div>
1155
+ <div class="dropdown-loading" aria-hidden="true">
1156
+ <slot name="loading">
1157
+ <span class="dropdown-loading-spinner"></span>
1158
+ <span class="dropdown-loading-text">Searching…</span>
1159
+ </slot>
1160
+ </div>
1108
1161
  </div>
1109
1162
  </dialog>
1110
1163
  </div>
@@ -1128,7 +1181,7 @@ export class TyMultiselect extends TyComponent {
1128
1181
  if (!shadow.querySelector('.multiselect-container')) {
1129
1182
  const stubClasses = this.buildStubClasses();
1130
1183
  const labelHtml = this._label ? `
1131
- <label class="dropdown-label">
1184
+ <label class="ty-field-label">
1132
1185
  ${this._label}
1133
1186
  ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1134
1187
  </label>
@@ -1179,32 +1232,36 @@ export class TyMultiselect extends TyComponent {
1179
1232
  <!-- HEADER (matches dropdown.ts) -->
1180
1233
  ${searchHeaderHtml}
1181
1234
 
1182
- <!-- BODY (two sections for multiselect) -->
1235
+ <!-- BODY: pinned selected strip + filter list -->
1183
1236
  <div class="mobile-body">
1184
-
1185
- <!-- SELECTED SECTION (collapsed by default) -->
1186
- <div class="mobile-selected-section" data-expanded="false" data-empty="true">
1237
+
1238
+ <!-- SELECTED STRIP (pinned, collapses when empty) -->
1239
+ <div class="mobile-selected-section" data-empty="true">
1187
1240
  <div class="section-header">
1188
1241
  <span class="section-title">${this._selectedLabel} <span class="section-count">(0)</span></span>
1189
- <span class="section-chevron">${CHEVRON_DOWN_SVG}</span>
1190
1242
  </div>
1191
1243
  <div class="section-content">
1192
1244
  <slot id="mobile-slot" name="selected"></slot>
1193
- <div class="empty-state">${this._noSelectionMessage}</div>
1194
1245
  </div>
1195
1246
  </div>
1196
-
1197
- <!-- AVAILABLE SECTION (expanded by default) -->
1198
- <div class="mobile-available-section" data-expanded="true" data-empty="false">
1247
+
1248
+ <!-- AVAILABLE LIST (always visible, takes remaining space) -->
1249
+ <div class="mobile-available-section" data-empty="false">
1199
1250
  <div class="section-header">
1200
1251
  <span class="section-title">${this._availableLabel}</span>
1201
1252
  </div>
1202
- <div class="section-content">
1253
+ <div class="section-content dropdown-options-wrapper">
1203
1254
  <slot id="options-slot"></slot>
1204
1255
  <div class="empty-state">${this._noOptionsMessage}</div>
1256
+ <div class="dropdown-loading" aria-hidden="true">
1257
+ <slot name="loading">
1258
+ <span class="dropdown-loading-spinner"></span>
1259
+ <span class="dropdown-loading-text">Searching…</span>
1260
+ </slot>
1261
+ </div>
1205
1262
  </div>
1206
1263
  </div>
1207
-
1264
+
1208
1265
  </div>
1209
1266
  </div>
1210
1267
  </dialog>
@@ -1228,9 +1285,6 @@ export class TyMultiselect extends TyComponent {
1228
1285
  const searchInput = shadow.querySelector('.mobile-search-input');
1229
1286
  const closeButton = shadow.querySelector('.mobile-close-button');
1230
1287
  const dialog = shadow.querySelector('.mobile-dialog');
1231
- // Get both section headers
1232
- const selectedHeader = shadow.querySelector('.mobile-selected-section .section-header');
1233
- const availableHeader = shadow.querySelector('.mobile-available-section .section-header');
1234
1288
  if (stub) {
1235
1289
  stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
1236
1290
  }
@@ -1242,13 +1296,6 @@ export class TyMultiselect extends TyComponent {
1242
1296
  if (searchInput) {
1243
1297
  searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
1244
1298
  }
1245
- // Toggle section expansion on header click
1246
- if (selectedHeader) {
1247
- selectedHeader.addEventListener('click', () => this.toggleSection('selected'));
1248
- }
1249
- if (availableHeader) {
1250
- availableHeader.addEventListener('click', () => this.toggleSection('available'));
1251
- }
1252
1299
  // Close button click
1253
1300
  if (closeButton) {
1254
1301
  closeButton.addEventListener('click', () => this.closeMobileModal());
@@ -1294,43 +1341,6 @@ export class TyMultiselect extends TyComponent {
1294
1341
  // It already handles mobile mode for auto-close
1295
1342
  this.handleTagClick(e);
1296
1343
  }
1297
- /**
1298
- * Toggle section expansion (mobile only)
1299
- * Clicking expanded section collapses it and expands the other
1300
- * Clicking collapsed section expands it and collapses the other
1301
- */
1302
- toggleSection(section) {
1303
- const shadow = this.shadowRoot;
1304
- const selectedSection = shadow.querySelector('.mobile-selected-section');
1305
- const availableSection = shadow.querySelector('.mobile-available-section');
1306
- if (!selectedSection || !availableSection)
1307
- return;
1308
- // If clicking already expanded section, collapse it and expand the other
1309
- if (this._state.expandedSection === section) {
1310
- const otherSection = section === 'selected' ? 'available' : 'selected';
1311
- this._state.expandedSection = otherSection;
1312
- }
1313
- else {
1314
- // Expand clicked section
1315
- this._state.expandedSection = section;
1316
- }
1317
- // Update DOM attributes
1318
- selectedSection.setAttribute('data-expanded', String(this._state.expandedSection === 'selected'));
1319
- availableSection.setAttribute('data-expanded', String(this._state.expandedSection === 'available'));
1320
- }
1321
- /**
1322
- * Sync section states to DOM without toggle logic
1323
- */
1324
- syncSectionStates() {
1325
- const shadow = this.shadowRoot;
1326
- const selectedSection = shadow.querySelector('.mobile-selected-section');
1327
- const availableSection = shadow.querySelector('.mobile-available-section');
1328
- if (!selectedSection || !availableSection)
1329
- return;
1330
- // Update DOM attributes to match current state
1331
- selectedSection.setAttribute('data-expanded', String(this._state.expandedSection === 'selected'));
1332
- availableSection.setAttribute('data-expanded', String(this._state.expandedSection === 'available'));
1333
- }
1334
1344
  /**
1335
1345
  * Update mobile selected section state (collapsed view, empty states, etc.)
1336
1346
  */
@@ -1351,14 +1361,13 @@ export class TyMultiselect extends TyComponent {
1351
1361
  }
1352
1362
  }
1353
1363
  if (availableSection) {
1354
- const allTags = this.getTagElements();
1355
- const availableCount = allTags.filter(tag => !tag.hasAttribute('selected')).length;
1356
- const hasAvailable = availableCount > 0;
1357
- availableSection.setAttribute('data-empty', String(!hasAvailable));
1364
+ // Count *visible* available tags — tags hidden by search filtering count as 0
1365
+ const visibleAvailable = this.getTagElements().filter(tag => !tag.hasAttribute('selected') && !tag.hasAttribute('hidden')).length;
1366
+ availableSection.setAttribute('data-empty', String(visibleAvailable === 0));
1358
1367
  // Update available header count
1359
1368
  const availableTitleSpan = shadow.querySelector('.mobile-available-section .section-title');
1360
1369
  if (availableTitleSpan) {
1361
- availableTitleSpan.textContent = `${this._availableLabel} (${availableCount})`;
1370
+ availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
1362
1371
  }
1363
1372
  }
1364
1373
  }
@@ -1415,6 +1424,12 @@ export class TyMultiselect extends TyComponent {
1415
1424
  set disabled(value) {
1416
1425
  this.setProperty('disabled', value);
1417
1426
  }
1427
+ get loading() {
1428
+ return this.getProperty('loading');
1429
+ }
1430
+ set loading(value) {
1431
+ this.setProperty('loading', value);
1432
+ }
1418
1433
  get readonly() {
1419
1434
  return this.getProperty('readonly');
1420
1435
  }
@@ -1562,15 +1577,15 @@ TyMultiselect.properties = {
1562
1577
  visual: true,
1563
1578
  default: 'Available'
1564
1579
  },
1565
- 'no-selection-message': {
1566
- type: 'string',
1567
- visual: true,
1568
- default: 'No items selected'
1569
- },
1570
1580
  'no-options-message': {
1571
1581
  type: 'string',
1572
1582
  visual: true,
1573
1583
  default: 'No options available'
1584
+ },
1585
+ loading: {
1586
+ type: 'boolean',
1587
+ visual: true,
1588
+ default: false
1574
1589
  }
1575
1590
  };
1576
1591
  // Register the custom element