tyrell-components 1.0.0-TC52 → 1.0.0-TC53

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 (105) hide show
  1. package/css/tyrell-theme.css +25 -2
  2. package/css/tyrell.css +4 -0
  3. package/dist/tyrell-colors-static.css +1 -316
  4. package/dist/tyrell-theme.css +1 -1443
  5. package/dist/tyrell.css +1 -1390
  6. package/dist/tyrell.js +1 -1
  7. package/lib/base/ty-component.js +196 -196
  8. package/lib/components/button.d.ts +1 -1
  9. package/lib/components/button.js +260 -259
  10. package/lib/components/calendar-month.d.ts +2 -2
  11. package/lib/components/calendar-month.js +426 -416
  12. package/lib/components/calendar-navigation.js +238 -238
  13. package/lib/components/calendar.js +605 -602
  14. package/lib/components/checkbox.d.ts +1 -1
  15. package/lib/components/checkbox.js +273 -272
  16. package/lib/components/copy.d.ts +1 -1
  17. package/lib/components/copy.js +279 -278
  18. package/lib/components/date-picker.d.ts +1 -1
  19. package/lib/components/date-picker.js +1103 -1108
  20. package/lib/components/file-upload.js +404 -404
  21. package/lib/components/icon.js +141 -141
  22. package/lib/components/input.d.ts +1 -1
  23. package/lib/components/input.js +593 -596
  24. package/lib/components/modal.js +319 -319
  25. package/lib/components/option.js +167 -167
  26. package/lib/components/popup.js +233 -233
  27. package/lib/components/radio.d.ts +2 -2
  28. package/lib/components/radio.js +407 -406
  29. package/lib/components/resize-observer.js +67 -67
  30. package/lib/components/scroll-container.js +294 -294
  31. package/lib/components/select.d.ts +1 -1
  32. package/lib/components/select.js +1524 -1532
  33. package/lib/components/selected-tags.js +184 -184
  34. package/lib/components/step.js +62 -62
  35. package/lib/components/switch.d.ts +1 -1
  36. package/lib/components/switch.js +214 -213
  37. package/lib/components/tab.js +62 -62
  38. package/lib/components/tabs.js +507 -507
  39. package/lib/components/tag.d.ts +1 -1
  40. package/lib/components/tag.js +236 -235
  41. package/lib/components/textarea.d.ts +1 -1
  42. package/lib/components/textarea.js +457 -456
  43. package/lib/components/tooltip.js +283 -283
  44. package/lib/components/wizard.js +384 -384
  45. package/lib/icons/heroicons/micro.js +392 -392
  46. package/lib/icons/heroicons/mini.js +382 -382
  47. package/lib/icons/heroicons/outline.js +337 -337
  48. package/lib/icons/heroicons/solid.js +407 -407
  49. package/lib/icons/lucide.js +22843 -22843
  50. package/lib/icons/material/filled.js +9653 -9653
  51. package/lib/icons/material/outlined.js +10271 -10271
  52. package/lib/icons/material/round.js +10495 -10495
  53. package/lib/icons/material/sharp.js +9905 -9905
  54. package/lib/icons/material/two-tone.js +12792 -12792
  55. package/lib/index.js +21 -21
  56. package/lib/styles/button.js +176 -288
  57. package/lib/styles/calendar-month.js +123 -183
  58. package/lib/styles/calendar-navigation.js +60 -79
  59. package/lib/styles/calendar.js +8 -9
  60. package/lib/styles/checkbox.js +45 -72
  61. package/lib/styles/copy.js +67 -91
  62. package/lib/styles/custom-scrollbar.js +80 -100
  63. package/lib/styles/date-picker.js +261 -331
  64. package/lib/styles/field-size.d.ts +6 -0
  65. package/lib/styles/field-size.js +91 -0
  66. package/lib/styles/file-upload.js +139 -169
  67. package/lib/styles/icon.js +92 -156
  68. package/lib/styles/input.js +125 -241
  69. package/lib/styles/modal.js +67 -102
  70. package/lib/styles/option.d.ts +1 -1
  71. package/lib/styles/option.js +47 -67
  72. package/lib/styles/popup.js +34 -65
  73. package/lib/styles/radio.js +69 -96
  74. package/lib/styles/resize-observer.js +2 -5
  75. package/lib/styles/scroll-container.js +100 -130
  76. package/lib/styles/select-base.js +525 -778
  77. package/lib/styles/select.js +140 -228
  78. package/lib/styles/step.js +22 -29
  79. package/lib/styles/switch.js +34 -55
  80. package/lib/styles/tab.js +22 -29
  81. package/lib/styles/tabs.js +171 -252
  82. package/lib/styles/tag.js +135 -188
  83. package/lib/styles/textarea.d.ts +1 -1
  84. package/lib/styles/textarea.js +205 -271
  85. package/lib/styles/tooltip.js +3 -3
  86. package/lib/styles/wizard.js +170 -226
  87. package/lib/types/common.d.ts +2 -0
  88. package/lib/types/common.js +6 -5
  89. package/lib/utils/calendar-utils.js +100 -100
  90. package/lib/utils/custom-scrollbar.js +272 -272
  91. package/lib/utils/flavor-sheet.js +14 -14
  92. package/lib/utils/icon-registry.js +127 -127
  93. package/lib/utils/loader-registry.js +3 -3
  94. package/lib/utils/locale.js +34 -34
  95. package/lib/utils/mobile.js +1 -1
  96. package/lib/utils/number-format.js +54 -54
  97. package/lib/utils/positioning.js +212 -212
  98. package/lib/utils/property-capture.js +36 -36
  99. package/lib/utils/property-manager.js +125 -125
  100. package/lib/utils/resize-observer.js +23 -23
  101. package/lib/utils/scroll-lock.js +57 -57
  102. package/lib/utils/styles.js +17 -17
  103. package/lib/version.d.ts +1 -1
  104. package/lib/version.js +1 -1
  105. package/package.json +4 -4
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_SIZE } from "../types/common.js";
1
2
  import { ensureStyles } from "../utils/styles.js";
2
3
  import { syncCustomFlavorSheet } from "../utils/flavor-sheet.js";
3
4
  import { selectBaseStyles, selectCustomFlavorCss } from "../styles/select-base.js";
@@ -11,1552 +12,1543 @@ import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scro
11
12
  let elementIdCounter = 0;
12
13
  const elementIds = new WeakMap();
13
14
  function getElementHash(element) {
14
- let id = elementIds.get(element);
15
- if (id === undefined) {
16
- id = ++elementIdCounter;
17
- elementIds.set(element, id);
18
- }
19
- return id;
15
+ let id = elementIds.get(element);
16
+ if (id === undefined) {
17
+ id = ++elementIdCounter;
18
+ elementIds.set(element, id);
19
+ }
20
+ return id;
20
21
  }
21
22
  import { REQUIRED_ICON_SVG } from "../utils/icons.js";
22
23
  const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
23
- <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" />
24
+ <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" />
24
25
  </svg>`;
25
26
  const SEARCH_AUTO_THRESHOLD = 7;
26
27
  function slugify(text) {
27
- return text
28
- .trim()
29
- .toLowerCase()
30
- .replace(/\s+/g, "_")
31
- .replace(/[^a-z0-9_]/g, "")
32
- .replace(/_+/g, "_")
33
- .replace(/^_|_$/g, "");
28
+ return text
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/\s+/g, "_")
32
+ .replace(/[^a-z0-9_]/g, "")
33
+ .replace(/_+/g, "_")
34
+ .replace(/^_|_$/g, "");
34
35
  }
35
36
  const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
36
- <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
37
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
37
38
  </svg>`;
38
39
  const CLEAR_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
39
- <line x1="18" y1="6" x2="6" y2="18"></line>
40
- <line x1="6" y1="6" x2="18" y2="18"></line>
40
+ <line x1="18" y1="6" x2="6" y2="18"></line>
41
+ <line x1="6" y1="6" x2="18" y2="18"></line>
41
42
  </svg>`;
42
43
  export class TySelect extends TyComponent {
43
- constructor() {
44
- super();
45
- this._name = "";
46
- this._multiple = false;
47
- this._compact = false;
48
- this._placeholder = "Select...";
49
- this._label = "";
50
- this._disabled = false;
51
- this._readonly = false;
52
- this._required = false;
53
- this._clearable = true;
54
- this._externalSearch = false;
55
- this._allowCreate = false;
56
- this._createTransform = "none";
57
- this._searchable = "auto";
58
- this._loading = false;
59
- this._scrollLockId = null;
60
- this._size = "md";
61
- this._availableLabel = "Available";
62
- this._noOptionsMessage = "No options available";
63
- this._state = {
64
- open: false,
65
- search: "",
66
- highlightedIndex: -1,
67
- filteredTags: [],
68
- selectedValues: [],
69
- mode: "desktop",
70
- };
71
- this._stubClickHandler = null;
72
- this._tagClickHandler = null;
73
- this._searchInputHandler = null;
74
- this._blockSearchClick = null;
75
- this._keyboardHandler = null;
76
- this._debounce = 0;
77
- this._searchDebounceTimer = null;
78
- this._optionsScrollbar = null;
79
- this._childObserver = null;
80
- this._selectedClone = null;
81
- this._customFlavorSheet = null;
82
- const shadow = this.shadowRoot;
83
- ensureStyles(shadow, { css: selectBaseStyles, id: "ty-select-base" });
84
- ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
85
- }
86
- onConnect() {
87
- const shadow = this.shadowRoot;
88
- const dialogs = shadow.querySelectorAll("dialog");
89
- dialogs.forEach((dialog) => {
90
- if (dialog.open) {
91
- console.warn("⚠️ Found open dialog on connect, closing it");
92
- dialog.close();
93
- }
94
- });
95
- this.render();
96
- requestAnimationFrame(() => {
97
- this.initializeState();
98
- });
99
- this._childObserver = new MutationObserver(() => {
100
- this.ensureOptionRoles();
101
- if (this._state.selectedValues.length > 0) {
102
- this.syncSelectedTags(this._state.selectedValues);
103
- }
104
- this.updateSearchVisibility();
105
- this.updateSelectionDisplay();
106
- this.updateMobileSelectedState();
107
- });
108
- this._childObserver.observe(this, { childList: true });
109
- this.ensureOptionRoles();
110
- this._syncCustomFlavor();
111
- }
112
- _syncCustomFlavor() {
113
- this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.getProperty("flavor"), ({ base }) => selectCustomFlavorCss(base));
114
- }
115
- onDisconnect() {
116
- const shadow = this.shadowRoot;
117
- const dialogs = shadow.querySelectorAll("dialog");
118
- dialogs.forEach((dialog) => {
119
- if (dialog.open) {
120
- dialog.close();
121
- }
122
- });
123
- if (this._keyboardHandler) {
124
- document.removeEventListener("keydown", this._keyboardHandler);
125
- this._keyboardHandler = null;
126
- }
127
- if (this._searchDebounceTimer !== null) {
128
- clearTimeout(this._searchDebounceTimer);
129
- this._searchDebounceTimer = null;
130
- }
131
- this._destroyOptionsScrollbar();
132
- if (this._childObserver) {
133
- this._childObserver.disconnect();
134
- this._childObserver = null;
135
- }
136
- }
137
- onPropertiesChanged(changes) {
138
- for (const { name, newValue } of changes) {
139
- switch (name) {
140
- case "value":
141
- const selectedValues = this.parseValue(newValue);
142
- this._state.selectedValues = selectedValues;
143
- if (this.isConnected && this.shadowRoot) {
144
- this.syncSelectedTags(selectedValues);
145
- this.updateSelectionDisplay();
146
- this.updateMobileSelectedState();
147
- }
148
- break;
149
- case "name":
150
- this._name = newValue || "";
151
- break;
152
- case "flavor":
153
- this._syncCustomFlavor();
154
- break;
155
- case "multiple":
156
- this._multiple = newValue;
157
- if (!newValue && this._state.selectedValues.length > 1) {
158
- this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
159
- }
160
- break;
161
- case "compact":
162
- this._compact = newValue;
163
- break;
164
- case "placeholder":
165
- this._placeholder = newValue || "Select...";
166
- if (this.isConnected && this.shadowRoot)
167
- this.updateSelectionDisplay();
168
- break;
169
- case "label":
170
- this._label = newValue || "";
171
- break;
172
- case "disabled":
173
- this._disabled = newValue;
174
- break;
175
- case "readonly":
176
- this._readonly = newValue;
177
- break;
178
- case "required":
179
- this._required = newValue;
180
- break;
181
- case "clearable":
182
- this._clearable = newValue;
183
- if (this.isConnected && this.shadowRoot)
184
- this.updateSelectionDisplay();
185
- break;
186
- case "externalSearch":
187
- this._externalSearch = newValue;
188
- break;
189
- case "searchable":
190
- this._searchable = newValue;
191
- if (this.isConnected && this.shadowRoot)
192
- this.updateSearchVisibility();
193
- break;
194
- case "allowCreate":
195
- this._allowCreate = newValue;
196
- if (this.isConnected && this.shadowRoot)
197
- this.updateSearchVisibility();
198
- break;
199
- case "createTransform":
200
- this._createTransform = newValue;
201
- break;
202
- case "size":
203
- this._size = newValue;
204
- break;
205
- case "debounce":
206
- this._debounce = newValue;
207
- break;
208
- case "available-label":
209
- this._availableLabel = newValue || "Available";
210
- break;
211
- case "no-options-message":
212
- this._noOptionsMessage = newValue || "No options available";
213
- break;
214
- case "loading":
215
- this._loading = newValue;
216
- this.applyLoadingState();
217
- break;
218
- }
219
- }
220
- }
221
- applyLoadingState() {
222
- const shadow = this.shadowRoot;
223
- if (!shadow)
224
- return;
225
- const svg = this._loading ? getLoaderSvg() : null;
226
- shadow.querySelectorAll(".dropdown-options-wrapper").forEach((wrapper) => {
227
- wrapper.classList.toggle("loading", this._loading);
228
- if (this._loading) {
229
- wrapper.setAttribute("aria-busy", "true");
230
- const spinner = wrapper.querySelector(".dropdown-loading-spinner");
231
- if (spinner && svg)
232
- spinner.innerHTML = svg;
233
- }
234
- else {
235
- wrapper.removeAttribute("aria-busy");
236
- }
237
- });
238
- }
239
- getFormValue() {
240
- const selectedValues = this._state.selectedValues;
241
- if (this._name && selectedValues.length > 0) {
242
- if (!this._multiple)
243
- return selectedValues[0];
244
- const formData = new FormData();
245
- selectedValues.forEach((value) => {
246
- formData.append(this._name, value);
247
- });
248
- return formData;
249
- }
250
- return null;
251
- }
252
- parseValue(value) {
253
- if (!value || typeof value !== "string" || value.trim() === "")
254
- return [];
255
- return value
256
- .split(",")
257
- .map((v) => v.trim())
258
- .filter((v) => v !== "");
259
- }
260
- initializeState() {
261
- const initialValue = this.getProperty("value") || "";
262
- if (initialValue) {
263
- const selectedValues = this.parseValue(initialValue);
264
- this._state.selectedValues = selectedValues;
265
- this.syncSelectedTags(selectedValues);
266
- this.updateSelectionDisplay();
267
- this.updateMobileSelectedState();
268
- }
269
- else {
270
- const allTags = this.getTagElements();
271
- const preSelectedTags = allTags
272
- .filter((tag) => tag.hasAttribute("selected"))
273
- .map((tag) => this.getTagData(tag).value);
274
- if (preSelectedTags.length > 0) {
275
- this.updateComponentValue(preSelectedTags, false);
276
- }
277
- }
278
- }
279
- getTagElements() {
280
- return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
281
- }
282
- getTagData(element) {
283
- const value = element.value ||
284
- element.getAttribute("value") ||
285
- element.textContent ||
286
- "";
287
- const text = element.getAttribute("label") || element.textContent || "";
288
- return { value, text, element };
289
- }
290
- selectTag(tag) {
291
- tag.setAttribute("selected", "");
292
- }
293
- deselectTag(tag) {
294
- tag.removeAttribute("selected");
295
- }
296
- getSelectedValues() {
297
- return this.getTagElements()
298
- .filter((tag) => tag.hasAttribute("selected"))
299
- .map((tag) => this.getTagData(tag).value)
300
- .filter((value) => value !== "");
301
- }
302
- syncSelectedTags(selectedValues) {
303
- const selectedSet = new Set(selectedValues);
304
- const tags = this.getTagElements();
305
- tags.forEach((tag) => {
306
- const tagValue = this.getTagData(tag).value;
307
- const shouldBeSelected = selectedSet.has(tagValue);
308
- const isSelected = tag.hasAttribute("selected");
309
- if (shouldBeSelected && !isSelected) {
310
- this.selectTag(tag);
311
- }
312
- else if (!shouldBeSelected && isSelected) {
313
- this.deselectTag(tag);
314
- }
315
- tag.setAttribute("aria-selected", String(shouldBeSelected));
316
- });
317
- }
318
- ensureOptionRoles() {
319
- this.getTagElements().forEach((tag) => {
320
- if (!tag.hasAttribute("role"))
321
- tag.setAttribute("role", "option");
322
- if (!tag.hasAttribute("aria-selected"))
323
- tag.setAttribute("aria-selected", "false");
324
- });
325
- }
326
- updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
327
- const oldValues = this.getSelectedValues();
328
- const valueStr = newValues.join(",");
329
- if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
330
- this.setProperty("value", valueStr);
331
- this.updateValidity();
332
- if (dispatchChange) {
333
- this.dispatchChangeEvent({
334
- value: this._multiple ? [...newValues] : (newValues[0] ?? null),
335
- values: newValues,
336
- items: this.getSelectedItems(newValues),
337
- action,
338
- item,
339
- });
340
- }
341
- }
342
- }
343
- getSelectedItems(values) {
344
- const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
345
- return values.map((value) => {
346
- const el = byValue.get(value);
347
- return {
348
- value,
349
- label: el?.getAttribute("label") || el?.textContent?.trim() || value,
350
- flavor: el?.getAttribute("flavor") ?? null,
351
- };
352
- });
353
- }
354
- calculatePosition() {
355
- const shadow = this.shadowRoot;
356
- const stub = shadow.querySelector(".select-stub");
357
- const dialog = shadow.querySelector(".dropdown-dialog");
358
- if (!stub || !dialog)
359
- return;
360
- const stubRect = stub.getBoundingClientRect();
361
- const MAX_POPUP_ESTIMATE = 440;
362
- const dialogRect = dialog.getBoundingClientRect();
363
- const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
364
- const wrapPadding = 20;
365
- const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
366
- const popupWidth = !isNaN(cssWidth)
367
- ? cssWidth
368
- : Math.max(stubRect.width, 320);
369
- const requested = this.getProperty("placement");
370
- const anchored = requested
371
- ? placementToAnchored(requested)
372
- : {
373
- side: "auto",
374
- align: this.getProperty("align"),
375
- };
376
- const pos = computeAnchoredPosition({
377
- anchorRect: stubRect,
378
- popupWidth,
379
- popupHeight: estimatedHeight,
380
- align: anchored.align,
381
- side: anchored.side,
382
- gap: 8,
383
- });
384
- const positionBelow = pos.below;
385
- const x = pos.x - wrapPadding;
386
- const y = (positionBelow ? pos.topY : pos.bottomY) - wrapPadding;
387
- const width = popupWidth + wrapPadding + wrapPadding;
388
- this.style.setProperty("--dropdown-x", `${x}px`);
389
- this.style.setProperty("--dropdown-y", `${y}px`);
390
- this.style.setProperty("--dropdown-width", `${width}px`);
391
- this.style.setProperty("--dropdown-offset-x", "0px");
392
- this.style.setProperty("--dropdown-offset-y", "0px");
393
- this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
394
- if (positionBelow) {
395
- dialog.classList.add("position-below");
396
- dialog.classList.remove("position-above");
397
- }
398
- else {
399
- dialog.classList.add("position-above");
400
- dialog.classList.remove("position-below");
401
- }
402
- this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
403
- }
404
- _setupOptionsScrollbar() {
405
- if (!isCustomScrollbarEnabled())
406
- return;
407
- const shadow = this.shadowRoot;
408
- const optionsDiv = shadow.querySelector(".dropdown-options");
409
- const optionsWrapper = shadow.querySelector(".dropdown-options-wrapper");
410
- if (!optionsDiv || !optionsWrapper)
411
- return;
412
- this._destroyOptionsScrollbar();
413
- optionsDiv.classList.add("ty-custom-scroll");
414
- this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
415
- vertical: true,
416
- });
417
- if (this._optionsScrollbar.trackY) {
418
- optionsWrapper.appendChild(this._optionsScrollbar.trackY);
419
- }
420
- }
421
- _destroyOptionsScrollbar() {
422
- if (this._optionsScrollbar) {
423
- this._optionsScrollbar.trackY?.remove();
424
- this._optionsScrollbar.destroy();
425
- this._optionsScrollbar = null;
426
- }
427
- }
428
- openDropdown() {
429
- const shadow = this.shadowRoot;
430
- const dialog = shadow.querySelector(".dropdown-dialog");
431
- if (!dialog)
432
- return;
433
- const stubEl = shadow.querySelector(".select-stub");
434
- if (stubEl)
435
- stubEl.setAttribute("aria-expanded", "true");
436
- const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
437
- this._scrollLockId = lockId;
438
- lockScroll(lockId);
439
- dialog.showModal();
440
- this.calculatePosition();
441
- dialog.classList.add("open");
442
- this._state.open = true;
443
- const chevron = shadow.querySelector(".dropdown-chevron");
444
- if (chevron)
445
- chevron.classList.add("open");
446
- const searchChevron = shadow.querySelector(".dropdown-search-chevron");
447
- if (searchChevron)
448
- searchChevron.classList.add("open");
449
- const tags = this.getTagElements().map((el) => this.getTagData(el));
450
- this._state.filteredTags = tags;
451
- this._state.highlightedIndex = -1;
452
- this.updateOptionsVisibility(true);
453
- this._setupOptionsScrollbar();
454
- const searchInput = shadow.querySelector(".dropdown-search-input");
455
- if (searchInput && this.shouldShowSearch()) {
456
- setTimeout(() => searchInput.focus(), 100);
457
- }
458
- this.fireOpenEvent();
459
- }
460
- closeDropdown() {
461
- const shadow = this.shadowRoot;
462
- const dialog = shadow.querySelector(".dropdown-dialog");
463
- if (!dialog)
464
- return;
465
- const stubEl = shadow.querySelector(".select-stub");
466
- if (stubEl)
467
- stubEl.setAttribute("aria-expanded", "false");
468
- this._destroyOptionsScrollbar();
469
- dialog.classList.remove("open");
470
- dialog.classList.remove("position-above");
471
- dialog.classList.remove("position-below");
472
- dialog.close();
473
- if (this._scrollLockId) {
474
- unlockScroll(this._scrollLockId);
475
- this._scrollLockId = null;
476
- }
477
- this._state.open = false;
478
- this._state.highlightedIndex = -1;
479
- const chevron = shadow.querySelector(".dropdown-chevron");
480
- if (chevron)
481
- chevron.classList.remove("open");
482
- const searchChevron = shadow.querySelector(".dropdown-search-chevron");
483
- if (searchChevron)
484
- searchChevron.classList.remove("open");
485
- const hadQuery = this._state.search !== "";
486
- this._state.search = "";
487
- const searchInput = shadow.querySelector(".dropdown-search-input");
488
- if (searchInput) {
489
- searchInput.value = "";
490
- }
491
- if (this._externalSearch) {
492
- if (hadQuery) {
493
- if (this._searchDebounceTimer !== null) {
494
- clearTimeout(this._searchDebounceTimer);
495
- this._searchDebounceTimer = null;
496
- }
497
- this.fireSearchEvent("");
498
- }
499
- }
500
- else {
501
- const allTags = this.getTagElements().map((el) => this.getTagData(el));
502
- this._state.filteredTags = allTags;
503
- this.updateTagVisibility(allTags, allTags);
504
- this.clearHighlights(allTags);
505
- }
506
- this.fireCloseEvent();
507
- }
508
- openMobileModal() {
509
- const shadow = this.shadowRoot;
510
- const dialog = shadow.querySelector(".mobile-dialog");
511
- if (!dialog)
512
- return;
513
- const stubEl = shadow.querySelector(".select-stub");
514
- if (stubEl)
515
- stubEl.setAttribute("aria-expanded", "true");
516
- const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
517
- this._scrollLockId = lockId;
518
- lockScroll(lockId);
519
- dialog.showModal();
520
- dialog.getBoundingClientRect();
521
- dialog.classList.add("open");
522
- this._state.open = true;
523
- const tags = this.getTagElements().map((el) => this.getTagData(el));
524
- this._state.filteredTags = tags;
525
- this.updateTagVisibility(tags, tags);
526
- requestAnimationFrame(() => {
527
- this.updateMobileSelectedState();
528
- });
529
- this.fireOpenEvent();
530
- }
531
- closeMobileModal() {
532
- const shadow = this.shadowRoot;
533
- const dialog = shadow.querySelector(".mobile-dialog");
534
- if (!dialog)
535
- return;
536
- const stubEl = shadow.querySelector(".select-stub");
537
- if (stubEl)
538
- stubEl.setAttribute("aria-expanded", "false");
539
- dialog.classList.remove("open");
540
- dialog.close();
541
- if (this._scrollLockId) {
542
- unlockScroll(this._scrollLockId);
543
- this._scrollLockId = null;
544
- }
545
- this._state.open = false;
546
- this._state.highlightedIndex = -1;
547
- const hadQuery = this._state.search !== "";
548
- this._state.search = "";
549
- const searchInput = shadow.querySelector(".mobile-search-input");
550
- if (searchInput) {
551
- searchInput.value = "";
552
- }
553
- if (this._externalSearch) {
554
- if (hadQuery) {
555
- if (this._searchDebounceTimer !== null) {
556
- clearTimeout(this._searchDebounceTimer);
557
- this._searchDebounceTimer = null;
558
- }
559
- this.fireSearchEvent("");
560
- }
561
- }
562
- else {
563
- const allTags = this.getTagElements();
564
- allTags.forEach((el) => el.removeAttribute("hidden"));
565
- }
566
- this.fireCloseEvent();
567
- }
568
- handleStubClick(e) {
569
- e.preventDefault();
570
- e.stopPropagation();
571
- if (this._disabled || this._readonly) {
572
- return;
573
- }
574
- this.openDropdown();
575
- }
576
- handleClearClick(e) {
577
- e.preventDefault();
578
- e.stopPropagation();
579
- if (this._disabled || this._readonly) {
580
- return;
581
- }
582
- this.clear();
583
- }
584
- handleStubKeydown(e) {
585
- if (this._disabled || this._readonly || this._state.open)
586
- return;
587
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
588
- e.preventDefault();
589
- this.openDropdown();
590
- }
591
- }
592
- handleTagClick(e) {
593
- const target = e.target;
594
- const tag = target.closest("ty-option, ty-tag");
595
- if (!tag || tag.hasAttribute("disabled"))
596
- return;
597
- e.preventDefault();
598
- e.stopPropagation();
599
- this.selectByTag(tag);
600
- }
601
- selectByTag(tag) {
602
- const tagValue = this.getTagData(tag).value;
603
- const currentValues = this.getSelectedValues();
604
- if (!this._multiple) {
605
- this.updateComponentValue([tagValue], true, "set", tagValue);
606
- if (this._state.mode === "mobile") {
607
- this.closeMobileModal();
608
- }
609
- else {
610
- this.closeDropdown();
611
- }
612
- return;
613
- }
614
- if (tag.hasAttribute("selected")) {
615
- const newValues = currentValues.filter((v) => v !== tagValue);
616
- this.updateComponentValue(newValues, true, "remove", tagValue);
617
- }
618
- else {
619
- this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
620
- }
621
- }
622
- tryCreateFromSearch() {
623
- if (!this._allowCreate)
624
- return false;
625
- const query = (this._state.search || "").trim();
626
- if (!query)
627
- return false;
628
- const queryLower = query.toLowerCase();
629
- const allTags = this.getTagElements().map((el) => this.getTagData(el));
630
- const existing = allTags.find((t) => t.value.toLowerCase() === queryLower ||
631
- t.text.toLowerCase() === queryLower);
632
- if (existing) {
633
- this.selectByTag(existing.element);
634
- return true;
635
- }
636
- const rawLabel = query;
637
- const defaultValue = this._createTransform === "slug" ? slugify(rawLabel) : rawLabel;
638
- if (!defaultValue)
639
- return true;
640
- const detail = { value: defaultValue, label: rawLabel };
641
- const event = new CustomEvent("create", {
642
- detail,
643
- bubbles: true,
644
- composed: true,
645
- cancelable: true,
646
- });
647
- this.dispatchEvent(event);
648
- if (event.defaultPrevented)
649
- return true;
650
- const finalValue = detail.value;
651
- const option = document.createElement("ty-option");
652
- option.setAttribute("value", finalValue);
653
- option.textContent = rawLabel;
654
- this.appendChild(option);
655
- if (!this._multiple) {
656
- this.updateComponentValue([finalValue], true, "create", finalValue);
657
- if (this._state.mode === "mobile") {
658
- this.closeMobileModal();
659
- }
660
- else {
661
- this.closeDropdown();
662
- }
663
- return true;
664
- }
665
- const currentValues = this.getSelectedValues();
666
- this.updateComponentValue([...currentValues, finalValue], true, "create", finalValue);
667
- this.clearSearchAfterCreate();
668
- return true;
669
- }
670
- clearSearchAfterCreate() {
671
- this._state.search = "";
672
- this._state.highlightedIndex = -1;
673
- const shadow = this.shadowRoot;
674
- if (!shadow)
675
- return;
676
- const desktopInput = shadow.querySelector(".dropdown-search-input");
677
- const mobileInput = shadow.querySelector(".mobile-search-input");
678
- if (desktopInput)
679
- desktopInput.value = "";
680
- if (mobileInput)
681
- mobileInput.value = "";
682
- const allTags = this.getTagElements().map((el) => this.getTagData(el));
683
- this._state.filteredTags = allTags;
684
- this.updateTagVisibility(allTags, allTags);
685
- this.updateOptionsVisibility(true);
686
- this.clearHighlights(allTags);
687
- this.updateMobileSelectedState();
688
- (desktopInput ?? mobileInput)?.focus();
689
- }
690
- blockSearchClick(e) {
691
- e.stopPropagation();
692
- e.preventDefault();
693
- }
694
- handleSearchInput(e) {
695
- const target = e.target;
696
- const query = target.value;
697
- this._state.search = query;
698
- if (this._externalSearch) {
699
- this.dispatchSearchEvent(query);
700
- return;
701
- }
702
- const allTags = this.getTagElements().map((el) => this.getTagData(el));
703
- const filtered = this.filterTags(allTags, query);
704
- this._state.filteredTags = filtered;
705
- this._state.highlightedIndex = -1;
706
- this.updateTagVisibility(filtered, allTags);
707
- this.updateOptionsVisibility(filtered.length > 0);
708
- this.updateMobileSelectedState();
709
- this.clearHighlights(allTags);
710
- }
711
- handleKeyboard(e) {
712
- if (!this._state.open)
713
- return;
714
- const shadow = this.shadowRoot;
715
- const searchInput = shadow.querySelector(".dropdown-search-input");
716
- const target = e.target;
717
- const shouldHandle = target === searchInput || document.activeElement !== searchInput;
718
- if (!shouldHandle)
719
- return;
720
- const filteredTags = this._state.filteredTags;
721
- const tagsCount = filteredTags.length;
722
- const currentHighlightedIndex = this._state.highlightedIndex;
723
- switch (e.key) {
724
- case "Escape":
725
- e.preventDefault();
726
- e.stopPropagation();
727
- this.closeDropdown();
728
- break;
729
- case "Enter":
730
- e.preventDefault();
731
- e.stopPropagation();
732
- if (currentHighlightedIndex >= 0 &&
733
- currentHighlightedIndex < tagsCount) {
734
- this.selectByTag(filteredTags[currentHighlightedIndex].element);
735
- }
736
- else {
737
- this.tryCreateFromSearch();
738
- }
739
- break;
740
- case "ArrowUp":
741
- e.preventDefault();
742
- e.stopPropagation();
743
- let newIndexUp;
744
- if (tagsCount === 0) {
745
- newIndexUp = -1;
746
- }
747
- else if (currentHighlightedIndex === -1) {
748
- newIndexUp = tagsCount - 1;
749
- }
750
- else if (currentHighlightedIndex === 0) {
751
- newIndexUp = tagsCount - 1;
752
- }
753
- else {
754
- newIndexUp = currentHighlightedIndex - 1;
755
- }
756
- this._state.highlightedIndex = newIndexUp;
757
- this.highlightTag(filteredTags, newIndexUp);
758
- break;
759
- case "ArrowDown":
760
- e.preventDefault();
761
- e.stopPropagation();
762
- let newIndexDown;
763
- if (tagsCount === 0) {
764
- newIndexDown = -1;
765
- }
766
- else if (currentHighlightedIndex === -1) {
767
- newIndexDown = 0;
768
- }
769
- else if (currentHighlightedIndex === tagsCount - 1) {
770
- newIndexDown = 0;
771
- }
772
- else {
773
- newIndexDown = currentHighlightedIndex + 1;
774
- }
775
- this._state.highlightedIndex = newIndexDown;
776
- this.highlightTag(filteredTags, newIndexDown);
777
- break;
778
- }
779
- }
780
- filterTags(tags, query) {
781
- if (!query || query.trim() === "") {
782
- return tags;
783
- }
784
- const searchLower = query.toLowerCase();
785
- return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
786
- }
787
- updateTagVisibility(filteredTags, allTags) {
788
- const visibleValues = new Set(filteredTags.map((tag) => tag.value));
789
- allTags.forEach(({ value, element }) => {
790
- if (visibleValues.has(value)) {
791
- element.removeAttribute("hidden");
792
- }
793
- else {
794
- element.setAttribute("hidden", "");
795
- }
796
- });
797
- }
798
- updateOptionsVisibility(hasOptions) {
799
- const shadow = this.shadowRoot;
800
- const options = shadow.querySelector(".dropdown-options");
801
- if (options) {
802
- options.style.display = hasOptions ? "" : "none";
803
- }
804
- }
805
- clearHighlights(tags) {
806
- tags.forEach(({ element }) => {
807
- element.removeAttribute("highlighted");
808
- });
809
- }
810
- highlightTag(tags, index) {
811
- this.clearHighlights(tags);
812
- if (index >= 0 && index < tags.length) {
813
- const { element } = tags[index];
814
- element.setAttribute("highlighted", "");
815
- element.scrollIntoView({
816
- behavior: "smooth",
817
- block: "nearest",
818
- inline: "nearest",
819
- });
820
- }
821
- }
822
- dispatchSearchEvent(query) {
823
- if (this._searchDebounceTimer !== null) {
824
- clearTimeout(this._searchDebounceTimer);
825
- this._searchDebounceTimer = null;
826
- }
827
- if (this._debounce > 0) {
828
- this._searchDebounceTimer = window.setTimeout(() => {
829
- this.fireSearchEvent(query);
830
- this._searchDebounceTimer = null;
831
- }, this._debounce);
832
- }
833
- else {
834
- this.fireSearchEvent(query);
835
- }
836
- }
837
- fireSearchEvent(query) {
838
- this.dispatchEvent(new CustomEvent("search", {
839
- detail: {
840
- query,
841
- element: this,
842
- },
843
- bubbles: true,
844
- composed: true,
845
- }));
846
- }
847
- fireOpenEvent() {
848
- this.dispatchEvent(new CustomEvent("open", {
849
- detail: { mode: this._state.mode, element: this },
850
- }));
851
- if (this._externalSearch) {
852
- this.fireSearchEvent("");
853
- }
854
- }
855
- fireCloseEvent() {
856
- this.dispatchEvent(new CustomEvent("close", {
857
- detail: { mode: this._state.mode, element: this },
858
- }));
859
- }
860
- dispatchChangeEvent(detail) {
861
- this.dispatchEvent(new CustomEvent("change", {
862
- detail,
863
- bubbles: true,
864
- cancelable: true,
865
- }));
866
- }
867
- render() {
868
- this._state.mode = isMobileTouch() ? "mobile" : "desktop";
869
- if (this._state.mode === "mobile") {
870
- this.renderMobile();
871
- }
872
- else {
873
- this.renderDesktop();
874
- }
875
- this.updateSearchVisibility();
876
- this.applyLoadingState();
877
- this.updateValidity();
878
- }
879
- updateValidity() {
880
- if (this.disabled) {
881
- this._internals.setValidity({});
882
- return;
883
- }
884
- if (this.required && this.getSelectedValues().length === 0) {
885
- const stub = this.shadowRoot?.querySelector(".select-stub");
886
- this._internals.setValidity({ valueMissing: true }, "Please select a value", stub ?? undefined);
887
- }
888
- else {
889
- this._internals.setValidity({});
890
- }
891
- }
892
- setupTriggerSlot() {
893
- const shadow = this.shadowRoot;
894
- const stub = shadow.querySelector(".select-stub");
895
- const slot = shadow.querySelector('slot[name="trigger"]');
896
- if (!stub || !slot)
897
- return;
898
- const sync = () => {
899
- const hasTrigger = slot.assignedElements().length > 0;
900
- stub.classList.toggle("custom-trigger", hasTrigger);
901
- this.toggleAttribute("has-trigger", hasTrigger);
902
- };
903
- slot.addEventListener("slotchange", sync);
904
- sync();
905
- }
906
- setupEventListeners() {
907
- const shadow = this.shadowRoot;
908
- const stub = shadow.querySelector(".select-stub");
909
- const optionsSlot = shadow.querySelector("#options-slot");
910
- const searchInput = shadow.querySelector(".dropdown-search-input");
911
- if (stub) {
912
- this._stubClickHandler = this.handleStubClick.bind(this);
913
- stub.addEventListener("click", this._stubClickHandler);
914
- stub.addEventListener("keydown", this.handleStubKeydown.bind(this));
915
- }
916
- const clearBtn = shadow.querySelector(".select-clear");
917
- clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
918
- this.setupTriggerSlot();
919
- if (optionsSlot) {
920
- this._tagClickHandler = this.handleTagClick.bind(this);
921
- optionsSlot.addEventListener("click", this._tagClickHandler);
922
- }
923
- if (searchInput) {
924
- this._searchInputHandler = this.handleSearchInput.bind(this);
925
- this._blockSearchClick = this.blockSearchClick.bind(this);
926
- searchInput.addEventListener("input", this._searchInputHandler);
927
- searchInput.addEventListener("click", this._blockSearchClick);
928
- }
929
- const dialog = shadow.querySelector(".dropdown-dialog");
930
- if (dialog) {
931
- dialog.addEventListener("click", (e) => {
932
- if (e.target === dialog) {
933
- this.closeDropdown();
934
- }
935
- });
936
- }
937
- this._keyboardHandler = this.handleKeyboard.bind(this);
938
- document.addEventListener("keydown", this._keyboardHandler);
939
- }
940
- buildStubClasses() {
941
- const classes = [this._size];
942
- if (this._disabled)
943
- classes.push("disabled");
944
- if (this._compact)
945
- classes.push("compact");
946
- return classes.join(" ");
947
- }
948
- renderDesktop() {
949
- const shadow = this.shadowRoot;
950
- if (!shadow.querySelector(".select-container")) {
951
- const stubClasses = this.buildStubClasses();
952
- const listboxId = `select-listbox-${getElementHash(this)}`;
953
- const labelId = `select-label-${getElementHash(this)}`;
954
- const labelHtml = this._label
955
- ? `
956
- <label class="ty-field-label" id="${labelId}">
957
- ${this._label}
958
- ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
959
- </label>
960
- `
961
- : "";
962
- const searchPlaceholder = "Search...";
963
- shadow.innerHTML = `
964
- <div class="select-container dropdown-mode-desktop">
965
- ${labelHtml}
966
- <div class="dropdown-wrapper">
967
- <div class="dropdown-stub select-stub ${stubClasses}"
968
- role="combobox"
969
- aria-haspopup="listbox"
970
- aria-expanded="false"
971
- aria-controls="${listboxId}"
972
- ${this._label ? `aria-labelledby="${labelId}"` : ""}
973
- tabindex="${this._disabled ? "-1" : "0"}"
974
- ${this._disabled ? 'disabled aria-disabled="true"' : ""}>
975
- <slot name="trigger">
976
- <slot name="start"></slot>
977
- <slot name="selected"></slot>
978
- <span class="dropdown-placeholder">${this._placeholder}</span>
979
- <span class="select-count" hidden></span>
980
- <slot name="end"></slot>
981
- <button class="select-clear" type="button" aria-label="Clear selection" hidden>
982
- ${CLEAR_ICON_SVG}
983
- </button>
984
- <div class="dropdown-chevron">
985
- ${CHEVRON_DOWN_SVG}
986
- </div>
987
- </slot>
988
- </div>
989
- <dialog class="dropdown-dialog">
990
- <div class="dropdown-header">
991
- <div class="dropdown-search-icon" aria-hidden="true">
992
- ${SEARCH_ICON_SVG}
993
- </div>
994
- <input
995
- class="dropdown-search-input ${this._size}"
996
- type="text"
997
- placeholder="${searchPlaceholder}"
998
- ${this._disabled ? "disabled" : ""}
999
- />
1000
- <div class="dropdown-search-chevron">
1001
- ${CHEVRON_DOWN_SVG}
1002
- </div>
1003
- </div>
1004
- <div class="dropdown-options-wrapper">
1005
- <div class="dropdown-options" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
1006
- <slot id="options-slot"></slot>
1007
- </div>
1008
- <div class="dropdown-loading" aria-hidden="true">
1009
- <slot name="loading">
1010
- <span class="dropdown-loading-spinner"></span>
1011
- <span class="dropdown-loading-text">Searching…</span>
1012
- </slot>
1013
- </div>
1014
- </div>
1015
- </dialog>
1016
- </div>
1017
- </div>
1018
- `;
1019
- this.setupEventListeners();
1020
- }
1021
- this.updateSelectionDisplay();
1022
- }
1023
- renderMobile() {
1024
- const shadow = this.shadowRoot;
1025
- if (!shadow.querySelector(".select-container")) {
1026
- const stubClasses = this.buildStubClasses();
1027
- const listboxId = `select-listbox-${getElementHash(this)}`;
1028
- const labelId = `select-label-${getElementHash(this)}`;
1029
- const labelHtml = this._label
1030
- ? `
1031
- <label class="ty-field-label" id="${labelId}">
1032
- ${this._label}
1033
- ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1034
- </label>
1035
- `
1036
- : "";
1037
- const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1038
- <line x1="18" y1="6" x2="6" y2="18"></line>
1039
- <line x1="6" y1="6" x2="18" y2="18"></line>
1040
- </svg>`;
1041
- const searchPlaceholder = this._label
1042
- ? `Search ${this._label}...`
1043
- : "Search...";
1044
- const searchHeaderHtml = `
1045
- <div class="mobile-search-header">
1046
- ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
1047
- <div class="mobile-header-content">
1048
- <div class="dropdown-search-icon" aria-hidden="true">
1049
- ${SEARCH_ICON_SVG}
1050
- </div>
1051
- <input
1052
- class="mobile-search-input ${this._size}"
1053
- type="text"
1054
- placeholder="${searchPlaceholder}"
1055
- autofocus
1056
- ${this._disabled ? "disabled" : ""}
1057
- />
1058
- <button class="mobile-close-button" type="button" aria-label="Close">
1059
- ${closeButtonSvg}
1060
- </button>
1061
- </div>
1062
- </div>
1063
- `;
1064
- shadow.innerHTML = `
1065
- <div class="select-container dropdown-mode-mobile">
1066
- ${labelHtml}
1067
- <div class="dropdown-wrapper">
1068
- <div class="dropdown-stub select-stub ${stubClasses}"
1069
- role="combobox"
1070
- aria-haspopup="listbox"
1071
- aria-expanded="false"
1072
- aria-controls="${listboxId}"
1073
- ${this._label ? `aria-labelledby="${labelId}"` : ""}
1074
- tabindex="${this._disabled ? "-1" : "0"}"
1075
- ${this._disabled ? 'disabled aria-disabled="true"' : ""}>
1076
- <slot name="trigger">
1077
- <slot name="start"></slot>
1078
- <slot name="selected"></slot>
1079
- <span class="dropdown-placeholder">${this._placeholder}</span>
1080
- <span class="select-count" hidden></span>
1081
- <slot name="end"></slot>
1082
- <button class="select-clear" type="button" aria-label="Clear selection" hidden>
1083
- ${CLEAR_ICON_SVG}
1084
- </button>
1085
- <div class="dropdown-chevron">
1086
- ${CHEVRON_DOWN_SVG}
1087
- </div>
1088
- </slot>
1089
- </div>
1090
-
1091
- <dialog class="mobile-dialog">
1092
- <div class="mobile-dialog-content">
1093
-
1094
- <!-- HEADER (matches dropdown.ts) -->
1095
- ${searchHeaderHtml}
1096
-
1097
- <!-- BODY: options list with toggleable selected state -->
1098
- <div class="mobile-body">
1099
-
1100
- <!-- AVAILABLE LIST (always visible, takes remaining space) -->
1101
- <div class="mobile-available-section" data-empty="false">
1102
- <div class="section-header">
1103
- <span class="section-title">${this._availableLabel}</span>
1104
- </div>
1105
- <div class="section-content dropdown-options-wrapper" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
1106
- <slot id="options-slot"></slot>
1107
- <div class="empty-state">${this._noOptionsMessage}</div>
1108
- <div class="dropdown-loading" aria-hidden="true">
1109
- <slot name="loading">
1110
- <span class="dropdown-loading-spinner"></span>
1111
- <span class="dropdown-loading-text">Searching…</span>
1112
- </slot>
1113
- </div>
1114
- </div>
1115
- </div>
1116
-
1117
- </div>
1118
- </div>
1119
- </dialog>
1120
- </div>
1121
- </div>
1122
- `;
1123
- this.setupMobileEventListeners();
1124
- }
1125
- this.updateSelectionDisplay();
1126
- }
1127
- setupMobileEventListeners() {
1128
- const shadow = this.shadowRoot;
1129
- const stub = shadow.querySelector(".select-stub");
1130
- const optionsSlot = shadow.querySelector("#options-slot");
1131
- const searchInput = shadow.querySelector(".mobile-search-input");
1132
- const closeButton = shadow.querySelector(".mobile-close-button");
1133
- const dialog = shadow.querySelector(".mobile-dialog");
1134
- if (stub) {
1135
- stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
1136
- stub.addEventListener("keydown", (e) => this.handleMobileStubKeydown(e));
1137
- }
1138
- const clearBtn = shadow.querySelector(".select-clear");
1139
- clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
1140
- this.setupTriggerSlot();
1141
- if (optionsSlot) {
1142
- optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
1143
- }
1144
- if (searchInput) {
1145
- searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
1146
- }
1147
- if (closeButton) {
1148
- closeButton.addEventListener("click", () => this.closeMobileModal());
1149
- }
1150
- if (dialog) {
1151
- dialog.addEventListener("click", (e) => {
1152
- if (e.target === dialog) {
1153
- this.closeMobileModal();
1154
- }
1155
- });
1156
- dialog.addEventListener("cancel", (e) => {
1157
- e.preventDefault();
1158
- this.closeMobileModal();
1159
- });
1160
- }
1161
- }
1162
- handleMobileStubClick(e) {
1163
- e.preventDefault();
1164
- e.stopPropagation();
1165
- if (this._disabled || this._readonly) {
1166
- return;
1167
- }
1168
- this.openMobileModal();
1169
- }
1170
- handleMobileStubKeydown(e) {
1171
- if (this._disabled || this._readonly || this._state.open)
1172
- return;
1173
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
1174
- e.preventDefault();
1175
- this.openMobileModal();
1176
- }
1177
- }
1178
- handleMobileTagClick(e) {
1179
- this.handleTagClick(e);
1180
- }
1181
- updateMobileSelectedState() {
1182
- if (this._state.mode !== "mobile")
1183
- return;
1184
- const shadow = this.shadowRoot;
1185
- const availableSection = shadow.querySelector(".mobile-available-section");
1186
- if (availableSection) {
1187
- const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
1188
- availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
1189
- const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
1190
- if (availableTitleSpan) {
1191
- availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
1192
- }
1193
- }
1194
- }
1195
- shouldShowSearch() {
1196
- if (this._externalSearch)
1197
- return true;
1198
- if (this._allowCreate)
1199
- return true;
1200
- if (this._searchable === "always")
1201
- return true;
1202
- if (this._searchable === "never")
1203
- return false;
1204
- return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
1205
- }
1206
- updateSearchVisibility() {
1207
- const shadow = this.shadowRoot;
1208
- if (!shadow)
1209
- return;
1210
- const show = this.shouldShowSearch();
1211
- const header = shadow.querySelector(".dropdown-header");
1212
- if (header)
1213
- header.hidden = !show;
1214
- const mobileInput = shadow.querySelector(".mobile-search-input");
1215
- if (mobileInput)
1216
- mobileInput.hidden = !show;
1217
- const mobileIcon = shadow.querySelector(".mobile-header-content .dropdown-search-icon");
1218
- if (mobileIcon)
1219
- mobileIcon.hidden = !show;
1220
- }
1221
- updateSelectionDisplay() {
1222
- const shadow = this.shadowRoot;
1223
- const stub = shadow.querySelector(".select-stub");
1224
- if (!stub)
1225
- return;
1226
- const values = this._state.selectedValues;
1227
- const count = values.length;
1228
- stub.classList.toggle("has-selection", count > 0);
1229
- stub.classList.toggle("compact", this._compact);
1230
- const badge = stub.querySelector(".select-count");
1231
- if (badge) {
1232
- badge.textContent = String(count);
1233
- badge.hidden = count === 0 || !this._multiple || !this._compact;
1234
- }
1235
- const clearBtn = stub.querySelector(".select-clear");
1236
- if (clearBtn) {
1237
- clearBtn.hidden = !(count > 0 && this._clearable && !this._disabled && !this._readonly);
1238
- }
1239
- const wantClone = !this._multiple && count > 0 ? values[0] : null;
1240
- this.syncSelectedClone(wantClone);
1241
- stub.classList.toggle("has-clone", wantClone !== null);
1242
- const textEl = stub.querySelector(".dropdown-placeholder");
1243
- if (textEl) {
1244
- textEl.hidden = wantClone !== null;
1245
- const showLabels = count > 0 && this._multiple && !this._compact;
1246
- if (showLabels) {
1247
- textEl.textContent = this.getSelectedItems(values)
1248
- .map((i) => i.label)
1249
- .join(", ");
1250
- textEl.classList.remove("placeholder-shown");
1251
- }
1252
- else {
1253
- textEl.textContent = this._placeholder;
1254
- textEl.classList.add("placeholder-shown");
1255
- }
1256
- }
1257
- }
1258
- syncSelectedClone(value) {
1259
- const existing = this.querySelector(':scope > [cloned][slot="selected"]');
1260
- if (existing?.getAttribute("value") === value) {
1261
- this._selectedClone = existing;
1262
- return;
1263
- }
1264
- existing?.remove();
1265
- if (value === null) {
1266
- this._selectedClone = null;
1267
- return;
1268
- }
1269
- const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
1270
- if (option) {
1271
- const clone = option.cloneNode(true);
1272
- clone.removeAttribute("selected");
1273
- clone.setAttribute("slot", "selected");
1274
- clone.setAttribute("cloned", "true");
1275
- this.appendChild(clone);
1276
- this._selectedClone = clone;
1277
- }
1278
- else if (this._selectedClone?.getAttribute("value") === value) {
1279
- const restored = this._selectedClone.cloneNode(true);
1280
- this.appendChild(restored);
1281
- this._selectedClone = restored;
1282
- }
1283
- }
1284
- deselectValue(value) {
1285
- const current = this.getSelectedValues();
1286
- if (!current.includes(value))
1287
- return;
1288
- this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
1289
- }
1290
- clear() {
1291
- this.updateComponentValue([], true, "clear", null);
1292
- }
1293
- get value() {
1294
- return this.getSelectedValues().join(",");
1295
- }
1296
- set value(val) {
1297
- this.setProperty("value", val);
1298
- }
1299
- get name() {
1300
- return this.getProperty("name");
1301
- }
1302
- set name(val) {
1303
- this.setProperty("name", val);
1304
- }
1305
- get multiple() {
1306
- return this.getProperty("multiple");
1307
- }
1308
- set multiple(value) {
1309
- this.setProperty("multiple", value);
1310
- }
1311
- get compact() {
1312
- return this.getProperty("compact");
1313
- }
1314
- set compact(value) {
1315
- this.setProperty("compact", value);
1316
- }
1317
- get placeholder() {
1318
- return this.getProperty("placeholder");
1319
- }
1320
- set placeholder(val) {
1321
- this.setProperty("placeholder", val);
1322
- }
1323
- get label() {
1324
- return this.getProperty("label");
1325
- }
1326
- set label(val) {
1327
- this.setProperty("label", val);
1328
- }
1329
- get disabled() {
1330
- return this.getProperty("disabled");
1331
- }
1332
- set disabled(value) {
1333
- this.setProperty("disabled", value);
1334
- }
1335
- get loading() {
1336
- return this.getProperty("loading");
1337
- }
1338
- set loading(value) {
1339
- this.setProperty("loading", value);
1340
- }
1341
- get readonly() {
1342
- return this.getProperty("readonly");
1343
- }
1344
- set readonly(value) {
1345
- this.setProperty("readonly", value);
1346
- }
1347
- get clearable() {
1348
- return this.getProperty("clearable");
1349
- }
1350
- set clearable(value) {
1351
- this.setProperty("clearable", value);
1352
- }
1353
- get required() {
1354
- return this.getProperty("required");
1355
- }
1356
- set required(value) {
1357
- this.setProperty("required", value);
1358
- }
1359
- get searchable() {
1360
- return this.getProperty("searchable");
1361
- }
1362
- set searchable(value) {
1363
- this.setProperty("searchable", value);
1364
- }
1365
- get externalSearch() {
1366
- return this.getProperty("externalSearch");
1367
- }
1368
- set externalSearch(value) {
1369
- this.setProperty("externalSearch", value);
1370
- }
1371
- get debounce() {
1372
- return this.getProperty("debounce");
1373
- }
1374
- set debounce(value) {
1375
- const numValue = typeof value === "string" ? parseInt(value, 10) : value;
1376
- this.setProperty("debounce", numValue);
1377
- }
1378
- get size() {
1379
- return this.getProperty("size");
1380
- }
1381
- set size(value) {
1382
- this.setProperty("size", value);
1383
- }
1384
- get align() {
1385
- return this.getProperty("align");
1386
- }
1387
- set align(value) {
1388
- this.setProperty("align", value);
1389
- }
1390
- get placement() {
1391
- return this.getProperty("placement");
1392
- }
1393
- set placement(value) {
1394
- this.setProperty("placement", value);
1395
- }
1396
- get flavor() {
1397
- return this.getProperty("flavor");
1398
- }
1399
- set flavor(value) {
1400
- this.setProperty("flavor", value);
1401
- }
1402
- get form() {
1403
- return this._internals.form;
1404
- }
44
+ constructor() {
45
+ super();
46
+ this._name = "";
47
+ this._multiple = false;
48
+ this._compact = false;
49
+ this._placeholder = "Select...";
50
+ this._label = "";
51
+ this._disabled = false;
52
+ this._readonly = false;
53
+ this._required = false;
54
+ this._clearable = true;
55
+ this._externalSearch = false;
56
+ this._allowCreate = false;
57
+ this._createTransform = "none";
58
+ this._searchable = "auto";
59
+ this._loading = false;
60
+ this._scrollLockId = null;
61
+ this._size = DEFAULT_SIZE;
62
+ this._availableLabel = "Available";
63
+ this._noOptionsMessage = "No options available";
64
+ this._state = {
65
+ open: false,
66
+ search: "",
67
+ highlightedIndex: -1,
68
+ filteredTags: [],
69
+ selectedValues: [],
70
+ mode: "desktop",
71
+ };
72
+ this._stubClickHandler = null;
73
+ this._tagClickHandler = null;
74
+ this._searchInputHandler = null;
75
+ this._blockSearchClick = null;
76
+ this._keyboardHandler = null;
77
+ this._debounce = 0;
78
+ this._searchDebounceTimer = null;
79
+ this._optionsScrollbar = null;
80
+ this._childObserver = null;
81
+ this._selectedClone = null;
82
+ this._customFlavorSheet = null;
83
+ const shadow = this.shadowRoot;
84
+ ensureStyles(shadow, { css: selectBaseStyles, id: "ty-select-base" });
85
+ ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
86
+ }
87
+ onConnect() {
88
+ const shadow = this.shadowRoot;
89
+ const dialogs = shadow.querySelectorAll("dialog");
90
+ dialogs.forEach((dialog) => {
91
+ if (dialog.open) {
92
+ console.warn("⚠️ Found open dialog on connect, closing it");
93
+ dialog.close();
94
+ }
95
+ });
96
+ this.render();
97
+ requestAnimationFrame(() => {
98
+ this.initializeState();
99
+ });
100
+ this._childObserver = new MutationObserver(() => {
101
+ this.ensureOptionRoles();
102
+ if (this._state.selectedValues.length > 0) {
103
+ this.syncSelectedTags(this._state.selectedValues);
104
+ }
105
+ this.updateSearchVisibility();
106
+ this.updateSelectionDisplay();
107
+ this.updateMobileSelectedState();
108
+ });
109
+ this._childObserver.observe(this, { childList: true });
110
+ this.ensureOptionRoles();
111
+ this._syncCustomFlavor();
112
+ }
113
+ _syncCustomFlavor() {
114
+ this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.getProperty("flavor"), ({ base }) => selectCustomFlavorCss(base));
115
+ }
116
+ onDisconnect() {
117
+ const shadow = this.shadowRoot;
118
+ const dialogs = shadow.querySelectorAll("dialog");
119
+ dialogs.forEach((dialog) => {
120
+ if (dialog.open) {
121
+ dialog.close();
122
+ }
123
+ });
124
+ if (this._keyboardHandler) {
125
+ document.removeEventListener("keydown", this._keyboardHandler);
126
+ this._keyboardHandler = null;
127
+ }
128
+ if (this._searchDebounceTimer !== null) {
129
+ clearTimeout(this._searchDebounceTimer);
130
+ this._searchDebounceTimer = null;
131
+ }
132
+ this._destroyOptionsScrollbar();
133
+ if (this._childObserver) {
134
+ this._childObserver.disconnect();
135
+ this._childObserver = null;
136
+ }
137
+ }
138
+ onPropertiesChanged(changes) {
139
+ for (const { name, newValue } of changes) {
140
+ switch (name) {
141
+ case "value":
142
+ const selectedValues = this.parseValue(newValue);
143
+ this._state.selectedValues = selectedValues;
144
+ if (this.isConnected && this.shadowRoot) {
145
+ this.syncSelectedTags(selectedValues);
146
+ this.updateSelectionDisplay();
147
+ this.updateMobileSelectedState();
148
+ }
149
+ break;
150
+ case "name":
151
+ this._name = newValue || "";
152
+ break;
153
+ case "flavor":
154
+ this._syncCustomFlavor();
155
+ break;
156
+ case "multiple":
157
+ this._multiple = newValue;
158
+ if (!newValue && this._state.selectedValues.length > 1) {
159
+ this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
160
+ }
161
+ break;
162
+ case "compact":
163
+ this._compact = newValue;
164
+ break;
165
+ case "placeholder":
166
+ this._placeholder = newValue || "Select...";
167
+ if (this.isConnected && this.shadowRoot)
168
+ this.updateSelectionDisplay();
169
+ break;
170
+ case "label":
171
+ this._label = newValue || "";
172
+ break;
173
+ case "disabled":
174
+ this._disabled = newValue;
175
+ break;
176
+ case "readonly":
177
+ this._readonly = newValue;
178
+ break;
179
+ case "required":
180
+ this._required = newValue;
181
+ break;
182
+ case "clearable":
183
+ this._clearable = newValue;
184
+ if (this.isConnected && this.shadowRoot)
185
+ this.updateSelectionDisplay();
186
+ break;
187
+ case "externalSearch":
188
+ this._externalSearch = newValue;
189
+ break;
190
+ case "searchable":
191
+ this._searchable = newValue;
192
+ if (this.isConnected && this.shadowRoot)
193
+ this.updateSearchVisibility();
194
+ break;
195
+ case "allowCreate":
196
+ this._allowCreate = newValue;
197
+ if (this.isConnected && this.shadowRoot)
198
+ this.updateSearchVisibility();
199
+ break;
200
+ case "createTransform":
201
+ this._createTransform = newValue;
202
+ break;
203
+ case "size":
204
+ this._size = newValue;
205
+ break;
206
+ case "debounce":
207
+ this._debounce = newValue;
208
+ break;
209
+ case "available-label":
210
+ this._availableLabel = newValue || "Available";
211
+ break;
212
+ case "no-options-message":
213
+ this._noOptionsMessage = newValue || "No options available";
214
+ break;
215
+ case "loading":
216
+ this._loading = newValue;
217
+ this.applyLoadingState();
218
+ break;
219
+ }
220
+ }
221
+ }
222
+ applyLoadingState() {
223
+ const shadow = this.shadowRoot;
224
+ if (!shadow)
225
+ return;
226
+ const svg = this._loading ? getLoaderSvg() : null;
227
+ shadow.querySelectorAll(".dropdown-options-wrapper").forEach((wrapper) => {
228
+ wrapper.classList.toggle("loading", this._loading);
229
+ if (this._loading) {
230
+ wrapper.setAttribute("aria-busy", "true");
231
+ const spinner = wrapper.querySelector(".dropdown-loading-spinner");
232
+ if (spinner && svg)
233
+ spinner.innerHTML = svg;
234
+ }
235
+ else {
236
+ wrapper.removeAttribute("aria-busy");
237
+ }
238
+ });
239
+ }
240
+ getFormValue() {
241
+ const selectedValues = this._state.selectedValues;
242
+ if (this._name && selectedValues.length > 0) {
243
+ if (!this._multiple)
244
+ return selectedValues[0];
245
+ const formData = new FormData();
246
+ selectedValues.forEach((value) => {
247
+ formData.append(this._name, value);
248
+ });
249
+ return formData;
250
+ }
251
+ return null;
252
+ }
253
+ parseValue(value) {
254
+ if (!value || typeof value !== "string" || value.trim() === "")
255
+ return [];
256
+ return value
257
+ .split(",")
258
+ .map((v) => v.trim())
259
+ .filter((v) => v !== "");
260
+ }
261
+ initializeState() {
262
+ const initialValue = this.getProperty("value") || "";
263
+ if (initialValue) {
264
+ const selectedValues = this.parseValue(initialValue);
265
+ this._state.selectedValues = selectedValues;
266
+ this.syncSelectedTags(selectedValues);
267
+ this.updateSelectionDisplay();
268
+ this.updateMobileSelectedState();
269
+ }
270
+ else {
271
+ const allTags = this.getTagElements();
272
+ const preSelectedTags = allTags
273
+ .filter((tag) => tag.hasAttribute("selected"))
274
+ .map((tag) => this.getTagData(tag).value);
275
+ if (preSelectedTags.length > 0) {
276
+ this.updateComponentValue(preSelectedTags, false);
277
+ }
278
+ }
279
+ }
280
+ getTagElements() {
281
+ return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
282
+ }
283
+ getTagData(element) {
284
+ const value = element.value ||
285
+ element.getAttribute("value") ||
286
+ element.textContent ||
287
+ "";
288
+ const text = element.getAttribute("label") || element.textContent || "";
289
+ return { value, text, element };
290
+ }
291
+ selectTag(tag) {
292
+ tag.setAttribute("selected", "");
293
+ }
294
+ deselectTag(tag) {
295
+ tag.removeAttribute("selected");
296
+ }
297
+ getSelectedValues() {
298
+ return this.getTagElements()
299
+ .filter((tag) => tag.hasAttribute("selected"))
300
+ .map((tag) => this.getTagData(tag).value)
301
+ .filter((value) => value !== "");
302
+ }
303
+ syncSelectedTags(selectedValues) {
304
+ const selectedSet = new Set(selectedValues);
305
+ const tags = this.getTagElements();
306
+ tags.forEach((tag) => {
307
+ const tagValue = this.getTagData(tag).value;
308
+ const shouldBeSelected = selectedSet.has(tagValue);
309
+ const isSelected = tag.hasAttribute("selected");
310
+ if (shouldBeSelected && !isSelected) {
311
+ this.selectTag(tag);
312
+ }
313
+ else if (!shouldBeSelected && isSelected) {
314
+ this.deselectTag(tag);
315
+ }
316
+ tag.setAttribute("aria-selected", String(shouldBeSelected));
317
+ });
318
+ }
319
+ ensureOptionRoles() {
320
+ this.getTagElements().forEach((tag) => {
321
+ if (!tag.hasAttribute("role"))
322
+ tag.setAttribute("role", "option");
323
+ if (!tag.hasAttribute("aria-selected"))
324
+ tag.setAttribute("aria-selected", "false");
325
+ });
326
+ }
327
+ updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
328
+ const oldValues = this.getSelectedValues();
329
+ const valueStr = newValues.join(",");
330
+ if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
331
+ this.setProperty("value", valueStr);
332
+ this.updateValidity();
333
+ if (dispatchChange) {
334
+ this.dispatchChangeEvent({
335
+ value: this._multiple ? [...newValues] : (newValues[0] ?? null),
336
+ values: newValues,
337
+ items: this.getSelectedItems(newValues),
338
+ action,
339
+ item,
340
+ });
341
+ }
342
+ }
343
+ }
344
+ getSelectedItems(values) {
345
+ const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
346
+ return values.map((value) => {
347
+ const el = byValue.get(value);
348
+ return {
349
+ value,
350
+ label: el?.getAttribute("label") || el?.textContent?.trim() || value,
351
+ flavor: el?.getAttribute("flavor") ?? null,
352
+ };
353
+ });
354
+ }
355
+ calculatePosition() {
356
+ const shadow = this.shadowRoot;
357
+ const stub = shadow.querySelector(".select-stub");
358
+ const dialog = shadow.querySelector(".dropdown-dialog");
359
+ if (!stub || !dialog)
360
+ return;
361
+ const stubRect = stub.getBoundingClientRect();
362
+ const MAX_POPUP_ESTIMATE = 440;
363
+ const dialogRect = dialog.getBoundingClientRect();
364
+ const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
365
+ const wrapPadding = 20;
366
+ const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
367
+ const popupWidth = !isNaN(cssWidth)
368
+ ? cssWidth
369
+ : Math.max(stubRect.width, 320);
370
+ const requested = this.getProperty("placement");
371
+ const anchored = requested
372
+ ? placementToAnchored(requested)
373
+ : {
374
+ side: "auto",
375
+ align: this.getProperty("align"),
376
+ };
377
+ const pos = computeAnchoredPosition({
378
+ anchorRect: stubRect,
379
+ popupWidth,
380
+ popupHeight: estimatedHeight,
381
+ align: anchored.align,
382
+ side: anchored.side,
383
+ gap: 8,
384
+ });
385
+ const positionBelow = pos.below;
386
+ const x = pos.x - wrapPadding;
387
+ const y = (positionBelow ? pos.topY : pos.bottomY) - wrapPadding;
388
+ const width = popupWidth + wrapPadding + wrapPadding;
389
+ this.style.setProperty("--dropdown-x", `${x}px`);
390
+ this.style.setProperty("--dropdown-y", `${y}px`);
391
+ this.style.setProperty("--dropdown-width", `${width}px`);
392
+ this.style.setProperty("--dropdown-offset-x", "0px");
393
+ this.style.setProperty("--dropdown-offset-y", "0px");
394
+ this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
395
+ if (positionBelow) {
396
+ dialog.classList.add("position-below");
397
+ dialog.classList.remove("position-above");
398
+ }
399
+ else {
400
+ dialog.classList.add("position-above");
401
+ dialog.classList.remove("position-below");
402
+ }
403
+ this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
404
+ }
405
+ _setupOptionsScrollbar() {
406
+ if (!isCustomScrollbarEnabled())
407
+ return;
408
+ const shadow = this.shadowRoot;
409
+ const optionsDiv = shadow.querySelector(".dropdown-options");
410
+ const optionsWrapper = shadow.querySelector(".dropdown-options-wrapper");
411
+ if (!optionsDiv || !optionsWrapper)
412
+ return;
413
+ this._destroyOptionsScrollbar();
414
+ optionsDiv.classList.add("ty-custom-scroll");
415
+ this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
416
+ vertical: true,
417
+ });
418
+ if (this._optionsScrollbar.trackY) {
419
+ optionsWrapper.appendChild(this._optionsScrollbar.trackY);
420
+ }
421
+ }
422
+ _destroyOptionsScrollbar() {
423
+ if (this._optionsScrollbar) {
424
+ this._optionsScrollbar.trackY?.remove();
425
+ this._optionsScrollbar.destroy();
426
+ this._optionsScrollbar = null;
427
+ }
428
+ }
429
+ openDropdown() {
430
+ const shadow = this.shadowRoot;
431
+ const dialog = shadow.querySelector(".dropdown-dialog");
432
+ if (!dialog)
433
+ return;
434
+ const stubEl = shadow.querySelector(".select-stub");
435
+ if (stubEl)
436
+ stubEl.setAttribute("aria-expanded", "true");
437
+ const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
438
+ this._scrollLockId = lockId;
439
+ lockScroll(lockId);
440
+ dialog.showModal();
441
+ this.calculatePosition();
442
+ dialog.classList.add("open");
443
+ this._state.open = true;
444
+ const chevron = shadow.querySelector(".dropdown-chevron");
445
+ if (chevron)
446
+ chevron.classList.add("open");
447
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
448
+ if (searchChevron)
449
+ searchChevron.classList.add("open");
450
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
451
+ this._state.filteredTags = tags;
452
+ this._state.highlightedIndex = -1;
453
+ this.updateOptionsVisibility(true);
454
+ this._setupOptionsScrollbar();
455
+ const searchInput = shadow.querySelector(".dropdown-search-input");
456
+ if (searchInput && this.shouldShowSearch()) {
457
+ setTimeout(() => searchInput.focus(), 100);
458
+ }
459
+ this.fireOpenEvent();
460
+ }
461
+ closeDropdown() {
462
+ const shadow = this.shadowRoot;
463
+ const dialog = shadow.querySelector(".dropdown-dialog");
464
+ if (!dialog)
465
+ return;
466
+ const stubEl = shadow.querySelector(".select-stub");
467
+ if (stubEl)
468
+ stubEl.setAttribute("aria-expanded", "false");
469
+ this._destroyOptionsScrollbar();
470
+ dialog.classList.remove("open");
471
+ dialog.classList.remove("position-above");
472
+ dialog.classList.remove("position-below");
473
+ dialog.close();
474
+ if (this._scrollLockId) {
475
+ unlockScroll(this._scrollLockId);
476
+ this._scrollLockId = null;
477
+ }
478
+ this._state.open = false;
479
+ this._state.highlightedIndex = -1;
480
+ const chevron = shadow.querySelector(".dropdown-chevron");
481
+ if (chevron)
482
+ chevron.classList.remove("open");
483
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
484
+ if (searchChevron)
485
+ searchChevron.classList.remove("open");
486
+ const hadQuery = this._state.search !== "";
487
+ this._state.search = "";
488
+ const searchInput = shadow.querySelector(".dropdown-search-input");
489
+ if (searchInput) {
490
+ searchInput.value = "";
491
+ }
492
+ if (this._externalSearch) {
493
+ if (hadQuery) {
494
+ if (this._searchDebounceTimer !== null) {
495
+ clearTimeout(this._searchDebounceTimer);
496
+ this._searchDebounceTimer = null;
497
+ }
498
+ this.fireSearchEvent("");
499
+ }
500
+ }
501
+ else {
502
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
503
+ this._state.filteredTags = allTags;
504
+ this.updateTagVisibility(allTags, allTags);
505
+ this.clearHighlights(allTags);
506
+ }
507
+ this.fireCloseEvent();
508
+ }
509
+ openMobileModal() {
510
+ const shadow = this.shadowRoot;
511
+ const dialog = shadow.querySelector(".mobile-dialog");
512
+ if (!dialog)
513
+ return;
514
+ const stubEl = shadow.querySelector(".select-stub");
515
+ if (stubEl)
516
+ stubEl.setAttribute("aria-expanded", "true");
517
+ const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
518
+ this._scrollLockId = lockId;
519
+ lockScroll(lockId);
520
+ dialog.showModal();
521
+ dialog.getBoundingClientRect();
522
+ dialog.classList.add("open");
523
+ this._state.open = true;
524
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
525
+ this._state.filteredTags = tags;
526
+ this.updateTagVisibility(tags, tags);
527
+ requestAnimationFrame(() => {
528
+ this.updateMobileSelectedState();
529
+ });
530
+ this.fireOpenEvent();
531
+ }
532
+ closeMobileModal() {
533
+ const shadow = this.shadowRoot;
534
+ const dialog = shadow.querySelector(".mobile-dialog");
535
+ if (!dialog)
536
+ return;
537
+ const stubEl = shadow.querySelector(".select-stub");
538
+ if (stubEl)
539
+ stubEl.setAttribute("aria-expanded", "false");
540
+ dialog.classList.remove("open");
541
+ dialog.close();
542
+ if (this._scrollLockId) {
543
+ unlockScroll(this._scrollLockId);
544
+ this._scrollLockId = null;
545
+ }
546
+ this._state.open = false;
547
+ this._state.highlightedIndex = -1;
548
+ const hadQuery = this._state.search !== "";
549
+ this._state.search = "";
550
+ const searchInput = shadow.querySelector(".mobile-search-input");
551
+ if (searchInput) {
552
+ searchInput.value = "";
553
+ }
554
+ if (this._externalSearch) {
555
+ if (hadQuery) {
556
+ if (this._searchDebounceTimer !== null) {
557
+ clearTimeout(this._searchDebounceTimer);
558
+ this._searchDebounceTimer = null;
559
+ }
560
+ this.fireSearchEvent("");
561
+ }
562
+ }
563
+ else {
564
+ const allTags = this.getTagElements();
565
+ allTags.forEach((el) => el.removeAttribute("hidden"));
566
+ }
567
+ this.fireCloseEvent();
568
+ }
569
+ handleStubClick(e) {
570
+ e.preventDefault();
571
+ e.stopPropagation();
572
+ if (this._disabled || this._readonly) {
573
+ return;
574
+ }
575
+ this.openDropdown();
576
+ }
577
+ handleClearClick(e) {
578
+ e.preventDefault();
579
+ e.stopPropagation();
580
+ if (this._disabled || this._readonly) {
581
+ return;
582
+ }
583
+ this.clear();
584
+ }
585
+ handleStubKeydown(e) {
586
+ if (this._disabled || this._readonly || this._state.open)
587
+ return;
588
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
589
+ e.preventDefault();
590
+ this.openDropdown();
591
+ }
592
+ }
593
+ handleTagClick(e) {
594
+ const target = e.target;
595
+ const tag = target.closest("ty-option, ty-tag");
596
+ if (!tag || tag.hasAttribute("disabled"))
597
+ return;
598
+ e.preventDefault();
599
+ e.stopPropagation();
600
+ this.selectByTag(tag);
601
+ }
602
+ selectByTag(tag) {
603
+ const tagValue = this.getTagData(tag).value;
604
+ const currentValues = this.getSelectedValues();
605
+ if (!this._multiple) {
606
+ this.updateComponentValue([tagValue], true, "set", tagValue);
607
+ if (this._state.mode === "mobile") {
608
+ this.closeMobileModal();
609
+ }
610
+ else {
611
+ this.closeDropdown();
612
+ }
613
+ return;
614
+ }
615
+ if (tag.hasAttribute("selected")) {
616
+ const newValues = currentValues.filter((v) => v !== tagValue);
617
+ this.updateComponentValue(newValues, true, "remove", tagValue);
618
+ }
619
+ else {
620
+ this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
621
+ }
622
+ }
623
+ tryCreateFromSearch() {
624
+ if (!this._allowCreate)
625
+ return false;
626
+ const query = (this._state.search || "").trim();
627
+ if (!query)
628
+ return false;
629
+ const queryLower = query.toLowerCase();
630
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
631
+ const existing = allTags.find((t) => t.value.toLowerCase() === queryLower ||
632
+ t.text.toLowerCase() === queryLower);
633
+ if (existing) {
634
+ this.selectByTag(existing.element);
635
+ return true;
636
+ }
637
+ const rawLabel = query;
638
+ const defaultValue = this._createTransform === "slug" ? slugify(rawLabel) : rawLabel;
639
+ if (!defaultValue)
640
+ return true;
641
+ const detail = { value: defaultValue, label: rawLabel };
642
+ const event = new CustomEvent("create", {
643
+ detail,
644
+ bubbles: true,
645
+ composed: true,
646
+ cancelable: true,
647
+ });
648
+ this.dispatchEvent(event);
649
+ if (event.defaultPrevented)
650
+ return true;
651
+ const finalValue = detail.value;
652
+ const option = document.createElement("ty-option");
653
+ option.setAttribute("value", finalValue);
654
+ option.textContent = rawLabel;
655
+ this.appendChild(option);
656
+ if (!this._multiple) {
657
+ this.updateComponentValue([finalValue], true, "create", finalValue);
658
+ if (this._state.mode === "mobile") {
659
+ this.closeMobileModal();
660
+ }
661
+ else {
662
+ this.closeDropdown();
663
+ }
664
+ return true;
665
+ }
666
+ const currentValues = this.getSelectedValues();
667
+ this.updateComponentValue([...currentValues, finalValue], true, "create", finalValue);
668
+ this.clearSearchAfterCreate();
669
+ return true;
670
+ }
671
+ clearSearchAfterCreate() {
672
+ this._state.search = "";
673
+ this._state.highlightedIndex = -1;
674
+ const shadow = this.shadowRoot;
675
+ if (!shadow)
676
+ return;
677
+ const desktopInput = shadow.querySelector(".dropdown-search-input");
678
+ const mobileInput = shadow.querySelector(".mobile-search-input");
679
+ if (desktopInput)
680
+ desktopInput.value = "";
681
+ if (mobileInput)
682
+ mobileInput.value = "";
683
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
684
+ this._state.filteredTags = allTags;
685
+ this.updateTagVisibility(allTags, allTags);
686
+ this.updateOptionsVisibility(true);
687
+ this.clearHighlights(allTags);
688
+ this.updateMobileSelectedState();
689
+ (desktopInput ?? mobileInput)?.focus();
690
+ }
691
+ blockSearchClick(e) {
692
+ e.stopPropagation();
693
+ e.preventDefault();
694
+ }
695
+ handleSearchInput(e) {
696
+ const target = e.target;
697
+ const query = target.value;
698
+ this._state.search = query;
699
+ if (this._externalSearch) {
700
+ this.dispatchSearchEvent(query);
701
+ return;
702
+ }
703
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
704
+ const filtered = this.filterTags(allTags, query);
705
+ this._state.filteredTags = filtered;
706
+ this._state.highlightedIndex = -1;
707
+ this.updateTagVisibility(filtered, allTags);
708
+ this.updateOptionsVisibility(filtered.length > 0);
709
+ this.updateMobileSelectedState();
710
+ this.clearHighlights(allTags);
711
+ }
712
+ handleKeyboard(e) {
713
+ if (!this._state.open)
714
+ return;
715
+ const shadow = this.shadowRoot;
716
+ const searchInput = shadow.querySelector(".dropdown-search-input");
717
+ const target = e.target;
718
+ const shouldHandle = target === searchInput || document.activeElement !== searchInput;
719
+ if (!shouldHandle)
720
+ return;
721
+ const filteredTags = this._state.filteredTags;
722
+ const tagsCount = filteredTags.length;
723
+ const currentHighlightedIndex = this._state.highlightedIndex;
724
+ switch (e.key) {
725
+ case "Escape":
726
+ e.preventDefault();
727
+ e.stopPropagation();
728
+ this.closeDropdown();
729
+ break;
730
+ case "Enter":
731
+ e.preventDefault();
732
+ e.stopPropagation();
733
+ if (currentHighlightedIndex >= 0 &&
734
+ currentHighlightedIndex < tagsCount) {
735
+ this.selectByTag(filteredTags[currentHighlightedIndex].element);
736
+ }
737
+ else {
738
+ this.tryCreateFromSearch();
739
+ }
740
+ break;
741
+ case "ArrowUp":
742
+ e.preventDefault();
743
+ e.stopPropagation();
744
+ let newIndexUp;
745
+ if (tagsCount === 0) {
746
+ newIndexUp = -1;
747
+ }
748
+ else if (currentHighlightedIndex === -1) {
749
+ newIndexUp = tagsCount - 1;
750
+ }
751
+ else if (currentHighlightedIndex === 0) {
752
+ newIndexUp = tagsCount - 1;
753
+ }
754
+ else {
755
+ newIndexUp = currentHighlightedIndex - 1;
756
+ }
757
+ this._state.highlightedIndex = newIndexUp;
758
+ this.highlightTag(filteredTags, newIndexUp);
759
+ break;
760
+ case "ArrowDown":
761
+ e.preventDefault();
762
+ e.stopPropagation();
763
+ let newIndexDown;
764
+ if (tagsCount === 0) {
765
+ newIndexDown = -1;
766
+ }
767
+ else if (currentHighlightedIndex === -1) {
768
+ newIndexDown = 0;
769
+ }
770
+ else if (currentHighlightedIndex === tagsCount - 1) {
771
+ newIndexDown = 0;
772
+ }
773
+ else {
774
+ newIndexDown = currentHighlightedIndex + 1;
775
+ }
776
+ this._state.highlightedIndex = newIndexDown;
777
+ this.highlightTag(filteredTags, newIndexDown);
778
+ break;
779
+ }
780
+ }
781
+ filterTags(tags, query) {
782
+ if (!query || query.trim() === "") {
783
+ return tags;
784
+ }
785
+ const searchLower = query.toLowerCase();
786
+ return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
787
+ }
788
+ updateTagVisibility(filteredTags, allTags) {
789
+ const visibleValues = new Set(filteredTags.map((tag) => tag.value));
790
+ allTags.forEach(({ value, element }) => {
791
+ if (visibleValues.has(value)) {
792
+ element.removeAttribute("hidden");
793
+ }
794
+ else {
795
+ element.setAttribute("hidden", "");
796
+ }
797
+ });
798
+ }
799
+ updateOptionsVisibility(hasOptions) {
800
+ const shadow = this.shadowRoot;
801
+ const options = shadow.querySelector(".dropdown-options");
802
+ if (options) {
803
+ options.style.display = hasOptions ? "" : "none";
804
+ }
805
+ }
806
+ clearHighlights(tags) {
807
+ tags.forEach(({ element }) => {
808
+ element.removeAttribute("highlighted");
809
+ });
810
+ }
811
+ highlightTag(tags, index) {
812
+ this.clearHighlights(tags);
813
+ if (index >= 0 && index < tags.length) {
814
+ const { element } = tags[index];
815
+ element.setAttribute("highlighted", "");
816
+ element.scrollIntoView({
817
+ behavior: "smooth",
818
+ block: "nearest",
819
+ inline: "nearest",
820
+ });
821
+ }
822
+ }
823
+ dispatchSearchEvent(query) {
824
+ if (this._searchDebounceTimer !== null) {
825
+ clearTimeout(this._searchDebounceTimer);
826
+ this._searchDebounceTimer = null;
827
+ }
828
+ if (this._debounce > 0) {
829
+ this._searchDebounceTimer = window.setTimeout(() => {
830
+ this.fireSearchEvent(query);
831
+ this._searchDebounceTimer = null;
832
+ }, this._debounce);
833
+ }
834
+ else {
835
+ this.fireSearchEvent(query);
836
+ }
837
+ }
838
+ fireSearchEvent(query) {
839
+ this.dispatchEvent(new CustomEvent("search", {
840
+ detail: {
841
+ query,
842
+ element: this,
843
+ },
844
+ bubbles: true,
845
+ composed: true,
846
+ }));
847
+ }
848
+ fireOpenEvent() {
849
+ this.dispatchEvent(new CustomEvent("open", {
850
+ detail: { mode: this._state.mode, element: this },
851
+ }));
852
+ if (this._externalSearch) {
853
+ this.fireSearchEvent("");
854
+ }
855
+ }
856
+ fireCloseEvent() {
857
+ this.dispatchEvent(new CustomEvent("close", {
858
+ detail: { mode: this._state.mode, element: this },
859
+ }));
860
+ }
861
+ dispatchChangeEvent(detail) {
862
+ this.dispatchEvent(new CustomEvent("change", {
863
+ detail,
864
+ bubbles: true,
865
+ cancelable: true,
866
+ }));
867
+ }
868
+ render() {
869
+ this._state.mode = isMobileTouch() ? "mobile" : "desktop";
870
+ if (this._state.mode === "mobile") {
871
+ this.renderMobile();
872
+ }
873
+ else {
874
+ this.renderDesktop();
875
+ }
876
+ this.updateSearchVisibility();
877
+ this.applyLoadingState();
878
+ this.updateValidity();
879
+ }
880
+ updateValidity() {
881
+ if (this.disabled) {
882
+ this._internals.setValidity({});
883
+ return;
884
+ }
885
+ if (this.required && this.getSelectedValues().length === 0) {
886
+ const stub = this.shadowRoot?.querySelector(".select-stub");
887
+ this._internals.setValidity({ valueMissing: true }, "Please select a value", stub ?? undefined);
888
+ }
889
+ else {
890
+ this._internals.setValidity({});
891
+ }
892
+ }
893
+ setupTriggerSlot() {
894
+ const shadow = this.shadowRoot;
895
+ const stub = shadow.querySelector(".select-stub");
896
+ const slot = shadow.querySelector('slot[name="trigger"]');
897
+ if (!stub || !slot)
898
+ return;
899
+ const sync = () => {
900
+ const hasTrigger = slot.assignedElements().length > 0;
901
+ stub.classList.toggle("custom-trigger", hasTrigger);
902
+ this.toggleAttribute("has-trigger", hasTrigger);
903
+ };
904
+ slot.addEventListener("slotchange", sync);
905
+ sync();
906
+ }
907
+ setupEventListeners() {
908
+ const shadow = this.shadowRoot;
909
+ const stub = shadow.querySelector(".select-stub");
910
+ const optionsSlot = shadow.querySelector("#options-slot");
911
+ const searchInput = shadow.querySelector(".dropdown-search-input");
912
+ if (stub) {
913
+ this._stubClickHandler = this.handleStubClick.bind(this);
914
+ stub.addEventListener("click", this._stubClickHandler);
915
+ stub.addEventListener("keydown", this.handleStubKeydown.bind(this));
916
+ }
917
+ const clearBtn = shadow.querySelector(".select-clear");
918
+ clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
919
+ this.setupTriggerSlot();
920
+ if (optionsSlot) {
921
+ this._tagClickHandler = this.handleTagClick.bind(this);
922
+ optionsSlot.addEventListener("click", this._tagClickHandler);
923
+ }
924
+ if (searchInput) {
925
+ this._searchInputHandler = this.handleSearchInput.bind(this);
926
+ this._blockSearchClick = this.blockSearchClick.bind(this);
927
+ searchInput.addEventListener("input", this._searchInputHandler);
928
+ searchInput.addEventListener("click", this._blockSearchClick);
929
+ }
930
+ const dialog = shadow.querySelector(".dropdown-dialog");
931
+ if (dialog) {
932
+ dialog.addEventListener("click", (e) => {
933
+ if (e.target === dialog) {
934
+ this.closeDropdown();
935
+ }
936
+ });
937
+ }
938
+ this._keyboardHandler = this.handleKeyboard.bind(this);
939
+ document.addEventListener("keydown", this._keyboardHandler);
940
+ }
941
+ buildStubClasses() {
942
+ const classes = [];
943
+ if (this._disabled)
944
+ classes.push("disabled");
945
+ if (this._compact)
946
+ classes.push("compact");
947
+ return classes.join(" ");
948
+ }
949
+ renderDesktop() {
950
+ const shadow = this.shadowRoot;
951
+ if (!shadow.querySelector(".select-container")) {
952
+ const stubClasses = this.buildStubClasses();
953
+ const listboxId = `select-listbox-${getElementHash(this)}`;
954
+ const labelId = `select-label-${getElementHash(this)}`;
955
+ const labelHtml = this._label
956
+ ? `
957
+ <label class="ty-field-label" id="${labelId}">
958
+ ${this._label}
959
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
960
+ </label>
961
+ `
962
+ : "";
963
+ const searchPlaceholder = "Search...";
964
+ shadow.innerHTML = `
965
+ <div class="select-container dropdown-mode-desktop">
966
+ ${labelHtml}
967
+ <div class="dropdown-wrapper">
968
+ <div class="dropdown-stub select-stub ${stubClasses}"
969
+ role="combobox"
970
+ aria-haspopup="listbox"
971
+ aria-expanded="false"
972
+ aria-controls="${listboxId}"
973
+ ${this._label ? `aria-labelledby="${labelId}"` : ""}
974
+ tabindex="${this._disabled ? "-1" : "0"}"
975
+ ${this._disabled ? 'disabled aria-disabled="true"' : ""}>
976
+ <slot name="trigger">
977
+ <slot name="start"></slot>
978
+ <slot name="selected"></slot>
979
+ <span class="dropdown-placeholder">${this._placeholder}</span>
980
+ <span class="select-count" hidden></span>
981
+ <slot name="end"></slot>
982
+ <button class="select-clear" type="button" aria-label="Clear selection" hidden>
983
+ ${CLEAR_ICON_SVG}
984
+ </button>
985
+ <div class="dropdown-chevron">
986
+ ${CHEVRON_DOWN_SVG}
987
+ </div>
988
+ </slot>
989
+ </div>
990
+ <dialog class="dropdown-dialog">
991
+ <div class="dropdown-header">
992
+ <div class="dropdown-search-icon" aria-hidden="true">
993
+ ${SEARCH_ICON_SVG}
994
+ </div>
995
+ <input
996
+ class="dropdown-search-input"
997
+ type="text"
998
+ placeholder="${searchPlaceholder}"
999
+ ${this._disabled ? "disabled" : ""}
1000
+ />
1001
+ <div class="dropdown-search-chevron">
1002
+ ${CHEVRON_DOWN_SVG}
1003
+ </div>
1004
+ </div>
1005
+ <div class="dropdown-options-wrapper">
1006
+ <div class="dropdown-options" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
1007
+ <slot id="options-slot"></slot>
1008
+ </div>
1009
+ <div class="dropdown-loading" aria-hidden="true">
1010
+ <slot name="loading">
1011
+ <span class="dropdown-loading-spinner"></span>
1012
+ <span class="dropdown-loading-text">Searching…</span>
1013
+ </slot>
1014
+ </div>
1015
+ </div>
1016
+ </dialog>
1017
+ </div>
1018
+ </div>
1019
+ `;
1020
+ this.setupEventListeners();
1021
+ }
1022
+ this.updateSelectionDisplay();
1023
+ }
1024
+ renderMobile() {
1025
+ const shadow = this.shadowRoot;
1026
+ if (!shadow.querySelector(".select-container")) {
1027
+ const stubClasses = this.buildStubClasses();
1028
+ const listboxId = `select-listbox-${getElementHash(this)}`;
1029
+ const labelId = `select-label-${getElementHash(this)}`;
1030
+ const labelHtml = this._label
1031
+ ? `
1032
+ <label class="ty-field-label" id="${labelId}">
1033
+ ${this._label}
1034
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1035
+ </label>
1036
+ `
1037
+ : "";
1038
+ const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1039
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1040
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1041
+ </svg>`;
1042
+ const searchPlaceholder = this._label
1043
+ ? `Search ${this._label}...`
1044
+ : "Search...";
1045
+ const searchHeaderHtml = `
1046
+ <div class="mobile-search-header">
1047
+ ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
1048
+ <div class="mobile-header-content">
1049
+ <div class="dropdown-search-icon" aria-hidden="true">
1050
+ ${SEARCH_ICON_SVG}
1051
+ </div>
1052
+ <input
1053
+ class="mobile-search-input"
1054
+ type="text"
1055
+ placeholder="${searchPlaceholder}"
1056
+ autofocus
1057
+ ${this._disabled ? "disabled" : ""}
1058
+ />
1059
+ <button class="mobile-close-button" type="button" aria-label="Close">
1060
+ ${closeButtonSvg}
1061
+ </button>
1062
+ </div>
1063
+ </div>
1064
+ `;
1065
+ shadow.innerHTML = `
1066
+ <div class="select-container dropdown-mode-mobile">
1067
+ ${labelHtml}
1068
+ <div class="dropdown-wrapper">
1069
+ <div class="dropdown-stub select-stub ${stubClasses}"
1070
+ role="combobox"
1071
+ aria-haspopup="listbox"
1072
+ aria-expanded="false"
1073
+ aria-controls="${listboxId}"
1074
+ ${this._label ? `aria-labelledby="${labelId}"` : ""}
1075
+ tabindex="${this._disabled ? "-1" : "0"}"
1076
+ ${this._disabled ? 'disabled aria-disabled="true"' : ""}>
1077
+ <slot name="trigger">
1078
+ <slot name="start"></slot>
1079
+ <slot name="selected"></slot>
1080
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1081
+ <span class="select-count" hidden></span>
1082
+ <slot name="end"></slot>
1083
+ <button class="select-clear" type="button" aria-label="Clear selection" hidden>
1084
+ ${CLEAR_ICON_SVG}
1085
+ </button>
1086
+ <div class="dropdown-chevron">
1087
+ ${CHEVRON_DOWN_SVG}
1088
+ </div>
1089
+ </slot>
1090
+ </div>
1091
+ <dialog class="mobile-dialog">
1092
+ <div class="mobile-dialog-content">
1093
+ <!-- HEADER (matches dropdown.ts) -->
1094
+ ${searchHeaderHtml}
1095
+ <!-- BODY: options list with toggleable selected state -->
1096
+ <div class="mobile-body">
1097
+ <!-- AVAILABLE LIST (always visible, takes remaining space) -->
1098
+ <div class="mobile-available-section" data-empty="false">
1099
+ <div class="section-header">
1100
+ <span class="section-title">${this._availableLabel}</span>
1101
+ </div>
1102
+ <div class="section-content dropdown-options-wrapper" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
1103
+ <slot id="options-slot"></slot>
1104
+ <div class="empty-state">${this._noOptionsMessage}</div>
1105
+ <div class="dropdown-loading" aria-hidden="true">
1106
+ <slot name="loading">
1107
+ <span class="dropdown-loading-spinner"></span>
1108
+ <span class="dropdown-loading-text">Searching…</span>
1109
+ </slot>
1110
+ </div>
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+ </div>
1115
+ </dialog>
1116
+ </div>
1117
+ </div>
1118
+ `;
1119
+ this.setupMobileEventListeners();
1120
+ }
1121
+ this.updateSelectionDisplay();
1122
+ }
1123
+ setupMobileEventListeners() {
1124
+ const shadow = this.shadowRoot;
1125
+ const stub = shadow.querySelector(".select-stub");
1126
+ const optionsSlot = shadow.querySelector("#options-slot");
1127
+ const searchInput = shadow.querySelector(".mobile-search-input");
1128
+ const closeButton = shadow.querySelector(".mobile-close-button");
1129
+ const dialog = shadow.querySelector(".mobile-dialog");
1130
+ if (stub) {
1131
+ stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
1132
+ stub.addEventListener("keydown", (e) => this.handleMobileStubKeydown(e));
1133
+ }
1134
+ const clearBtn = shadow.querySelector(".select-clear");
1135
+ clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
1136
+ this.setupTriggerSlot();
1137
+ if (optionsSlot) {
1138
+ optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
1139
+ }
1140
+ if (searchInput) {
1141
+ searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
1142
+ }
1143
+ if (closeButton) {
1144
+ closeButton.addEventListener("click", () => this.closeMobileModal());
1145
+ }
1146
+ if (dialog) {
1147
+ dialog.addEventListener("click", (e) => {
1148
+ if (e.target === dialog) {
1149
+ this.closeMobileModal();
1150
+ }
1151
+ });
1152
+ dialog.addEventListener("cancel", (e) => {
1153
+ e.preventDefault();
1154
+ this.closeMobileModal();
1155
+ });
1156
+ }
1157
+ }
1158
+ handleMobileStubClick(e) {
1159
+ e.preventDefault();
1160
+ e.stopPropagation();
1161
+ if (this._disabled || this._readonly) {
1162
+ return;
1163
+ }
1164
+ this.openMobileModal();
1165
+ }
1166
+ handleMobileStubKeydown(e) {
1167
+ if (this._disabled || this._readonly || this._state.open)
1168
+ return;
1169
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
1170
+ e.preventDefault();
1171
+ this.openMobileModal();
1172
+ }
1173
+ }
1174
+ handleMobileTagClick(e) {
1175
+ this.handleTagClick(e);
1176
+ }
1177
+ updateMobileSelectedState() {
1178
+ if (this._state.mode !== "mobile")
1179
+ return;
1180
+ const shadow = this.shadowRoot;
1181
+ const availableSection = shadow.querySelector(".mobile-available-section");
1182
+ if (availableSection) {
1183
+ const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
1184
+ availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
1185
+ const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
1186
+ if (availableTitleSpan) {
1187
+ availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
1188
+ }
1189
+ }
1190
+ }
1191
+ shouldShowSearch() {
1192
+ if (this._externalSearch)
1193
+ return true;
1194
+ if (this._allowCreate)
1195
+ return true;
1196
+ if (this._searchable === "always")
1197
+ return true;
1198
+ if (this._searchable === "never")
1199
+ return false;
1200
+ return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
1201
+ }
1202
+ updateSearchVisibility() {
1203
+ const shadow = this.shadowRoot;
1204
+ if (!shadow)
1205
+ return;
1206
+ const show = this.shouldShowSearch();
1207
+ const header = shadow.querySelector(".dropdown-header");
1208
+ if (header)
1209
+ header.hidden = !show;
1210
+ const mobileInput = shadow.querySelector(".mobile-search-input");
1211
+ if (mobileInput)
1212
+ mobileInput.hidden = !show;
1213
+ const mobileIcon = shadow.querySelector(".mobile-header-content .dropdown-search-icon");
1214
+ if (mobileIcon)
1215
+ mobileIcon.hidden = !show;
1216
+ }
1217
+ updateSelectionDisplay() {
1218
+ const shadow = this.shadowRoot;
1219
+ const stub = shadow.querySelector(".select-stub");
1220
+ if (!stub)
1221
+ return;
1222
+ const values = this._state.selectedValues;
1223
+ const count = values.length;
1224
+ stub.classList.toggle("has-selection", count > 0);
1225
+ stub.classList.toggle("compact", this._compact);
1226
+ const badge = stub.querySelector(".select-count");
1227
+ if (badge) {
1228
+ badge.textContent = String(count);
1229
+ badge.hidden = count === 0 || !this._multiple || !this._compact;
1230
+ }
1231
+ const clearBtn = stub.querySelector(".select-clear");
1232
+ if (clearBtn) {
1233
+ clearBtn.hidden = !(count > 0 && this._clearable && !this._disabled && !this._readonly);
1234
+ }
1235
+ const wantClone = !this._multiple && count > 0 ? values[0] : null;
1236
+ this.syncSelectedClone(wantClone);
1237
+ stub.classList.toggle("has-clone", wantClone !== null);
1238
+ const textEl = stub.querySelector(".dropdown-placeholder");
1239
+ if (textEl) {
1240
+ textEl.hidden = wantClone !== null;
1241
+ const showLabels = count > 0 && this._multiple && !this._compact;
1242
+ if (showLabels) {
1243
+ textEl.textContent = this.getSelectedItems(values)
1244
+ .map((i) => i.label)
1245
+ .join(", ");
1246
+ textEl.classList.remove("placeholder-shown");
1247
+ }
1248
+ else {
1249
+ textEl.textContent = this._placeholder;
1250
+ textEl.classList.add("placeholder-shown");
1251
+ }
1252
+ }
1253
+ }
1254
+ syncSelectedClone(value) {
1255
+ const existing = this.querySelector(':scope > [cloned][slot="selected"]');
1256
+ if (existing?.getAttribute("value") === value) {
1257
+ this._selectedClone = existing;
1258
+ return;
1259
+ }
1260
+ existing?.remove();
1261
+ if (value === null) {
1262
+ this._selectedClone = null;
1263
+ return;
1264
+ }
1265
+ const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
1266
+ if (option) {
1267
+ const clone = option.cloneNode(true);
1268
+ clone.removeAttribute("selected");
1269
+ clone.setAttribute("slot", "selected");
1270
+ clone.setAttribute("cloned", "true");
1271
+ this.appendChild(clone);
1272
+ this._selectedClone = clone;
1273
+ }
1274
+ else if (this._selectedClone?.getAttribute("value") === value) {
1275
+ const restored = this._selectedClone.cloneNode(true);
1276
+ this.appendChild(restored);
1277
+ this._selectedClone = restored;
1278
+ }
1279
+ }
1280
+ deselectValue(value) {
1281
+ const current = this.getSelectedValues();
1282
+ if (!current.includes(value))
1283
+ return;
1284
+ this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
1285
+ }
1286
+ clear() {
1287
+ this.updateComponentValue([], true, "clear", null);
1288
+ }
1289
+ get value() {
1290
+ return this.getSelectedValues().join(",");
1291
+ }
1292
+ set value(val) {
1293
+ this.setProperty("value", val);
1294
+ }
1295
+ get name() {
1296
+ return this.getProperty("name");
1297
+ }
1298
+ set name(val) {
1299
+ this.setProperty("name", val);
1300
+ }
1301
+ get multiple() {
1302
+ return this.getProperty("multiple");
1303
+ }
1304
+ set multiple(value) {
1305
+ this.setProperty("multiple", value);
1306
+ }
1307
+ get compact() {
1308
+ return this.getProperty("compact");
1309
+ }
1310
+ set compact(value) {
1311
+ this.setProperty("compact", value);
1312
+ }
1313
+ get placeholder() {
1314
+ return this.getProperty("placeholder");
1315
+ }
1316
+ set placeholder(val) {
1317
+ this.setProperty("placeholder", val);
1318
+ }
1319
+ get label() {
1320
+ return this.getProperty("label");
1321
+ }
1322
+ set label(val) {
1323
+ this.setProperty("label", val);
1324
+ }
1325
+ get disabled() {
1326
+ return this.getProperty("disabled");
1327
+ }
1328
+ set disabled(value) {
1329
+ this.setProperty("disabled", value);
1330
+ }
1331
+ get loading() {
1332
+ return this.getProperty("loading");
1333
+ }
1334
+ set loading(value) {
1335
+ this.setProperty("loading", value);
1336
+ }
1337
+ get readonly() {
1338
+ return this.getProperty("readonly");
1339
+ }
1340
+ set readonly(value) {
1341
+ this.setProperty("readonly", value);
1342
+ }
1343
+ get clearable() {
1344
+ return this.getProperty("clearable");
1345
+ }
1346
+ set clearable(value) {
1347
+ this.setProperty("clearable", value);
1348
+ }
1349
+ get required() {
1350
+ return this.getProperty("required");
1351
+ }
1352
+ set required(value) {
1353
+ this.setProperty("required", value);
1354
+ }
1355
+ get searchable() {
1356
+ return this.getProperty("searchable");
1357
+ }
1358
+ set searchable(value) {
1359
+ this.setProperty("searchable", value);
1360
+ }
1361
+ get externalSearch() {
1362
+ return this.getProperty("externalSearch");
1363
+ }
1364
+ set externalSearch(value) {
1365
+ this.setProperty("externalSearch", value);
1366
+ }
1367
+ get debounce() {
1368
+ return this.getProperty("debounce");
1369
+ }
1370
+ set debounce(value) {
1371
+ const numValue = typeof value === "string" ? parseInt(value, 10) : value;
1372
+ this.setProperty("debounce", numValue);
1373
+ }
1374
+ get size() {
1375
+ return this.getProperty("size");
1376
+ }
1377
+ set size(value) {
1378
+ this.setProperty("size", value);
1379
+ }
1380
+ get align() {
1381
+ return this.getProperty("align");
1382
+ }
1383
+ set align(value) {
1384
+ this.setProperty("align", value);
1385
+ }
1386
+ get placement() {
1387
+ return this.getProperty("placement");
1388
+ }
1389
+ set placement(value) {
1390
+ this.setProperty("placement", value);
1391
+ }
1392
+ get flavor() {
1393
+ return this.getProperty("flavor");
1394
+ }
1395
+ set flavor(value) {
1396
+ this.setProperty("flavor", value);
1397
+ }
1398
+ get form() {
1399
+ return this._internals.form;
1400
+ }
1405
1401
  }
1406
1402
  TySelect.properties = {
1407
- value: {
1408
- type: "string",
1409
- visual: true,
1410
- formValue: true,
1411
- emitChange: false,
1412
- default: "",
1413
- coerce: (v) => {
1414
- if (Array.isArray(v)) {
1415
- return v.join(",");
1416
- }
1417
- if (v === null || v === undefined) {
1418
- return "";
1419
- }
1420
- return String(v);
1421
- },
1422
- },
1423
- name: {
1424
- type: "string",
1425
- default: "",
1426
- },
1427
- multiple: {
1428
- type: "boolean",
1429
- visual: true,
1430
- default: false,
1431
- },
1432
- compact: {
1433
- type: "boolean",
1434
- visual: true,
1435
- default: false,
1436
- },
1437
- placeholder: {
1438
- type: "string",
1439
- visual: true,
1440
- default: "Select...",
1441
- },
1442
- label: {
1443
- type: "string",
1444
- visual: true,
1445
- default: "",
1446
- },
1447
- disabled: {
1448
- type: "boolean",
1449
- visual: true,
1450
- default: false,
1451
- },
1452
- readonly: {
1453
- type: "boolean",
1454
- visual: true,
1455
- default: false,
1456
- },
1457
- required: {
1458
- type: "boolean",
1459
- visual: true,
1460
- default: false,
1461
- },
1462
- clearable: {
1463
- type: "boolean",
1464
- visual: true,
1465
- default: true,
1466
- aliases: { "not-clearable": false },
1467
- },
1468
- externalSearch: {
1469
- type: "boolean",
1470
- visual: true,
1471
- default: false,
1472
- aliases: { "external-search": true },
1473
- },
1474
- allowCreate: {
1475
- type: "boolean",
1476
- visual: true,
1477
- default: false,
1478
- aliases: { "allow-create": true },
1479
- },
1480
- createTransform: {
1481
- type: "string",
1482
- visual: false,
1483
- default: "none",
1484
- aliases: { "create-transform": true },
1485
- coerce: (v) => (v === "slug" ? "slug" : "none"),
1486
- },
1487
- searchable: {
1488
- type: "string",
1489
- visual: true,
1490
- default: "auto",
1491
- coerce: (v) => {
1492
- if (v === true || v === "" || v === "true")
1493
- return "always";
1494
- if (v === false || v === "false")
1495
- return "never";
1496
- return "auto";
1497
- },
1498
- },
1499
- align: {
1500
- type: "string",
1501
- visual: true,
1502
- default: "start",
1503
- validate: (v) => ["start", "center", "end"].includes(v),
1504
- coerce: (v) => v === "end" ? "end" : v === "center" ? "center" : "start",
1505
- },
1506
- placement: {
1507
- type: "string",
1508
- visual: true,
1509
- default: "",
1510
- },
1511
- size: {
1512
- type: "string",
1513
- visual: true,
1514
- default: "md",
1515
- validate: (v) => ["sm", "md", "lg"].includes(v),
1516
- coerce: (v) => {
1517
- if (v === "xs")
1518
- return "sm";
1519
- if (v === "xl")
1520
- return "lg";
1521
- if (!["sm", "md", "lg"].includes(v)) {
1522
- console.warn(`[ty-select] Invalid size. Using md.`);
1523
- return "md";
1524
- }
1525
- return v;
1526
- },
1527
- },
1528
- debounce: {
1529
- type: "number",
1530
- default: 0,
1531
- validate: (v) => v >= 0 && v <= 5000,
1532
- coerce: (v) => {
1533
- const num = Number(v);
1534
- if (isNaN(num))
1535
- return 0;
1536
- return Math.max(0, Math.min(5000, num));
1537
- },
1538
- },
1539
- "available-label": {
1540
- type: "string",
1541
- visual: true,
1542
- default: "Available",
1543
- },
1544
- "no-options-message": {
1545
- type: "string",
1546
- visual: true,
1547
- default: "No options available",
1548
- },
1549
- loading: {
1550
- type: "boolean",
1551
- visual: true,
1552
- default: false,
1553
- },
1554
- flavor: {
1555
- type: "string",
1556
- visual: true,
1557
- default: "neutral",
1558
- },
1403
+ value: {
1404
+ type: "string",
1405
+ visual: true,
1406
+ formValue: true,
1407
+ emitChange: false,
1408
+ default: "",
1409
+ coerce: (v) => {
1410
+ if (Array.isArray(v)) {
1411
+ return v.join(",");
1412
+ }
1413
+ if (v === null || v === undefined) {
1414
+ return "";
1415
+ }
1416
+ return String(v);
1417
+ },
1418
+ },
1419
+ name: {
1420
+ type: "string",
1421
+ default: "",
1422
+ },
1423
+ multiple: {
1424
+ type: "boolean",
1425
+ visual: true,
1426
+ default: false,
1427
+ },
1428
+ compact: {
1429
+ type: "boolean",
1430
+ visual: true,
1431
+ default: false,
1432
+ },
1433
+ placeholder: {
1434
+ type: "string",
1435
+ visual: true,
1436
+ default: "Select...",
1437
+ },
1438
+ label: {
1439
+ type: "string",
1440
+ visual: true,
1441
+ default: "",
1442
+ },
1443
+ disabled: {
1444
+ type: "boolean",
1445
+ visual: true,
1446
+ default: false,
1447
+ },
1448
+ readonly: {
1449
+ type: "boolean",
1450
+ visual: true,
1451
+ default: false,
1452
+ },
1453
+ required: {
1454
+ type: "boolean",
1455
+ visual: true,
1456
+ default: false,
1457
+ },
1458
+ clearable: {
1459
+ type: "boolean",
1460
+ visual: true,
1461
+ default: true,
1462
+ aliases: { "not-clearable": false },
1463
+ },
1464
+ externalSearch: {
1465
+ type: "boolean",
1466
+ visual: true,
1467
+ default: false,
1468
+ aliases: { "external-search": true },
1469
+ },
1470
+ allowCreate: {
1471
+ type: "boolean",
1472
+ visual: true,
1473
+ default: false,
1474
+ aliases: { "allow-create": true },
1475
+ },
1476
+ createTransform: {
1477
+ type: "string",
1478
+ visual: false,
1479
+ default: "none",
1480
+ aliases: { "create-transform": true },
1481
+ coerce: (v) => (v === "slug" ? "slug" : "none"),
1482
+ },
1483
+ searchable: {
1484
+ type: "string",
1485
+ visual: true,
1486
+ default: "auto",
1487
+ coerce: (v) => {
1488
+ if (v === true || v === "" || v === "true")
1489
+ return "always";
1490
+ if (v === false || v === "false")
1491
+ return "never";
1492
+ return "auto";
1493
+ },
1494
+ },
1495
+ align: {
1496
+ type: "string",
1497
+ visual: true,
1498
+ default: "start",
1499
+ validate: (v) => ["start", "center", "end"].includes(v),
1500
+ coerce: (v) => v === "end" ? "end" : v === "center" ? "center" : "start",
1501
+ },
1502
+ placement: {
1503
+ type: "string",
1504
+ visual: true,
1505
+ default: "",
1506
+ },
1507
+ size: {
1508
+ type: "string",
1509
+ visual: true,
1510
+ default: DEFAULT_SIZE,
1511
+ validate: (v) => ["xs", "sm", "md", "lg", "xl"].includes(v),
1512
+ coerce: (v) => {
1513
+ if (!["xs", "sm", "md", "lg", "xl"].includes(v)) {
1514
+ console.warn(`[ty-select] Invalid size. Using ${DEFAULT_SIZE}.`);
1515
+ return DEFAULT_SIZE;
1516
+ }
1517
+ return v;
1518
+ },
1519
+ },
1520
+ debounce: {
1521
+ type: "number",
1522
+ default: 0,
1523
+ validate: (v) => v >= 0 && v <= 5000,
1524
+ coerce: (v) => {
1525
+ const num = Number(v);
1526
+ if (isNaN(num))
1527
+ return 0;
1528
+ return Math.max(0, Math.min(5000, num));
1529
+ },
1530
+ },
1531
+ "available-label": {
1532
+ type: "string",
1533
+ visual: true,
1534
+ default: "Available",
1535
+ },
1536
+ "no-options-message": {
1537
+ type: "string",
1538
+ visual: true,
1539
+ default: "No options available",
1540
+ },
1541
+ loading: {
1542
+ type: "boolean",
1543
+ visual: true,
1544
+ default: false,
1545
+ },
1546
+ flavor: {
1547
+ type: "string",
1548
+ visual: true,
1549
+ default: "neutral",
1550
+ },
1559
1551
  };
1560
1552
  if (!customElements.get("ty-select")) {
1561
- customElements.define("ty-select", TySelect);
1553
+ customElements.define("ty-select", TySelect);
1562
1554
  }