tyrell-components 1.0.0-TC55 → 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.
- package/README.md +53 -53
- package/css/tyrell-theme.css +7 -2
- package/css/tyrell.css +6 -15
- package/dist/tyrell-theme.css +1 -1
- package/dist/tyrell.css +1 -1
- package/dist/tyrell.js +1 -1
- package/lib/base/ty-component.d.ts +14 -0
- package/lib/base/ty-component.d.ts.map +1 -0
- package/lib/base/ty-component.js +27 -0
- package/lib/base/ty-component.js.map +1 -0
- package/lib/components/button.d.ts.map +1 -0
- package/lib/components/button.js +7 -0
- package/lib/components/button.js.map +1 -0
- package/lib/components/calendar-month.d.ts.map +1 -0
- package/lib/components/calendar-month.js +3 -2
- package/lib/components/calendar-month.js.map +1 -0
- package/lib/components/calendar-navigation.d.ts +11 -0
- package/lib/components/calendar-navigation.d.ts.map +1 -0
- package/lib/components/calendar-navigation.js +51 -40
- package/lib/components/calendar-navigation.js.map +1 -0
- package/lib/components/calendar.d.ts +26 -0
- package/lib/components/calendar.d.ts.map +1 -0
- package/lib/components/calendar.js +177 -4
- package/lib/components/calendar.js.map +1 -0
- package/lib/components/checkbox.d.ts.map +1 -0
- package/lib/components/checkbox.js +3 -3
- package/lib/components/checkbox.js.map +1 -0
- package/lib/components/copy.d.ts.map +1 -0
- package/lib/components/copy.js +9 -2
- package/lib/components/copy.js.map +1 -0
- package/lib/components/date-picker.d.ts +15 -16
- package/lib/components/date-picker.d.ts.map +1 -0
- package/lib/components/date-picker.js +175 -249
- package/lib/components/date-picker.js.map +1 -0
- package/lib/components/dropdown.d.ts +403 -0
- package/lib/components/dropdown.d.ts.map +1 -0
- package/lib/components/dropdown.js +1315 -0
- package/lib/components/dropdown.js.map +1 -0
- package/lib/components/file-upload.d.ts +4 -0
- package/lib/components/file-upload.js +14 -0
- package/lib/components/icon.d.ts.map +1 -0
- package/lib/components/icon.js.map +1 -0
- package/lib/components/input.d.ts.map +1 -0
- package/lib/components/input.js +3 -3
- package/lib/components/input.js.map +1 -0
- package/lib/components/modal.d.ts +2 -8
- package/lib/components/modal.d.ts.map +1 -0
- package/lib/components/modal.js +8 -18
- package/lib/components/modal.js.map +1 -0
- package/lib/components/multiselect.d.ts +389 -0
- package/lib/components/multiselect.d.ts.map +1 -0
- package/lib/components/multiselect.js +1301 -0
- package/lib/components/multiselect.js.map +1 -0
- package/lib/components/option.d.ts.map +1 -0
- package/lib/components/option.js.map +1 -0
- package/lib/components/popup.d.ts.map +1 -0
- package/lib/components/popup.js +4 -1
- package/lib/components/popup.js.map +1 -0
- package/lib/components/radio.d.ts +15 -0
- package/lib/components/radio.js +34 -4
- package/lib/components/resize-observer.d.ts.map +1 -0
- package/lib/components/resize-observer.js.map +1 -0
- package/lib/components/scroll-container.d.ts +3 -2
- package/lib/components/scroll-container.d.ts.map +1 -0
- package/lib/components/scroll-container.js +5 -8
- package/lib/components/scroll-container.js.map +1 -0
- package/lib/components/select.d.ts +14 -6
- package/lib/components/select.js +61 -19
- package/lib/components/step.d.ts.map +1 -0
- package/lib/components/step.js.map +1 -0
- package/lib/components/switch.js +3 -3
- package/lib/components/tab.d.ts +49 -1
- package/lib/components/tab.d.ts.map +1 -0
- package/lib/components/tab.js +151 -10
- package/lib/components/tab.js.map +1 -0
- package/lib/components/tabs.d.ts +14 -2
- package/lib/components/tabs.d.ts.map +1 -0
- package/lib/components/tabs.js +184 -135
- package/lib/components/tabs.js.map +1 -0
- package/lib/components/tag.d.ts +4 -7
- package/lib/components/tag.d.ts.map +1 -0
- package/lib/components/tag.js +6 -7
- package/lib/components/tag.js.map +1 -0
- package/lib/components/textarea.d.ts +4 -3
- package/lib/components/textarea.d.ts.map +1 -0
- package/lib/components/textarea.js +16 -15
- package/lib/components/textarea.js.map +1 -0
- package/lib/components/tooltip.d.ts +4 -3
- package/lib/components/tooltip.d.ts.map +1 -0
- package/lib/components/tooltip.js +34 -24
- package/lib/components/tooltip.js.map +1 -0
- package/lib/components/wizard.d.ts +5 -4
- package/lib/components/wizard.d.ts.map +1 -0
- package/lib/components/wizard.js +120 -156
- package/lib/components/wizard.js.map +1 -0
- package/lib/icons/fontawesome/brands.d.ts.map +1 -0
- package/lib/icons/fontawesome/brands.js.map +1 -0
- package/lib/icons/fontawesome/regular.d.ts.map +1 -0
- package/lib/icons/fontawesome/regular.js.map +1 -0
- package/lib/icons/fontawesome/solid.d.ts.map +1 -0
- package/lib/icons/fontawesome/solid.js.map +1 -0
- package/lib/icons/heroicons/micro.d.ts.map +1 -0
- package/lib/icons/heroicons/micro.js.map +1 -0
- package/lib/icons/heroicons/mini.d.ts.map +1 -0
- package/lib/icons/heroicons/mini.js.map +1 -0
- package/lib/icons/heroicons/outline.d.ts.map +1 -0
- package/lib/icons/heroicons/outline.js.map +1 -0
- package/lib/icons/heroicons/solid.d.ts.map +1 -0
- package/lib/icons/heroicons/solid.js.map +1 -0
- package/lib/icons/lucide.d.ts.map +1 -0
- package/lib/icons/lucide.js.map +1 -0
- package/lib/icons/material/filled.d.ts.map +1 -0
- package/lib/icons/material/filled.js.map +1 -0
- package/lib/icons/material/outlined.d.ts.map +1 -0
- package/lib/icons/material/outlined.js.map +1 -0
- package/lib/icons/material/round.d.ts.map +1 -0
- package/lib/icons/material/round.js.map +1 -0
- package/lib/icons/material/sharp.d.ts.map +1 -0
- package/lib/icons/material/sharp.js.map +1 -0
- package/lib/icons/material/two-tone.d.ts.map +1 -0
- package/lib/icons/material/two-tone.js.map +1 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js.map +1 -0
- package/lib/styles/button.d.ts.map +1 -0
- package/lib/styles/button.js +68 -58
- package/lib/styles/button.js.map +1 -0
- package/lib/styles/calendar-month.d.ts +3 -0
- package/lib/styles/calendar-month.d.ts.map +1 -0
- package/lib/styles/calendar-month.js +43 -27
- package/lib/styles/calendar-month.js.map +1 -0
- package/lib/styles/calendar-navigation.d.ts +1 -1
- package/lib/styles/calendar-navigation.d.ts.map +1 -0
- package/lib/styles/calendar-navigation.js +55 -26
- package/lib/styles/calendar-navigation.js.map +1 -0
- package/lib/styles/calendar.d.ts +3 -2
- package/lib/styles/calendar.d.ts.map +1 -0
- package/lib/styles/calendar.js +74 -0
- package/lib/styles/calendar.js.map +1 -0
- package/lib/styles/checkbox.js +22 -10
- package/lib/styles/copy.d.ts.map +1 -0
- package/lib/styles/copy.js +5 -1
- package/lib/styles/copy.js.map +1 -0
- package/lib/styles/date-picker.d.ts.map +1 -0
- package/lib/styles/date-picker.js +52 -22
- 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 +725 -0
- package/lib/styles/dropdown.js.map +1 -0
- package/lib/styles/icon.d.ts.map +1 -0
- package/lib/styles/icon.js.map +1 -0
- package/lib/styles/input.d.ts.map +1 -0
- package/lib/styles/input.js.map +1 -0
- package/lib/styles/modal.d.ts.map +1 -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 +568 -0
- package/lib/styles/multiselect.js.map +1 -0
- package/lib/styles/option.d.ts +1 -1
- package/lib/styles/option.d.ts.map +1 -0
- package/lib/styles/option.js +3 -3
- package/lib/styles/option.js.map +1 -0
- package/lib/styles/popup.d.ts +1 -1
- package/lib/styles/popup.d.ts.map +1 -0
- package/lib/styles/popup.js +8 -5
- package/lib/styles/popup.js.map +1 -0
- package/lib/styles/radio.js +8 -2
- package/lib/styles/resize-observer.d.ts.map +1 -0
- package/lib/styles/resize-observer.js.map +1 -0
- package/lib/styles/scroll-container.d.ts +1 -1
- package/lib/styles/scroll-container.d.ts.map +1 -0
- package/lib/styles/scroll-container.js +1 -1
- package/lib/styles/scroll-container.js.map +1 -0
- package/lib/styles/select.d.ts +1 -1
- package/lib/styles/select.js +6 -0
- package/lib/styles/step.d.ts +1 -1
- package/lib/styles/step.d.ts.map +1 -0
- package/lib/styles/step.js +1 -9
- package/lib/styles/step.js.map +1 -0
- package/lib/styles/switch.js +6 -3
- package/lib/styles/tab.d.ts +1 -1
- package/lib/styles/tab.d.ts.map +1 -0
- package/lib/styles/tab.js +67 -3
- package/lib/styles/tab.js.map +1 -0
- package/lib/styles/tabs.d.ts +1 -1
- package/lib/styles/tabs.d.ts.map +1 -0
- package/lib/styles/tabs.js +7 -3
- package/lib/styles/tabs.js.map +1 -0
- package/lib/styles/tag.d.ts.map +1 -0
- package/lib/styles/tag.js +1 -2
- package/lib/styles/tag.js.map +1 -0
- package/lib/styles/textarea.d.ts.map +1 -0
- package/lib/styles/textarea.js.map +1 -0
- package/lib/styles/tooltip.d.ts.map +1 -0
- package/lib/styles/tooltip.js.map +1 -0
- package/lib/styles/wizard.d.ts +9 -5
- package/lib/styles/wizard.d.ts.map +1 -0
- package/lib/styles/wizard.js +211 -99
- package/lib/styles/wizard.js.map +1 -0
- package/lib/types/common.d.ts +2 -2
- package/lib/types/common.d.ts.map +1 -0
- package/lib/types/common.js.map +1 -0
- package/lib/utils/calendar-utils.d.ts.map +1 -0
- package/lib/utils/calendar-utils.js.map +1 -0
- package/lib/utils/icon-registry.d.ts.map +1 -0
- package/lib/utils/icon-registry.js.map +1 -0
- package/lib/utils/locale.d.ts.map +1 -0
- package/lib/utils/locale.js.map +1 -0
- package/lib/utils/number-format.d.ts.map +1 -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 +23 -0
- package/lib/utils/parse-boolean.js.map +1 -0
- package/lib/utils/positioning.d.ts.map +1 -0
- package/lib/utils/positioning.js.map +1 -0
- package/lib/utils/property-capture.d.ts.map +1 -0
- package/lib/utils/property-capture.js.map +1 -0
- package/lib/utils/property-manager.d.ts +1 -1
- package/lib/utils/property-manager.d.ts.map +1 -0
- package/lib/utils/property-manager.js +2 -0
- package/lib/utils/property-manager.js.map +1 -0
- package/lib/utils/resize-observer.d.ts.map +1 -0
- package/lib/utils/resize-observer.js.map +1 -0
- package/lib/utils/scroll-lock.d.ts.map +1 -0
- package/lib/utils/scroll-lock.js.map +1 -0
- package/lib/utils/styles.d.ts.map +1 -0
- package/lib/utils/styles.js.map +1 -0
- package/lib/utils/validity.d.ts +26 -0
- package/lib/utils/validity.js +53 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -0
- 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
|