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