tyrell-components 1.0.0-TC54 → 1.0.0-TC56

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 (239) hide show
  1. package/README.md +53 -53
  2. package/css/tyrell-theme.css +7 -2
  3. package/css/tyrell.css +6 -15
  4. package/dist/tyrell-theme.css +1 -1
  5. package/dist/tyrell.css +1 -1
  6. package/dist/tyrell.js +1 -1
  7. package/lib/base/ty-component.d.ts +14 -0
  8. package/lib/base/ty-component.d.ts.map +1 -0
  9. package/lib/base/ty-component.js +27 -0
  10. package/lib/base/ty-component.js.map +1 -0
  11. package/lib/components/button.d.ts.map +1 -0
  12. package/lib/components/button.js +7 -0
  13. package/lib/components/button.js.map +1 -0
  14. package/lib/components/calendar-month.d.ts.map +1 -0
  15. package/lib/components/calendar-month.js +20 -6
  16. package/lib/components/calendar-month.js.map +1 -0
  17. package/lib/components/calendar-navigation.d.ts +11 -0
  18. package/lib/components/calendar-navigation.d.ts.map +1 -0
  19. package/lib/components/calendar-navigation.js +51 -40
  20. package/lib/components/calendar-navigation.js.map +1 -0
  21. package/lib/components/calendar.d.ts +31 -3
  22. package/lib/components/calendar.d.ts.map +1 -0
  23. package/lib/components/calendar.js +197 -18
  24. package/lib/components/calendar.js.map +1 -0
  25. package/lib/components/checkbox.d.ts.map +1 -0
  26. package/lib/components/checkbox.js +3 -3
  27. package/lib/components/checkbox.js.map +1 -0
  28. package/lib/components/copy.d.ts.map +1 -0
  29. package/lib/components/copy.js +14 -6
  30. package/lib/components/copy.js.map +1 -0
  31. package/lib/components/date-picker.d.ts +15 -9
  32. package/lib/components/date-picker.d.ts.map +1 -0
  33. package/lib/components/date-picker.js +187 -236
  34. package/lib/components/date-picker.js.map +1 -0
  35. package/lib/components/dropdown.d.ts +403 -0
  36. package/lib/components/dropdown.d.ts.map +1 -0
  37. package/lib/components/dropdown.js +1315 -0
  38. package/lib/components/dropdown.js.map +1 -0
  39. package/lib/components/file-upload.d.ts +4 -0
  40. package/lib/components/file-upload.js +14 -0
  41. package/lib/components/icon.d.ts.map +1 -0
  42. package/lib/components/icon.js.map +1 -0
  43. package/lib/components/input.d.ts.map +1 -0
  44. package/lib/components/input.js +3 -3
  45. package/lib/components/input.js.map +1 -0
  46. package/lib/components/modal.d.ts +2 -8
  47. package/lib/components/modal.d.ts.map +1 -0
  48. package/lib/components/modal.js +8 -18
  49. package/lib/components/modal.js.map +1 -0
  50. package/lib/components/multiselect.d.ts +389 -0
  51. package/lib/components/multiselect.d.ts.map +1 -0
  52. package/lib/components/multiselect.js +1301 -0
  53. package/lib/components/multiselect.js.map +1 -0
  54. package/lib/components/option.d.ts.map +1 -0
  55. package/lib/components/option.js +1 -1
  56. package/lib/components/option.js.map +1 -0
  57. package/lib/components/popup.d.ts.map +1 -0
  58. package/lib/components/popup.js +4 -1
  59. package/lib/components/popup.js.map +1 -0
  60. package/lib/components/radio.d.ts +15 -0
  61. package/lib/components/radio.js +34 -4
  62. package/lib/components/resize-observer.d.ts.map +1 -0
  63. package/lib/components/resize-observer.js.map +1 -0
  64. package/lib/components/scroll-container.d.ts +3 -2
  65. package/lib/components/scroll-container.d.ts.map +1 -0
  66. package/lib/components/scroll-container.js +14 -19
  67. package/lib/components/scroll-container.js.map +1 -0
  68. package/lib/components/select.d.ts +52 -16
  69. package/lib/components/select.js +170 -67
  70. package/lib/components/step.d.ts.map +1 -0
  71. package/lib/components/step.js.map +1 -0
  72. package/lib/components/switch.js +3 -3
  73. package/lib/components/tab.d.ts +49 -1
  74. package/lib/components/tab.d.ts.map +1 -0
  75. package/lib/components/tab.js +151 -10
  76. package/lib/components/tab.js.map +1 -0
  77. package/lib/components/tabs.d.ts +14 -2
  78. package/lib/components/tabs.d.ts.map +1 -0
  79. package/lib/components/tabs.js +186 -130
  80. package/lib/components/tabs.js.map +1 -0
  81. package/lib/components/tag.d.ts +4 -7
  82. package/lib/components/tag.d.ts.map +1 -0
  83. package/lib/components/tag.js +6 -7
  84. package/lib/components/tag.js.map +1 -0
  85. package/lib/components/textarea.d.ts +4 -3
  86. package/lib/components/textarea.d.ts.map +1 -0
  87. package/lib/components/textarea.js +16 -15
  88. package/lib/components/textarea.js.map +1 -0
  89. package/lib/components/tooltip.d.ts +4 -3
  90. package/lib/components/tooltip.d.ts.map +1 -0
  91. package/lib/components/tooltip.js +34 -24
  92. package/lib/components/tooltip.js.map +1 -0
  93. package/lib/components/wizard.d.ts +5 -4
  94. package/lib/components/wizard.d.ts.map +1 -0
  95. package/lib/components/wizard.js +120 -156
  96. package/lib/components/wizard.js.map +1 -0
  97. package/lib/icons/fontawesome/brands.d.ts.map +1 -0
  98. package/lib/icons/fontawesome/brands.js.map +1 -0
  99. package/lib/icons/fontawesome/regular.d.ts.map +1 -0
  100. package/lib/icons/fontawesome/regular.js.map +1 -0
  101. package/lib/icons/fontawesome/solid.d.ts.map +1 -0
  102. package/lib/icons/fontawesome/solid.js.map +1 -0
  103. package/lib/icons/heroicons/micro.d.ts.map +1 -0
  104. package/lib/icons/heroicons/micro.js.map +1 -0
  105. package/lib/icons/heroicons/mini.d.ts.map +1 -0
  106. package/lib/icons/heroicons/mini.js.map +1 -0
  107. package/lib/icons/heroicons/outline.d.ts.map +1 -0
  108. package/lib/icons/heroicons/outline.js.map +1 -0
  109. package/lib/icons/heroicons/solid.d.ts.map +1 -0
  110. package/lib/icons/heroicons/solid.js.map +1 -0
  111. package/lib/icons/lucide.d.ts.map +1 -0
  112. package/lib/icons/lucide.js.map +1 -0
  113. package/lib/icons/material/filled.d.ts.map +1 -0
  114. package/lib/icons/material/filled.js.map +1 -0
  115. package/lib/icons/material/outlined.d.ts.map +1 -0
  116. package/lib/icons/material/outlined.js.map +1 -0
  117. package/lib/icons/material/round.d.ts.map +1 -0
  118. package/lib/icons/material/round.js.map +1 -0
  119. package/lib/icons/material/sharp.d.ts.map +1 -0
  120. package/lib/icons/material/sharp.js.map +1 -0
  121. package/lib/icons/material/two-tone.d.ts.map +1 -0
  122. package/lib/icons/material/two-tone.js.map +1 -0
  123. package/lib/index.d.ts +1 -1
  124. package/lib/index.d.ts.map +1 -0
  125. package/lib/index.js.map +1 -0
  126. package/lib/styles/button.d.ts.map +1 -0
  127. package/lib/styles/button.js +68 -58
  128. package/lib/styles/button.js.map +1 -0
  129. package/lib/styles/calendar-month.d.ts +3 -0
  130. package/lib/styles/calendar-month.d.ts.map +1 -0
  131. package/lib/styles/calendar-month.js +47 -31
  132. package/lib/styles/calendar-month.js.map +1 -0
  133. package/lib/styles/calendar-navigation.d.ts +1 -1
  134. package/lib/styles/calendar-navigation.d.ts.map +1 -0
  135. package/lib/styles/calendar-navigation.js +55 -22
  136. package/lib/styles/calendar-navigation.js.map +1 -0
  137. package/lib/styles/calendar.d.ts +3 -2
  138. package/lib/styles/calendar.d.ts.map +1 -0
  139. package/lib/styles/calendar.js +75 -0
  140. package/lib/styles/calendar.js.map +1 -0
  141. package/lib/styles/checkbox.js +22 -10
  142. package/lib/styles/copy.d.ts.map +1 -0
  143. package/lib/styles/copy.js +5 -1
  144. package/lib/styles/copy.js.map +1 -0
  145. package/lib/styles/date-picker.d.ts.map +1 -0
  146. package/lib/styles/date-picker.js +52 -22
  147. package/lib/styles/date-picker.js.map +1 -0
  148. package/lib/styles/dropdown.d.ts +12 -0
  149. package/lib/styles/dropdown.d.ts.map +1 -0
  150. package/lib/styles/dropdown.js +725 -0
  151. package/lib/styles/dropdown.js.map +1 -0
  152. package/lib/styles/icon.d.ts.map +1 -0
  153. package/lib/styles/icon.js.map +1 -0
  154. package/lib/styles/input.d.ts.map +1 -0
  155. package/lib/styles/input.js.map +1 -0
  156. package/lib/styles/modal.d.ts.map +1 -0
  157. package/lib/styles/modal.js.map +1 -0
  158. package/lib/styles/multiselect.d.ts +6 -0
  159. package/lib/styles/multiselect.d.ts.map +1 -0
  160. package/lib/styles/multiselect.js +568 -0
  161. package/lib/styles/multiselect.js.map +1 -0
  162. package/lib/styles/option.d.ts +1 -1
  163. package/lib/styles/option.d.ts.map +1 -0
  164. package/lib/styles/option.js +3 -3
  165. package/lib/styles/option.js.map +1 -0
  166. package/lib/styles/popup.d.ts +1 -1
  167. package/lib/styles/popup.d.ts.map +1 -0
  168. package/lib/styles/popup.js +8 -5
  169. package/lib/styles/popup.js.map +1 -0
  170. package/lib/styles/radio.js +8 -2
  171. package/lib/styles/resize-observer.d.ts.map +1 -0
  172. package/lib/styles/resize-observer.js.map +1 -0
  173. package/lib/styles/scroll-container.d.ts +1 -1
  174. package/lib/styles/scroll-container.d.ts.map +1 -0
  175. package/lib/styles/scroll-container.js +1 -1
  176. package/lib/styles/scroll-container.js.map +1 -0
  177. package/lib/styles/select-base.js +81 -14
  178. package/lib/styles/select.d.ts +1 -1
  179. package/lib/styles/select.js +6 -0
  180. package/lib/styles/step.d.ts +1 -1
  181. package/lib/styles/step.d.ts.map +1 -0
  182. package/lib/styles/step.js +1 -9
  183. package/lib/styles/step.js.map +1 -0
  184. package/lib/styles/switch.js +6 -3
  185. package/lib/styles/tab.d.ts +1 -1
  186. package/lib/styles/tab.d.ts.map +1 -0
  187. package/lib/styles/tab.js +67 -3
  188. package/lib/styles/tab.js.map +1 -0
  189. package/lib/styles/tabs.d.ts +1 -1
  190. package/lib/styles/tabs.d.ts.map +1 -0
  191. package/lib/styles/tabs.js +7 -3
  192. package/lib/styles/tabs.js.map +1 -0
  193. package/lib/styles/tag.d.ts.map +1 -0
  194. package/lib/styles/tag.js +1 -2
  195. package/lib/styles/tag.js.map +1 -0
  196. package/lib/styles/textarea.d.ts.map +1 -0
  197. package/lib/styles/textarea.js.map +1 -0
  198. package/lib/styles/tooltip.d.ts.map +1 -0
  199. package/lib/styles/tooltip.js.map +1 -0
  200. package/lib/styles/wizard.d.ts +9 -5
  201. package/lib/styles/wizard.d.ts.map +1 -0
  202. package/lib/styles/wizard.js +211 -99
  203. package/lib/styles/wizard.js.map +1 -0
  204. package/lib/types/common.d.ts +2 -2
  205. package/lib/types/common.d.ts.map +1 -0
  206. package/lib/types/common.js.map +1 -0
  207. package/lib/utils/calendar-utils.d.ts.map +1 -0
  208. package/lib/utils/calendar-utils.js.map +1 -0
  209. package/lib/utils/icon-registry.d.ts.map +1 -0
  210. package/lib/utils/icon-registry.js.map +1 -0
  211. package/lib/utils/locale.d.ts.map +1 -0
  212. package/lib/utils/locale.js.map +1 -0
  213. package/lib/utils/number-format.d.ts.map +1 -0
  214. package/lib/utils/number-format.js.map +1 -0
  215. package/lib/utils/parse-boolean.d.ts +39 -0
  216. package/lib/utils/parse-boolean.d.ts.map +1 -0
  217. package/lib/utils/parse-boolean.js +23 -0
  218. package/lib/utils/parse-boolean.js.map +1 -0
  219. package/lib/utils/positioning.d.ts.map +1 -0
  220. package/lib/utils/positioning.js.map +1 -0
  221. package/lib/utils/property-capture.d.ts.map +1 -0
  222. package/lib/utils/property-capture.js.map +1 -0
  223. package/lib/utils/property-manager.d.ts +1 -1
  224. package/lib/utils/property-manager.d.ts.map +1 -0
  225. package/lib/utils/property-manager.js +2 -0
  226. package/lib/utils/property-manager.js.map +1 -0
  227. package/lib/utils/resize-observer.d.ts.map +1 -0
  228. package/lib/utils/resize-observer.js.map +1 -0
  229. package/lib/utils/scroll-lock.d.ts.map +1 -0
  230. package/lib/utils/scroll-lock.js.map +1 -0
  231. package/lib/utils/styles.d.ts.map +1 -0
  232. package/lib/utils/styles.js.map +1 -0
  233. package/lib/utils/validity.d.ts +26 -0
  234. package/lib/utils/validity.js +53 -0
  235. package/lib/version.d.ts +1 -1
  236. package/lib/version.d.ts.map +1 -0
  237. package/lib/version.js +1 -1
  238. package/lib/version.js.map +1 -0
  239. package/package.json +25 -4
@@ -0,0 +1,1315 @@
1
+
2
+ import { ensureStyles } from '../utils/styles.js';
3
+ import { dropdownStyles } from '../styles/dropdown.js';
4
+ import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
5
+ import { TyComponent } from '../base/ty-component.js';
6
+ // ============================================================================
7
+ // DEVICE DETECTION
8
+ // ============================================================================
9
+ function isMobileDevice() {
10
+ const width = window.innerWidth;
11
+ const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
12
+ return width <= 768 || (width <= 1024 && hasTouch);
13
+ }
14
+ // ============================================================================
15
+ // Element Hash Utility (equivalent to ClojureScript's hash function)
16
+ // ============================================================================
17
+ let elementIdCounter = 0;
18
+ const elementIds = new WeakMap();
19
+ function getElementHash(element) {
20
+ let id = elementIds.get(element);
21
+ if (id === undefined) {
22
+ id = ++elementIdCounter;
23
+ elementIds.set(element, id);
24
+ }
25
+ return id;
26
+ }
27
+ // ============================================================================
28
+ // SVG Icons
29
+ // ============================================================================
30
+ const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-asterisk"><path d="M12 6v12"/><path d="M17.196 9 6.804 15"/><path d="m6.804 9 10.392 6"/></svg>`;
31
+ const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
32
+ <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" />
33
+ </svg>`;
34
+ const CLEAR_ICON_SVG = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
35
+ <path d="M6 6L14 14M14 6L6 14" stroke-linecap="round" />
36
+ </svg>`;
37
+ export class TyDropdown extends TyComponent {
38
+ constructor() {
39
+ super(); // TyComponent handles attachInternals() and attachShadow()
40
+ // ============================================================================
41
+ // PRIVATE FIELDS (will be removed in Phase 3)
42
+ // ============================================================================
43
+ this._value = '';
44
+ this._name = '';
45
+ this._placeholder = 'Select an option...';
46
+ this._label = '';
47
+ this._disabled = false;
48
+ this._readonly = false;
49
+ this._required = false;
50
+ this._searchable = true;
51
+ this._clearable = true;
52
+ this._size = 'md';
53
+ this._flavor = 'neutral';
54
+ // Component state
55
+ this._state = {
56
+ open: false,
57
+ search: '',
58
+ highlightedIndex: -1,
59
+ filteredOptions: [],
60
+ currentValue: null,
61
+ mode: isMobileDevice() ? 'mobile' : 'desktop'
62
+ };
63
+ // Scroll lock ID (consistent across open/close cycles)
64
+ this._scrollLockId = null;
65
+ // Event handler references for cleanup
66
+ this._stubClickHandler = null;
67
+ this._outsideClickHandler = null;
68
+ this._optionClickHandler = null;
69
+ this._searchInputHandler = null;
70
+ this._searchBlurHandler = null;
71
+ this._blockSearchClick = null;
72
+ this._keyboardHandler = null;
73
+ this._clearClickHandler = null;
74
+ // Delay/debounce properties for search event
75
+ this._delay = 0;
76
+ this._searchDebounceTimer = null;
77
+ const shadow = this.shadowRoot;
78
+ ensureStyles(shadow, { css: dropdownStyles, id: 'ty-dropdown' });
79
+ // Render based on device type
80
+ }
81
+ render() {
82
+ if (this._state.mode === 'mobile') {
83
+ this.renderMobile();
84
+ }
85
+ else {
86
+ this.renderDesktop();
87
+ }
88
+ }
89
+ // ============================================================================
90
+ // LIFECYCLE HOOKS - TyComponent integration
91
+ // ============================================================================
92
+ onConnect() {
93
+ this.initializeState();
94
+ }
95
+ onDisconnect() {
96
+ // Clean up document-level listeners
97
+ const outsideClickHandler = this.tyOutsideClickHandler;
98
+ const keyboardHandler = this.tyKeyboardHandler;
99
+ if (outsideClickHandler) {
100
+ document.removeEventListener('click', outsideClickHandler);
101
+ this.tyOutsideClickHandler = null;
102
+ }
103
+ if (keyboardHandler) {
104
+ document.removeEventListener('keydown', keyboardHandler);
105
+ this.tyKeyboardHandler = null;
106
+ }
107
+ // Clear any pending debounce timer
108
+ if (this._searchDebounceTimer !== null) {
109
+ clearTimeout(this._searchDebounceTimer);
110
+ this._searchDebounceTimer = null;
111
+ }
112
+ }
113
+ onPropertiesChanged(changes) {
114
+ // Sync private fields from PropertyManager
115
+ // (Render methods still use private fields)
116
+ for (const { name, newValue } of changes) {
117
+ switch (name) {
118
+ case 'value':
119
+ this._value = newValue || '';
120
+ this._state.currentValue = newValue || null;
121
+ this.syncSelectedOption();
122
+ break;
123
+ case 'name':
124
+ this._name = newValue || '';
125
+ break;
126
+ case 'placeholder':
127
+ this._placeholder = newValue || 'Select an option...';
128
+ this.updatePlaceholderInDOM();
129
+ break;
130
+ case 'label':
131
+ this._label = newValue || '';
132
+ break;
133
+ case 'disabled':
134
+ this._disabled = newValue;
135
+ break;
136
+ case 'readonly':
137
+ this._readonly = newValue;
138
+ break;
139
+ case 'required':
140
+ this._required = newValue;
141
+ break;
142
+ case 'searchable':
143
+ this._searchable = newValue;
144
+ break;
145
+ case 'clearable':
146
+ this._clearable = newValue;
147
+ this.updateClearButton();
148
+ break;
149
+ case 'size':
150
+ this._size = newValue;
151
+ break;
152
+ case 'flavor':
153
+ this._flavor = newValue;
154
+ break;
155
+ case 'delay':
156
+ this._delay = newValue;
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ getFormValue() {
162
+ return this._state.currentValue || null;
163
+ }
164
+ parseValue(value) {
165
+ // Defensive check: ensure value is actually a string before calling .trim()
166
+ if (!value || typeof value !== 'string' || value.trim() === '')
167
+ return null;
168
+ return value.trim();
169
+ }
170
+ validateFlavor(flavor) {
171
+ const validFlavors = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
172
+ const normalized = (flavor || 'neutral');
173
+ if (!validFlavors.includes(normalized)) {
174
+ console.warn(`[ty-dropdown] Invalid flavor '${flavor}'. Using 'neutral'. ` +
175
+ `Valid flavors: ${validFlavors.join(', ')}`);
176
+ return 'neutral';
177
+ }
178
+ return normalized;
179
+ }
180
+ initializeState() {
181
+ if (this._value) {
182
+ this._state.currentValue = this.parseValue(this._value);
183
+ // CRITICAL: Options may not be slotted yet when connectedCallback runs
184
+ // Defer sync to next frame to ensure options are available
185
+ requestAnimationFrame(() => {
186
+ this.syncSelectedOption();
187
+ // updateFormValue() called automatically by TyComponent
188
+ });
189
+ }
190
+ // Listen for clear-selection events from ty-options (mobile clear button)
191
+ this.addEventListener('clear-selection', (e) => {
192
+ e.stopPropagation(); // Prevent bubbling
193
+ // Clear the selection
194
+ this.clearSelection();
195
+ this._state.currentValue = null;
196
+ this.updateComponentValue();
197
+ this.updateSelectionDisplay();
198
+ // updateFormValue() called automatically by TyComponent
199
+ // Dispatch change event
200
+ this.dispatchEvent(new CustomEvent('change', {
201
+ detail: {
202
+ value: null,
203
+ text: '',
204
+ option: null,
205
+ originalEvent: e
206
+ },
207
+ bubbles: true,
208
+ composed: true
209
+ }));
210
+ // If in mobile modal, close it
211
+ if (this._state.open && this._state.mode === 'mobile') {
212
+ this.closeMobileModal();
213
+ }
214
+ });
215
+ // Render the component with current state
216
+ this.render();
217
+ }
218
+ updatePlaceholderInDOM() {
219
+ const shadow = this.shadowRoot;
220
+ // Update stub placeholder
221
+ const stubPlaceholder = shadow.querySelector('.dropdown-placeholder');
222
+ if (stubPlaceholder) {
223
+ stubPlaceholder.textContent = this._placeholder;
224
+ }
225
+ // Update search input placeholder (desktop)
226
+ const searchInput = shadow.querySelector('.dropdown-search-input');
227
+ if (searchInput) {
228
+ searchInput.placeholder = this._placeholder;
229
+ }
230
+ // Update mobile search input placeholder
231
+ const mobileSearchInput = shadow.querySelector('.mobile-search-input');
232
+ if (mobileSearchInput) {
233
+ mobileSearchInput.placeholder = this._placeholder;
234
+ }
235
+ }
236
+ // ============================================================================
237
+ // SHARED CORE METHODS
238
+ // ============================================================================
239
+ // Methods used by BOTH desktop and mobile implementations:
240
+ //
241
+ // OPTION MANAGEMENT:
242
+ // - getOptions(): Get all option elements from slot
243
+ // - getOptionData(): Extract value and text from option element
244
+ // - selectOption(): Select an option (used by both desktop/mobile clicks)
245
+ // - clearSelection(): Clear all selections
246
+ // - syncSelectedOption(): Sync selected option based on current value
247
+ //
248
+ // STATE SYNCHRONIZATION:
249
+ // - updateComponentValue(): Update component value attribute
250
+ // - updateFormValue(): Update form value via ElementInternals
251
+ //
252
+ // DISPLAY UPDATES:
253
+ // - updateSelectionDisplay(): Show/hide placeholder
254
+ // - updateClearButton(): Show/hide clear button (desktop only)
255
+ //
256
+ // EVENT DISPATCHING:
257
+ // - dispatchChangeEvent(): Dispatch change event
258
+ // - dispatchSearchEvent(): Dispatch search event with debounce
259
+ // - fireSearchEvent(): Fire the actual search event
260
+ //
261
+ // FILTERING & SEARCH:
262
+ // - filterOptions(): Filter options by query
263
+ // - updateOptionVisibility(): Show/hide options based on filter
264
+ //
265
+ // HIGHLIGHTING (keyboard navigation):
266
+ // - clearHighlights(): Clear all option highlights
267
+ // - highlightOption(): Highlight option at index
268
+ //
269
+ // UTILITY:
270
+ // - buildStubClasses(): Build CSS class list for stub
271
+ // ============================================================================
272
+ getOptions() {
273
+ const shadow = this.shadowRoot;
274
+ const slot = shadow.querySelector('slot:not([name])');
275
+ if (!slot)
276
+ return [];
277
+ const assigned = (slot.assignedElements ? slot.assignedElements() : []);
278
+ return assigned.filter(el => {
279
+ const tag = el.tagName;
280
+ return tag === 'OPTION' || tag === 'TY-OPTION' || tag === 'TY-TAG';
281
+ });
282
+ }
283
+ getOptionData(element) {
284
+ const tag = element.tagName;
285
+ if (tag === 'OPTION') {
286
+ const optionEl = element;
287
+ return {
288
+ value: optionEl.value || optionEl.textContent || '',
289
+ text: optionEl.textContent || '',
290
+ element
291
+ };
292
+ }
293
+ if (tag === 'TY-OPTION') {
294
+ // CRITICAL: Use .value property, not getAttribute!
295
+ // Framework may set property before attribute
296
+ const tyOption = element;
297
+ return {
298
+ value: tyOption.value || element.textContent || '',
299
+ text: element.textContent || '',
300
+ element
301
+ };
302
+ }
303
+ if (tag === 'TY-TAG') {
304
+ return {
305
+ value: element.getAttribute('value') || element.textContent || '',
306
+ text: element.textContent || '',
307
+ element
308
+ };
309
+ }
310
+ return {
311
+ value: element.textContent || '',
312
+ text: element.textContent || '',
313
+ element
314
+ };
315
+ }
316
+ clearSelection() {
317
+ const shadow = this.shadowRoot;
318
+ const options = this.getOptions();
319
+ // Clear selected attribute from all options
320
+ options.forEach(opt => opt.removeAttribute('selected'));
321
+ // Remove clones from selected slot
322
+ const selectedSlot = shadow.querySelector('slot[name="selected"]');
323
+ if (selectedSlot) {
324
+ const assigned = selectedSlot.assignedElements ? selectedSlot.assignedElements() : [];
325
+ const clones = Array.from(assigned).filter((el) => el.hasAttribute('cloned'));
326
+ clones.forEach((clone) => clone.remove());
327
+ }
328
+ }
329
+ selectOption(option, originalEvent) {
330
+ const optionData = this.getOptionData(option);
331
+ const isEmpty = !optionData.value || optionData.value.trim() === '';
332
+ this.clearSelection(); // If value is empty, just clear and don't clone anything
333
+ if (isEmpty) {
334
+ this._state.currentValue = null;
335
+ }
336
+ else {
337
+ // Clone option for display in stub
338
+ const clone = option.cloneNode(true);
339
+ clone.setAttribute('slot', 'selected');
340
+ clone.setAttribute('cloned', 'true');
341
+ option.parentNode.appendChild(clone);
342
+ // Mark original as selected
343
+ option.setAttribute('selected', '');
344
+ // Update state
345
+ this._state.currentValue = optionData.value;
346
+ }
347
+ this.updateComponentValue();
348
+ this.updateSelectionDisplay();
349
+ // updateFormValue() called automatically by TyComponent
350
+ // Dispatch change event
351
+ this.dispatchChangeEvent(option, originalEvent);
352
+ }
353
+ syncSelectedOption() {
354
+ const options = this.getOptions();
355
+ const currentValue = this._state.currentValue;
356
+ if (!currentValue) {
357
+ this.clearSelection();
358
+ return;
359
+ }
360
+ // Find matching option
361
+ const matchingOption = options.find(opt => {
362
+ const data = this.getOptionData(opt);
363
+ return data.value === currentValue;
364
+ });
365
+ if (matchingOption) {
366
+ this.selectOption(matchingOption);
367
+ }
368
+ }
369
+ updateComponentValue() {
370
+ const currentValue = this._state.currentValue;
371
+ if (currentValue !== null) {
372
+ this.setAttribute('value', currentValue);
373
+ }
374
+ else {
375
+ this.removeAttribute('value');
376
+ }
377
+ // Also set property
378
+ this._value = currentValue || '';
379
+ }
380
+ updateSelectionDisplay() {
381
+ const shadow = this.shadowRoot;
382
+ const stub = shadow.querySelector('.dropdown-stub');
383
+ if (!stub)
384
+ return;
385
+ const options = this.getOptions();
386
+ const hasSelected = options.some(opt => opt.hasAttribute('selected'));
387
+ if (hasSelected) {
388
+ stub.classList.add('has-selection');
389
+ }
390
+ else {
391
+ stub.classList.remove('has-selection');
392
+ }
393
+ // Update clear button visibility
394
+ this.updateClearButton();
395
+ }
396
+ updateClearButton() {
397
+ const shadow = this.shadowRoot;
398
+ const clearBtn = shadow.querySelector('.dropdown-clear-btn');
399
+ if (!clearBtn)
400
+ return;
401
+ const hasSelection = this._state.currentValue !== null && this._state.currentValue !== '';
402
+ const shouldShow = this._clearable &&
403
+ hasSelection &&
404
+ !this._disabled &&
405
+ !this._readonly &&
406
+ !this._state.open &&
407
+ this._state.mode !== 'mobile';
408
+ // CRITICAL: Only show clear button when dropdown is closed
409
+ if (shouldShow) {
410
+ clearBtn.style.display = 'block';
411
+ }
412
+ else {
413
+ clearBtn.style.display = 'none';
414
+ }
415
+ }
416
+ dispatchChangeEvent(option, originalEvent) {
417
+ const optionData = this.getOptionData(option);
418
+ this.dispatchEvent(new CustomEvent('change', {
419
+ detail: {
420
+ value: this._state.currentValue,
421
+ text: optionData.text,
422
+ option,
423
+ originalEvent: originalEvent || null
424
+ },
425
+ bubbles: true,
426
+ composed: true
427
+ }));
428
+ }
429
+ filterOptions(options, query) {
430
+ if (!query || query.trim() === '') {
431
+ return options;
432
+ }
433
+ const searchLower = query.toLowerCase();
434
+ return options.filter(({ text }) => text.toLowerCase().includes(searchLower));
435
+ }
436
+ updateOptionVisibility(filteredOptions, allOptions) {
437
+ const visibleValues = new Set(filteredOptions.map(opt => opt.value));
438
+ allOptions.forEach(({ value, element }) => {
439
+ if (visibleValues.has(value)) {
440
+ element.removeAttribute('hidden');
441
+ }
442
+ else {
443
+ element.setAttribute('hidden', '');
444
+ }
445
+ });
446
+ }
447
+ clearHighlights(options) {
448
+ options.forEach(({ element }) => {
449
+ element.removeAttribute('highlighted');
450
+ });
451
+ }
452
+ highlightOption(options, index) {
453
+ this.clearHighlights(options);
454
+ if (index >= 0 && index < options.length) {
455
+ const { element } = options[index];
456
+ element.setAttribute('highlighted', '');
457
+ // Scroll into view
458
+ element.scrollIntoView({
459
+ behavior: 'smooth',
460
+ block: 'nearest',
461
+ inline: 'nearest'
462
+ });
463
+ }
464
+ }
465
+ // ============================================================================
466
+ // SHARED EVENT HANDLERS
467
+ // ============================================================================
468
+ // Event handlers used by BOTH desktop and mobile:
469
+ //
470
+ // - handleSearchInput(): Handles search input for both dialog and modal
471
+ // * Desktop: Filters options locally OR dispatches search event
472
+ // * Mobile: Same behavior
473
+ //
474
+ // - handleSearchBlur(): Resets search when input loses focus
475
+ // * Desktop: Clears search and shows all options
476
+ // * Mobile: Same behavior
477
+ // ============================================================================
478
+ setupDesktopEventListeners() {
479
+ const shadow = this.shadowRoot;
480
+ const stub = shadow.querySelector('.dropdown-stub');
481
+ const optionsSlot = shadow.querySelector('#options-slot');
482
+ const searchInput = shadow.querySelector('.dropdown-search-input');
483
+ if (stub) {
484
+ this._stubClickHandler = this.handleDesktopStubClick.bind(this);
485
+ stub.addEventListener('click', this._stubClickHandler);
486
+ }
487
+ // Add option click handler to slot
488
+ if (optionsSlot) {
489
+ this._optionClickHandler = this.handleDesktopOptionClick.bind(this);
490
+ optionsSlot.addEventListener('click', this._optionClickHandler);
491
+ }
492
+ // Add search input handlers
493
+ if (searchInput) {
494
+ this._searchInputHandler = this.handleSearchInput.bind(this);
495
+ this._blockSearchClick = this.blockSearchClick.bind(this);
496
+ searchInput.addEventListener('input', this._searchInputHandler);
497
+ searchInput.addEventListener('click', this._blockSearchClick);
498
+ // searchInput.addEventListener('blur', this._searchBlurHandler)
499
+ }
500
+ // Add clear button handler
501
+ const clearBtn = shadow.querySelector('.dropdown-clear-btn');
502
+ if (clearBtn) {
503
+ this._clearClickHandler = this.handleDesktopClearClick.bind(this);
504
+ clearBtn.addEventListener('click', this._clearClickHandler);
505
+ }
506
+ // Setup document-level event listeners immediately (like ClojureScript version)
507
+ this._outsideClickHandler = this.handleDesktopOutsideClick.bind(this);
508
+ this._keyboardHandler = this.handleDesktopKeyboard.bind(this);
509
+ document.addEventListener('click', this._outsideClickHandler);
510
+ document.addEventListener('keydown', this._keyboardHandler);
511
+ }
512
+ // removeEventListeners removed - now using ClojureScript pattern
513
+ // ============================================================================
514
+ // SHARED HELPER METHODS (Internal)
515
+ // ============================================================================
516
+ // Internal helpers that reduce duplication between desktop and mobile.
517
+ // These are called by both implementations but not exposed publicly.
518
+ // ============================================================================
519
+ lockDropdownScroll() {
520
+ const dropdownId = `dropdown-${this.id || 'anon'}-${getElementHash(this)}`;
521
+ this._scrollLockId = dropdownId;
522
+ lockScroll(dropdownId);
523
+ }
524
+ unlockDropdownScroll() {
525
+ if (this._scrollLockId) {
526
+ unlockScroll(this._scrollLockId);
527
+ this._scrollLockId = null;
528
+ }
529
+ }
530
+ initializeOptionsState(highlightSelected = false) {
531
+ const options = this.getOptions().map(el => this.getOptionData(el));
532
+ const currentValue = this._state.currentValue;
533
+ this._state.filteredOptions = options;
534
+ if (highlightSelected && currentValue) {
535
+ // Find the index of the currently selected option
536
+ const selectedIndex = options.findIndex(opt => opt.value === currentValue);
537
+ this._state.highlightedIndex = selectedIndex;
538
+ // Highlight and scroll to selected option if it exists
539
+ if (selectedIndex >= 0) {
540
+ this.highlightOption(options, selectedIndex);
541
+ }
542
+ }
543
+ else {
544
+ this._state.highlightedIndex = -1;
545
+ }
546
+ }
547
+ handleStubClickBase(e, openCallback) {
548
+ e.preventDefault();
549
+ e.stopPropagation();
550
+ if (this._disabled || this._readonly) {
551
+ return;
552
+ }
553
+ openCallback();
554
+ }
555
+ handleOptionClickBase(e, closeCallback) {
556
+ e.preventDefault();
557
+ e.stopPropagation();
558
+ const target = e.target;
559
+ // Find the option element (might be clicking on child element)
560
+ const option = target.closest('option, ty-option, ty-tag');
561
+ if (!option)
562
+ return;
563
+ // Select the option
564
+ this.selectOption(option, e);
565
+ // Update display
566
+ this.updateSelectionDisplay();
567
+ // Close dropdown/modal
568
+ closeCallback();
569
+ }
570
+ handleClearClickBase(e, closeCallback) {
571
+ e.preventDefault();
572
+ e.stopPropagation();
573
+ // Clear the selection
574
+ this.clearSelection();
575
+ this._state.currentValue = null;
576
+ this.updateComponentValue();
577
+ this.updateSelectionDisplay();
578
+ // updateFormValue() called automatically by TyComponent
579
+ // Dispatch change event with null value
580
+ this.dispatchEvent(new CustomEvent('change', {
581
+ detail: {
582
+ value: null,
583
+ text: '',
584
+ option: null,
585
+ originalEvent: e
586
+ },
587
+ bubbles: true,
588
+ composed: true
589
+ }));
590
+ // Close if callback provided (mobile modal case)
591
+ if (closeCallback) {
592
+ closeCallback();
593
+ }
594
+ }
595
+ // ============================================================================
596
+ // DESKTOP IMPLEMENTATION (Dialog-based)
597
+ // ============================================================================
598
+ // Desktop uses <dialog> element with smart positioning.
599
+ //
600
+ // EVENT FLOW:
601
+ // 1. User clicks stub → handleDesktopStubClick() → openDesktopDropdown()
602
+ // 2. Dialog shown with showModal(), positioned via calculateDesktopPosition()
603
+ // 3. User types in search → handleSearchInput() [SHARED]
604
+ // 4. User clicks option → handleDesktopOptionClick() → selectOption() [SHARED] → closeDesktopDropdown()
605
+ // 5. User clicks outside → handleDesktopOutsideClick() → closeDesktopDropdown()
606
+ // 6. User presses Escape → handleDesktopKeyboard() → closeDesktopDropdown()
607
+ // 7. User clicks clear button → handleDesktopClearClick() → clearSelection() [SHARED]
608
+ //
609
+ // METHODS:
610
+ // - calculateDesktopPosition(): Smart dropdown positioning (below/above stub)
611
+ // - openDesktopDropdown(): Open desktop dialog
612
+ // - closeDesktopDropdown(): Close desktop dialog
613
+ // - handleDesktopStubClick(): Open on stub click
614
+ // - handleDesktopOptionClick(): Select option and close
615
+ // - handleDesktopClearClick(): Clear selection
616
+ // - handleDesktopOutsideClick(): Close on outside click
617
+ // - handleDesktopKeyboard(): Arrow navigation + Enter/Escape
618
+ // - renderDesktop(): Render desktop HTML
619
+ // - setupDesktopEventListeners(): Setup desktop event handlers
620
+ // ============================================================================
621
+ calculateDesktopPosition() {
622
+ const shadow = this.shadowRoot;
623
+ const stub = shadow.querySelector('.dropdown-stub');
624
+ const dialog = shadow.querySelector('.dropdown-dialog');
625
+ if (!stub || !dialog)
626
+ return;
627
+ const stubRect = stub.getBoundingClientRect();
628
+ const viewportHeight = window.innerHeight;
629
+ const viewportWidth = window.innerWidth;
630
+ // Get dialog dimensions (it's already shown with showModal)
631
+ const dialogRect = dialog.getBoundingClientRect();
632
+ const estimatedHeight = dialogRect.height || 200;
633
+ const padding = 8;
634
+ const wrapPadding = 20;
635
+ // Available space calculations
636
+ const spaceBelow = viewportHeight - stubRect.bottom;
637
+ const spaceRight = viewportWidth - stubRect.left;
638
+ // Smart direction logic
639
+ const positionBelow = spaceBelow >= estimatedHeight + padding;
640
+ const fitsHorizontally = spaceRight >= stubRect.width;
641
+ // Calculate position coordinates
642
+ const x = fitsHorizontally
643
+ ? stubRect.left - wrapPadding
644
+ : Math.max(padding, viewportWidth - stubRect.width - padding);
645
+ const y = positionBelow
646
+ ? stubRect.top - wrapPadding
647
+ : viewportHeight - stubRect.bottom - wrapPadding;
648
+ const width = stubRect.width + wrapPadding + wrapPadding;
649
+ // Set CSS variables for positioning
650
+ this.style.setProperty('--dropdown-x', `${x}px`);
651
+ this.style.setProperty('--dropdown-y', `${y}px`);
652
+ this.style.setProperty('--dropdown-width', `${width}px`);
653
+ this.style.setProperty('--dropdown-offset-x', '0px');
654
+ this.style.setProperty('--dropdown-offset-y', '0px');
655
+ this.style.setProperty('--dropdown-padding', `${wrapPadding}px`);
656
+ // Set direction classes for CSS styling
657
+ if (positionBelow) {
658
+ dialog.classList.add('position-below');
659
+ dialog.classList.remove('position-above');
660
+ }
661
+ else {
662
+ dialog.classList.add('position-above');
663
+ dialog.classList.remove('position-below');
664
+ }
665
+ // Optional: Store direction for debugging
666
+ this.style.setProperty('--dropdown-direction', positionBelow ? 'below' : 'above');
667
+ }
668
+ openDesktopDropdown() {
669
+ console.log('opening desktop dropdown');
670
+ const shadow = this.shadowRoot;
671
+ const dialog = shadow.querySelector('.dropdown-dialog');
672
+ if (!dialog)
673
+ return;
674
+ // Lock scroll
675
+ this.lockDropdownScroll();
676
+ // Show modal first so browser can calculate dimensions
677
+ dialog.showModal();
678
+ dialog.classList.add('open');
679
+ // Position dropdown AFTER showing modal
680
+ this.calculateDesktopPosition();
681
+ // Update component state
682
+ this._state.open = true;
683
+ // Update visual states
684
+ const chevron = shadow.querySelector('.dropdown-chevron');
685
+ if (chevron)
686
+ chevron.classList.add('open');
687
+ const searchChevron = shadow.querySelector('.dropdown-search-chevron');
688
+ if (searchChevron)
689
+ searchChevron.classList.add('open');
690
+ // Initialize options state and highlight selected option
691
+ this.initializeOptionsState(true);
692
+ // Focus search input if searchable
693
+ if (this._searchable) {
694
+ const searchInput = shadow.querySelector('.dropdown-search-input');
695
+ if (searchInput) {
696
+ setTimeout(() => searchInput.focus(), 100);
697
+ }
698
+ }
699
+ // Hide clear button when dropdown is open
700
+ this.updateClearButton();
701
+ }
702
+ closeDesktopDropdown() {
703
+ const shadow = this.shadowRoot;
704
+ const dialog = shadow.querySelector('.dropdown-dialog');
705
+ if (!dialog)
706
+ return;
707
+ // Unlock scroll
708
+ this.unlockDropdownScroll();
709
+ // Close dialog
710
+ dialog.classList.remove('open');
711
+ dialog.classList.remove('position-above');
712
+ dialog.classList.remove('position-below');
713
+ dialog.close();
714
+ // Update state
715
+ this._state.open = false;
716
+ this._state.highlightedIndex = -1;
717
+ // Update visual states
718
+ const chevron = shadow.querySelector('.dropdown-chevron');
719
+ if (chevron)
720
+ chevron.classList.remove('open');
721
+ const searchChevron = shadow.querySelector('.dropdown-search-chevron');
722
+ if (searchChevron)
723
+ searchChevron.classList.remove('open');
724
+ // Reset search if not searchable
725
+ if (!this._searchable) {
726
+ this._state.search = '';
727
+ }
728
+ // Show clear button when dropdown is closed (if applicable)
729
+ this.updateClearButton();
730
+ }
731
+ handleDesktopOutsideClick(e) {
732
+ if (!this._state.open)
733
+ return;
734
+ const target = e.target;
735
+ const shadow = this.shadowRoot;
736
+ // CRITICAL: Check if click is inside .dropdown-wrapper (like ClojureScript version)
737
+ // Not the whole component - this prevents the component itself from being counted
738
+ const wrapper = shadow.querySelector('.dropdown-wrapper');
739
+ const clickedInside = wrapper && wrapper.contains(target);
740
+ if (!clickedInside) {
741
+ this.closeDesktopDropdown();
742
+ }
743
+ }
744
+ handleSearchInput(e) {
745
+ const target = e.target;
746
+ const query = target.value;
747
+ // Update search state
748
+ this._state.search = query;
749
+ if (this._searchable) {
750
+ // Internal search: filter options locally
751
+ const allOptions = this.getOptions().map(el => this.getOptionData(el));
752
+ const filtered = this.filterOptions(allOptions, query);
753
+ // Update state
754
+ this._state.filteredOptions = filtered;
755
+ this._state.highlightedIndex = -1;
756
+ // Update visibility
757
+ this.updateOptionVisibility(filtered, allOptions);
758
+ // Clear highlights
759
+ this.clearHighlights(allOptions);
760
+ }
761
+ else {
762
+ // External search: dispatch event for external handling
763
+ this.dispatchSearchEvent(query, e);
764
+ }
765
+ }
766
+ handleSearchBlur(_e) {
767
+ // Blur handler disabled - search reset happens in closeDesktopDropdown
768
+ // This prevents race conditions where blur timer fires before click completes
769
+ }
770
+ blockSearchClick(e) {
771
+ e.stopPropagation();
772
+ e.preventDefault();
773
+ }
774
+ dispatchSearchEvent(query, originalEvent) {
775
+ // Clear existing timer
776
+ if (this._searchDebounceTimer !== null) {
777
+ clearTimeout(this._searchDebounceTimer);
778
+ this._searchDebounceTimer = null;
779
+ }
780
+ // If delay is set, debounce the event
781
+ if (this._delay > 0) {
782
+ this._searchDebounceTimer = window.setTimeout(() => {
783
+ this.fireSearchEvent(query, originalEvent);
784
+ this._searchDebounceTimer = null;
785
+ }, this._delay);
786
+ }
787
+ else {
788
+ // Fire immediately if no delay
789
+ this.fireSearchEvent(query, originalEvent);
790
+ }
791
+ }
792
+ fireSearchEvent(query, originalEvent) {
793
+ this.dispatchEvent(new CustomEvent('search', {
794
+ detail: {
795
+ query,
796
+ originalEvent: originalEvent || null
797
+ },
798
+ bubbles: true,
799
+ composed: true
800
+ }));
801
+ }
802
+ // ============================================================================
803
+ // DESKTOP KEYBOARD NAVIGATION
804
+ // ============================================================================
805
+ // Handles: Escape, Enter, ArrowUp, ArrowDown
806
+ // Works with both search input focus and document-level events
807
+ // Wraps around: ArrowUp at index 0 → last option, ArrowDown at last → first
808
+ // ============================================================================
809
+ handleDesktopKeyboard(e) {
810
+ if (!this._state.open)
811
+ return;
812
+ const shadow = this.shadowRoot;
813
+ const searchInput = shadow.querySelector('.dropdown-search-input');
814
+ const target = e.target;
815
+ // Only handle navigation keys when dropdown is open and either:
816
+ // 1. Event comes from search input, OR
817
+ // 2. Event comes from document but search input is not focused
818
+ const shouldHandle = target === searchInput ||
819
+ document.activeElement !== searchInput;
820
+ if (!shouldHandle)
821
+ return;
822
+ // Get current state values
823
+ const filteredOptions = this._state.filteredOptions;
824
+ const optionsCount = filteredOptions.length;
825
+ const currentHighlightedIndex = this._state.highlightedIndex;
826
+ switch (e.key) {
827
+ case 'Escape':
828
+ e.preventDefault();
829
+ e.stopPropagation();
830
+ this.closeDesktopDropdown();
831
+ break;
832
+ case 'Enter':
833
+ e.preventDefault();
834
+ e.stopPropagation();
835
+ // Select highlighted option if any
836
+ if (currentHighlightedIndex >= 0 && currentHighlightedIndex < optionsCount) {
837
+ const option = filteredOptions[currentHighlightedIndex];
838
+ this.selectOption(option.element, e);
839
+ this.updateSelectionDisplay();
840
+ this.closeDesktopDropdown();
841
+ }
842
+ break;
843
+ case 'ArrowUp':
844
+ e.preventDefault();
845
+ e.stopPropagation();
846
+ let newIndexUp;
847
+ if (optionsCount === 0) {
848
+ newIndexUp = -1;
849
+ }
850
+ else if (currentHighlightedIndex === -1) {
851
+ // Nothing highlighted, go to last option
852
+ newIndexUp = optionsCount - 1;
853
+ }
854
+ else if (currentHighlightedIndex === 0) {
855
+ // At first option, wrap to last
856
+ newIndexUp = optionsCount - 1;
857
+ }
858
+ else {
859
+ // Move up one
860
+ newIndexUp = currentHighlightedIndex - 1;
861
+ }
862
+ this._state.highlightedIndex = newIndexUp;
863
+ this.highlightOption(filteredOptions, newIndexUp);
864
+ break;
865
+ case 'ArrowDown':
866
+ e.preventDefault();
867
+ e.stopPropagation();
868
+ let newIndexDown;
869
+ if (optionsCount === 0) {
870
+ newIndexDown = -1;
871
+ }
872
+ else if (currentHighlightedIndex === -1) {
873
+ // Nothing highlighted, go to first option
874
+ newIndexDown = 0;
875
+ }
876
+ else if (currentHighlightedIndex === optionsCount - 1) {
877
+ // At last option, wrap to first
878
+ newIndexDown = 0;
879
+ }
880
+ else {
881
+ // Move down one
882
+ newIndexDown = currentHighlightedIndex + 1;
883
+ }
884
+ this._state.highlightedIndex = newIndexDown;
885
+ this.highlightOption(filteredOptions, newIndexDown);
886
+ break;
887
+ }
888
+ }
889
+ handleDesktopStubClick(e) {
890
+ this.handleStubClickBase(e, () => this.openDesktopDropdown());
891
+ }
892
+ handleDesktopOptionClick(e) {
893
+ this.handleOptionClickBase(e, () => this.closeDesktopDropdown());
894
+ }
895
+ handleDesktopClearClick(e) {
896
+ this.handleClearClickBase(e);
897
+ // Desktop doesn't close on clear, just clears the selection
898
+ }
899
+ buildStubClasses() {
900
+ const classes = [this._size];
901
+ if (this._disabled)
902
+ classes.push('disabled');
903
+ if (this._clearable)
904
+ classes.push('clearable');
905
+ return classes.join(' ');
906
+ }
907
+ renderDesktop() {
908
+ const shadow = this.shadowRoot;
909
+ // Only set innerHTML and setup listeners if container doesn't exist (like ClojureScript)
910
+ if (!shadow.querySelector('.dropdown-container')) {
911
+ const stubClasses = this.buildStubClasses();
912
+ const labelHtml = this._label ? `
913
+ <label class="dropdown-label">
914
+ ${this._label}
915
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
916
+ </label>
917
+ ` : '';
918
+ shadow.innerHTML = `
919
+ <div class="dropdown-container dropdown-mode-desktop">
920
+ ${labelHtml}
921
+ <div class="dropdown-wrapper">
922
+ <div class="dropdown-stub ${stubClasses}"
923
+ ${this._disabled ? 'disabled' : ''}>
924
+ <slot name="selected"></slot>
925
+ <span class="dropdown-placeholder">${this._placeholder}</span>
926
+ <button class="dropdown-clear-btn" type="button" aria-label="Clear selection">
927
+ ${CLEAR_ICON_SVG}
928
+ </button>
929
+ <div class="dropdown-chevron">
930
+ ${CHEVRON_DOWN_SVG}
931
+ </div>
932
+ </div>
933
+ <dialog class="dropdown-dialog">
934
+ <div class="dropdown-header">
935
+ <input
936
+ class="dropdown-search-input ${this._size}"
937
+ type="text"
938
+ placeholder="${this._placeholder}"
939
+ ${this._disabled ? 'disabled' : ''}
940
+ />
941
+ <div class="dropdown-search-chevron">
942
+ ${CHEVRON_DOWN_SVG}
943
+ </div>
944
+ </div>
945
+ <div class="dropdown-options">
946
+ <slot id="options-slot"></slot>
947
+ </div>
948
+ </dialog>
949
+ </div>
950
+ </div>
951
+ `;
952
+ // Setup event listeners ONCE (only when rendering for the first time)
953
+ this.setupDesktopEventListeners();
954
+ }
955
+ // Dynamic label creation (like input.ts fix)
956
+ const existingLabel = shadow.querySelector('.dropdown-label');
957
+ const container = shadow.querySelector('.dropdown-container');
958
+ const wrapper = shadow.querySelector('.dropdown-wrapper');
959
+ if (this._label) {
960
+ if (existingLabel) {
961
+ // Label exists, update it
962
+ existingLabel.innerHTML = this._label + (this._required ? '<span class="required-icon">' + REQUIRED_ICON_SVG + '</span>' : '');
963
+ existingLabel.style.display = 'flex';
964
+ }
965
+ else if (container && wrapper) {
966
+ // Label doesn't exist but we need one - CREATE IT!
967
+ const labelEl = document.createElement('label');
968
+ labelEl.className = 'dropdown-label';
969
+ labelEl.innerHTML = this._label + (this._required ? '<span class="required-icon">' + REQUIRED_ICON_SVG + '</span>' : '');
970
+ container.insertBefore(labelEl, wrapper);
971
+ }
972
+ }
973
+ else if (existingLabel) {
974
+ // No label text, hide existing label
975
+ ;
976
+ existingLabel.style.display = 'none';
977
+ }
978
+ // Always update selection display
979
+ this.updateSelectionDisplay();
980
+ }
981
+ // ============================================================================
982
+ // MOBILE IMPLEMENTATION (Full-screen Modal)
983
+ // ============================================================================
984
+ // Mobile uses full-screen modal with backdrop.
985
+ //
986
+ // EVENT FLOW:
987
+ // 1. User clicks stub → handleMobileStubClick() → openMobileModal()
988
+ // 2. Modal shown with CSS animation (display: flex + open class)
989
+ // 3. User types in search → handleSearchInput() [SHARED]
990
+ // 4. User clicks option → handleMobileOptionClick() → selectOption() [SHARED] → closeMobileModal()
991
+ // 5. User clicks backdrop → closeMobileModal()
992
+ // 6. User clicks close button → closeMobileModal()
993
+ // 7. User presses Escape → handleMobileKeyboard() → closeMobileModal()
994
+ // 8. ty-option dispatches clear-selection → handleMobileClearClick() → closeMobileModal()
995
+ //
996
+ // METHODS:
997
+ // - openMobileModal(): Open mobile modal
998
+ // - closeMobileModal(): Close mobile modal
999
+ // - handleMobileStubClick(): Open on stub click
1000
+ // - handleMobileOptionClick(): Select option and close
1001
+ // - handleMobileClearClick(): Clear selection and close
1002
+ // - handleMobileKeyboard(): Escape key handler only (no arrow navigation)
1003
+ // - renderMobile(): Render mobile HTML
1004
+ // - setupMobileEventListeners(): Setup mobile event handlers
1005
+ // ============================================================================
1006
+ renderMobile() {
1007
+ const shadow = this.shadowRoot;
1008
+ // Only set innerHTML and setup listeners if container doesn't exist
1009
+ if (!shadow.querySelector('.dropdown-container')) {
1010
+ const stubClasses = this.buildStubClasses();
1011
+ const labelHtml = this._label ? `
1012
+ <label class="dropdown-label">
1013
+ ${this._label}
1014
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1015
+ </label>
1016
+ ` : '';
1017
+ // Close button SVG (X icon)
1018
+ const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1019
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1020
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1021
+ </svg>`;
1022
+ // Conditional search header - only show when searchable
1023
+ const searchHeaderHtml = this._searchable ? `
1024
+ <div class="mobile-search-header">
1025
+ <div class="mobile-header-content">
1026
+ <input
1027
+ class="mobile-search-input ${this._size}"
1028
+ type="text"
1029
+ placeholder="${this._placeholder}"
1030
+ ${this._disabled ? 'disabled' : ''}
1031
+ />
1032
+ <button class="mobile-close-button" type="button" aria-label="Close">
1033
+ ${closeButtonSvg}
1034
+ </button>
1035
+ </div>
1036
+ </div>
1037
+ ` : `
1038
+ <div class="mobile-header-nosearch">
1039
+ <button class="mobile-close-button" type="button" aria-label="Close">
1040
+ ${closeButtonSvg}
1041
+ </button>
1042
+ </div>
1043
+ `;
1044
+ shadow.innerHTML = `
1045
+ <div class="dropdown-container dropdown-mode-mobile">
1046
+ ${labelHtml}
1047
+ <div class="dropdown-wrapper">
1048
+ <div class="dropdown-stub ${stubClasses}"
1049
+ ${this._disabled ? 'disabled' : ''}>
1050
+ <slot name="selected"></slot>
1051
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1052
+ <div class="dropdown-chevron">
1053
+ ${CHEVRON_DOWN_SVG}
1054
+ </div>
1055
+ </div>
1056
+ <div class="mobile-modal" style="display: none;">
1057
+ <div class="mobile-modal-backdrop"></div>
1058
+ <div class="mobile-modal-content">
1059
+ ${searchHeaderHtml}
1060
+ <div class="mobile-options-container">
1061
+ <slot id="options-slot"></slot>
1062
+ </div>
1063
+ </div>
1064
+ </div>
1065
+ </div>
1066
+ </div>
1067
+ `;
1068
+ // Setup event listeners ONCE (only when rendering for the first time)
1069
+ this.setupMobileEventListeners();
1070
+ }
1071
+ // Always update selection display
1072
+ this.updateSelectionDisplay();
1073
+ }
1074
+ setupMobileEventListeners() {
1075
+ const shadow = this.shadowRoot;
1076
+ const stub = shadow.querySelector('.dropdown-stub');
1077
+ const optionsSlot = shadow.querySelector('#options-slot');
1078
+ const searchInput = shadow.querySelector('.mobile-search-input');
1079
+ const backdrop = shadow.querySelector('.mobile-modal-backdrop');
1080
+ const closeButton = shadow.querySelector('.mobile-close-button');
1081
+ if (stub) {
1082
+ stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
1083
+ }
1084
+ // Add option click handler to slot
1085
+ if (optionsSlot) {
1086
+ optionsSlot.addEventListener('click', (e) => this.handleMobileOptionClick(e));
1087
+ }
1088
+ // Add search input handlers (if searchable)
1089
+ if (searchInput) {
1090
+ searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
1091
+ }
1092
+ // Close button click
1093
+ if (closeButton) {
1094
+ closeButton.addEventListener('click', () => this.closeMobileModal());
1095
+ }
1096
+ // Backdrop click to close
1097
+ if (backdrop) {
1098
+ backdrop.addEventListener('click', () => this.closeMobileModal());
1099
+ }
1100
+ // Listen for clear-selection events from ty-option
1101
+ if (optionsSlot) {
1102
+ optionsSlot.addEventListener('clear-selection', (e) => {
1103
+ e.preventDefault();
1104
+ e.stopPropagation();
1105
+ this.handleMobileClearClick(e);
1106
+ });
1107
+ }
1108
+ // Document keyboard handler for Escape key
1109
+ const keyboardHandler = (e) => this.handleMobileKeyboard(e);
1110
+ document.addEventListener('keydown', keyboardHandler);
1111
+ this.tyKeyboardHandler = keyboardHandler;
1112
+ }
1113
+ handleMobileStubClick(e) {
1114
+ this.handleStubClickBase(e, () => this.openMobileModal());
1115
+ }
1116
+ handleMobileOptionClick(e) {
1117
+ this.handleOptionClickBase(e, () => this.closeMobileModal());
1118
+ }
1119
+ handleMobileKeyboard(e) {
1120
+ if (!this._state.open)
1121
+ return;
1122
+ // Only handle Escape key on mobile (no arrow navigation needed for touch)
1123
+ if (e.key === 'Escape') {
1124
+ e.preventDefault();
1125
+ e.stopPropagation();
1126
+ this.closeMobileModal();
1127
+ }
1128
+ }
1129
+ handleMobileClearClick(e) {
1130
+ this.handleClearClickBase(e, () => this.closeMobileModal());
1131
+ // Mobile closes modal after clearing
1132
+ }
1133
+ openMobileModal() {
1134
+ const shadow = this.shadowRoot;
1135
+ const modal = shadow.querySelector('.mobile-modal');
1136
+ if (!modal)
1137
+ return;
1138
+ // Lock scroll
1139
+ this.lockDropdownScroll();
1140
+ // Show modal with animation
1141
+ modal.style.display = 'flex';
1142
+ requestAnimationFrame(() => {
1143
+ modal.classList.add('open');
1144
+ });
1145
+ // Update component state
1146
+ this._state.open = true;
1147
+ // Initialize options state (no highlight on mobile)
1148
+ this.initializeOptionsState(false);
1149
+ // Focus search input if searchable
1150
+ if (this._searchable) {
1151
+ const searchInput = shadow.querySelector('.mobile-search-input');
1152
+ if (searchInput) {
1153
+ // Small delay to ensure modal is visible and keyboard doesn't glitch
1154
+ setTimeout(() => searchInput.focus(), 300);
1155
+ }
1156
+ }
1157
+ // Hide clear button when modal is open (stub clear button - desktop only)
1158
+ this.updateClearButton();
1159
+ }
1160
+ closeMobileModal() {
1161
+ const shadow = this.shadowRoot;
1162
+ const modal = shadow.querySelector('.mobile-modal');
1163
+ if (!modal)
1164
+ return;
1165
+ // Unlock scroll
1166
+ this.unlockDropdownScroll();
1167
+ // Hide modal with animation
1168
+ modal.classList.remove('open');
1169
+ setTimeout(() => {
1170
+ modal.style.display = 'none';
1171
+ }, 300); // Match CSS transition duration
1172
+ // Update state
1173
+ this._state.open = false;
1174
+ this._state.highlightedIndex = -1;
1175
+ // Reset search if searchable
1176
+ if (this._searchable) {
1177
+ this._state.search = '';
1178
+ const searchInput = shadow.querySelector('.mobile-search-input');
1179
+ if (searchInput) {
1180
+ searchInput.value = '';
1181
+ }
1182
+ }
1183
+ // Show clear button when modal is closed (if applicable)
1184
+ this.updateClearButton();
1185
+ }
1186
+ // ============================================================================
1187
+ // PUBLIC API - Getters/Setters
1188
+ // ============================================================================
1189
+ // ============================================================================
1190
+ // PROPERTY ACCESSORS - Simplified with TyComponent
1191
+ // ============================================================================
1192
+ get value() { return this.getProperty('value'); }
1193
+ set value(v) { this.setProperty('value', v); }
1194
+ get name() { return this.getProperty('name'); }
1195
+ set name(v) { this.setProperty('name', v); }
1196
+ get placeholder() { return this.getProperty('placeholder'); }
1197
+ set placeholder(v) { this.setProperty('placeholder', v); }
1198
+ get label() { return this.getProperty('label'); }
1199
+ set label(v) { this.setProperty('label', v); }
1200
+ get disabled() { return this.getProperty('disabled'); }
1201
+ set disabled(v) { this.setProperty('disabled', v); }
1202
+ get readonly() { return this.getProperty('readonly'); }
1203
+ set readonly(v) { this.setProperty('readonly', v); }
1204
+ get required() { return this.getProperty('required'); }
1205
+ set required(v) { this.setProperty('required', v); }
1206
+ get searchable() { return this.getProperty('searchable'); }
1207
+ set searchable(v) { this.setProperty('searchable', v); }
1208
+ get clearable() { return this.getProperty('clearable'); }
1209
+ set clearable(v) { this.setProperty('clearable', v); }
1210
+ get size() { return this.getProperty('size'); }
1211
+ set size(v) { this.setProperty('size', v); }
1212
+ get flavor() { return this.getProperty('flavor'); }
1213
+ set flavor(v) { this.setProperty('flavor', v); }
1214
+ get delay() { return this.getProperty('delay'); }
1215
+ set delay(v) { this.setProperty('delay', v); }
1216
+ get form() {
1217
+ return this._internals.form;
1218
+ }
1219
+ }
1220
+ // ============================================================================
1221
+ // PROPERTY CONFIGURATION - Declarative property lifecycle
1222
+ // ============================================================================
1223
+ TyDropdown.properties = {
1224
+ value: {
1225
+ type: 'string',
1226
+ visual: true,
1227
+ formValue: true,
1228
+ emitChange: true,
1229
+ default: ''
1230
+ },
1231
+ name: {
1232
+ type: 'string',
1233
+ default: ''
1234
+ },
1235
+ placeholder: {
1236
+ type: 'string',
1237
+ visual: true,
1238
+ default: 'Select an option...'
1239
+ },
1240
+ label: {
1241
+ type: 'string',
1242
+ visual: true,
1243
+ default: ''
1244
+ },
1245
+ disabled: {
1246
+ type: 'boolean',
1247
+ visual: true,
1248
+ default: false
1249
+ },
1250
+ readonly: {
1251
+ type: 'boolean',
1252
+ visual: true,
1253
+ default: false
1254
+ },
1255
+ required: {
1256
+ type: 'boolean',
1257
+ visual: true,
1258
+ default: false
1259
+ },
1260
+ searchable: {
1261
+ type: 'boolean',
1262
+ visual: true,
1263
+ default: true,
1264
+ aliases: { 'not-searchable': false }
1265
+ },
1266
+ clearable: {
1267
+ type: 'boolean',
1268
+ visual: true,
1269
+ default: true,
1270
+ aliases: { 'not-clearable': false }
1271
+ },
1272
+ size: {
1273
+ type: 'string',
1274
+ visual: true,
1275
+ default: 'md',
1276
+ validate: (v) => ['sm', 'md', 'lg'].includes(v),
1277
+ coerce: (v) => {
1278
+ if (!['sm', 'md', 'lg'].includes(v)) {
1279
+ console.warn(`[ty-dropdown] Invalid size. Using md.`);
1280
+ return 'md';
1281
+ }
1282
+ return v;
1283
+ }
1284
+ },
1285
+ flavor: {
1286
+ type: 'string',
1287
+ visual: true,
1288
+ default: 'neutral',
1289
+ validate: (v) => ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'].includes(v),
1290
+ coerce: (v) => {
1291
+ const valid = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
1292
+ if (!valid.includes(v)) {
1293
+ console.warn(`[ty-dropdown] Invalid flavor. Using neutral.`);
1294
+ return 'neutral';
1295
+ }
1296
+ return v;
1297
+ }
1298
+ },
1299
+ delay: {
1300
+ type: 'number',
1301
+ default: 0,
1302
+ validate: (v) => v >= 0 && v <= 5000,
1303
+ coerce: (v) => {
1304
+ const num = Number(v);
1305
+ if (isNaN(num))
1306
+ return 0;
1307
+ return Math.max(0, Math.min(5000, num));
1308
+ }
1309
+ }
1310
+ };
1311
+ // Register the custom element
1312
+ if (!customElements.get('ty-dropdown')) {
1313
+ customElements.define('ty-dropdown', TyDropdown);
1314
+ }
1315
+ //# sourceMappingURL=dropdown.js.map