tyrell-components 1.0.0-TC25 → 1.0.0-TC27

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 (137) hide show
  1. package/css/tyrell-brand.css +15 -11
  2. package/css/tyrell.css +4 -4
  3. package/dist/tyrell-brand.css +15 -11
  4. package/dist/tyrell.css +4 -4
  5. package/dist/tyrell.js +1 -1
  6. package/lib/components/calendar-month.d.ts +6 -0
  7. package/lib/components/calendar-month.d.ts.map +1 -1
  8. package/lib/components/calendar-month.js +52 -9
  9. package/lib/components/calendar-month.js.map +1 -1
  10. package/lib/components/calendar-navigation.d.ts +31 -5
  11. package/lib/components/calendar-navigation.d.ts.map +1 -1
  12. package/lib/components/calendar-navigation.js +71 -31
  13. package/lib/components/calendar-navigation.js.map +1 -1
  14. package/lib/components/calendar.d.ts +14 -1
  15. package/lib/components/calendar.d.ts.map +1 -1
  16. package/lib/components/calendar.js +65 -4
  17. package/lib/components/calendar.js.map +1 -1
  18. package/lib/components/checkbox.d.ts +8 -0
  19. package/lib/components/checkbox.d.ts.map +1 -1
  20. package/lib/components/checkbox.js +48 -27
  21. package/lib/components/checkbox.js.map +1 -1
  22. package/lib/components/copy.d.ts +10 -0
  23. package/lib/components/copy.d.ts.map +1 -1
  24. package/lib/components/copy.js +25 -4
  25. package/lib/components/copy.js.map +1 -1
  26. package/lib/components/date-picker.d.ts +14 -0
  27. package/lib/components/date-picker.d.ts.map +1 -1
  28. package/lib/components/date-picker.js +29 -0
  29. package/lib/components/date-picker.js.map +1 -1
  30. package/lib/components/input.d.ts +6 -0
  31. package/lib/components/input.d.ts.map +1 -1
  32. package/lib/components/input.js +30 -0
  33. package/lib/components/input.js.map +1 -1
  34. package/lib/components/modal.js +6 -0
  35. package/lib/components/modal.js.map +1 -1
  36. package/lib/components/option.d.ts +0 -16
  37. package/lib/components/option.d.ts.map +1 -1
  38. package/lib/components/option.js +9 -1
  39. package/lib/components/option.js.map +1 -1
  40. package/lib/components/radio.d.ts +1 -0
  41. package/lib/components/radio.d.ts.map +1 -1
  42. package/lib/components/radio.js +37 -8
  43. package/lib/components/radio.js.map +1 -1
  44. package/lib/components/{multiselect.d.ts → select.d.ts} +110 -67
  45. package/lib/components/select.d.ts.map +1 -0
  46. package/lib/components/{multiselect.js → select.js} +642 -490
  47. package/lib/components/select.js.map +1 -0
  48. package/lib/components/selected-tags.d.ts +80 -0
  49. package/lib/components/selected-tags.d.ts.map +1 -0
  50. package/lib/components/selected-tags.js +250 -0
  51. package/lib/components/selected-tags.js.map +1 -0
  52. package/lib/components/switch.d.ts.map +1 -1
  53. package/lib/components/switch.js +19 -7
  54. package/lib/components/switch.js.map +1 -1
  55. package/lib/components/tabs.d.ts.map +1 -1
  56. package/lib/components/tabs.js +10 -0
  57. package/lib/components/tabs.js.map +1 -1
  58. package/lib/index.d.ts +2 -2
  59. package/lib/index.d.ts.map +1 -1
  60. package/lib/index.js +2 -2
  61. package/lib/index.js.map +1 -1
  62. package/lib/styles/calendar-month.d.ts +1 -1
  63. package/lib/styles/calendar-month.d.ts.map +1 -1
  64. package/lib/styles/calendar-month.js +9 -1
  65. package/lib/styles/calendar-month.js.map +1 -1
  66. package/lib/styles/calendar-navigation.d.ts +1 -1
  67. package/lib/styles/calendar-navigation.d.ts.map +1 -1
  68. package/lib/styles/calendar-navigation.js +7 -1
  69. package/lib/styles/calendar-navigation.js.map +1 -1
  70. package/lib/styles/checkbox.d.ts +5 -5
  71. package/lib/styles/checkbox.d.ts.map +1 -1
  72. package/lib/styles/checkbox.js +147 -8
  73. package/lib/styles/checkbox.js.map +1 -1
  74. package/lib/styles/copy.d.ts +1 -1
  75. package/lib/styles/copy.d.ts.map +1 -1
  76. package/lib/styles/copy.js +32 -0
  77. package/lib/styles/copy.js.map +1 -1
  78. package/lib/styles/input.d.ts +1 -1
  79. package/lib/styles/input.d.ts.map +1 -1
  80. package/lib/styles/input.js +28 -298
  81. package/lib/styles/input.js.map +1 -1
  82. package/lib/styles/option.d.ts +1 -1
  83. package/lib/styles/option.d.ts.map +1 -1
  84. package/lib/styles/option.js +29 -5
  85. package/lib/styles/option.js.map +1 -1
  86. package/lib/styles/radio.d.ts +1 -1
  87. package/lib/styles/radio.d.ts.map +1 -1
  88. package/lib/styles/select.d.ts +12 -0
  89. package/lib/styles/select.d.ts.map +1 -0
  90. package/lib/styles/select.js +245 -0
  91. package/lib/styles/select.js.map +1 -0
  92. package/lib/styles/switch.d.ts +1 -1
  93. package/lib/styles/switch.d.ts.map +1 -1
  94. package/lib/styles/textarea.d.ts +1 -1
  95. package/lib/styles/textarea.d.ts.map +1 -1
  96. package/lib/styles/textarea.js +1 -1
  97. package/lib/utils/calendar-utils.d.ts +0 -48
  98. package/lib/utils/calendar-utils.d.ts.map +1 -1
  99. package/lib/utils/calendar-utils.js +0 -69
  100. package/lib/utils/calendar-utils.js.map +1 -1
  101. package/lib/utils/icon-registry.d.ts +0 -13
  102. package/lib/utils/icon-registry.d.ts.map +1 -1
  103. package/lib/utils/icon-registry.js +0 -35
  104. package/lib/utils/icon-registry.js.map +1 -1
  105. package/lib/utils/locale.d.ts +0 -57
  106. package/lib/utils/locale.d.ts.map +1 -1
  107. package/lib/utils/locale.js +0 -89
  108. package/lib/utils/locale.js.map +1 -1
  109. package/lib/utils/number-format.d.ts +0 -21
  110. package/lib/utils/number-format.d.ts.map +1 -1
  111. package/lib/utils/number-format.js +0 -27
  112. package/lib/utils/number-format.js.map +1 -1
  113. package/lib/utils/positioning.d.ts +0 -24
  114. package/lib/utils/positioning.d.ts.map +1 -1
  115. package/lib/utils/positioning.js +3 -86
  116. package/lib/utils/positioning.js.map +1 -1
  117. package/lib/utils/scroll-lock.d.ts.map +1 -1
  118. package/lib/utils/scroll-lock.js +0 -1
  119. package/lib/utils/scroll-lock.js.map +1 -1
  120. package/lib/utils/styles.d.ts +0 -5
  121. package/lib/utils/styles.d.ts.map +1 -1
  122. package/lib/utils/styles.js +0 -7
  123. package/lib/utils/styles.js.map +1 -1
  124. package/lib/version.d.ts +1 -1
  125. package/lib/version.js +1 -1
  126. package/package.json +8 -8
  127. package/css/tyrell-simple.css +0 -257
  128. package/lib/components/dropdown.d.ts +0 -424
  129. package/lib/components/dropdown.d.ts.map +0 -1
  130. package/lib/components/dropdown.js +0 -1640
  131. package/lib/components/dropdown.js.map +0 -1
  132. package/lib/components/multiselect.d.ts.map +0 -1
  133. package/lib/components/multiselect.js.map +0 -1
  134. package/lib/styles/dropdown.d.ts +0 -12
  135. package/lib/styles/dropdown.d.ts.map +0 -1
  136. package/lib/styles/dropdown.js +0 -1081
  137. package/lib/styles/dropdown.js.map +0 -1
@@ -1,52 +1,51 @@
1
1
  /**
2
- * TyMultiselect Web Component
3
- * PORTED FROM: clj/ty/components/multiselect.cljs
2
+ * TySelect Web Component
4
3
  *
5
- * A multiselect dropdown component using ty-tag for selections with:
6
- * - Tag-only options (only ty-tag elements supported)
7
- * - Multiple selection with visual tags
8
- * - Desktop mode with smart positioning
9
- * - Mobile mode with full-screen modal
10
- * - Search and filtering capabilities
11
- * - Keyboard navigation
12
- * - Form association for native form submission with multiple values
13
- * - Scroll locking when dropdown is open
14
- * - Outside click to close
4
+ * The select control — replaces ty-dropdown and ty-multiselect.
5
+ *
6
+ * Cardinality (native <select> semantics):
7
+ * - default: SINGLE select — scalar value, picking an option closes the popup
8
+ * - `multiple`: multi select — comma value / repeated FormData entries,
9
+ * options toggle and the popup stays open
10
+ *
11
+ * Skins:
12
+ * - default: form FIELD — full width, --ty-input-* tokens, matches ty-input;
13
+ * shows the selected label(s) inline
14
+ * - `compact`: content-hugging trigger for toolbars/filter bars; single shows
15
+ * the selected label, multiple shows placeholder + count badge (pair with
16
+ * <ty-selected-tags>)
17
+ * - slot="trigger": consumer skin — replaces field/compact chrome entirely,
18
+ * behavior/ARIA/form participation unchanged
19
+ *
20
+ * - ty-option children (ty-tag also accepted)
21
+ * - Desktop popup with smart positioning, mobile full-screen modal
22
+ * - Search / external-search / debounce, keyboard navigation
23
+ * - Form association: single submits one entry, multiple submits repeated
24
+ * `name=` entries (HTMX-ready)
25
+ * - change event detail: { value, values, items: [{value, label, flavor}], action, item }
26
+ * (`value` is a scalar for single, array for multiple)
15
27
  *
16
28
  * @example
17
29
  * ```html
18
- * <!-- Basic multiselect -->
19
- * <ty-multiselect label="Skills" placeholder="Select skills" required>
20
- * <ty-tag value="js">JavaScript</ty-tag>
21
- * <ty-tag value="ts">TypeScript</ty-tag>
22
- * <ty-tag value="py">Python</ty-tag>
23
- * </ty-multiselect>
30
+ * <!-- Single select, field skin -->
31
+ * <ty-select label="Robot" name="robot">
32
+ * <ty-option value="bobo">Bobo Robot</ty-option>
33
+ * <ty-option value="eywa">EYWA Dataset Example</ty-option>
34
+ * </ty-select>
24
35
  *
25
- * <!-- With pre-selected values -->
26
- * <ty-multiselect value="js,ts">
27
- * <ty-tag value="js">JavaScript</ty-tag>
28
- * <ty-tag value="ts">TypeScript</ty-tag>
29
- * <ty-tag value="py">Python</ty-tag>
30
- * </ty-multiselect>
31
- *
32
- * <!-- With rich tag content -->
33
- * <ty-multiselect label="Team Members">
34
- * <ty-tag value="1" flavor="primary">
35
- * <div class="flex items-center gap-2">
36
- * <img src="avatar1.jpg" class="w-6 h-6 rounded-full" />
37
- * <span>John Doe</span>
38
- * </div>
39
- * </ty-tag>
40
- * </ty-multiselect>
36
+ * <!-- Multi select, compact skin + out-of-band chips -->
37
+ * <ty-select multiple compact label="Robots" name="robots" id="robots">...</ty-select>
38
+ * <ty-selected-tags for="robots"></ty-selected-tags>
41
39
  * ```
42
40
  */
43
- import { ensureStyles } from '../utils/styles.js';
44
- import { multiselectStyles } from '../styles/multiselect.js';
45
- import { getLoaderSvg } from '../utils/loader-registry.js';
46
- import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
47
- import { isMobileTouch } from '../utils/mobile.js';
48
- import { TyComponent } from '../base/ty-component.js';
49
- import { CustomScrollbar, isCustomScrollbarEnabled } from '../utils/custom-scrollbar.js';
41
+ import { ensureStyles } from "../utils/styles.js";
42
+ import { multiselectStyles } from "../styles/multiselect.js";
43
+ import { selectStyles } from "../styles/select.js";
44
+ import { getLoaderSvg } from "../utils/loader-registry.js";
45
+ import { lockScroll, unlockScroll } from "../utils/scroll-lock.js";
46
+ import { isMobileTouch } from "../utils/mobile.js";
47
+ import { TyComponent } from "../base/ty-component.js";
48
+ import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scrollbar.js";
50
49
  // ============================================================================
51
50
  // Element Hash Utility (for consistent scroll lock IDs)
52
51
  // ============================================================================
@@ -87,41 +86,53 @@ const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" he
87
86
  const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
88
87
  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
89
88
  </svg>`;
89
+ /**
90
+ * 'auto' searchable mode: option count above which the search row appears.
91
+ * Short lists scan faster than they search — native <select> has no search.
92
+ */
93
+ const SEARCH_AUTO_THRESHOLD = 7;
94
+ /**
95
+ * Search (magnifier) icon SVG — popup search input adornment (from Lucide)
96
+ */
97
+ const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
98
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
99
+ </svg>`;
90
100
  /**
91
101
  * Ty Multiselect Component
92
102
  */
93
- export class TyMultiselect extends TyComponent {
103
+ export class TySelect extends TyComponent {
94
104
  constructor() {
95
105
  super(); // TyComponent handles attachInternals() and attachShadow()
96
106
  // ============================================================================
97
107
  // INTERNAL STATE
98
108
  // ============================================================================
99
- this._name = '';
100
- this._placeholder = 'Select options...';
101
- this._label = '';
109
+ this._name = "";
110
+ this._multiple = false;
111
+ this._compact = false;
112
+ this._placeholder = "Select...";
113
+ this._label = "";
102
114
  this._disabled = false;
103
115
  this._readonly = false;
104
116
  this._required = false;
105
117
  this._externalSearch = false;
118
+ this._searchable = "auto";
106
119
  this._loading = false;
107
120
  this._scrollLockId = null;
108
- this._size = 'md';
109
- this._selectedLabel = 'Selected';
110
- this._availableLabel = 'Available';
111
- this._noOptionsMessage = 'No options available';
121
+ this._size = "md";
122
+ this._availableLabel = "Available";
123
+ this._noOptionsMessage = "No options available";
112
124
  // Component state
113
125
  this._state = {
114
126
  open: false,
115
- search: '',
127
+ search: "",
116
128
  highlightedIndex: -1,
117
129
  filteredTags: [],
118
130
  selectedValues: [],
119
- mode: 'desktop' // Updated dynamically on render via syncMode()
131
+ mode: "desktop", // Updated dynamically on render via syncMode()
120
132
  };
121
133
  // Event handler references for cleanup
122
134
  this._stubClickHandler = null;
123
135
  this._tagClickHandler = null;
124
- this._tagDismissHandler = null;
125
136
  this._searchInputHandler = null;
126
137
  this._blockSearchClick = null;
127
138
  this._keyboardHandler = null;
@@ -133,8 +144,12 @@ export class TyMultiselect extends TyComponent {
133
144
  // MutationObserver for light-DOM children — re-syncs selected tags' visual
134
145
  // state when consumers swap tag children (external-search refresh pattern).
135
146
  this._childObserver = null;
147
+ // Last single-select display clone — survives external-search option swaps
148
+ // that remove the matching option (and often the clone itself) from the DOM.
149
+ this._selectedClone = null;
136
150
  const shadow = this.shadowRoot;
137
- ensureStyles(shadow, { css: multiselectStyles, id: 'ty-multiselect' });
151
+ ensureStyles(shadow, { css: multiselectStyles, id: "ty-select-base" });
152
+ ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
138
153
  // DON'T render here - wait for onConnect() to initialize values first
139
154
  // This matches dropdown.ts pattern and prevents showing empty state
140
155
  }
@@ -145,10 +160,10 @@ export class TyMultiselect extends TyComponent {
145
160
  onConnect() {
146
161
  // SAFETY: Close any open dialogs to prevent scroll locking
147
162
  const shadow = this.shadowRoot;
148
- const dialogs = shadow.querySelectorAll('dialog');
149
- dialogs.forEach(dialog => {
163
+ const dialogs = shadow.querySelectorAll("dialog");
164
+ dialogs.forEach((dialog) => {
150
165
  if (dialog.open) {
151
- console.warn('⚠️ Found open dialog on connect, closing it');
166
+ console.warn("⚠️ Found open dialog on connect, closing it");
152
167
  dialog.close();
153
168
  }
154
169
  });
@@ -167,6 +182,12 @@ export class TyMultiselect extends TyComponent {
167
182
  if (this._state.selectedValues.length > 0) {
168
183
  this.syncSelectedTags(this._state.selectedValues);
169
184
  }
185
+ // Option count may have crossed the 'auto' searchable threshold
186
+ this.updateSearchVisibility();
187
+ // External-search consumers replace ALL ty-option children — often
188
+ // including our display clone. Rebuild the selection display (idempotent:
189
+ // a clone that already matches is left alone, so no observer loop).
190
+ this.updateSelectionDisplay();
170
191
  });
171
192
  this._childObserver.observe(this, { childList: true });
172
193
  }
@@ -177,15 +198,15 @@ export class TyMultiselect extends TyComponent {
177
198
  onDisconnect() {
178
199
  // CRITICAL: Close all dialogs to prevent scroll locking
179
200
  const shadow = this.shadowRoot;
180
- const dialogs = shadow.querySelectorAll('dialog');
181
- dialogs.forEach(dialog => {
201
+ const dialogs = shadow.querySelectorAll("dialog");
202
+ dialogs.forEach((dialog) => {
182
203
  if (dialog.open) {
183
204
  dialog.close();
184
205
  }
185
206
  });
186
207
  // Clean up document-level listeners
187
208
  if (this._keyboardHandler) {
188
- document.removeEventListener('keydown', this._keyboardHandler);
209
+ document.removeEventListener("keydown", this._keyboardHandler);
189
210
  this._keyboardHandler = null;
190
211
  }
191
212
  // Clear any pending debounce timer
@@ -208,7 +229,7 @@ export class TyMultiselect extends TyComponent {
208
229
  onPropertiesChanged(changes) {
209
230
  for (const { name, newValue } of changes) {
210
231
  switch (name) {
211
- case 'value':
232
+ case "value":
212
233
  const selectedValues = this.parseValue(newValue);
213
234
  this._state.selectedValues = selectedValues;
214
235
  // CRITICAL: Only sync tags if we're connected and tags exist
@@ -218,48 +239,59 @@ export class TyMultiselect extends TyComponent {
218
239
  this.updateSelectionDisplay();
219
240
  this.updateMobileSelectedState();
220
241
  }
221
- else {
222
- console.warn('💜 [multiselect] onPropertiesChanged - NOT connected yet, skipping sync (will happen in initializeState)');
242
+ // (not-yet-connected: sync deferred to initializeState)
243
+ break;
244
+ case "name":
245
+ this._name = newValue || "";
246
+ break;
247
+ case "multiple":
248
+ this._multiple = newValue;
249
+ // Dropping to single with a multi selection: keep only the first
250
+ if (!newValue && this._state.selectedValues.length > 1) {
251
+ this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
223
252
  }
224
253
  break;
225
- case 'name':
226
- this._name = newValue || '';
254
+ case "compact":
255
+ this._compact = newValue;
227
256
  break;
228
- case 'placeholder':
229
- this._placeholder = newValue || 'Select options...';
230
- // No need to update display - placeholder text change doesn't affect visibility
257
+ case "placeholder":
258
+ this._placeholder = newValue || "Select...";
259
+ if (this.isConnected && this.shadowRoot)
260
+ this.updateSelectionDisplay();
231
261
  break;
232
- case 'label':
233
- this._label = newValue || '';
262
+ case "label":
263
+ this._label = newValue || "";
234
264
  break;
235
- case 'disabled':
265
+ case "disabled":
236
266
  this._disabled = newValue;
237
267
  break;
238
- case 'readonly':
268
+ case "readonly":
239
269
  this._readonly = newValue;
240
270
  break;
241
- case 'required':
271
+ case "required":
242
272
  this._required = newValue;
243
273
  break;
244
- case 'externalSearch':
274
+ case "externalSearch":
245
275
  this._externalSearch = newValue;
246
276
  break;
247
- case 'size':
277
+ case "searchable":
278
+ this._searchable = newValue;
279
+ if (this.isConnected && this.shadowRoot)
280
+ this.updateSearchVisibility();
281
+ break;
282
+ case "size":
248
283
  this._size = newValue;
249
284
  break;
250
- case 'debounce':
285
+ case "debounce":
251
286
  this._debounce = newValue;
252
287
  break;
253
- case 'selected-label':
254
- this._selectedLabel = newValue || 'Selected';
255
- break;
256
- case 'available-label':
257
- this._availableLabel = newValue || 'Available';
288
+ case "available-label":
289
+ this._availableLabel = newValue || "Available";
258
290
  break;
259
- case 'no-options-message':
260
- this._noOptionsMessage = newValue || 'No options available';
291
+ case "no-options-message":
292
+ this._noOptionsMessage = newValue || "No options available";
261
293
  break;
262
- case 'loading':
294
+ case "loading":
263
295
  this._loading = newValue;
264
296
  this.applyLoadingState();
265
297
  break;
@@ -277,16 +309,16 @@ export class TyMultiselect extends TyComponent {
277
309
  if (!shadow)
278
310
  return;
279
311
  const svg = this._loading ? getLoaderSvg() : null;
280
- shadow.querySelectorAll('.dropdown-options-wrapper').forEach((wrapper) => {
281
- wrapper.classList.toggle('loading', this._loading);
312
+ shadow.querySelectorAll(".dropdown-options-wrapper").forEach((wrapper) => {
313
+ wrapper.classList.toggle("loading", this._loading);
282
314
  if (this._loading) {
283
- wrapper.setAttribute('aria-busy', 'true');
284
- const spinner = wrapper.querySelector('.dropdown-loading-spinner');
315
+ wrapper.setAttribute("aria-busy", "true");
316
+ const spinner = wrapper.querySelector(".dropdown-loading-spinner");
285
317
  if (spinner && svg)
286
318
  spinner.innerHTML = svg;
287
319
  }
288
320
  else {
289
- wrapper.removeAttribute('aria-busy');
321
+ wrapper.removeAttribute("aria-busy");
290
322
  }
291
323
  });
292
324
  }
@@ -297,8 +329,12 @@ export class TyMultiselect extends TyComponent {
297
329
  getFormValue() {
298
330
  const selectedValues = this._state.selectedValues;
299
331
  if (this._name && selectedValues.length > 0) {
332
+ // Single select submits one plain entry
333
+ if (!this._multiple)
334
+ return selectedValues[0];
335
+ // Multiple submits repeated `name=` entries (HTMX standard)
300
336
  const formData = new FormData();
301
- selectedValues.forEach(value => {
337
+ selectedValues.forEach((value) => {
302
338
  formData.append(this._name, value);
303
339
  });
304
340
  return formData;
@@ -310,16 +346,19 @@ export class TyMultiselect extends TyComponent {
310
346
  */
311
347
  parseValue(value) {
312
348
  // Defensive check: ensure value is actually a string before calling .trim()
313
- if (!value || typeof value !== 'string' || value.trim() === '')
349
+ if (!value || typeof value !== "string" || value.trim() === "")
314
350
  return [];
315
- return value.split(',').map(v => v.trim()).filter(v => v !== '');
351
+ return value
352
+ .split(",")
353
+ .map((v) => v.trim())
354
+ .filter((v) => v !== "");
316
355
  }
317
356
  /**
318
357
  * Initialize component state from attributes
319
358
  * Reads from both property and attribute (like ClojureScript version)
320
359
  */
321
360
  initializeState() {
322
- const initialValue = this.getProperty('value') || '';
361
+ const initialValue = this.getProperty("value") || "";
323
362
  if (initialValue) {
324
363
  // Explicit value provided - sync tags directly
325
364
  // DON'T use updateComponentValue() because the property is already set!
@@ -336,8 +375,8 @@ export class TyMultiselect extends TyComponent {
336
375
  // No explicit value - check for pre-selected tags
337
376
  const allTags = this.getTagElements();
338
377
  const preSelectedTags = allTags
339
- .filter(tag => tag.hasAttribute('selected'))
340
- .map(tag => this.getTagData(tag).value);
378
+ .filter((tag) => tag.hasAttribute("selected"))
379
+ .map((tag) => this.getTagData(tag).value);
341
380
  if (preSelectedTags.length > 0) {
342
381
  // Set the property value and sync (updateComponentValue handles everything)
343
382
  this.updateComponentValue(preSelectedTags, false);
@@ -348,78 +387,48 @@ export class TyMultiselect extends TyComponent {
348
387
  // TAG MANAGEMENT METHODS (Phase 2)
349
388
  // ============================================================================
350
389
  /**
351
- * Get all ty-tag elements from the component (ALL slots)
390
+ * Get all option elements from the component (ty-option preferred, ty-tag accepted)
352
391
  */
353
392
  getTagElements() {
354
- // Get ALL ty-tag children, regardless of slot assignment
355
- const tags = Array.from(this.querySelectorAll('ty-tag'));
356
- return tags;
393
+ // :not([cloned]) — the single-select display clone (slot="selected")
394
+ // lives in our light DOM too and must never count as an option
395
+ return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
357
396
  }
358
397
  /**
359
398
  * Extract value and text from a ty-tag element
360
399
  */
361
400
  getTagData(element) {
362
401
  // Get value from either property or attribute
363
- const value = element.value || element.getAttribute('value') || element.textContent || '';
364
- const text = element.textContent || '';
402
+ const value = element.value ||
403
+ element.getAttribute("value") ||
404
+ element.textContent ||
405
+ "";
406
+ // Display label: explicit label attribute wins over textContent —
407
+ // native <option label> semantics; lets rich options declare clean text
408
+ const text = element.getAttribute("label") || element.textContent || "";
365
409
  return { value, text, element };
366
410
  }
367
411
  /**
368
- * Select a tag - set selected state, move to selected slot, make dismissible
412
+ * Select an option - attribute only. The option stays in the list with
413
+ * selected styling; chips are rendered out-of-band (ty-selected-tags).
369
414
  */
370
415
  selectTag(tag) {
371
- const tagValue = this.getTagData(tag).value;
372
- // Set selected attribute
373
- tag.setAttribute('selected', '');
374
- // Move to selected slot
375
- tag.setAttribute('slot', 'selected');
376
- // Make dismissible
377
- tag.setAttribute('dismissible', 'true');
378
- // Force re-slotting by removing and re-adding
379
- const parent = tag.parentNode;
380
- if (parent) {
381
- parent.removeChild(tag);
382
- parent.appendChild(tag);
383
- }
384
- else {
385
- console.warn(`⚠️ [multiselect] - No parent for "${tagValue}"!`);
386
- }
416
+ tag.setAttribute("selected", "");
387
417
  }
388
418
  /**
389
- * Deselect a tag - remove selected state, remove from selected slot, remove dismissible
419
+ * Deselect an option - attribute only.
390
420
  */
391
421
  deselectTag(tag) {
392
- // Remove selected attribute
393
- tag.removeAttribute('selected');
394
- // Remove from selected slot
395
- tag.removeAttribute('slot');
396
- // Remove dismissible
397
- tag.removeAttribute('dismissible');
398
- // Force re-slotting by removing and re-adding
399
- const parent = tag.parentNode;
400
- if (parent) {
401
- parent.removeChild(tag);
402
- parent.appendChild(tag);
403
- }
422
+ tag.removeAttribute("selected");
404
423
  }
405
424
  /**
406
425
  * Get array of currently selected values from tags (ALWAYS reads from DOM)
407
426
  */
408
427
  getSelectedValues() {
409
428
  return this.getTagElements()
410
- .filter(tag => tag.hasAttribute('selected'))
411
- .map(tag => this.getTagData(tag).value)
412
- .filter(value => value !== '');
413
- }
414
- /**
415
- * Check if all available tags are selected
416
- */
417
- allTagsSelected() {
418
- const tags = this.getTagElements();
419
- if (tags.length === 0)
420
- return false;
421
- const selectedCount = tags.filter(tag => tag.hasAttribute('selected')).length;
422
- return selectedCount === tags.length;
429
+ .filter((tag) => tag.hasAttribute("selected"))
430
+ .map((tag) => this.getTagData(tag).value)
431
+ .filter((value) => value !== "");
423
432
  }
424
433
  /**
425
434
  * Sync tag selection states with desired values
@@ -427,10 +436,10 @@ export class TyMultiselect extends TyComponent {
427
436
  syncSelectedTags(selectedValues) {
428
437
  const selectedSet = new Set(selectedValues);
429
438
  const tags = this.getTagElements();
430
- tags.forEach(tag => {
439
+ tags.forEach((tag) => {
431
440
  const tagValue = this.getTagData(tag).value;
432
441
  const shouldBeSelected = selectedSet.has(tagValue);
433
- const isSelected = tag.hasAttribute('selected');
442
+ const isSelected = tag.hasAttribute("selected");
434
443
  if (shouldBeSelected && !isSelected) {
435
444
  this.selectTag(tag);
436
445
  }
@@ -443,9 +452,9 @@ export class TyMultiselect extends TyComponent {
443
452
  * Central update function - synchronizes everything
444
453
  * Uses TyComponent's property system for proper lifecycle
445
454
  */
446
- updateComponentValue(newValues, dispatchChange = false, action = 'set', item = null) {
455
+ updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
447
456
  const oldValues = this.getSelectedValues();
448
- const valueStr = newValues.join(',');
457
+ const valueStr = newValues.join(",");
449
458
  // Only update if changed
450
459
  if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
451
460
  // Use TyComponent's property system - this will trigger:
@@ -453,18 +462,34 @@ export class TyMultiselect extends TyComponent {
453
462
  // 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
454
463
  // 3. updateFormValue() → automatic (formValue: true in config)
455
464
  // 4. render() → automatic if visual properties changed
456
- this.setProperty('value', valueStr);
457
- // Dispatch custom multiselect change event (with action/item details)
465
+ this.setProperty("value", valueStr);
466
+ // Dispatch change event (with rich items for out-of-band chip rendering)
458
467
  if (dispatchChange) {
459
468
  this.dispatchChangeEvent({
469
+ // Scalar for single select, array for multiple
470
+ value: this._multiple ? [...newValues] : (newValues[0] ?? null),
460
471
  values: newValues,
472
+ items: this.getSelectedItems(newValues),
461
473
  action,
462
- item
474
+ item,
463
475
  });
464
476
  }
465
477
  }
466
- else {
467
- }
478
+ }
479
+ /**
480
+ * Build rich item info for values, read off the matching option elements
481
+ */
482
+ getSelectedItems(values) {
483
+ const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
484
+ return values.map((value) => {
485
+ const el = byValue.get(value);
486
+ return {
487
+ value,
488
+ // label attribute wins (native <option label> semantics)
489
+ label: el?.getAttribute("label") || el?.textContent?.trim() || value,
490
+ flavor: el?.getAttribute("flavor") ?? null,
491
+ };
492
+ });
468
493
  }
469
494
  // ============================================================================
470
495
  // DROPDOWN METHODS (Phase 3 & 4)
@@ -474,50 +499,61 @@ export class TyMultiselect extends TyComponent {
474
499
  */
475
500
  calculatePosition() {
476
501
  const shadow = this.shadowRoot;
477
- const stub = shadow.querySelector('.multiselect-stub');
478
- const dialog = shadow.querySelector('.dropdown-dialog');
502
+ const stub = shadow.querySelector(".multiselect-stub");
503
+ const dialog = shadow.querySelector(".dropdown-dialog");
479
504
  if (!stub || !dialog)
480
505
  return;
481
506
  const stubRect = stub.getBoundingClientRect();
482
507
  const viewportHeight = window.innerHeight;
483
508
  const viewportWidth = window.innerWidth;
484
- // Get dialog dimensions (it's already shown with showModal)
509
+ // Get dialog dimensions (it's already shown with showModal).
510
+ // Cap the estimate: with hundreds of options the raw measurement can be
511
+ // the uncapped content height, which made "fits below?" always false.
512
+ // 440px ≈ options max-height (24rem) + search header.
513
+ const MAX_POPUP_ESTIMATE = 440;
485
514
  const dialogRect = dialog.getBoundingClientRect();
486
- const estimatedHeight = dialogRect.height || 200;
515
+ const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
487
516
  const padding = 8;
488
517
  const wrapPadding = 20;
518
+ // Popup width is independent of the trigger — a small button trigger still
519
+ // gets a usable list. Overridable via --ty-select-popup-width.
520
+ const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
521
+ const popupWidth = !isNaN(cssWidth)
522
+ ? cssWidth
523
+ : Math.max(stubRect.width, 320);
489
524
  // Available space calculations
490
525
  const spaceBelow = viewportHeight - stubRect.bottom;
491
- const spaceRight = viewportWidth - stubRect.left;
492
- // Smart direction logic
493
- const positionBelow = spaceBelow >= estimatedHeight + padding;
494
- const fitsHorizontally = spaceRight >= stubRect.width;
495
- // Calculate position coordinates
496
- const x = fitsHorizontally
497
- ? stubRect.left - wrapPadding
498
- : Math.max(padding, viewportWidth - stubRect.width - padding);
526
+ const spaceAbove = stubRect.top;
527
+ // Smart direction logic: below when it fits — and when neither side
528
+ // fits, take the side with MORE room instead of blindly flipping up
529
+ // (a field near the viewport top used to clip the popup off-screen).
530
+ const positionBelow = spaceBelow >= estimatedHeight + padding || spaceBelow >= spaceAbove;
531
+ // Anchor to the trigger's left edge, clamped into the viewport
532
+ const x = Math.max(padding - wrapPadding, Math.min(stubRect.left - wrapPadding, viewportWidth - popupWidth - wrapPadding - padding));
533
+ // Open below (or above) the trigger — never covering it
534
+ const gap = 4;
499
535
  const y = positionBelow
500
- ? stubRect.top - wrapPadding
501
- : viewportHeight - stubRect.bottom - wrapPadding;
502
- const width = stubRect.width + wrapPadding + wrapPadding;
536
+ ? stubRect.bottom + gap - wrapPadding
537
+ : viewportHeight - stubRect.top + gap - wrapPadding;
538
+ const width = popupWidth + wrapPadding + wrapPadding;
503
539
  // Set CSS variables for positioning
504
- this.style.setProperty('--dropdown-x', `${x}px`);
505
- this.style.setProperty('--dropdown-y', `${y}px`);
506
- this.style.setProperty('--dropdown-width', `${width}px`);
507
- this.style.setProperty('--dropdown-offset-x', '0px');
508
- this.style.setProperty('--dropdown-offset-y', '0px');
509
- this.style.setProperty('--dropdown-padding', `${wrapPadding}px`);
540
+ this.style.setProperty("--dropdown-x", `${x}px`);
541
+ this.style.setProperty("--dropdown-y", `${y}px`);
542
+ this.style.setProperty("--dropdown-width", `${width}px`);
543
+ this.style.setProperty("--dropdown-offset-x", "0px");
544
+ this.style.setProperty("--dropdown-offset-y", "0px");
545
+ this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
510
546
  // Set direction classes for CSS styling
511
547
  if (positionBelow) {
512
- dialog.classList.add('position-below');
513
- dialog.classList.remove('position-above');
548
+ dialog.classList.add("position-below");
549
+ dialog.classList.remove("position-above");
514
550
  }
515
551
  else {
516
- dialog.classList.add('position-above');
517
- dialog.classList.remove('position-below');
552
+ dialog.classList.add("position-above");
553
+ dialog.classList.remove("position-below");
518
554
  }
519
555
  // Optional: Store direction for debugging
520
- this.style.setProperty('--dropdown-direction', positionBelow ? 'below' : 'above');
556
+ this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
521
557
  }
522
558
  // ============================================================================
523
559
  // CUSTOM SCROLLBAR FOR OPTIONS
@@ -526,13 +562,15 @@ export class TyMultiselect extends TyComponent {
526
562
  if (!isCustomScrollbarEnabled())
527
563
  return;
528
564
  const shadow = this.shadowRoot;
529
- const optionsDiv = shadow.querySelector('.dropdown-options');
530
- const optionsWrapper = shadow.querySelector('.dropdown-options-wrapper');
565
+ const optionsDiv = shadow.querySelector(".dropdown-options");
566
+ const optionsWrapper = shadow.querySelector(".dropdown-options-wrapper");
531
567
  if (!optionsDiv || !optionsWrapper)
532
568
  return;
533
569
  this._destroyOptionsScrollbar();
534
- optionsDiv.classList.add('ty-custom-scroll');
535
- this._optionsScrollbar = new CustomScrollbar(optionsDiv, { vertical: true });
570
+ optionsDiv.classList.add("ty-custom-scroll");
571
+ this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
572
+ vertical: true,
573
+ });
536
574
  if (this._optionsScrollbar.trackY) {
537
575
  optionsWrapper.appendChild(this._optionsScrollbar.trackY);
538
576
  }
@@ -555,38 +593,38 @@ export class TyMultiselect extends TyComponent {
555
593
  */
556
594
  openDropdown() {
557
595
  const shadow = this.shadowRoot;
558
- const dialog = shadow.querySelector('.dropdown-dialog');
596
+ const dialog = shadow.querySelector(".dropdown-dialog");
559
597
  if (!dialog)
560
598
  return;
561
599
  // Lock body scroll while dropdown is open
562
- const lockId = `multiselect-${this.id || 'anon'}-${getElementHash(this)}`;
600
+ const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
563
601
  this._scrollLockId = lockId;
564
602
  lockScroll(lockId);
565
603
  // Show modal
566
604
  dialog.showModal();
567
- dialog.classList.add('open');
605
+ dialog.classList.add("open");
568
606
  // Position dropdown AFTER showing modal
569
607
  this.calculatePosition();
570
608
  // Update component state
571
609
  this._state.open = true;
572
610
  // Update visual states
573
- const chevron = shadow.querySelector('.dropdown-chevron');
611
+ const chevron = shadow.querySelector(".dropdown-chevron");
574
612
  if (chevron)
575
- chevron.classList.add('open');
576
- const searchChevron = shadow.querySelector('.dropdown-search-chevron');
613
+ chevron.classList.add("open");
614
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
577
615
  if (searchChevron)
578
- searchChevron.classList.add('open');
616
+ searchChevron.classList.add("open");
579
617
  // Initialize options state
580
- const tags = this.getTagElements().map(el => this.getTagData(el));
618
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
581
619
  this._state.filteredTags = tags;
582
620
  this._state.highlightedIndex = -1;
583
621
  // Ensure options area is visible (may have been hidden from previous search)
584
622
  this.updateOptionsVisibility(true);
585
623
  // Setup custom scrollbar on options
586
624
  this._setupOptionsScrollbar();
587
- // Focus search input
588
- const searchInput = shadow.querySelector('.dropdown-search-input');
589
- if (searchInput) {
625
+ // Focus search input (when the search row is shown)
626
+ const searchInput = shadow.querySelector(".dropdown-search-input");
627
+ if (searchInput && this.shouldShowSearch()) {
590
628
  setTimeout(() => searchInput.focus(), 100);
591
629
  }
592
630
  // Lifecycle event (also fires empty search if external-search)
@@ -597,15 +635,15 @@ export class TyMultiselect extends TyComponent {
597
635
  */
598
636
  closeDropdown() {
599
637
  const shadow = this.shadowRoot;
600
- const dialog = shadow.querySelector('.dropdown-dialog');
638
+ const dialog = shadow.querySelector(".dropdown-dialog");
601
639
  if (!dialog)
602
640
  return;
603
641
  // Destroy custom scrollbar
604
642
  this._destroyOptionsScrollbar();
605
643
  // Close dialog
606
- dialog.classList.remove('open');
607
- dialog.classList.remove('position-above');
608
- dialog.classList.remove('position-below');
644
+ dialog.classList.remove("open");
645
+ dialog.classList.remove("position-above");
646
+ dialog.classList.remove("position-below");
609
647
  dialog.close();
610
648
  // Unlock body scroll (paired with the lock in openDropdown)
611
649
  if (this._scrollLockId) {
@@ -616,18 +654,18 @@ export class TyMultiselect extends TyComponent {
616
654
  this._state.open = false;
617
655
  this._state.highlightedIndex = -1;
618
656
  // Update visual states
619
- const chevron = shadow.querySelector('.dropdown-chevron');
657
+ const chevron = shadow.querySelector(".dropdown-chevron");
620
658
  if (chevron)
621
- chevron.classList.remove('open');
622
- const searchChevron = shadow.querySelector('.dropdown-search-chevron');
659
+ chevron.classList.remove("open");
660
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
623
661
  if (searchChevron)
624
- searchChevron.classList.remove('open');
662
+ searchChevron.classList.remove("open");
625
663
  // Reset search and restore all tags
626
- const hadQuery = this._state.search !== '';
627
- this._state.search = '';
628
- const searchInput = shadow.querySelector('.dropdown-search-input');
664
+ const hadQuery = this._state.search !== "";
665
+ this._state.search = "";
666
+ const searchInput = shadow.querySelector(".dropdown-search-input");
629
667
  if (searchInput) {
630
- searchInput.value = '';
668
+ searchInput.value = "";
631
669
  }
632
670
  if (this._externalSearch) {
633
671
  // External mode — notify consumer so it can reset its own filtered state.
@@ -639,12 +677,12 @@ export class TyMultiselect extends TyComponent {
639
677
  clearTimeout(this._searchDebounceTimer);
640
678
  this._searchDebounceTimer = null;
641
679
  }
642
- this.fireSearchEvent('');
680
+ this.fireSearchEvent("");
643
681
  }
644
682
  }
645
683
  else {
646
684
  // Internal mode — restore visibility of all tags ourselves
647
- const allTags = this.getTagElements().map(el => this.getTagData(el));
685
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
648
686
  this._state.filteredTags = allTags;
649
687
  this.updateTagVisibility(allTags, allTags);
650
688
  this.clearHighlights(allTags);
@@ -658,28 +696,24 @@ export class TyMultiselect extends TyComponent {
658
696
  */
659
697
  openMobileModal() {
660
698
  const shadow = this.shadowRoot;
661
- const dialog = shadow.querySelector('.mobile-dialog');
699
+ const dialog = shadow.querySelector(".mobile-dialog");
662
700
  if (!dialog)
663
701
  return;
664
702
  // Lock body scroll while mobile modal is open
665
- const lockId = `multiselect-${this.id || 'anon'}-${getElementHash(this)}`;
703
+ const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
666
704
  this._scrollLockId = lockId;
667
705
  lockScroll(lockId);
668
706
  // Show dialog using native API (handles z-index automatically)
669
707
  dialog.showModal();
670
- dialog.classList.add('open');
671
- const stub_slot = shadow.querySelector('#stub-slot');
672
- const mobile_slot = shadow.querySelector('#mobile-slot');
673
- stub_slot.name = "selected-blocked";
674
- mobile_slot.name = "selected";
708
+ dialog.classList.add("open");
675
709
  // Update component state
676
710
  this._state.open = true;
677
711
  // Initialize options state
678
- const tags = this.getTagElements().map(el => this.getTagData(el));
712
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
679
713
  this._state.filteredTags = tags;
680
- // Focus search input
681
- const searchInput = shadow.querySelector('.mobile-search-input');
682
- if (searchInput) {
714
+ // Focus search input (when the search row is shown)
715
+ const searchInput = shadow.querySelector(".mobile-search-input");
716
+ if (searchInput && this.shouldShowSearch()) {
683
717
  // Small delay to ensure dialog is ready
684
718
  setTimeout(() => searchInput.focus(), 100);
685
719
  }
@@ -696,30 +730,26 @@ export class TyMultiselect extends TyComponent {
696
730
  */
697
731
  closeMobileModal() {
698
732
  const shadow = this.shadowRoot;
699
- const dialog = shadow.querySelector('.mobile-dialog');
733
+ const dialog = shadow.querySelector(".mobile-dialog");
700
734
  if (!dialog)
701
735
  return;
702
736
  // Close immediately — ::backdrop doesn't support transitions
703
- dialog.classList.remove('open');
737
+ dialog.classList.remove("open");
704
738
  dialog.close();
705
739
  // Unlock body scroll (paired with the lock in openMobileModal)
706
740
  if (this._scrollLockId) {
707
741
  unlockScroll(this._scrollLockId);
708
742
  this._scrollLockId = null;
709
743
  }
710
- const stub_slot = shadow.querySelector('#stub-slot');
711
- const mobile_slot = shadow.querySelector('#mobile-slot');
712
- stub_slot.name = "selected";
713
- mobile_slot.name = "selected-blocked";
714
744
  // Update state
715
745
  this._state.open = false;
716
746
  this._state.highlightedIndex = -1;
717
747
  // Reset search
718
- const hadQuery = this._state.search !== '';
719
- this._state.search = '';
720
- const searchInput = shadow.querySelector('.mobile-search-input');
748
+ const hadQuery = this._state.search !== "";
749
+ this._state.search = "";
750
+ const searchInput = shadow.querySelector(".mobile-search-input");
721
751
  if (searchInput) {
722
- searchInput.value = '';
752
+ searchInput.value = "";
723
753
  }
724
754
  if (this._externalSearch) {
725
755
  // External mode — notify consumer that the search was cleared on close
@@ -729,13 +759,13 @@ export class TyMultiselect extends TyComponent {
729
759
  clearTimeout(this._searchDebounceTimer);
730
760
  this._searchDebounceTimer = null;
731
761
  }
732
- this.fireSearchEvent('');
762
+ this.fireSearchEvent("");
733
763
  }
734
764
  }
735
765
  else {
736
766
  // Internal mode — unhide all tags ourselves
737
767
  const allTags = this.getTagElements();
738
- allTags.forEach(el => el.removeAttribute('hidden'));
768
+ allTags.forEach((el) => el.removeAttribute("hidden"));
739
769
  }
740
770
  // Lifecycle event
741
771
  this.fireCloseEvent();
@@ -749,51 +779,38 @@ export class TyMultiselect extends TyComponent {
749
779
  if (this._disabled || this._readonly) {
750
780
  return;
751
781
  }
752
- // Don't open if all tags are already selected
753
- if (this.allTagsSelected()) {
754
- return;
755
- }
756
782
  this.openDropdown();
757
783
  }
758
784
  handleTagClick(e) {
759
785
  const target = e.target;
760
- // Find the ty-tag element
761
- const tag = target.tagName === 'TY-TAG'
762
- ? target
763
- : target.closest('ty-tag');
764
- if (!tag || tag.hasAttribute('disabled'))
786
+ // Find the option element (ty-option or ty-tag)
787
+ const tag = target.closest("ty-option, ty-tag");
788
+ if (!tag || tag.hasAttribute("disabled"))
765
789
  return;
766
- if (tag.hasAttribute('selected'))
767
- return; // Already selected
768
790
  e.preventDefault();
769
791
  e.stopPropagation();
770
792
  const tagValue = this.getTagData(tag).value;
771
793
  const currentValues = this.getSelectedValues();
772
- const newValues = [...currentValues, tagValue];
773
- // Use central update function with change event
774
- this.updateComponentValue(newValues, true, 'add', tagValue);
775
- // Auto-close if all tags selected
776
- if (this.allTagsSelected()) {
777
- if (this._state.mode === 'desktop') {
778
- this.closeDropdown();
794
+ // Single select: picking replaces the selection and closes the popup
795
+ // (native <select> semantics — re-picking the same option just closes)
796
+ if (!this._multiple) {
797
+ this.updateComponentValue([tagValue], true, "set", tagValue);
798
+ if (this._state.mode === "mobile") {
799
+ this.closeMobileModal();
779
800
  }
780
801
  else {
781
- this.closeMobileModal();
802
+ this.closeDropdown();
782
803
  }
783
- }
784
- }
785
- handleTagDismiss(e) {
786
- e.preventDefault();
787
- e.stopPropagation();
788
- const customEvent = e;
789
- const tag = customEvent.detail?.target;
790
- if (!tag)
791
804
  return;
792
- const tagValue = this.getTagData(tag).value;
793
- const currentValues = this.getSelectedValues();
794
- const newValues = currentValues.filter(v => v !== tagValue);
795
- // Use central update function with change event
796
- this.updateComponentValue(newValues, true, 'remove', tagValue);
805
+ }
806
+ // Multiple: toggle — clicking a selected option deselects it
807
+ if (tag.hasAttribute("selected")) {
808
+ const newValues = currentValues.filter((v) => v !== tagValue);
809
+ this.updateComponentValue(newValues, true, "remove", tagValue);
810
+ }
811
+ else {
812
+ this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
813
+ }
797
814
  }
798
815
  blockSearchClick(e) {
799
816
  e.stopPropagation();
@@ -810,11 +827,10 @@ export class TyMultiselect extends TyComponent {
810
827
  this.dispatchSearchEvent(query);
811
828
  return;
812
829
  }
813
- // Internal search: filter tags locally
814
- const allTags = this.getTagElements().map(el => this.getTagData(el));
815
- // Only filter from non-selected tags
816
- const availableTags = allTags.filter(t => !t.element.hasAttribute('selected'));
817
- const filtered = this.filterTags(availableTags, query);
830
+ // Internal search: filter ALL options locally — selected ones stay visible
831
+ // (with selected styling) so they can be toggled off while searching
832
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
833
+ const filtered = this.filterTags(allTags, query);
818
834
  // Update state
819
835
  this._state.filteredTags = filtered;
820
836
  this._state.highlightedIndex = -1;
@@ -831,13 +847,12 @@ export class TyMultiselect extends TyComponent {
831
847
  if (!this._state.open)
832
848
  return;
833
849
  const shadow = this.shadowRoot;
834
- const searchInput = shadow.querySelector('.dropdown-search-input');
850
+ const searchInput = shadow.querySelector(".dropdown-search-input");
835
851
  const target = e.target;
836
852
  // Only handle navigation keys when dropdown is open and either:
837
853
  // 1. Event comes from search input, OR
838
854
  // 2. Event comes from document but search input is not focused
839
- const shouldHandle = target === searchInput ||
840
- document.activeElement !== searchInput;
855
+ const shouldHandle = target === searchInput || document.activeElement !== searchInput;
841
856
  if (!shouldHandle)
842
857
  return;
843
858
  // Get current state values
@@ -845,21 +860,22 @@ export class TyMultiselect extends TyComponent {
845
860
  const tagsCount = filteredTags.length;
846
861
  const currentHighlightedIndex = this._state.highlightedIndex;
847
862
  switch (e.key) {
848
- case 'Escape':
863
+ case "Escape":
849
864
  e.preventDefault();
850
865
  e.stopPropagation();
851
866
  this.closeDropdown();
852
867
  break;
853
- case 'Enter':
868
+ case "Enter":
854
869
  e.preventDefault();
855
870
  e.stopPropagation();
856
871
  // Select highlighted tag if any
857
- if (currentHighlightedIndex >= 0 && currentHighlightedIndex < tagsCount) {
872
+ if (currentHighlightedIndex >= 0 &&
873
+ currentHighlightedIndex < tagsCount) {
858
874
  const tag = filteredTags[currentHighlightedIndex];
859
875
  this.handleTagClick({ target: tag.element });
860
876
  }
861
877
  break;
862
- case 'ArrowUp':
878
+ case "ArrowUp":
863
879
  e.preventDefault();
864
880
  e.stopPropagation();
865
881
  let newIndexUp;
@@ -881,7 +897,7 @@ export class TyMultiselect extends TyComponent {
881
897
  this._state.highlightedIndex = newIndexUp;
882
898
  this.highlightTag(filteredTags, newIndexUp);
883
899
  break;
884
- case 'ArrowDown':
900
+ case "ArrowDown":
885
901
  e.preventDefault();
886
902
  e.stopPropagation();
887
903
  let newIndexDown;
@@ -912,7 +928,7 @@ export class TyMultiselect extends TyComponent {
912
928
  * Filter tags based on search query
913
929
  */
914
930
  filterTags(tags, query) {
915
- if (!query || query.trim() === '') {
931
+ if (!query || query.trim() === "") {
916
932
  return tags;
917
933
  }
918
934
  const searchLower = query.toLowerCase();
@@ -922,13 +938,13 @@ export class TyMultiselect extends TyComponent {
922
938
  * Update visibility of tags based on filtered list
923
939
  */
924
940
  updateTagVisibility(filteredTags, allTags) {
925
- const visibleValues = new Set(filteredTags.map(tag => tag.value));
941
+ const visibleValues = new Set(filteredTags.map((tag) => tag.value));
926
942
  allTags.forEach(({ value, element }) => {
927
943
  if (visibleValues.has(value)) {
928
- element.removeAttribute('hidden');
944
+ element.removeAttribute("hidden");
929
945
  }
930
946
  else {
931
- element.setAttribute('hidden', '');
947
+ element.setAttribute("hidden", "");
932
948
  }
933
949
  });
934
950
  }
@@ -937,9 +953,9 @@ export class TyMultiselect extends TyComponent {
937
953
  */
938
954
  updateOptionsVisibility(hasOptions) {
939
955
  const shadow = this.shadowRoot;
940
- const options = shadow.querySelector('.dropdown-options');
956
+ const options = shadow.querySelector(".dropdown-options");
941
957
  if (options) {
942
- options.style.display = hasOptions ? '' : 'none';
958
+ options.style.display = hasOptions ? "" : "none";
943
959
  }
944
960
  }
945
961
  /**
@@ -947,7 +963,7 @@ export class TyMultiselect extends TyComponent {
947
963
  */
948
964
  clearHighlights(tags) {
949
965
  tags.forEach(({ element }) => {
950
- element.removeAttribute('highlighted');
966
+ element.removeAttribute("highlighted");
951
967
  });
952
968
  }
953
969
  /**
@@ -957,12 +973,12 @@ export class TyMultiselect extends TyComponent {
957
973
  this.clearHighlights(tags);
958
974
  if (index >= 0 && index < tags.length) {
959
975
  const { element } = tags[index];
960
- element.setAttribute('highlighted', '');
976
+ element.setAttribute("highlighted", "");
961
977
  // Scroll into view
962
978
  element.scrollIntoView({
963
- behavior: 'smooth',
964
- block: 'nearest',
965
- inline: 'nearest'
979
+ behavior: "smooth",
980
+ block: "nearest",
981
+ inline: "nearest",
966
982
  });
967
983
  }
968
984
  }
@@ -992,13 +1008,13 @@ export class TyMultiselect extends TyComponent {
992
1008
  * Fire the actual search event
993
1009
  */
994
1010
  fireSearchEvent(query) {
995
- this.dispatchEvent(new CustomEvent('search', {
1011
+ this.dispatchEvent(new CustomEvent("search", {
996
1012
  detail: {
997
1013
  query,
998
- element: this
1014
+ element: this,
999
1015
  },
1000
1016
  bubbles: true,
1001
- composed: true
1017
+ composed: true,
1002
1018
  }));
1003
1019
  }
1004
1020
  /**
@@ -1007,20 +1023,20 @@ export class TyMultiselect extends TyComponent {
1007
1023
  * query so consumers have a clean hook to reset/refetch the option list.
1008
1024
  */
1009
1025
  fireOpenEvent() {
1010
- this.dispatchEvent(new CustomEvent('open', {
1026
+ this.dispatchEvent(new CustomEvent("open", {
1011
1027
  detail: { mode: this._state.mode, element: this },
1012
1028
  bubbles: true,
1013
- composed: true
1029
+ composed: true,
1014
1030
  }));
1015
1031
  if (this._externalSearch) {
1016
- this.fireSearchEvent('');
1032
+ this.fireSearchEvent("");
1017
1033
  }
1018
1034
  }
1019
1035
  fireCloseEvent() {
1020
- this.dispatchEvent(new CustomEvent('close', {
1036
+ this.dispatchEvent(new CustomEvent("close", {
1021
1037
  detail: { mode: this._state.mode, element: this },
1022
1038
  bubbles: true,
1023
- composed: true
1039
+ composed: true,
1024
1040
  }));
1025
1041
  }
1026
1042
  // ============================================================================
@@ -1030,10 +1046,10 @@ export class TyMultiselect extends TyComponent {
1030
1046
  * Dispatch custom change event
1031
1047
  */
1032
1048
  dispatchChangeEvent(detail) {
1033
- this.dispatchEvent(new CustomEvent('change', {
1049
+ this.dispatchEvent(new CustomEvent("change", {
1034
1050
  detail,
1035
1051
  bubbles: true,
1036
- cancelable: true
1052
+ cancelable: true,
1037
1053
  }));
1038
1054
  }
1039
1055
  // ============================================================================
@@ -1045,45 +1061,61 @@ export class TyMultiselect extends TyComponent {
1045
1061
  */
1046
1062
  render() {
1047
1063
  // Sync mode on every render so rotation/resize is picked up
1048
- this._state.mode = isMobileTouch() ? 'mobile' : 'desktop';
1049
- if (this._state.mode === 'mobile') {
1064
+ this._state.mode = isMobileTouch() ? "mobile" : "desktop";
1065
+ if (this._state.mode === "mobile") {
1050
1066
  this.renderMobile();
1051
1067
  }
1052
1068
  else {
1053
1069
  this.renderDesktop();
1054
1070
  }
1071
+ this.updateSearchVisibility();
1055
1072
  // Loading wrapper is rendered dynamically — re-apply each render
1056
1073
  this.applyLoadingState();
1057
1074
  }
1075
+ /**
1076
+ * When a consumer slots their own trigger (slot="trigger"), strip the
1077
+ * default field chrome so the trigger is styled entirely by the consumer.
1078
+ */
1079
+ setupTriggerSlot() {
1080
+ const shadow = this.shadowRoot;
1081
+ const stub = shadow.querySelector(".multiselect-stub");
1082
+ const slot = shadow.querySelector('slot[name="trigger"]');
1083
+ if (!stub || !slot)
1084
+ return;
1085
+ const sync = () => stub.classList.toggle("custom-trigger", slot.assignedElements().length > 0);
1086
+ slot.addEventListener("slotchange", sync);
1087
+ sync();
1088
+ }
1058
1089
  /**
1059
1090
  * Setup event listeners
1060
1091
  */
1061
1092
  setupEventListeners() {
1062
1093
  const shadow = this.shadowRoot;
1063
- const stub = shadow.querySelector('.multiselect-stub');
1064
- const optionsSlot = shadow.querySelector('#options-slot');
1065
- const searchInput = shadow.querySelector('.dropdown-search-input');
1094
+ const stub = shadow.querySelector(".multiselect-stub");
1095
+ const optionsSlot = shadow.querySelector("#options-slot");
1096
+ const searchInput = shadow.querySelector(".dropdown-search-input");
1066
1097
  if (stub) {
1067
1098
  this._stubClickHandler = this.handleStubClick.bind(this);
1068
- stub.addEventListener('click', this._stubClickHandler);
1099
+ stub.addEventListener("click", this._stubClickHandler);
1069
1100
  }
1101
+ this.setupTriggerSlot();
1070
1102
  // Add tag click handler to slot
1071
1103
  if (optionsSlot) {
1072
1104
  this._tagClickHandler = this.handleTagClick.bind(this);
1073
- optionsSlot.addEventListener('click', this._tagClickHandler);
1105
+ optionsSlot.addEventListener("click", this._tagClickHandler);
1074
1106
  }
1075
1107
  // Add search input handlers
1076
1108
  if (searchInput) {
1077
1109
  this._searchInputHandler = this.handleSearchInput.bind(this);
1078
1110
  this._blockSearchClick = this.blockSearchClick.bind(this);
1079
- searchInput.addEventListener('input', this._searchInputHandler);
1080
- searchInput.addEventListener('click', this._blockSearchClick);
1111
+ searchInput.addEventListener("input", this._searchInputHandler);
1112
+ searchInput.addEventListener("click", this._blockSearchClick);
1081
1113
  // searchInput.addEventListener('blur', this._searchBlurHandler)
1082
1114
  }
1083
1115
  // Setup dialog backdrop click handler
1084
- const dialog = shadow.querySelector('.dropdown-dialog');
1116
+ const dialog = shadow.querySelector(".dropdown-dialog");
1085
1117
  if (dialog) {
1086
- dialog.addEventListener('click', (e) => {
1118
+ dialog.addEventListener("click", (e) => {
1087
1119
  // Only close if clicking directly on the dialog (backdrop), not its children
1088
1120
  if (e.target === dialog) {
1089
1121
  this.closeDropdown();
@@ -1092,10 +1124,7 @@ export class TyMultiselect extends TyComponent {
1092
1124
  }
1093
1125
  // Setup keyboard handler
1094
1126
  this._keyboardHandler = this.handleKeyboard.bind(this);
1095
- document.addEventListener('keydown', this._keyboardHandler);
1096
- // Listen for dismiss events from selected tags
1097
- this._tagDismissHandler = this.handleTagDismiss.bind(this);
1098
- this.addEventListener('dismiss', this._tagDismissHandler);
1127
+ document.addEventListener("keydown", this._keyboardHandler);
1099
1128
  }
1100
1129
  /**
1101
1130
  * Build CSS class list for stub
@@ -1103,8 +1132,10 @@ export class TyMultiselect extends TyComponent {
1103
1132
  buildStubClasses() {
1104
1133
  const classes = [this._size];
1105
1134
  if (this._disabled)
1106
- classes.push('disabled');
1107
- return classes.join(' ');
1135
+ classes.push("disabled");
1136
+ if (this._compact)
1137
+ classes.push("compact");
1138
+ return classes.join(" ");
1108
1139
  }
1109
1140
  /**
1110
1141
  * Render desktop mode with dialog
@@ -1112,37 +1143,44 @@ export class TyMultiselect extends TyComponent {
1112
1143
  renderDesktop() {
1113
1144
  const shadow = this.shadowRoot;
1114
1145
  // Only set innerHTML and setup listeners if container doesn't exist
1115
- if (!shadow.querySelector('.multiselect-container')) {
1146
+ if (!shadow.querySelector(".multiselect-container")) {
1116
1147
  const stubClasses = this.buildStubClasses();
1117
- const labelHtml = this._label ? `
1148
+ const labelHtml = this._label
1149
+ ? `
1118
1150
  <label class="ty-field-label">
1119
1151
  ${this._label}
1120
- ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1152
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1121
1153
  </label>
1122
- ` : '';
1123
- const searchPlaceholder = 'Search...';
1154
+ `
1155
+ : "";
1156
+ const searchPlaceholder = "Search...";
1124
1157
  shadow.innerHTML = `
1125
1158
  <div class="multiselect-container dropdown-mode-desktop">
1126
1159
  ${labelHtml}
1127
1160
  <div class="dropdown-wrapper">
1128
1161
  <div class="dropdown-stub multiselect-stub ${stubClasses}"
1129
- ${this._disabled ? 'disabled' : ''}>
1130
- <slot name="start"></slot>
1131
- <div class="multiselect-chips">
1162
+ ${this._disabled ? "disabled" : ""}>
1163
+ <slot name="trigger">
1164
+ <slot name="start"></slot>
1132
1165
  <slot name="selected"></slot>
1133
- </div>
1134
- <span class="dropdown-placeholder">${this._placeholder}</span>
1135
- <div class="dropdown-chevron">
1136
- ${CHEVRON_DOWN_SVG}
1137
- </div>
1166
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1167
+ <span class="select-count" hidden></span>
1168
+ <slot name="end"></slot>
1169
+ <div class="dropdown-chevron">
1170
+ ${CHEVRON_DOWN_SVG}
1171
+ </div>
1172
+ </slot>
1138
1173
  </div>
1139
1174
  <dialog class="dropdown-dialog">
1140
1175
  <div class="dropdown-header">
1141
- <input
1142
- class="dropdown-search-input ${this._size}"
1176
+ <div class="dropdown-search-icon" aria-hidden="true">
1177
+ ${SEARCH_ICON_SVG}
1178
+ </div>
1179
+ <input
1180
+ class="dropdown-search-input ${this._size}"
1143
1181
  type="text"
1144
1182
  placeholder="${searchPlaceholder}"
1145
- ${this._disabled ? 'disabled' : ''}
1183
+ ${this._disabled ? "disabled" : ""}
1146
1184
  />
1147
1185
  <div class="dropdown-search-chevron">
1148
1186
  ${CHEVRON_DOWN_SVG}
@@ -1178,31 +1216,38 @@ export class TyMultiselect extends TyComponent {
1178
1216
  renderMobile() {
1179
1217
  const shadow = this.shadowRoot;
1180
1218
  // Only set innerHTML and setup listeners if container doesn't exist
1181
- if (!shadow.querySelector('.multiselect-container')) {
1219
+ if (!shadow.querySelector(".multiselect-container")) {
1182
1220
  const stubClasses = this.buildStubClasses();
1183
- const labelHtml = this._label ? `
1221
+ const labelHtml = this._label
1222
+ ? `
1184
1223
  <label class="ty-field-label">
1185
1224
  ${this._label}
1186
- ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1225
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1187
1226
  </label>
1188
- ` : '';
1227
+ `
1228
+ : "";
1189
1229
  // Close button SVG (X icon)
1190
1230
  const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1191
1231
  <line x1="18" y1="6" x2="6" y2="18"></line>
1192
1232
  <line x1="6" y1="6" x2="18" y2="18"></line>
1193
1233
  </svg>`;
1194
1234
  // Search placeholder: "Search <label>..." or just "Search..."
1195
- const searchPlaceholder = this._label ? `Search ${this._label}...` : 'Search...';
1235
+ const searchPlaceholder = this._label
1236
+ ? `Search ${this._label}...`
1237
+ : "Search...";
1196
1238
  // Search is always available — only the mode (internal vs external) varies.
1197
1239
  const searchHeaderHtml = `
1198
1240
  <div class="mobile-search-header">
1199
- ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ''}
1241
+ ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
1200
1242
  <div class="mobile-header-content">
1243
+ <div class="dropdown-search-icon" aria-hidden="true">
1244
+ ${SEARCH_ICON_SVG}
1245
+ </div>
1201
1246
  <input
1202
1247
  class="mobile-search-input ${this._size}"
1203
1248
  type="text"
1204
1249
  placeholder="${searchPlaceholder}"
1205
- ${this._disabled ? 'disabled' : ''}
1250
+ ${this._disabled ? "disabled" : ""}
1206
1251
  />
1207
1252
  <button class="mobile-close-button" type="button" aria-label="Close">
1208
1253
  ${closeButtonSvg}
@@ -1215,35 +1260,27 @@ export class TyMultiselect extends TyComponent {
1215
1260
  ${labelHtml}
1216
1261
  <div class="dropdown-wrapper">
1217
1262
  <div class="dropdown-stub multiselect-stub ${stubClasses}"
1218
- ${this._disabled ? 'disabled' : ''}>
1219
- <slot name="start"></slot>
1220
- <div class="multiselect-chips">
1221
- <slot id="stub-slot" name="selected"></slot>
1222
- </div>
1223
- <span class="dropdown-placeholder">${this._placeholder}</span>
1224
- <div class="dropdown-chevron">
1225
- ${CHEVRON_DOWN_SVG}
1226
- </div>
1263
+ ${this._disabled ? "disabled" : ""}>
1264
+ <slot name="trigger">
1265
+ <slot name="start"></slot>
1266
+ <slot name="selected"></slot>
1267
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1268
+ <span class="select-count" hidden></span>
1269
+ <slot name="end"></slot>
1270
+ <div class="dropdown-chevron">
1271
+ ${CHEVRON_DOWN_SVG}
1272
+ </div>
1273
+ </slot>
1227
1274
  </div>
1228
1275
 
1229
1276
  <dialog class="mobile-dialog">
1230
1277
  <div class="mobile-dialog-content">
1231
-
1278
+
1232
1279
  <!-- HEADER (matches dropdown.ts) -->
1233
1280
  ${searchHeaderHtml}
1234
-
1235
- <!-- BODY: pinned selected strip + filter list -->
1236
- <div class="mobile-body">
1237
1281
 
1238
- <!-- SELECTED STRIP (pinned, collapses when empty) -->
1239
- <div class="mobile-selected-section" data-empty="true">
1240
- <div class="section-header">
1241
- <span class="section-title">${this._selectedLabel} <span class="section-count">(0)</span></span>
1242
- </div>
1243
- <div class="section-content">
1244
- <slot id="mobile-slot" name="selected"></slot>
1245
- </div>
1246
- </div>
1282
+ <!-- BODY: options list with toggleable selected state -->
1283
+ <div class="mobile-body">
1247
1284
 
1248
1285
  <!-- AVAILABLE LIST (always visible, takes remaining space) -->
1249
1286
  <div class="mobile-available-section" data-empty="false">
@@ -1280,31 +1317,30 @@ export class TyMultiselect extends TyComponent {
1280
1317
  */
1281
1318
  setupMobileEventListeners() {
1282
1319
  const shadow = this.shadowRoot;
1283
- const stub = shadow.querySelector('.multiselect-stub');
1284
- const optionsSlot = shadow.querySelector('#options-slot');
1285
- const searchInput = shadow.querySelector('.mobile-search-input');
1286
- const closeButton = shadow.querySelector('.mobile-close-button');
1287
- const dialog = shadow.querySelector('.mobile-dialog');
1320
+ const stub = shadow.querySelector(".multiselect-stub");
1321
+ const optionsSlot = shadow.querySelector("#options-slot");
1322
+ const searchInput = shadow.querySelector(".mobile-search-input");
1323
+ const closeButton = shadow.querySelector(".mobile-close-button");
1324
+ const dialog = shadow.querySelector(".mobile-dialog");
1288
1325
  if (stub) {
1289
- stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
1326
+ stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
1290
1327
  }
1328
+ this.setupTriggerSlot();
1291
1329
  // Add tag click handler to slot
1292
1330
  if (optionsSlot) {
1293
- optionsSlot.addEventListener('click', (e) => this.handleMobileTagClick(e));
1331
+ optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
1294
1332
  }
1295
1333
  // Add search input handlers (if searchable)
1296
1334
  if (searchInput) {
1297
- searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
1335
+ searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
1298
1336
  }
1299
1337
  // Close button click
1300
1338
  if (closeButton) {
1301
- closeButton.addEventListener('click', () => this.closeMobileModal());
1339
+ closeButton.addEventListener("click", () => this.closeMobileModal());
1302
1340
  }
1303
- this._tagDismissHandler = this.handleTagDismiss.bind(this);
1304
- this.addEventListener('dismiss', this._tagDismissHandler);
1305
1341
  // Backdrop click to close (native dialog behavior)
1306
1342
  if (dialog) {
1307
- dialog.addEventListener('click', (e) => {
1343
+ dialog.addEventListener("click", (e) => {
1308
1344
  // Only close if clicking directly on the dialog element (backdrop)
1309
1345
  // Not if clicking on its children (dialog-content)
1310
1346
  if (e.target === dialog) {
@@ -1312,7 +1348,7 @@ export class TyMultiselect extends TyComponent {
1312
1348
  }
1313
1349
  });
1314
1350
  // Also handle Escape key via cancel event
1315
- dialog.addEventListener('cancel', (e) => {
1351
+ dialog.addEventListener("cancel", (e) => {
1316
1352
  e.preventDefault(); // Prevent default to handle it our way
1317
1353
  this.closeMobileModal();
1318
1354
  });
@@ -1327,10 +1363,6 @@ export class TyMultiselect extends TyComponent {
1327
1363
  if (this._disabled || this._readonly) {
1328
1364
  return;
1329
1365
  }
1330
- // Don't open if all tags are already selected
1331
- if (this.allTagsSelected()) {
1332
- return;
1333
- }
1334
1366
  this.openMobileModal();
1335
1367
  }
1336
1368
  /**
@@ -1342,130 +1374,241 @@ export class TyMultiselect extends TyComponent {
1342
1374
  this.handleTagClick(e);
1343
1375
  }
1344
1376
  /**
1345
- * Update mobile selected section state (collapsed view, empty states, etc.)
1377
+ * Update mobile options section state (count, empty state)
1346
1378
  */
1347
1379
  updateMobileSelectedState() {
1348
- if (this._state.mode !== 'mobile')
1380
+ if (this._state.mode !== "mobile")
1349
1381
  return;
1350
1382
  const shadow = this.shadowRoot;
1351
- const selectedSection = shadow.querySelector('.mobile-selected-section');
1352
- const availableSection = shadow.querySelector('.mobile-available-section');
1353
- const sectionCountSpan = shadow.querySelector('.section-count');
1354
- if (selectedSection) {
1355
- const selectedCount = this._state.selectedValues.length;
1356
- const hasSelected = selectedCount > 0;
1357
- selectedSection.setAttribute('data-empty', String(!hasSelected));
1358
- // Update header count
1359
- if (sectionCountSpan) {
1360
- sectionCountSpan.textContent = `(${selectedCount})`;
1361
- }
1362
- }
1383
+ const availableSection = shadow.querySelector(".mobile-available-section");
1363
1384
  if (availableSection) {
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));
1385
+ // Count *visible* options — options hidden by search filtering count as 0
1386
+ const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
1387
+ availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
1367
1388
  // Update available header count
1368
- const availableTitleSpan = shadow.querySelector('.mobile-available-section .section-title');
1389
+ const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
1369
1390
  if (availableTitleSpan) {
1370
1391
  availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
1371
1392
  }
1372
1393
  }
1373
1394
  }
1374
1395
  /**
1375
- * Update selection display (show/hide placeholder)
1376
- * Matches dropdown.ts pattern - uses CSS via has-selection class
1396
+ * Update selection display: count badge next to the placeholder
1397
+ */
1398
+ /**
1399
+ * Should the popup show a search row? external-search always (the input is
1400
+ * the mechanism); searchable always/never wins otherwise; 'auto' shows it
1401
+ * only when the option list is long enough to need filtering.
1402
+ */
1403
+ shouldShowSearch() {
1404
+ if (this._externalSearch)
1405
+ return true;
1406
+ if (this._searchable === "always")
1407
+ return true;
1408
+ if (this._searchable === "never")
1409
+ return false;
1410
+ return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
1411
+ }
1412
+ /**
1413
+ * Toggle the search row (desktop header / mobile input) per shouldShowSearch.
1414
+ */
1415
+ updateSearchVisibility() {
1416
+ const shadow = this.shadowRoot;
1417
+ if (!shadow)
1418
+ return;
1419
+ const show = this.shouldShowSearch();
1420
+ const header = shadow.querySelector(".dropdown-header");
1421
+ if (header)
1422
+ header.hidden = !show;
1423
+ // Mobile keeps its header (label + close button); only the input+icon hide
1424
+ const mobileInput = shadow.querySelector(".mobile-search-input");
1425
+ if (mobileInput)
1426
+ mobileInput.hidden = !show;
1427
+ const mobileIcon = shadow.querySelector(".mobile-header-content .dropdown-search-icon");
1428
+ if (mobileIcon)
1429
+ mobileIcon.hidden = !show;
1430
+ }
1431
+ /**
1432
+ * Selection summary appropriate to cardinality × skin:
1433
+ * - single (field or compact): the selected OPTION itself — cloned into
1434
+ * slot="selected" so rich HTML options display intact (dropdown parity);
1435
+ * placeholder when empty
1436
+ * - multiple + field: joined labels (ellipsis via CSS), or placeholder
1437
+ * - multiple + compact: placeholder + count badge
1377
1438
  */
1378
1439
  updateSelectionDisplay() {
1379
1440
  const shadow = this.shadowRoot;
1380
- const stub = shadow.querySelector('.multiselect-stub');
1441
+ const stub = shadow.querySelector(".multiselect-stub");
1381
1442
  if (!stub)
1382
1443
  return;
1383
- const tags = this.getTagElements();
1384
- const selectedTags = tags.filter(tag => tag.hasAttribute('selected'));
1385
- const hasSelected = selectedTags.length > 0;
1386
- if (hasSelected) {
1387
- stub.classList.add('has-selection');
1444
+ // Selection comes from STATE, not from scanning option elements —
1445
+ // external search replaces the option children, and the selection must
1446
+ // survive even when the current results don't include the selected value.
1447
+ const values = this._state.selectedValues;
1448
+ const count = values.length;
1449
+ stub.classList.toggle("has-selection", count > 0);
1450
+ stub.classList.toggle("compact", this._compact);
1451
+ const badge = stub.querySelector(".select-count");
1452
+ if (badge) {
1453
+ badge.textContent = String(count);
1454
+ badge.hidden = count === 0 || !this._multiple || !this._compact;
1388
1455
  }
1389
- else {
1390
- stub.classList.remove('has-selection');
1456
+ // Single select: project the selected option into the stub as a clone
1457
+ // (rich HTML content survives — same mechanism as ty-dropdown).
1458
+ const wantClone = !this._multiple && count > 0 ? values[0] : null;
1459
+ this.syncSelectedClone(wantClone);
1460
+ stub.classList.toggle("has-clone", wantClone !== null);
1461
+ const textEl = stub.querySelector(".dropdown-placeholder");
1462
+ if (textEl) {
1463
+ // The clone replaces the text display entirely in single mode
1464
+ textEl.hidden = wantClone !== null;
1465
+ const showLabels = count > 0 && this._multiple && !this._compact;
1466
+ if (showLabels) {
1467
+ textEl.textContent = this.getSelectedItems(values)
1468
+ .map((i) => i.label)
1469
+ .join(", ");
1470
+ textEl.classList.remove("placeholder-shown");
1471
+ }
1472
+ else {
1473
+ textEl.textContent = this._placeholder;
1474
+ textEl.classList.add("placeholder-shown");
1475
+ }
1476
+ }
1477
+ }
1478
+ /**
1479
+ * Keep exactly one display clone (slot="selected", marked `cloned`) in our
1480
+ * light DOM matching the given value — or none. The clone never counts as
1481
+ * an option (getTagElements filters [cloned]) and ty-option's own
1482
+ * :host([cloned]) styling strips its list-row chrome.
1483
+ */
1484
+ syncSelectedClone(value) {
1485
+ const existing = this.querySelector(':scope > [cloned][slot="selected"]');
1486
+ if (existing?.getAttribute("value") === value) {
1487
+ this._selectedClone = existing;
1488
+ return; // already right
1489
+ }
1490
+ existing?.remove();
1491
+ if (value === null) {
1492
+ this._selectedClone = null;
1493
+ return;
1494
+ }
1495
+ const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
1496
+ if (option) {
1497
+ const clone = option.cloneNode(true);
1498
+ clone.removeAttribute("selected"); // no tick/selected styling in the stub
1499
+ clone.setAttribute("slot", "selected");
1500
+ clone.setAttribute("cloned", "true");
1501
+ this.appendChild(clone);
1502
+ this._selectedClone = clone;
1503
+ }
1504
+ else if (this._selectedClone?.getAttribute("value") === value) {
1505
+ // The option list no longer contains the selection (external search
1506
+ // swapped the children — possibly wiping our clone with them). Restore
1507
+ // the saved display clone so the selection stays visible.
1508
+ const restored = this._selectedClone.cloneNode(true);
1509
+ this.appendChild(restored);
1510
+ this._selectedClone = restored;
1391
1511
  }
1392
1512
  }
1393
1513
  // ============================================================================
1394
1514
  // PUBLIC API - Getters/Setters
1395
1515
  // ============================================================================
1516
+ /**
1517
+ * Deselect a value programmatically WITH a change event.
1518
+ * Used by out-of-band chip displays (ty-selected-tags) so dismissing a chip
1519
+ * fires change like any in-popup interaction (HTMX hx-trigger="change" etc.).
1520
+ */
1521
+ deselectValue(value) {
1522
+ const current = this.getSelectedValues();
1523
+ if (!current.includes(value))
1524
+ return;
1525
+ this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
1526
+ }
1396
1527
  get value() {
1397
1528
  // Always read from DOM - tags with 'selected' attribute are source of truth
1398
- return this.getSelectedValues().join(',');
1529
+ return this.getSelectedValues().join(",");
1399
1530
  }
1400
1531
  set value(val) {
1401
- this.setProperty('value', val);
1532
+ this.setProperty("value", val);
1402
1533
  }
1403
1534
  get name() {
1404
- return this.getProperty('name');
1535
+ return this.getProperty("name");
1405
1536
  }
1406
1537
  set name(val) {
1407
- this.setProperty('name', val);
1538
+ this.setProperty("name", val);
1539
+ }
1540
+ get multiple() {
1541
+ return this.getProperty("multiple");
1542
+ }
1543
+ set multiple(value) {
1544
+ this.setProperty("multiple", value);
1545
+ }
1546
+ get compact() {
1547
+ return this.getProperty("compact");
1548
+ }
1549
+ set compact(value) {
1550
+ this.setProperty("compact", value);
1408
1551
  }
1409
1552
  get placeholder() {
1410
- return this.getProperty('placeholder');
1553
+ return this.getProperty("placeholder");
1411
1554
  }
1412
1555
  set placeholder(val) {
1413
- this.setProperty('placeholder', val);
1556
+ this.setProperty("placeholder", val);
1414
1557
  }
1415
1558
  get label() {
1416
- return this.getProperty('label');
1559
+ return this.getProperty("label");
1417
1560
  }
1418
1561
  set label(val) {
1419
- this.setProperty('label', val);
1562
+ this.setProperty("label", val);
1420
1563
  }
1421
1564
  get disabled() {
1422
- return this.getProperty('disabled');
1565
+ return this.getProperty("disabled");
1423
1566
  }
1424
1567
  set disabled(value) {
1425
- this.setProperty('disabled', value);
1568
+ this.setProperty("disabled", value);
1426
1569
  }
1427
1570
  get loading() {
1428
- return this.getProperty('loading');
1571
+ return this.getProperty("loading");
1429
1572
  }
1430
1573
  set loading(value) {
1431
- this.setProperty('loading', value);
1574
+ this.setProperty("loading", value);
1432
1575
  }
1433
1576
  get readonly() {
1434
- return this.getProperty('readonly');
1577
+ return this.getProperty("readonly");
1435
1578
  }
1436
1579
  set readonly(value) {
1437
- this.setProperty('readonly', value);
1580
+ this.setProperty("readonly", value);
1438
1581
  }
1439
1582
  get required() {
1440
- return this.getProperty('required');
1583
+ return this.getProperty("required");
1441
1584
  }
1442
1585
  set required(value) {
1443
- this.setProperty('required', value);
1586
+ this.setProperty("required", value);
1587
+ }
1588
+ get searchable() {
1589
+ return this.getProperty("searchable");
1590
+ }
1591
+ set searchable(value) {
1592
+ this.setProperty("searchable", value);
1444
1593
  }
1445
1594
  get externalSearch() {
1446
- return this.getProperty('externalSearch');
1595
+ return this.getProperty("externalSearch");
1447
1596
  }
1448
1597
  set externalSearch(value) {
1449
- this.setProperty('externalSearch', value);
1598
+ this.setProperty("externalSearch", value);
1450
1599
  }
1451
1600
  get debounce() {
1452
- return this.getProperty('debounce');
1601
+ return this.getProperty("debounce");
1453
1602
  }
1454
1603
  set debounce(value) {
1455
- const numValue = typeof value === 'string' ? parseInt(value, 10) : value;
1456
- this.setProperty('debounce', numValue);
1604
+ const numValue = typeof value === "string" ? parseInt(value, 10) : value;
1605
+ this.setProperty("debounce", numValue);
1457
1606
  }
1458
1607
  get size() {
1459
- return this.getProperty('size');
1608
+ return this.getProperty("size");
1460
1609
  }
1461
1610
  set size(value) {
1462
- this.setProperty('size', value);
1463
- }
1464
- get flavor() {
1465
- return this.getProperty('flavor');
1466
- }
1467
- set flavor(value) {
1468
- this.setProperty('flavor', value);
1611
+ this.setProperty("size", value);
1469
1612
  }
1470
1613
  get form() {
1471
1614
  return this._internals.form;
@@ -1474,90 +1617,104 @@ export class TyMultiselect extends TyComponent {
1474
1617
  // ============================================================================
1475
1618
  // PROPERTY CONFIGURATION - Declarative property lifecycle
1476
1619
  // ============================================================================
1477
- TyMultiselect.properties = {
1620
+ TySelect.properties = {
1478
1621
  value: {
1479
- type: 'string',
1622
+ type: "string",
1480
1623
  visual: true,
1481
1624
  formValue: true,
1482
1625
  emitChange: false,
1483
- default: '',
1626
+ default: "",
1484
1627
  coerce: (v) => {
1485
1628
  // Handle array input (from React, Reagent, etc.)
1486
1629
  if (Array.isArray(v)) {
1487
- return v.join(',');
1630
+ return v.join(",");
1488
1631
  }
1489
1632
  // Handle null/undefined
1490
1633
  if (v === null || v === undefined) {
1491
- return '';
1634
+ return "";
1492
1635
  }
1493
1636
  // String already
1494
1637
  return String(v);
1495
- }
1638
+ },
1496
1639
  },
1497
1640
  name: {
1498
- type: 'string',
1499
- default: ''
1641
+ type: "string",
1642
+ default: "",
1643
+ },
1644
+ // Cardinality — native <select> semantics: absent = single select
1645
+ multiple: {
1646
+ type: "boolean",
1647
+ visual: true,
1648
+ default: false,
1649
+ },
1650
+ // Skin — absent = form field (matches ty-input); present = compact
1651
+ // content-hugging trigger (toolbars, filter bars)
1652
+ compact: {
1653
+ type: "boolean",
1654
+ visual: true,
1655
+ default: false,
1500
1656
  },
1501
1657
  placeholder: {
1502
- type: 'string',
1658
+ type: "string",
1503
1659
  visual: true,
1504
- default: 'Select options...'
1660
+ default: "Select...",
1505
1661
  },
1506
1662
  label: {
1507
- type: 'string',
1663
+ type: "string",
1508
1664
  visual: true,
1509
- default: ''
1665
+ default: "",
1510
1666
  },
1511
1667
  disabled: {
1512
- type: 'boolean',
1668
+ type: "boolean",
1513
1669
  visual: true,
1514
- default: false
1670
+ default: false,
1515
1671
  },
1516
1672
  readonly: {
1517
- type: 'boolean',
1673
+ type: "boolean",
1518
1674
  visual: true,
1519
- default: false
1675
+ default: false,
1520
1676
  },
1521
1677
  required: {
1522
- type: 'boolean',
1678
+ type: "boolean",
1523
1679
  visual: true,
1524
- default: false
1680
+ default: false,
1525
1681
  },
1526
1682
  externalSearch: {
1527
- type: 'boolean',
1683
+ type: "boolean",
1528
1684
  visual: true,
1529
1685
  default: false,
1530
- aliases: { 'external-search': true }
1686
+ aliases: { "external-search": true },
1531
1687
  },
1532
- size: {
1533
- type: 'string',
1688
+ // Search row visibility: 'auto' (default) shows it only for long option
1689
+ // lists; searchable / searchable="true" forces it on, searchable="false"
1690
+ // off. external-search always shows it (the input IS the mechanism).
1691
+ searchable: {
1692
+ type: "string",
1534
1693
  visual: true,
1535
- default: 'md',
1536
- validate: (v) => ['sm', 'md', 'lg'].includes(v),
1694
+ default: "auto",
1537
1695
  coerce: (v) => {
1538
- if (!['sm', 'md', 'lg'].includes(v)) {
1539
- console.warn(`[ty-multiselect] Invalid size. Using md.`);
1540
- return 'md';
1541
- }
1542
- return v;
1543
- }
1696
+ if (v === true || v === "" || v === "true")
1697
+ return "always";
1698
+ if (v === false || v === "false")
1699
+ return "never";
1700
+ return "auto";
1701
+ },
1544
1702
  },
1545
- flavor: {
1546
- type: 'string',
1703
+ size: {
1704
+ type: "string",
1547
1705
  visual: true,
1548
- default: 'neutral',
1549
- validate: (v) => ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'].includes(v),
1706
+ default: "md",
1707
+ validate: (v) => ["sm", "md", "lg"].includes(v),
1550
1708
  coerce: (v) => {
1551
- const valid = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
1552
- if (!valid.includes(v)) {
1553
- console.warn(`[ty-multiselect] Invalid flavor. Using neutral.`);
1554
- return 'neutral';
1709
+ if (!["sm", "md", "lg"].includes(v)) {
1710
+ console.warn(`[ty-select] Invalid size. Using md.`);
1711
+ return "md";
1555
1712
  }
1556
1713
  return v;
1557
- }
1714
+ },
1558
1715
  },
1559
1716
  debounce: {
1560
- type: 'number',
1717
+ type: "number",
1561
1718
  default: 0,
1562
1719
  validate: (v) => v >= 0 && v <= 5000,
1563
1720
  coerce: (v) => {
@@ -1565,31 +1722,26 @@ TyMultiselect.properties = {
1565
1722
  if (isNaN(num))
1566
1723
  return 0;
1567
1724
  return Math.max(0, Math.min(5000, num));
1568
- }
1569
- },
1570
- 'selected-label': {
1571
- type: 'string',
1572
- visual: true,
1573
- default: 'Selected'
1725
+ },
1574
1726
  },
1575
- 'available-label': {
1576
- type: 'string',
1727
+ "available-label": {
1728
+ type: "string",
1577
1729
  visual: true,
1578
- default: 'Available'
1730
+ default: "Available",
1579
1731
  },
1580
- 'no-options-message': {
1581
- type: 'string',
1732
+ "no-options-message": {
1733
+ type: "string",
1582
1734
  visual: true,
1583
- default: 'No options available'
1735
+ default: "No options available",
1584
1736
  },
1585
1737
  loading: {
1586
- type: 'boolean',
1738
+ type: "boolean",
1587
1739
  visual: true,
1588
- default: false
1589
- }
1740
+ default: false,
1741
+ },
1590
1742
  };
1591
1743
  // Register the custom element
1592
- if (!customElements.get('ty-multiselect')) {
1593
- customElements.define('ty-multiselect', TyMultiselect);
1744
+ if (!customElements.get("ty-select")) {
1745
+ customElements.define("ty-select", TySelect);
1594
1746
  }
1595
- //# sourceMappingURL=multiselect.js.map
1747
+ //# sourceMappingURL=select.js.map