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,1301 @@
|
|
|
1
|
+
|
|
2
|
+
import { ensureStyles } from '../utils/styles.js';
|
|
3
|
+
import { multiselectStyles } from '../styles/multiselect.js';
|
|
4
|
+
import { TyComponent } from '../base/ty-component.js';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// DEVICE DETECTION
|
|
7
|
+
// ============================================================================
|
|
8
|
+
function isMobileDevice() {
|
|
9
|
+
const width = window.innerWidth;
|
|
10
|
+
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
11
|
+
return width <= 768 || (width <= 1024 && hasTouch);
|
|
12
|
+
}
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Element Hash Utility (for consistent scroll lock IDs)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
let elementIdCounter = 0;
|
|
17
|
+
const elementIds = new WeakMap();
|
|
18
|
+
function getElementHash(element) {
|
|
19
|
+
let id = elementIds.get(element);
|
|
20
|
+
if (id === undefined) {
|
|
21
|
+
id = ++elementIdCounter;
|
|
22
|
+
elementIds.set(element, id);
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// SVG Icons
|
|
28
|
+
// ============================================================================
|
|
29
|
+
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>`;
|
|
30
|
+
const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
|
|
31
|
+
<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" />
|
|
32
|
+
</svg>`;
|
|
33
|
+
export class TyMultiselect extends TyComponent {
|
|
34
|
+
// Debug: Log when expandedSection changes
|
|
35
|
+
set expandedSection(value) {
|
|
36
|
+
this._state.expandedSection = value;
|
|
37
|
+
}
|
|
38
|
+
get expandedSection() {
|
|
39
|
+
return this._state.expandedSection;
|
|
40
|
+
}
|
|
41
|
+
constructor() {
|
|
42
|
+
super(); // TyComponent handles attachInternals() and attachShadow()
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// INTERNAL STATE
|
|
45
|
+
// ============================================================================
|
|
46
|
+
this._value = '';
|
|
47
|
+
this._name = '';
|
|
48
|
+
this._placeholder = 'Select options...';
|
|
49
|
+
this._label = '';
|
|
50
|
+
this._disabled = false;
|
|
51
|
+
this._readonly = false;
|
|
52
|
+
this._required = false;
|
|
53
|
+
this._searchable = true;
|
|
54
|
+
this._size = 'md';
|
|
55
|
+
this._flavor = 'neutral';
|
|
56
|
+
this._selectedLabel = 'Selected';
|
|
57
|
+
this._availableLabel = 'Available';
|
|
58
|
+
this._noSelectionMessage = 'No items selected';
|
|
59
|
+
this._noOptionsMessage = 'No options available';
|
|
60
|
+
// Component state
|
|
61
|
+
this._state = {
|
|
62
|
+
open: false,
|
|
63
|
+
search: '',
|
|
64
|
+
highlightedIndex: -1,
|
|
65
|
+
filteredTags: [],
|
|
66
|
+
selectedValues: [],
|
|
67
|
+
mode: isMobileDevice() ? 'mobile' : 'desktop',
|
|
68
|
+
expandedSection: 'selected' // Will be corrected by toggleSection call
|
|
69
|
+
};
|
|
70
|
+
// Event handler references for cleanup
|
|
71
|
+
this._stubClickHandler = null;
|
|
72
|
+
this._outsideClickHandler = null;
|
|
73
|
+
this._tagClickHandler = null;
|
|
74
|
+
this._tagDismissHandler = null;
|
|
75
|
+
this._searchInputHandler = null;
|
|
76
|
+
this._blockSearchClick = null;
|
|
77
|
+
this._keyboardHandler = null;
|
|
78
|
+
// Delay/debounce properties for search event
|
|
79
|
+
this._delay = 0;
|
|
80
|
+
this._searchDebounceTimer = null;
|
|
81
|
+
const shadow = this.shadowRoot;
|
|
82
|
+
ensureStyles(shadow, { css: multiselectStyles, id: 'ty-multiselect' });
|
|
83
|
+
// DON'T render here - wait for onConnect() to initialize values first
|
|
84
|
+
// This matches dropdown.ts pattern and prevents showing empty state
|
|
85
|
+
}
|
|
86
|
+
onConnect() {
|
|
87
|
+
// SAFETY: Close any open dialogs to prevent scroll locking
|
|
88
|
+
const shadow = this.shadowRoot;
|
|
89
|
+
const dialogs = shadow.querySelectorAll('dialog');
|
|
90
|
+
dialogs.forEach(dialog => {
|
|
91
|
+
if (dialog.open) {
|
|
92
|
+
console.warn('⚠️ Found open dialog on connect, closing it');
|
|
93
|
+
dialog.close();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
// Render FIRST to create DOM structure
|
|
97
|
+
this.render();
|
|
98
|
+
// THEN initialize and sync tags (after DOM exists)
|
|
99
|
+
requestAnimationFrame(() => {
|
|
100
|
+
this.initializeState();
|
|
101
|
+
// Visual updates happen automatically via onPropertiesChanged
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
onDisconnect() {
|
|
105
|
+
// CRITICAL: Close all dialogs to prevent scroll locking
|
|
106
|
+
const shadow = this.shadowRoot;
|
|
107
|
+
const dialogs = shadow.querySelectorAll('dialog');
|
|
108
|
+
dialogs.forEach(dialog => {
|
|
109
|
+
if (dialog.open) {
|
|
110
|
+
dialog.close();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// Clean up document-level listeners
|
|
114
|
+
if (this._keyboardHandler) {
|
|
115
|
+
document.removeEventListener('keydown', this._keyboardHandler);
|
|
116
|
+
this._keyboardHandler = null;
|
|
117
|
+
}
|
|
118
|
+
// Clear any pending debounce timer
|
|
119
|
+
if (this._searchDebounceTimer !== null) {
|
|
120
|
+
clearTimeout(this._searchDebounceTimer);
|
|
121
|
+
this._searchDebounceTimer = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
onPropertiesChanged(changes) {
|
|
125
|
+
for (const { name, newValue } of changes) {
|
|
126
|
+
switch (name) {
|
|
127
|
+
case 'value':
|
|
128
|
+
this._value = newValue || '';
|
|
129
|
+
const selectedValues = this.parseValue(newValue);
|
|
130
|
+
this._state.selectedValues = selectedValues;
|
|
131
|
+
// CRITICAL: Only sync tags if we're connected and tags exist
|
|
132
|
+
// During initial property setup (before onConnect), tags don't exist yet
|
|
133
|
+
if (this.isConnected && this.shadowRoot) {
|
|
134
|
+
this.syncSelectedTags(selectedValues);
|
|
135
|
+
this.updateSelectionDisplay();
|
|
136
|
+
this.updateMobileSelectedState();
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
console.warn('💜 [multiselect] onPropertiesChanged - NOT connected yet, skipping sync (will happen in initializeState)');
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case 'name':
|
|
143
|
+
this._name = newValue || '';
|
|
144
|
+
break;
|
|
145
|
+
case 'placeholder':
|
|
146
|
+
this._placeholder = newValue || 'Select options...';
|
|
147
|
+
// No need to update display - placeholder text change doesn't affect visibility
|
|
148
|
+
break;
|
|
149
|
+
case 'label':
|
|
150
|
+
this._label = newValue || '';
|
|
151
|
+
break;
|
|
152
|
+
case 'disabled':
|
|
153
|
+
this._disabled = newValue;
|
|
154
|
+
break;
|
|
155
|
+
case 'readonly':
|
|
156
|
+
this._readonly = newValue;
|
|
157
|
+
break;
|
|
158
|
+
case 'required':
|
|
159
|
+
this._required = newValue;
|
|
160
|
+
break;
|
|
161
|
+
case 'searchable':
|
|
162
|
+
this._searchable = newValue;
|
|
163
|
+
break;
|
|
164
|
+
case 'size':
|
|
165
|
+
this._size = newValue;
|
|
166
|
+
break;
|
|
167
|
+
case 'flavor':
|
|
168
|
+
this._flavor = newValue;
|
|
169
|
+
break;
|
|
170
|
+
case 'delay':
|
|
171
|
+
this._delay = newValue;
|
|
172
|
+
break;
|
|
173
|
+
case 'selected-label':
|
|
174
|
+
this._selectedLabel = newValue || 'Selected';
|
|
175
|
+
break;
|
|
176
|
+
case 'available-label':
|
|
177
|
+
this._availableLabel = newValue || 'Available';
|
|
178
|
+
break;
|
|
179
|
+
case 'no-selection-message':
|
|
180
|
+
this._noSelectionMessage = newValue || 'No items selected';
|
|
181
|
+
break;
|
|
182
|
+
case 'no-options-message':
|
|
183
|
+
this._noOptionsMessage = newValue || 'No options available';
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
getFormValue() {
|
|
189
|
+
const selectedValues = this._state.selectedValues;
|
|
190
|
+
if (this._name && selectedValues.length > 0) {
|
|
191
|
+
const formData = new FormData();
|
|
192
|
+
selectedValues.forEach(value => {
|
|
193
|
+
formData.append(this._name, value);
|
|
194
|
+
});
|
|
195
|
+
return formData;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
parseValue(value) {
|
|
200
|
+
// Defensive check: ensure value is actually a string before calling .trim()
|
|
201
|
+
if (!value || typeof value !== 'string' || value.trim() === '')
|
|
202
|
+
return [];
|
|
203
|
+
return value.split(',').map(v => v.trim()).filter(v => v !== '');
|
|
204
|
+
}
|
|
205
|
+
initializeState() {
|
|
206
|
+
const initialValue = this.getProperty('value') || '';
|
|
207
|
+
if (initialValue) {
|
|
208
|
+
// Explicit value provided - sync tags directly
|
|
209
|
+
// DON'T use updateComponentValue() because the property is already set!
|
|
210
|
+
const selectedValues = this.parseValue(initialValue);
|
|
211
|
+
// Update internal state
|
|
212
|
+
this._state.selectedValues = selectedValues;
|
|
213
|
+
// Sync the tags to match the property value
|
|
214
|
+
this.syncSelectedTags(selectedValues);
|
|
215
|
+
// Update the visual display
|
|
216
|
+
this.updateSelectionDisplay();
|
|
217
|
+
this.updateMobileSelectedState();
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// No explicit value - check for pre-selected tags
|
|
221
|
+
const allTags = this.getTagElements();
|
|
222
|
+
const preSelectedTags = allTags
|
|
223
|
+
.filter(tag => tag.hasAttribute('selected'))
|
|
224
|
+
.map(tag => this.getTagData(tag).value);
|
|
225
|
+
if (preSelectedTags.length > 0) {
|
|
226
|
+
// Set the property value and sync (updateComponentValue handles everything)
|
|
227
|
+
this.updateComponentValue(preSelectedTags, false);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// TAG MANAGEMENT METHODS (Phase 2)
|
|
233
|
+
// ============================================================================
|
|
234
|
+
getTagElements() {
|
|
235
|
+
// Get ALL ty-tag children, regardless of slot assignment
|
|
236
|
+
const tags = Array.from(this.querySelectorAll('ty-tag'));
|
|
237
|
+
return tags;
|
|
238
|
+
}
|
|
239
|
+
getTagData(element) {
|
|
240
|
+
// Get value from either property or attribute
|
|
241
|
+
const value = element.value || element.getAttribute('value') || element.textContent || '';
|
|
242
|
+
const text = element.textContent || '';
|
|
243
|
+
return { value, text, element };
|
|
244
|
+
}
|
|
245
|
+
selectTag(tag) {
|
|
246
|
+
const tagValue = this.getTagData(tag).value;
|
|
247
|
+
// Set selected attribute
|
|
248
|
+
tag.setAttribute('selected', '');
|
|
249
|
+
// Move to selected slot
|
|
250
|
+
tag.setAttribute('slot', 'selected');
|
|
251
|
+
// Make dismissible
|
|
252
|
+
tag.setAttribute('dismissible', 'true');
|
|
253
|
+
// Force re-slotting by removing and re-adding
|
|
254
|
+
const parent = tag.parentNode;
|
|
255
|
+
if (parent) {
|
|
256
|
+
parent.removeChild(tag);
|
|
257
|
+
parent.appendChild(tag);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.warn(`⚠️ [multiselect] - No parent for "${tagValue}"!`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
deselectTag(tag) {
|
|
264
|
+
// Remove selected attribute
|
|
265
|
+
tag.removeAttribute('selected');
|
|
266
|
+
// Remove from selected slot
|
|
267
|
+
tag.removeAttribute('slot');
|
|
268
|
+
// Remove dismissible
|
|
269
|
+
tag.removeAttribute('dismissible');
|
|
270
|
+
// Force re-slotting by removing and re-adding
|
|
271
|
+
const parent = tag.parentNode;
|
|
272
|
+
if (parent) {
|
|
273
|
+
parent.removeChild(tag);
|
|
274
|
+
parent.appendChild(tag);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
getSelectedValues() {
|
|
278
|
+
return this.getTagElements()
|
|
279
|
+
.filter(tag => tag.hasAttribute('selected'))
|
|
280
|
+
.map(tag => this.getTagData(tag).value)
|
|
281
|
+
.filter(value => value !== '');
|
|
282
|
+
}
|
|
283
|
+
allTagsSelected() {
|
|
284
|
+
const tags = this.getTagElements();
|
|
285
|
+
if (tags.length === 0)
|
|
286
|
+
return false;
|
|
287
|
+
const selectedCount = tags.filter(tag => tag.hasAttribute('selected')).length;
|
|
288
|
+
return selectedCount === tags.length;
|
|
289
|
+
}
|
|
290
|
+
syncSelectedTags(selectedValues) {
|
|
291
|
+
const selectedSet = new Set(selectedValues);
|
|
292
|
+
const tags = this.getTagElements();
|
|
293
|
+
tags.forEach(tag => {
|
|
294
|
+
const tagValue = this.getTagData(tag).value;
|
|
295
|
+
const shouldBeSelected = selectedSet.has(tagValue);
|
|
296
|
+
const isSelected = tag.hasAttribute('selected');
|
|
297
|
+
if (shouldBeSelected && !isSelected) {
|
|
298
|
+
this.selectTag(tag);
|
|
299
|
+
}
|
|
300
|
+
else if (!shouldBeSelected && isSelected) {
|
|
301
|
+
this.deselectTag(tag);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
updateComponentValue(newValues, dispatchChange = false, action = 'set', item = null) {
|
|
306
|
+
const oldValues = this.getSelectedValues();
|
|
307
|
+
const valueStr = newValues.join(',');
|
|
308
|
+
// Only update if changed
|
|
309
|
+
if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
|
|
310
|
+
const currentPropertyValue = this.getProperty('value');
|
|
311
|
+
// Use TyComponent's property system - this will trigger:
|
|
312
|
+
// 1. onPropertiesChanged() → syncs tags via syncSelectedTags()
|
|
313
|
+
// 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
|
|
314
|
+
// 3. updateFormValue() → automatic (formValue: true in config)
|
|
315
|
+
// 4. render() → automatic if visual properties changed
|
|
316
|
+
this.setProperty('value', valueStr);
|
|
317
|
+
// Dispatch custom multiselect change event (with action/item details)
|
|
318
|
+
if (dispatchChange) {
|
|
319
|
+
this.dispatchChangeEvent({
|
|
320
|
+
values: newValues,
|
|
321
|
+
action,
|
|
322
|
+
item
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// ============================================================================
|
|
330
|
+
// DROPDOWN METHODS (Phase 3 & 4)
|
|
331
|
+
// ============================================================================
|
|
332
|
+
calculatePosition() {
|
|
333
|
+
const shadow = this.shadowRoot;
|
|
334
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
335
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
336
|
+
if (!stub || !dialog)
|
|
337
|
+
return;
|
|
338
|
+
const stubRect = stub.getBoundingClientRect();
|
|
339
|
+
const viewportHeight = window.innerHeight;
|
|
340
|
+
const viewportWidth = window.innerWidth;
|
|
341
|
+
// Get dialog dimensions (it's already shown with showModal)
|
|
342
|
+
const dialogRect = dialog.getBoundingClientRect();
|
|
343
|
+
const estimatedHeight = dialogRect.height || 200;
|
|
344
|
+
const padding = 8;
|
|
345
|
+
const wrapPadding = 20;
|
|
346
|
+
// Available space calculations
|
|
347
|
+
const spaceBelow = viewportHeight - stubRect.bottom;
|
|
348
|
+
const spaceRight = viewportWidth - stubRect.left;
|
|
349
|
+
// Smart direction logic
|
|
350
|
+
const positionBelow = spaceBelow >= estimatedHeight + padding;
|
|
351
|
+
const fitsHorizontally = spaceRight >= stubRect.width;
|
|
352
|
+
// Calculate position coordinates
|
|
353
|
+
const x = fitsHorizontally
|
|
354
|
+
? stubRect.left - wrapPadding
|
|
355
|
+
: Math.max(padding, viewportWidth - stubRect.width - padding);
|
|
356
|
+
const y = positionBelow
|
|
357
|
+
? stubRect.top - wrapPadding
|
|
358
|
+
: viewportHeight - stubRect.bottom - wrapPadding;
|
|
359
|
+
const width = stubRect.width + wrapPadding + wrapPadding;
|
|
360
|
+
// Set CSS variables for positioning
|
|
361
|
+
this.style.setProperty('--dropdown-x', `${x}px`);
|
|
362
|
+
this.style.setProperty('--dropdown-y', `${y}px`);
|
|
363
|
+
this.style.setProperty('--dropdown-width', `${width}px`);
|
|
364
|
+
this.style.setProperty('--dropdown-offset-x', '0px');
|
|
365
|
+
this.style.setProperty('--dropdown-offset-y', '0px');
|
|
366
|
+
this.style.setProperty('--dropdown-padding', `${wrapPadding}px`);
|
|
367
|
+
// Set direction classes for CSS styling
|
|
368
|
+
if (positionBelow) {
|
|
369
|
+
dialog.classList.add('position-below');
|
|
370
|
+
dialog.classList.remove('position-above');
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
dialog.classList.add('position-above');
|
|
374
|
+
dialog.classList.remove('position-below');
|
|
375
|
+
}
|
|
376
|
+
// Optional: Store direction for debugging
|
|
377
|
+
this.style.setProperty('--dropdown-direction', positionBelow ? 'below' : 'above');
|
|
378
|
+
}
|
|
379
|
+
openDropdown() {
|
|
380
|
+
const shadow = this.shadowRoot;
|
|
381
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
382
|
+
if (!dialog)
|
|
383
|
+
return;
|
|
384
|
+
// Show modal (browser handles scroll locking automatically)
|
|
385
|
+
dialog.showModal();
|
|
386
|
+
dialog.classList.add('open');
|
|
387
|
+
// Position dropdown AFTER showing modal
|
|
388
|
+
this.calculatePosition();
|
|
389
|
+
// Update component state
|
|
390
|
+
this._state.open = true;
|
|
391
|
+
// Update visual states
|
|
392
|
+
const chevron = shadow.querySelector('.dropdown-chevron');
|
|
393
|
+
if (chevron)
|
|
394
|
+
chevron.classList.add('open');
|
|
395
|
+
const searchChevron = shadow.querySelector('.dropdown-search-chevron');
|
|
396
|
+
if (searchChevron)
|
|
397
|
+
searchChevron.classList.add('open');
|
|
398
|
+
// Initialize options state
|
|
399
|
+
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
400
|
+
this._state.filteredTags = tags;
|
|
401
|
+
this._state.highlightedIndex = -1;
|
|
402
|
+
// Ensure options area is visible (may have been hidden from previous search)
|
|
403
|
+
this.updateOptionsVisibility(true);
|
|
404
|
+
// Focus search input if searchable
|
|
405
|
+
if (this._searchable) {
|
|
406
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
407
|
+
if (searchInput) {
|
|
408
|
+
setTimeout(() => searchInput.focus(), 100);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
closeDropdown() {
|
|
413
|
+
const shadow = this.shadowRoot;
|
|
414
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
415
|
+
if (!dialog)
|
|
416
|
+
return;
|
|
417
|
+
// Close dialog (browser handles scroll unlocking automatically)
|
|
418
|
+
dialog.classList.remove('open');
|
|
419
|
+
dialog.classList.remove('position-above');
|
|
420
|
+
dialog.classList.remove('position-below');
|
|
421
|
+
dialog.close();
|
|
422
|
+
// Update state
|
|
423
|
+
this._state.open = false;
|
|
424
|
+
this._state.highlightedIndex = -1;
|
|
425
|
+
// Update visual states
|
|
426
|
+
const chevron = shadow.querySelector('.dropdown-chevron');
|
|
427
|
+
if (chevron)
|
|
428
|
+
chevron.classList.remove('open');
|
|
429
|
+
const searchChevron = shadow.querySelector('.dropdown-search-chevron');
|
|
430
|
+
if (searchChevron)
|
|
431
|
+
searchChevron.classList.remove('open');
|
|
432
|
+
// Reset search if searchable
|
|
433
|
+
if (this._searchable) {
|
|
434
|
+
this._state.search = '';
|
|
435
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
436
|
+
this._state.search = '';
|
|
437
|
+
if (searchInput) {
|
|
438
|
+
searchInput.value = '';
|
|
439
|
+
}
|
|
440
|
+
// Show all tags
|
|
441
|
+
const allTags = this.getTagElements().map(el => this.getTagData(el));
|
|
442
|
+
this._state.filteredTags = allTags;
|
|
443
|
+
this.updateTagVisibility(allTags, allTags);
|
|
444
|
+
this.clearHighlights(allTags);
|
|
445
|
+
if (searchInput) {
|
|
446
|
+
searchInput.value = '';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
openMobileModal() {
|
|
451
|
+
const shadow = this.shadowRoot;
|
|
452
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
453
|
+
if (!dialog)
|
|
454
|
+
return;
|
|
455
|
+
// Show dialog using native API (handles z-index automatically)
|
|
456
|
+
dialog.showModal();
|
|
457
|
+
dialog.classList.add('open');
|
|
458
|
+
const stub_slot = shadow.querySelector('#stub-slot');
|
|
459
|
+
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
460
|
+
stub_slot.name = "selected-blocked";
|
|
461
|
+
mobile_slot.name = "selected";
|
|
462
|
+
// Update component state
|
|
463
|
+
this._state.open = true;
|
|
464
|
+
// Initialize options state
|
|
465
|
+
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
466
|
+
this._state.filteredTags = tags;
|
|
467
|
+
// Focus search input if searchable
|
|
468
|
+
if (this._searchable) {
|
|
469
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
470
|
+
if (searchInput) {
|
|
471
|
+
// Small delay to ensure dialog is ready
|
|
472
|
+
setTimeout(() => searchInput.focus(), 100);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Initialize sections (available expanded by default)
|
|
476
|
+
this._state.expandedSection = 'available';
|
|
477
|
+
this.syncSectionStates();
|
|
478
|
+
// Update state after slots are ready
|
|
479
|
+
requestAnimationFrame(() => {
|
|
480
|
+
this.updateMobileSelectedState();
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
closeMobileModal() {
|
|
484
|
+
const shadow = this.shadowRoot;
|
|
485
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
486
|
+
if (!dialog)
|
|
487
|
+
return;
|
|
488
|
+
// Close dialog using native API
|
|
489
|
+
dialog.classList.remove('open');
|
|
490
|
+
dialog.close();
|
|
491
|
+
const stub_slot = shadow.querySelector('#stub-slot');
|
|
492
|
+
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
493
|
+
stub_slot.name = "selected";
|
|
494
|
+
mobile_slot.name = "selected-blocked";
|
|
495
|
+
// Update state
|
|
496
|
+
this._state.open = false;
|
|
497
|
+
this._state.highlightedIndex = -1;
|
|
498
|
+
// Reset search if searchable
|
|
499
|
+
if (this._searchable) {
|
|
500
|
+
this._state.search = '';
|
|
501
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
502
|
+
if (searchInput) {
|
|
503
|
+
searchInput.value = '';
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// ============================================================================
|
|
508
|
+
// EVENT HANDLERS (Phase 5 & 6)
|
|
509
|
+
// ============================================================================
|
|
510
|
+
handleStubClick(e) {
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
e.stopPropagation();
|
|
513
|
+
if (this._disabled || this._readonly) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// Don't open if all tags are already selected
|
|
517
|
+
if (this.allTagsSelected()) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.openDropdown();
|
|
521
|
+
}
|
|
522
|
+
handleOutsideClick(e) {
|
|
523
|
+
if (!this._state.open)
|
|
524
|
+
return;
|
|
525
|
+
const target = e.target;
|
|
526
|
+
// Check if click is inside this component (handles shadow DOM retargeting)
|
|
527
|
+
// Also check composedPath for clicks that originated inside shadow DOM
|
|
528
|
+
const path = e.composedPath();
|
|
529
|
+
const clickedInside = path.includes(this);
|
|
530
|
+
if (!clickedInside) {
|
|
531
|
+
this.closeDropdown();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
handleTagClick(e) {
|
|
535
|
+
const target = e.target;
|
|
536
|
+
// Find the ty-tag element
|
|
537
|
+
const tag = target.tagName === 'TY-TAG'
|
|
538
|
+
? target
|
|
539
|
+
: target.closest('ty-tag');
|
|
540
|
+
if (!tag || tag.hasAttribute('disabled'))
|
|
541
|
+
return;
|
|
542
|
+
if (tag.hasAttribute('selected'))
|
|
543
|
+
return; // Already selected
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
e.stopPropagation();
|
|
546
|
+
const tagValue = this.getTagData(tag).value;
|
|
547
|
+
const currentValues = this.getSelectedValues();
|
|
548
|
+
const newValues = [...currentValues, tagValue];
|
|
549
|
+
// Use central update function with change event
|
|
550
|
+
this.updateComponentValue(newValues, true, 'add', tagValue);
|
|
551
|
+
// Auto-close if all tags selected
|
|
552
|
+
if (this.allTagsSelected()) {
|
|
553
|
+
if (this._state.mode === 'desktop') {
|
|
554
|
+
this.closeDropdown();
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
this.closeMobileModal();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
handleTagDismiss(e) {
|
|
562
|
+
e.preventDefault();
|
|
563
|
+
e.stopPropagation();
|
|
564
|
+
const customEvent = e;
|
|
565
|
+
const tag = customEvent.detail?.target;
|
|
566
|
+
if (!tag)
|
|
567
|
+
return;
|
|
568
|
+
const tagValue = this.getTagData(tag).value;
|
|
569
|
+
const currentValues = this.getSelectedValues();
|
|
570
|
+
const newValues = currentValues.filter(v => v !== tagValue);
|
|
571
|
+
// Use central update function with change event
|
|
572
|
+
this.updateComponentValue(newValues, true, 'remove', tagValue);
|
|
573
|
+
}
|
|
574
|
+
blockSearchClick(e) {
|
|
575
|
+
e.stopPropagation();
|
|
576
|
+
e.preventDefault();
|
|
577
|
+
}
|
|
578
|
+
handleSearchInput(e) {
|
|
579
|
+
const target = e.target;
|
|
580
|
+
const query = target.value;
|
|
581
|
+
// Update search state
|
|
582
|
+
this._state.search = query;
|
|
583
|
+
if (this._searchable) {
|
|
584
|
+
// Internal search: filter tags locally
|
|
585
|
+
const allTags = this.getTagElements().map(el => this.getTagData(el));
|
|
586
|
+
// Only filter from non-selected tags
|
|
587
|
+
const availableTags = allTags.filter(t => !t.element.hasAttribute('selected'));
|
|
588
|
+
const filtered = this.filterTags(availableTags, query);
|
|
589
|
+
// Update state
|
|
590
|
+
this._state.filteredTags = filtered;
|
|
591
|
+
this._state.highlightedIndex = -1;
|
|
592
|
+
// Update visibility
|
|
593
|
+
this.updateTagVisibility(filtered, allTags);
|
|
594
|
+
// Hide options area if no results
|
|
595
|
+
this.updateOptionsVisibility(filtered.length > 0);
|
|
596
|
+
// Clear highlights
|
|
597
|
+
this.clearHighlights(allTags);
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// External search: dispatch event for external handling
|
|
601
|
+
this.dispatchSearchEvent(query);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
handleKeyboard(e) {
|
|
605
|
+
if (!this._state.open)
|
|
606
|
+
return;
|
|
607
|
+
const shadow = this.shadowRoot;
|
|
608
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
609
|
+
const target = e.target;
|
|
610
|
+
// Only handle navigation keys when dropdown is open and either:
|
|
611
|
+
// 1. Event comes from search input, OR
|
|
612
|
+
// 2. Event comes from document but search input is not focused
|
|
613
|
+
const shouldHandle = target === searchInput ||
|
|
614
|
+
document.activeElement !== searchInput;
|
|
615
|
+
if (!shouldHandle)
|
|
616
|
+
return;
|
|
617
|
+
// Get current state values
|
|
618
|
+
const filteredTags = this._state.filteredTags;
|
|
619
|
+
const tagsCount = filteredTags.length;
|
|
620
|
+
const currentHighlightedIndex = this._state.highlightedIndex;
|
|
621
|
+
switch (e.key) {
|
|
622
|
+
case 'Escape':
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
e.stopPropagation();
|
|
625
|
+
this.closeDropdown();
|
|
626
|
+
break;
|
|
627
|
+
case 'Enter':
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
e.stopPropagation();
|
|
630
|
+
// Select highlighted tag if any
|
|
631
|
+
if (currentHighlightedIndex >= 0 && currentHighlightedIndex < tagsCount) {
|
|
632
|
+
const tag = filteredTags[currentHighlightedIndex];
|
|
633
|
+
this.handleTagClick({ target: tag.element });
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
case 'ArrowUp':
|
|
637
|
+
e.preventDefault();
|
|
638
|
+
e.stopPropagation();
|
|
639
|
+
let newIndexUp;
|
|
640
|
+
if (tagsCount === 0) {
|
|
641
|
+
newIndexUp = -1;
|
|
642
|
+
}
|
|
643
|
+
else if (currentHighlightedIndex === -1) {
|
|
644
|
+
// Nothing highlighted, go to last tag
|
|
645
|
+
newIndexUp = tagsCount - 1;
|
|
646
|
+
}
|
|
647
|
+
else if (currentHighlightedIndex === 0) {
|
|
648
|
+
// At first tag, wrap to last
|
|
649
|
+
newIndexUp = tagsCount - 1;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
// Move up one
|
|
653
|
+
newIndexUp = currentHighlightedIndex - 1;
|
|
654
|
+
}
|
|
655
|
+
this._state.highlightedIndex = newIndexUp;
|
|
656
|
+
this.highlightTag(filteredTags, newIndexUp);
|
|
657
|
+
break;
|
|
658
|
+
case 'ArrowDown':
|
|
659
|
+
e.preventDefault();
|
|
660
|
+
e.stopPropagation();
|
|
661
|
+
let newIndexDown;
|
|
662
|
+
if (tagsCount === 0) {
|
|
663
|
+
newIndexDown = -1;
|
|
664
|
+
}
|
|
665
|
+
else if (currentHighlightedIndex === -1) {
|
|
666
|
+
// Nothing highlighted, go to first tag
|
|
667
|
+
newIndexDown = 0;
|
|
668
|
+
}
|
|
669
|
+
else if (currentHighlightedIndex === tagsCount - 1) {
|
|
670
|
+
// At last tag, wrap to first
|
|
671
|
+
newIndexDown = 0;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// Move down one
|
|
675
|
+
newIndexDown = currentHighlightedIndex + 1;
|
|
676
|
+
}
|
|
677
|
+
this._state.highlightedIndex = newIndexDown;
|
|
678
|
+
this.highlightTag(filteredTags, newIndexDown);
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// ============================================================================
|
|
683
|
+
// SEARCH & FILTERING HELPERS (Phase 6)
|
|
684
|
+
// ============================================================================
|
|
685
|
+
filterTags(tags, query) {
|
|
686
|
+
if (!query || query.trim() === '') {
|
|
687
|
+
return tags;
|
|
688
|
+
}
|
|
689
|
+
const searchLower = query.toLowerCase();
|
|
690
|
+
return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
|
|
691
|
+
}
|
|
692
|
+
updateTagVisibility(filteredTags, allTags) {
|
|
693
|
+
const visibleValues = new Set(filteredTags.map(tag => tag.value));
|
|
694
|
+
allTags.forEach(({ value, element }) => {
|
|
695
|
+
if (visibleValues.has(value)) {
|
|
696
|
+
element.removeAttribute('hidden');
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
element.setAttribute('hidden', '');
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
updateOptionsVisibility(hasOptions) {
|
|
704
|
+
const shadow = this.shadowRoot;
|
|
705
|
+
const options = shadow.querySelector('.dropdown-options');
|
|
706
|
+
if (options) {
|
|
707
|
+
options.style.display = hasOptions ? '' : 'none';
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
clearHighlights(tags) {
|
|
711
|
+
tags.forEach(({ element }) => {
|
|
712
|
+
element.removeAttribute('highlighted');
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
highlightTag(tags, index) {
|
|
716
|
+
this.clearHighlights(tags);
|
|
717
|
+
if (index >= 0 && index < tags.length) {
|
|
718
|
+
const { element } = tags[index];
|
|
719
|
+
element.setAttribute('highlighted', '');
|
|
720
|
+
// Scroll into view
|
|
721
|
+
element.scrollIntoView({
|
|
722
|
+
behavior: 'smooth',
|
|
723
|
+
block: 'nearest',
|
|
724
|
+
inline: 'nearest'
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
dispatchSearchEvent(query) {
|
|
729
|
+
// Clear existing timer
|
|
730
|
+
if (this._searchDebounceTimer !== null) {
|
|
731
|
+
clearTimeout(this._searchDebounceTimer);
|
|
732
|
+
this._searchDebounceTimer = null;
|
|
733
|
+
}
|
|
734
|
+
// If delay is set, debounce the event
|
|
735
|
+
if (this._delay > 0) {
|
|
736
|
+
this._searchDebounceTimer = window.setTimeout(() => {
|
|
737
|
+
this.fireSearchEvent(query);
|
|
738
|
+
this._searchDebounceTimer = null;
|
|
739
|
+
}, this._delay);
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
// Fire immediately if no delay
|
|
743
|
+
this.fireSearchEvent(query);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
fireSearchEvent(query) {
|
|
747
|
+
this.dispatchEvent(new CustomEvent('search', {
|
|
748
|
+
detail: {
|
|
749
|
+
query,
|
|
750
|
+
element: this
|
|
751
|
+
},
|
|
752
|
+
bubbles: true,
|
|
753
|
+
composed: true
|
|
754
|
+
}));
|
|
755
|
+
}
|
|
756
|
+
// ============================================================================
|
|
757
|
+
// CHANGE EVENT DISPATCHING (Phase 5)
|
|
758
|
+
// ============================================================================
|
|
759
|
+
dispatchChangeEvent(detail) {
|
|
760
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
761
|
+
detail,
|
|
762
|
+
bubbles: true,
|
|
763
|
+
cancelable: true
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
// ============================================================================
|
|
767
|
+
// RENDERING
|
|
768
|
+
// ============================================================================
|
|
769
|
+
render() {
|
|
770
|
+
if (this._state.mode === 'mobile') {
|
|
771
|
+
this.renderMobile();
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
this.renderDesktop();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
setupEventListeners() {
|
|
778
|
+
const shadow = this.shadowRoot;
|
|
779
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
780
|
+
const optionsSlot = shadow.querySelector('#options-slot');
|
|
781
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
782
|
+
if (stub) {
|
|
783
|
+
this._stubClickHandler = this.handleStubClick.bind(this);
|
|
784
|
+
stub.addEventListener('click', this._stubClickHandler);
|
|
785
|
+
}
|
|
786
|
+
// Add tag click handler to slot
|
|
787
|
+
if (optionsSlot) {
|
|
788
|
+
this._tagClickHandler = this.handleTagClick.bind(this);
|
|
789
|
+
optionsSlot.addEventListener('click', this._tagClickHandler);
|
|
790
|
+
}
|
|
791
|
+
// Add search input handlers
|
|
792
|
+
if (searchInput) {
|
|
793
|
+
this._searchInputHandler = this.handleSearchInput.bind(this);
|
|
794
|
+
this._blockSearchClick = this.blockSearchClick.bind(this);
|
|
795
|
+
searchInput.addEventListener('input', this._searchInputHandler);
|
|
796
|
+
searchInput.addEventListener('click', this._blockSearchClick);
|
|
797
|
+
// searchInput.addEventListener('blur', this._searchBlurHandler)
|
|
798
|
+
}
|
|
799
|
+
// Setup dialog backdrop click handler
|
|
800
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
801
|
+
if (dialog) {
|
|
802
|
+
dialog.addEventListener('click', (e) => {
|
|
803
|
+
// Only close if clicking directly on the dialog (backdrop), not its children
|
|
804
|
+
if (e.target === dialog) {
|
|
805
|
+
this.closeDropdown();
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
// Setup keyboard handler
|
|
810
|
+
this._keyboardHandler = this.handleKeyboard.bind(this);
|
|
811
|
+
document.addEventListener('keydown', this._keyboardHandler);
|
|
812
|
+
// Listen for dismiss events from selected tags
|
|
813
|
+
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
814
|
+
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
815
|
+
}
|
|
816
|
+
buildStubClasses() {
|
|
817
|
+
const classes = [this._size];
|
|
818
|
+
if (this._disabled)
|
|
819
|
+
classes.push('disabled');
|
|
820
|
+
return classes.join(' ');
|
|
821
|
+
}
|
|
822
|
+
renderDesktop() {
|
|
823
|
+
const shadow = this.shadowRoot;
|
|
824
|
+
// Only set innerHTML and setup listeners if container doesn't exist
|
|
825
|
+
if (!shadow.querySelector('.multiselect-container')) {
|
|
826
|
+
const stubClasses = this.buildStubClasses();
|
|
827
|
+
const labelHtml = this._label ? `
|
|
828
|
+
<label class="dropdown-label">
|
|
829
|
+
${this._label}
|
|
830
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
|
|
831
|
+
</label>
|
|
832
|
+
` : '';
|
|
833
|
+
const searchPlaceholder = this._searchable ? 'Search...' : this._placeholder;
|
|
834
|
+
shadow.innerHTML = `
|
|
835
|
+
<div class="multiselect-container dropdown-mode-mobile">
|
|
836
|
+
${labelHtml}
|
|
837
|
+
<div class="dropdown-wrapper">
|
|
838
|
+
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
839
|
+
${this._disabled ? 'disabled' : ''}>
|
|
840
|
+
<div class="multiselect-chips">
|
|
841
|
+
<slot name="selected"></slot>
|
|
842
|
+
</div>
|
|
843
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
844
|
+
<div class="dropdown-chevron">
|
|
845
|
+
${CHEVRON_DOWN_SVG}
|
|
846
|
+
</div>
|
|
847
|
+
</div>
|
|
848
|
+
<dialog class="dropdown-dialog">
|
|
849
|
+
<div class="dropdown-header">
|
|
850
|
+
<input
|
|
851
|
+
class="dropdown-search-input ${this._size}"
|
|
852
|
+
type="text"
|
|
853
|
+
placeholder="${searchPlaceholder}"
|
|
854
|
+
${this._disabled ? 'disabled' : ''}
|
|
855
|
+
/>
|
|
856
|
+
<div class="dropdown-search-chevron">
|
|
857
|
+
${CHEVRON_DOWN_SVG}
|
|
858
|
+
</div>
|
|
859
|
+
</div>
|
|
860
|
+
<div class="dropdown-options">
|
|
861
|
+
<slot id="options-slot"></slot>
|
|
862
|
+
</div>
|
|
863
|
+
</dialog>
|
|
864
|
+
</div>
|
|
865
|
+
</div>
|
|
866
|
+
`;
|
|
867
|
+
// Setup event listeners ONCE
|
|
868
|
+
this.setupEventListeners();
|
|
869
|
+
// Don't initialize here - will be done in connectedCallback
|
|
870
|
+
// after properties are set and children are available
|
|
871
|
+
}
|
|
872
|
+
// Always update placeholder visibility on re-render
|
|
873
|
+
this.updateSelectionDisplay();
|
|
874
|
+
}
|
|
875
|
+
renderMobile() {
|
|
876
|
+
const shadow = this.shadowRoot;
|
|
877
|
+
// Only set innerHTML and setup listeners if container doesn't exist
|
|
878
|
+
if (!shadow.querySelector('.multiselect-container')) {
|
|
879
|
+
const stubClasses = this.buildStubClasses();
|
|
880
|
+
const labelHtml = this._label ? `
|
|
881
|
+
<label class="dropdown-label">
|
|
882
|
+
${this._label}
|
|
883
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
|
|
884
|
+
</label>
|
|
885
|
+
` : '';
|
|
886
|
+
// Close button SVG (X icon)
|
|
887
|
+
const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
888
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
889
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
890
|
+
</svg>`;
|
|
891
|
+
// Conditional search header - match dropdown.ts pattern
|
|
892
|
+
const searchHeaderHtml = this._searchable ? `
|
|
893
|
+
<div class="mobile-search-header">
|
|
894
|
+
<div class="mobile-header-content">
|
|
895
|
+
<input
|
|
896
|
+
class="mobile-search-input ${this._size}"
|
|
897
|
+
type="text"
|
|
898
|
+
placeholder="Search..."
|
|
899
|
+
${this._disabled ? 'disabled' : ''}
|
|
900
|
+
/>
|
|
901
|
+
<button class="mobile-close-button" type="button" aria-label="Close">
|
|
902
|
+
${closeButtonSvg}
|
|
903
|
+
</button>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
` : `
|
|
907
|
+
<div class="mobile-header-nosearch">
|
|
908
|
+
<button class="mobile-close-button" type="button" aria-label="Close">
|
|
909
|
+
${closeButtonSvg}
|
|
910
|
+
</button>
|
|
911
|
+
</div>
|
|
912
|
+
`;
|
|
913
|
+
shadow.innerHTML = `
|
|
914
|
+
<div class="multiselect-container dropdown-mode-mobile">
|
|
915
|
+
${labelHtml}
|
|
916
|
+
<div class="dropdown-wrapper">
|
|
917
|
+
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
918
|
+
${this._disabled ? 'disabled' : ''}>
|
|
919
|
+
<div class="multiselect-chips">
|
|
920
|
+
<slot id="stub-slot" name="selected"></slot>
|
|
921
|
+
</div>
|
|
922
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
923
|
+
<div class="dropdown-chevron">
|
|
924
|
+
${CHEVRON_DOWN_SVG}
|
|
925
|
+
</div>
|
|
926
|
+
</div>
|
|
927
|
+
<dialog class="mobile-dialog">
|
|
928
|
+
<div class="mobile-dialog-content">
|
|
929
|
+
<!-- HEADER (matches dropdown.ts) -->
|
|
930
|
+
${searchHeaderHtml}
|
|
931
|
+
<!-- BODY (two sections for multiselect) -->
|
|
932
|
+
<div class="mobile-body">
|
|
933
|
+
<!-- SELECTED SECTION (collapsed by default) -->
|
|
934
|
+
<div class="mobile-selected-section" data-expanded="false" data-empty="true">
|
|
935
|
+
<div class="section-header">
|
|
936
|
+
<span class="section-title">${this._selectedLabel} <span class="section-count">(0)</span></span>
|
|
937
|
+
<span class="section-chevron">${CHEVRON_DOWN_SVG}</span>
|
|
938
|
+
</div>
|
|
939
|
+
<div class="section-content">
|
|
940
|
+
<slot id="mobile-slot" name="selected"></slot>
|
|
941
|
+
<div class="empty-state">${this._noSelectionMessage}</div>
|
|
942
|
+
</div>
|
|
943
|
+
</div>
|
|
944
|
+
<!-- AVAILABLE SECTION (expanded by default) -->
|
|
945
|
+
<div class="mobile-available-section" data-expanded="true" data-empty="false">
|
|
946
|
+
<div class="section-header">
|
|
947
|
+
<span class="section-title">${this._availableLabel}</span>
|
|
948
|
+
</div>
|
|
949
|
+
<div class="section-content">
|
|
950
|
+
<slot id="options-slot"></slot>
|
|
951
|
+
<div class="empty-state">${this._noOptionsMessage}</div>
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
</div>
|
|
955
|
+
</div>
|
|
956
|
+
</dialog>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
`;
|
|
960
|
+
// Setup event listeners ONCE
|
|
961
|
+
this.setupMobileEventListeners();
|
|
962
|
+
}
|
|
963
|
+
// Always update placeholder visibility
|
|
964
|
+
this.updateSelectionDisplay();
|
|
965
|
+
}
|
|
966
|
+
setupMobileEventListeners() {
|
|
967
|
+
const shadow = this.shadowRoot;
|
|
968
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
969
|
+
const optionsSlot = shadow.querySelector('#options-slot');
|
|
970
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
971
|
+
const closeButton = shadow.querySelector('.mobile-close-button');
|
|
972
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
973
|
+
// Get both section headers
|
|
974
|
+
const selectedHeader = shadow.querySelector('.mobile-selected-section .section-header');
|
|
975
|
+
const availableHeader = shadow.querySelector('.mobile-available-section .section-header');
|
|
976
|
+
if (stub) {
|
|
977
|
+
stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
|
|
978
|
+
}
|
|
979
|
+
// Add tag click handler to slot
|
|
980
|
+
if (optionsSlot) {
|
|
981
|
+
optionsSlot.addEventListener('click', (e) => this.handleMobileTagClick(e));
|
|
982
|
+
}
|
|
983
|
+
// Add search input handlers (if searchable)
|
|
984
|
+
if (searchInput) {
|
|
985
|
+
searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
|
|
986
|
+
}
|
|
987
|
+
// Toggle section expansion on header click
|
|
988
|
+
if (selectedHeader) {
|
|
989
|
+
selectedHeader.addEventListener('click', () => this.toggleSection('selected'));
|
|
990
|
+
}
|
|
991
|
+
if (availableHeader) {
|
|
992
|
+
availableHeader.addEventListener('click', () => this.toggleSection('available'));
|
|
993
|
+
}
|
|
994
|
+
// Close button click
|
|
995
|
+
if (closeButton) {
|
|
996
|
+
closeButton.addEventListener('click', () => this.closeMobileModal());
|
|
997
|
+
}
|
|
998
|
+
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
999
|
+
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
1000
|
+
// Backdrop click to close (native dialog behavior)
|
|
1001
|
+
if (dialog) {
|
|
1002
|
+
dialog.addEventListener('click', (e) => {
|
|
1003
|
+
// Only close if clicking directly on the dialog element (backdrop)
|
|
1004
|
+
// Not if clicking on its children (dialog-content)
|
|
1005
|
+
if (e.target === dialog) {
|
|
1006
|
+
this.closeMobileModal();
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
// Also handle Escape key via cancel event
|
|
1010
|
+
dialog.addEventListener('cancel', (e) => {
|
|
1011
|
+
e.preventDefault(); // Prevent default to handle it our way
|
|
1012
|
+
this.closeMobileModal();
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
handleMobileStubClick(e) {
|
|
1017
|
+
e.preventDefault();
|
|
1018
|
+
e.stopPropagation();
|
|
1019
|
+
if (this._disabled || this._readonly) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Don't open if all tags are already selected
|
|
1023
|
+
if (this.allTagsSelected()) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.openMobileModal();
|
|
1027
|
+
}
|
|
1028
|
+
handleMobileTagClick(e) {
|
|
1029
|
+
// Use the same tag click handler as desktop
|
|
1030
|
+
// It already handles mobile mode for auto-close
|
|
1031
|
+
this.handleTagClick(e);
|
|
1032
|
+
}
|
|
1033
|
+
toggleSection(section) {
|
|
1034
|
+
const shadow = this.shadowRoot;
|
|
1035
|
+
const selectedSection = shadow.querySelector('.mobile-selected-section');
|
|
1036
|
+
const availableSection = shadow.querySelector('.mobile-available-section');
|
|
1037
|
+
if (!selectedSection || !availableSection)
|
|
1038
|
+
return;
|
|
1039
|
+
// If clicking already expanded section, collapse it and expand the other
|
|
1040
|
+
if (this._state.expandedSection === section) {
|
|
1041
|
+
const otherSection = section === 'selected' ? 'available' : 'selected';
|
|
1042
|
+
this._state.expandedSection = otherSection;
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
// Expand clicked section
|
|
1046
|
+
this._state.expandedSection = section;
|
|
1047
|
+
}
|
|
1048
|
+
// Update DOM attributes
|
|
1049
|
+
selectedSection.setAttribute('data-expanded', String(this._state.expandedSection === 'selected'));
|
|
1050
|
+
availableSection.setAttribute('data-expanded', String(this._state.expandedSection === 'available'));
|
|
1051
|
+
}
|
|
1052
|
+
syncSectionStates() {
|
|
1053
|
+
const shadow = this.shadowRoot;
|
|
1054
|
+
const selectedSection = shadow.querySelector('.mobile-selected-section');
|
|
1055
|
+
const availableSection = shadow.querySelector('.mobile-available-section');
|
|
1056
|
+
if (!selectedSection || !availableSection)
|
|
1057
|
+
return;
|
|
1058
|
+
// Update DOM attributes to match current state
|
|
1059
|
+
selectedSection.setAttribute('data-expanded', String(this._state.expandedSection === 'selected'));
|
|
1060
|
+
availableSection.setAttribute('data-expanded', String(this._state.expandedSection === 'available'));
|
|
1061
|
+
}
|
|
1062
|
+
updateMobileSelectedState() {
|
|
1063
|
+
if (this._state.mode !== 'mobile')
|
|
1064
|
+
return;
|
|
1065
|
+
const shadow = this.shadowRoot;
|
|
1066
|
+
const selectedSection = shadow.querySelector('.mobile-selected-section');
|
|
1067
|
+
const availableSection = shadow.querySelector('.mobile-available-section');
|
|
1068
|
+
const sectionCountSpan = shadow.querySelector('.section-count');
|
|
1069
|
+
if (selectedSection) {
|
|
1070
|
+
const selectedCount = this._state.selectedValues.length;
|
|
1071
|
+
const hasSelected = selectedCount > 0;
|
|
1072
|
+
selectedSection.setAttribute('data-empty', String(!hasSelected));
|
|
1073
|
+
// Update header count
|
|
1074
|
+
if (sectionCountSpan) {
|
|
1075
|
+
sectionCountSpan.textContent = `(${selectedCount})`;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (availableSection) {
|
|
1079
|
+
const allTags = this.getTagElements();
|
|
1080
|
+
const availableCount = allTags.filter(tag => !tag.hasAttribute('selected')).length;
|
|
1081
|
+
const hasAvailable = availableCount > 0;
|
|
1082
|
+
availableSection.setAttribute('data-empty', String(!hasAvailable));
|
|
1083
|
+
// Update available header count
|
|
1084
|
+
const availableTitleSpan = shadow.querySelector('.mobile-available-section .section-title');
|
|
1085
|
+
if (availableTitleSpan) {
|
|
1086
|
+
availableTitleSpan.textContent = `${this._availableLabel} (${availableCount})`;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
updateSelectionDisplay() {
|
|
1091
|
+
const shadow = this.shadowRoot;
|
|
1092
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
1093
|
+
if (!stub)
|
|
1094
|
+
return;
|
|
1095
|
+
const tags = this.getTagElements();
|
|
1096
|
+
const selectedTags = tags.filter(tag => tag.hasAttribute('selected'));
|
|
1097
|
+
const hasSelected = selectedTags.length > 0;
|
|
1098
|
+
if (hasSelected) {
|
|
1099
|
+
stub.classList.add('has-selection');
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
stub.classList.remove('has-selection');
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// ============================================================================
|
|
1106
|
+
// PUBLIC API - Getters/Setters
|
|
1107
|
+
// ============================================================================
|
|
1108
|
+
get value() {
|
|
1109
|
+
// Always read from DOM - tags with 'selected' attribute are source of truth
|
|
1110
|
+
return this.getSelectedValues().join(',');
|
|
1111
|
+
}
|
|
1112
|
+
set value(val) {
|
|
1113
|
+
this.setProperty('value', val);
|
|
1114
|
+
}
|
|
1115
|
+
get name() {
|
|
1116
|
+
return this.getProperty('name');
|
|
1117
|
+
}
|
|
1118
|
+
set name(val) {
|
|
1119
|
+
this.setProperty('name', val);
|
|
1120
|
+
}
|
|
1121
|
+
get placeholder() {
|
|
1122
|
+
return this.getProperty('placeholder');
|
|
1123
|
+
}
|
|
1124
|
+
set placeholder(val) {
|
|
1125
|
+
this.setProperty('placeholder', val);
|
|
1126
|
+
}
|
|
1127
|
+
get label() {
|
|
1128
|
+
return this.getProperty('label');
|
|
1129
|
+
}
|
|
1130
|
+
set label(val) {
|
|
1131
|
+
this.setProperty('label', val);
|
|
1132
|
+
}
|
|
1133
|
+
get disabled() {
|
|
1134
|
+
return this.getProperty('disabled');
|
|
1135
|
+
}
|
|
1136
|
+
set disabled(value) {
|
|
1137
|
+
this.setProperty('disabled', value);
|
|
1138
|
+
}
|
|
1139
|
+
get readonly() {
|
|
1140
|
+
return this.getProperty('readonly');
|
|
1141
|
+
}
|
|
1142
|
+
set readonly(value) {
|
|
1143
|
+
this.setProperty('readonly', value);
|
|
1144
|
+
}
|
|
1145
|
+
get required() {
|
|
1146
|
+
return this.getProperty('required');
|
|
1147
|
+
}
|
|
1148
|
+
set required(value) {
|
|
1149
|
+
this.setProperty('required', value);
|
|
1150
|
+
}
|
|
1151
|
+
get searchable() {
|
|
1152
|
+
return this.getProperty('searchable');
|
|
1153
|
+
}
|
|
1154
|
+
set searchable(value) {
|
|
1155
|
+
this.setProperty('searchable', value);
|
|
1156
|
+
}
|
|
1157
|
+
get delay() {
|
|
1158
|
+
return this.getProperty('delay');
|
|
1159
|
+
}
|
|
1160
|
+
set delay(value) {
|
|
1161
|
+
const numValue = typeof value === 'string' ? parseInt(value, 10) : value;
|
|
1162
|
+
this.setProperty('delay', numValue);
|
|
1163
|
+
}
|
|
1164
|
+
get size() {
|
|
1165
|
+
return this.getProperty('size');
|
|
1166
|
+
}
|
|
1167
|
+
set size(value) {
|
|
1168
|
+
this.setProperty('size', value);
|
|
1169
|
+
}
|
|
1170
|
+
get flavor() {
|
|
1171
|
+
return this.getProperty('flavor');
|
|
1172
|
+
}
|
|
1173
|
+
set flavor(value) {
|
|
1174
|
+
this.setProperty('flavor', value);
|
|
1175
|
+
}
|
|
1176
|
+
get form() {
|
|
1177
|
+
return this._internals.form;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// ============================================================================
|
|
1181
|
+
// PROPERTY CONFIGURATION - Declarative property lifecycle
|
|
1182
|
+
// ============================================================================
|
|
1183
|
+
TyMultiselect.properties = {
|
|
1184
|
+
value: {
|
|
1185
|
+
type: 'string',
|
|
1186
|
+
visual: true,
|
|
1187
|
+
formValue: true,
|
|
1188
|
+
emitChange: false,
|
|
1189
|
+
default: '',
|
|
1190
|
+
coerce: (v) => {
|
|
1191
|
+
// Handle array input (from React, Reagent, etc.)
|
|
1192
|
+
if (Array.isArray(v)) {
|
|
1193
|
+
return v.join(',');
|
|
1194
|
+
}
|
|
1195
|
+
// Handle null/undefined
|
|
1196
|
+
if (v === null || v === undefined) {
|
|
1197
|
+
return '';
|
|
1198
|
+
}
|
|
1199
|
+
// String already
|
|
1200
|
+
return String(v);
|
|
1201
|
+
}
|
|
1202
|
+
},
|
|
1203
|
+
name: {
|
|
1204
|
+
type: 'string',
|
|
1205
|
+
default: ''
|
|
1206
|
+
},
|
|
1207
|
+
placeholder: {
|
|
1208
|
+
type: 'string',
|
|
1209
|
+
visual: true,
|
|
1210
|
+
default: 'Select options...'
|
|
1211
|
+
},
|
|
1212
|
+
label: {
|
|
1213
|
+
type: 'string',
|
|
1214
|
+
visual: true,
|
|
1215
|
+
default: ''
|
|
1216
|
+
},
|
|
1217
|
+
disabled: {
|
|
1218
|
+
type: 'boolean',
|
|
1219
|
+
visual: true,
|
|
1220
|
+
default: false
|
|
1221
|
+
},
|
|
1222
|
+
readonly: {
|
|
1223
|
+
type: 'boolean',
|
|
1224
|
+
visual: true,
|
|
1225
|
+
default: false
|
|
1226
|
+
},
|
|
1227
|
+
required: {
|
|
1228
|
+
type: 'boolean',
|
|
1229
|
+
visual: true,
|
|
1230
|
+
default: false
|
|
1231
|
+
},
|
|
1232
|
+
searchable: {
|
|
1233
|
+
type: 'boolean',
|
|
1234
|
+
visual: true,
|
|
1235
|
+
default: true,
|
|
1236
|
+
aliases: { 'not-searchable': false }
|
|
1237
|
+
},
|
|
1238
|
+
size: {
|
|
1239
|
+
type: 'string',
|
|
1240
|
+
visual: true,
|
|
1241
|
+
default: 'md',
|
|
1242
|
+
validate: (v) => ['sm', 'md', 'lg'].includes(v),
|
|
1243
|
+
coerce: (v) => {
|
|
1244
|
+
if (!['sm', 'md', 'lg'].includes(v)) {
|
|
1245
|
+
console.warn(`[ty-multiselect] Invalid size. Using md.`);
|
|
1246
|
+
return 'md';
|
|
1247
|
+
}
|
|
1248
|
+
return v;
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
flavor: {
|
|
1252
|
+
type: 'string',
|
|
1253
|
+
visual: true,
|
|
1254
|
+
default: 'neutral',
|
|
1255
|
+
validate: (v) => ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'].includes(v),
|
|
1256
|
+
coerce: (v) => {
|
|
1257
|
+
const valid = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
|
|
1258
|
+
if (!valid.includes(v)) {
|
|
1259
|
+
console.warn(`[ty-multiselect] Invalid flavor. Using neutral.`);
|
|
1260
|
+
return 'neutral';
|
|
1261
|
+
}
|
|
1262
|
+
return v;
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
delay: {
|
|
1266
|
+
type: 'number',
|
|
1267
|
+
default: 0,
|
|
1268
|
+
validate: (v) => v >= 0 && v <= 5000,
|
|
1269
|
+
coerce: (v) => {
|
|
1270
|
+
const num = Number(v);
|
|
1271
|
+
if (isNaN(num))
|
|
1272
|
+
return 0;
|
|
1273
|
+
return Math.max(0, Math.min(5000, num));
|
|
1274
|
+
}
|
|
1275
|
+
},
|
|
1276
|
+
'selected-label': {
|
|
1277
|
+
type: 'string',
|
|
1278
|
+
visual: true,
|
|
1279
|
+
default: 'Selected'
|
|
1280
|
+
},
|
|
1281
|
+
'available-label': {
|
|
1282
|
+
type: 'string',
|
|
1283
|
+
visual: true,
|
|
1284
|
+
default: 'Available'
|
|
1285
|
+
},
|
|
1286
|
+
'no-selection-message': {
|
|
1287
|
+
type: 'string',
|
|
1288
|
+
visual: true,
|
|
1289
|
+
default: 'No items selected'
|
|
1290
|
+
},
|
|
1291
|
+
'no-options-message': {
|
|
1292
|
+
type: 'string',
|
|
1293
|
+
visual: true,
|
|
1294
|
+
default: 'No options available'
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
// Register the custom element
|
|
1298
|
+
if (!customElements.get('ty-multiselect')) {
|
|
1299
|
+
customElements.define('ty-multiselect', TyMultiselect);
|
|
1300
|
+
}
|
|
1301
|
+
//# sourceMappingURL=multiselect.js.map
|