tyrell-components 1.0.0-RC10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/css/tyrell-brand.css +767 -0
- package/css/tyrell.css +1679 -0
- package/dist/tyrell-brand.css +767 -0
- package/dist/tyrell.css +1679 -0
- package/dist/tyrell.js +2 -0
- package/lib/base/ty-component.d.ts +133 -0
- package/lib/base/ty-component.d.ts.map +1 -0
- package/lib/base/ty-component.js +297 -0
- package/lib/base/ty-component.js.map +1 -0
- package/lib/components/button.d.ts +135 -0
- package/lib/components/button.d.ts.map +1 -0
- package/lib/components/button.js +277 -0
- package/lib/components/button.js.map +1 -0
- package/lib/components/calendar-month.d.ts +132 -0
- package/lib/components/calendar-month.d.ts.map +1 -0
- package/lib/components/calendar-month.js +440 -0
- package/lib/components/calendar-month.js.map +1 -0
- package/lib/components/calendar-navigation.d.ts +137 -0
- package/lib/components/calendar-navigation.d.ts.map +1 -0
- package/lib/components/calendar-navigation.js +366 -0
- package/lib/components/calendar-navigation.js.map +1 -0
- package/lib/components/calendar.d.ts +166 -0
- package/lib/components/calendar.d.ts.map +1 -0
- package/lib/components/calendar.js +774 -0
- package/lib/components/calendar.js.map +1 -0
- package/lib/components/checkbox.d.ts +189 -0
- package/lib/components/checkbox.d.ts.map +1 -0
- package/lib/components/checkbox.js +400 -0
- package/lib/components/checkbox.js.map +1 -0
- package/lib/components/copy.d.ts +180 -0
- package/lib/components/copy.d.ts.map +1 -0
- package/lib/components/copy.js +393 -0
- package/lib/components/copy.js.map +1 -0
- package/lib/components/date-picker.d.ts +379 -0
- package/lib/components/date-picker.d.ts.map +1 -0
- package/lib/components/date-picker.js +1586 -0
- package/lib/components/date-picker.js.map +1 -0
- package/lib/components/dropdown.d.ts +424 -0
- package/lib/components/dropdown.d.ts.map +1 -0
- package/lib/components/dropdown.js +1640 -0
- package/lib/components/dropdown.js.map +1 -0
- package/lib/components/file-upload.d.ts +121 -0
- package/lib/components/file-upload.d.ts.map +1 -0
- package/lib/components/file-upload.js +441 -0
- package/lib/components/file-upload.js.map +1 -0
- package/lib/components/icon.d.ts +118 -0
- package/lib/components/icon.d.ts.map +1 -0
- package/lib/components/icon.js +245 -0
- package/lib/components/icon.js.map +1 -0
- package/lib/components/input.d.ts +270 -0
- package/lib/components/input.d.ts.map +1 -0
- package/lib/components/input.js +721 -0
- package/lib/components/input.js.map +1 -0
- package/lib/components/modal.d.ts +78 -0
- package/lib/components/modal.d.ts.map +1 -0
- package/lib/components/modal.js +497 -0
- package/lib/components/modal.js.map +1 -0
- package/lib/components/multiselect.d.ts +397 -0
- package/lib/components/multiselect.d.ts.map +1 -0
- package/lib/components/multiselect.js +1595 -0
- package/lib/components/multiselect.js.map +1 -0
- package/lib/components/option.d.ts +66 -0
- package/lib/components/option.d.ts.map +1 -0
- package/lib/components/option.js +314 -0
- package/lib/components/option.js.map +1 -0
- package/lib/components/popup.d.ts +43 -0
- package/lib/components/popup.d.ts.map +1 -0
- package/lib/components/popup.js +380 -0
- package/lib/components/popup.js.map +1 -0
- package/lib/components/radio.d.ts +198 -0
- package/lib/components/radio.d.ts.map +1 -0
- package/lib/components/radio.js +437 -0
- package/lib/components/radio.js.map +1 -0
- package/lib/components/resize-observer.d.ts +48 -0
- package/lib/components/resize-observer.d.ts.map +1 -0
- package/lib/components/resize-observer.js +108 -0
- package/lib/components/resize-observer.js.map +1 -0
- package/lib/components/scroll-container.d.ts +51 -0
- package/lib/components/scroll-container.d.ts.map +1 -0
- package/lib/components/scroll-container.js +239 -0
- package/lib/components/scroll-container.js.map +1 -0
- package/lib/components/step.d.ts +26 -0
- package/lib/components/step.d.ts.map +1 -0
- package/lib/components/step.js +75 -0
- package/lib/components/step.js.map +1 -0
- package/lib/components/switch.d.ts +111 -0
- package/lib/components/switch.d.ts.map +1 -0
- package/lib/components/switch.js +240 -0
- package/lib/components/switch.js.map +1 -0
- package/lib/components/tab.d.ts +23 -0
- package/lib/components/tab.d.ts.map +1 -0
- package/lib/components/tab.js +76 -0
- package/lib/components/tab.js.map +1 -0
- package/lib/components/tabs.d.ts +93 -0
- package/lib/components/tabs.d.ts.map +1 -0
- package/lib/components/tabs.js +653 -0
- package/lib/components/tabs.js.map +1 -0
- package/lib/components/tag.d.ts +144 -0
- package/lib/components/tag.d.ts.map +1 -0
- package/lib/components/tag.js +316 -0
- package/lib/components/tag.js.map +1 -0
- package/lib/components/textarea.d.ts +241 -0
- package/lib/components/textarea.d.ts.map +1 -0
- package/lib/components/textarea.js +585 -0
- package/lib/components/textarea.js.map +1 -0
- package/lib/components/tooltip.d.ts +40 -0
- package/lib/components/tooltip.d.ts.map +1 -0
- package/lib/components/tooltip.js +439 -0
- package/lib/components/tooltip.js.map +1 -0
- package/lib/components/wizard.d.ts +86 -0
- package/lib/components/wizard.d.ts.map +1 -0
- package/lib/components/wizard.js +636 -0
- package/lib/components/wizard.js.map +1 -0
- package/lib/icons/fontawesome/brands.d.ts +557 -0
- package/lib/icons/fontawesome/brands.d.ts.map +1 -0
- package/lib/icons/fontawesome/brands.js +557 -0
- package/lib/icons/fontawesome/brands.js.map +1 -0
- package/lib/icons/fontawesome/regular.d.ts +281 -0
- package/lib/icons/fontawesome/regular.d.ts.map +1 -0
- package/lib/icons/fontawesome/regular.js +281 -0
- package/lib/icons/fontawesome/regular.js.map +1 -0
- package/lib/icons/fontawesome/solid.d.ts +1992 -0
- package/lib/icons/fontawesome/solid.d.ts.map +1 -0
- package/lib/icons/fontawesome/solid.js +1992 -0
- package/lib/icons/fontawesome/solid.js.map +1 -0
- package/lib/icons/heroicons/micro.d.ts +324 -0
- package/lib/icons/heroicons/micro.d.ts.map +1 -0
- package/lib/icons/heroicons/micro.js +1032 -0
- package/lib/icons/heroicons/micro.js.map +1 -0
- package/lib/icons/heroicons/mini.d.ts +332 -0
- package/lib/icons/heroicons/mini.d.ts.map +1 -0
- package/lib/icons/heroicons/mini.js +1038 -0
- package/lib/icons/heroicons/mini.js.map +1 -0
- package/lib/icons/heroicons/outline.d.ts +332 -0
- package/lib/icons/heroicons/outline.d.ts.map +1 -0
- package/lib/icons/heroicons/outline.js +993 -0
- package/lib/icons/heroicons/outline.js.map +1 -0
- package/lib/icons/heroicons/solid.d.ts +332 -0
- package/lib/icons/heroicons/solid.d.ts.map +1 -0
- package/lib/icons/heroicons/solid.js +1063 -0
- package/lib/icons/heroicons/solid.js.map +1 -0
- package/lib/icons/lucide.d.ts +1872 -0
- package/lib/icons/lucide.d.ts.map +1 -0
- package/lib/icons/lucide.js +28212 -0
- package/lib/icons/lucide.js.map +1 -0
- package/lib/icons/material/filled.d.ts +2180 -0
- package/lib/icons/material/filled.d.ts.map +1 -0
- package/lib/icons/material/filled.js +14003 -0
- package/lib/icons/material/filled.js.map +1 -0
- package/lib/icons/material/outlined.d.ts +2142 -0
- package/lib/icons/material/outlined.d.ts.map +1 -0
- package/lib/icons/material/outlined.js +14545 -0
- package/lib/icons/material/outlined.js.map +1 -0
- package/lib/icons/material/round.d.ts +2147 -0
- package/lib/icons/material/round.d.ts.map +1 -0
- package/lib/icons/material/round.js +14779 -0
- package/lib/icons/material/round.js.map +1 -0
- package/lib/icons/material/sharp.d.ts +2147 -0
- package/lib/icons/material/sharp.d.ts.map +1 -0
- package/lib/icons/material/sharp.js +14189 -0
- package/lib/icons/material/sharp.js.map +1 -0
- package/lib/icons/material/two-tone.d.ts +2185 -0
- package/lib/icons/material/two-tone.d.ts.map +1 -0
- package/lib/icons/material/two-tone.js +17152 -0
- package/lib/icons/material/two-tone.js.map +1 -0
- package/lib/index.d.ts +86 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +78 -0
- package/lib/index.js.map +1 -0
- package/lib/styles/button.d.ts +14 -0
- package/lib/styles/button.d.ts.map +1 -0
- package/lib/styles/button.js +498 -0
- package/lib/styles/button.js.map +1 -0
- package/lib/styles/calendar-month.d.ts +6 -0
- package/lib/styles/calendar-month.d.ts.map +1 -0
- package/lib/styles/calendar-month.js +275 -0
- package/lib/styles/calendar-month.js.map +1 -0
- package/lib/styles/calendar-navigation.d.ts +6 -0
- package/lib/styles/calendar-navigation.d.ts.map +1 -0
- package/lib/styles/calendar-navigation.js +143 -0
- package/lib/styles/calendar-navigation.js.map +1 -0
- package/lib/styles/calendar.d.ts +6 -0
- package/lib/styles/calendar.d.ts.map +1 -0
- package/lib/styles/calendar.js +28 -0
- package/lib/styles/calendar.js.map +1 -0
- package/lib/styles/checkbox.d.ts +9 -0
- package/lib/styles/checkbox.d.ts.map +1 -0
- package/lib/styles/checkbox.js +19 -0
- package/lib/styles/checkbox.js.map +1 -0
- package/lib/styles/copy.d.ts +7 -0
- package/lib/styles/copy.d.ts.map +1 -0
- package/lib/styles/copy.js +94 -0
- package/lib/styles/copy.js.map +1 -0
- package/lib/styles/custom-scrollbar.d.ts +6 -0
- package/lib/styles/custom-scrollbar.d.ts.map +1 -0
- package/lib/styles/custom-scrollbar.js +157 -0
- package/lib/styles/custom-scrollbar.js.map +1 -0
- package/lib/styles/date-picker.d.ts +6 -0
- package/lib/styles/date-picker.d.ts.map +1 -0
- package/lib/styles/date-picker.js +438 -0
- package/lib/styles/date-picker.js.map +1 -0
- package/lib/styles/dropdown.d.ts +12 -0
- package/lib/styles/dropdown.d.ts.map +1 -0
- package/lib/styles/dropdown.js +1081 -0
- package/lib/styles/dropdown.js.map +1 -0
- package/lib/styles/file-upload.d.ts +2 -0
- package/lib/styles/file-upload.d.ts.map +1 -0
- package/lib/styles/file-upload.js +241 -0
- package/lib/styles/file-upload.js.map +1 -0
- package/lib/styles/icon.d.ts +6 -0
- package/lib/styles/icon.d.ts.map +1 -0
- package/lib/styles/icon.js +241 -0
- package/lib/styles/icon.js.map +1 -0
- package/lib/styles/input.d.ts +7 -0
- package/lib/styles/input.d.ts.map +1 -0
- package/lib/styles/input.js +685 -0
- package/lib/styles/input.js.map +1 -0
- package/lib/styles/modal.d.ts +8 -0
- package/lib/styles/modal.d.ts.map +1 -0
- package/lib/styles/modal.js +134 -0
- package/lib/styles/modal.js.map +1 -0
- package/lib/styles/multiselect.d.ts +6 -0
- package/lib/styles/multiselect.d.ts.map +1 -0
- package/lib/styles/multiselect.js +825 -0
- package/lib/styles/multiselect.js.map +1 -0
- package/lib/styles/option.d.ts +6 -0
- package/lib/styles/option.d.ts.map +1 -0
- package/lib/styles/option.js +116 -0
- package/lib/styles/option.js.map +1 -0
- package/lib/styles/popup.d.ts +8 -0
- package/lib/styles/popup.d.ts.map +1 -0
- package/lib/styles/popup.js +95 -0
- package/lib/styles/popup.js.map +1 -0
- package/lib/styles/radio.d.ts +8 -0
- package/lib/styles/radio.d.ts.map +1 -0
- package/lib/styles/radio.js +160 -0
- package/lib/styles/radio.js.map +1 -0
- package/lib/styles/resize-observer.d.ts +6 -0
- package/lib/styles/resize-observer.d.ts.map +1 -0
- package/lib/styles/resize-observer.js +18 -0
- package/lib/styles/resize-observer.js.map +1 -0
- package/lib/styles/scroll-container.d.ts +6 -0
- package/lib/styles/scroll-container.d.ts.map +1 -0
- package/lib/styles/scroll-container.js +198 -0
- package/lib/styles/scroll-container.js.map +1 -0
- package/lib/styles/step.d.ts +5 -0
- package/lib/styles/step.d.ts.map +1 -0
- package/lib/styles/step.js +50 -0
- package/lib/styles/step.js.map +1 -0
- package/lib/styles/switch.d.ts +9 -0
- package/lib/styles/switch.d.ts.map +1 -0
- package/lib/styles/switch.js +100 -0
- package/lib/styles/switch.js.map +1 -0
- package/lib/styles/tab.d.ts +5 -0
- package/lib/styles/tab.d.ts.map +1 -0
- package/lib/styles/tab.js +51 -0
- package/lib/styles/tab.js.map +1 -0
- package/lib/styles/tabs.d.ts +13 -0
- package/lib/styles/tabs.d.ts.map +1 -0
- package/lib/styles/tabs.js +184 -0
- package/lib/styles/tabs.js.map +1 -0
- package/lib/styles/tag.d.ts +6 -0
- package/lib/styles/tag.d.ts.map +1 -0
- package/lib/styles/tag.js +409 -0
- package/lib/styles/tag.js.map +1 -0
- package/lib/styles/textarea.d.ts +6 -0
- package/lib/styles/textarea.d.ts.map +1 -0
- package/lib/styles/textarea.js +350 -0
- package/lib/styles/textarea.js.map +1 -0
- package/lib/styles/tooltip.d.ts +9 -0
- package/lib/styles/tooltip.d.ts.map +1 -0
- package/lib/styles/tooltip.js +133 -0
- package/lib/styles/tooltip.js.map +1 -0
- package/lib/styles/wizard.d.ts +25 -0
- package/lib/styles/wizard.d.ts.map +1 -0
- package/lib/styles/wizard.js +348 -0
- package/lib/styles/wizard.js.map +1 -0
- package/lib/types/common.d.ts +143 -0
- package/lib/types/common.d.ts.map +1 -0
- package/lib/types/common.js +5 -0
- package/lib/types/common.js.map +1 -0
- package/lib/utils/calendar-utils.d.ts +176 -0
- package/lib/utils/calendar-utils.d.ts.map +1 -0
- package/lib/utils/calendar-utils.js +370 -0
- package/lib/utils/calendar-utils.js.map +1 -0
- package/lib/utils/custom-scrollbar.d.ts +82 -0
- package/lib/utils/custom-scrollbar.d.ts.map +1 -0
- package/lib/utils/custom-scrollbar.js +320 -0
- package/lib/utils/custom-scrollbar.js.map +1 -0
- package/lib/utils/icon-registry.d.ts +78 -0
- package/lib/utils/icon-registry.d.ts.map +1 -0
- package/lib/utils/icon-registry.js +304 -0
- package/lib/utils/icon-registry.js.map +1 -0
- package/lib/utils/loader-registry.d.ts +35 -0
- package/lib/utils/loader-registry.d.ts.map +1 -0
- package/lib/utils/loader-registry.js +43 -0
- package/lib/utils/loader-registry.js.map +1 -0
- package/lib/utils/locale.d.ts +136 -0
- package/lib/utils/locale.d.ts.map +1 -0
- package/lib/utils/locale.js +213 -0
- package/lib/utils/locale.js.map +1 -0
- package/lib/utils/mobile.d.ts +14 -0
- package/lib/utils/mobile.d.ts.map +1 -0
- package/lib/utils/mobile.js +21 -0
- package/lib/utils/mobile.js.map +1 -0
- package/lib/utils/number-format.d.ts +83 -0
- package/lib/utils/number-format.d.ts.map +1 -0
- package/lib/utils/number-format.js +143 -0
- package/lib/utils/number-format.js.map +1 -0
- package/lib/utils/parse-boolean.d.ts +39 -0
- package/lib/utils/parse-boolean.d.ts.map +1 -0
- package/lib/utils/parse-boolean.js +58 -0
- package/lib/utils/parse-boolean.js.map +1 -0
- package/lib/utils/positioning.d.ts +143 -0
- package/lib/utils/positioning.d.ts.map +1 -0
- package/lib/utils/positioning.js +308 -0
- package/lib/utils/positioning.js.map +1 -0
- package/lib/utils/property-capture.d.ts +132 -0
- package/lib/utils/property-capture.d.ts.map +1 -0
- package/lib/utils/property-capture.js +152 -0
- package/lib/utils/property-capture.js.map +1 -0
- package/lib/utils/property-manager.d.ts +90 -0
- package/lib/utils/property-manager.d.ts.map +1 -0
- package/lib/utils/property-manager.js +197 -0
- package/lib/utils/property-manager.js.map +1 -0
- package/lib/utils/resize-observer.d.ts +42 -0
- package/lib/utils/resize-observer.d.ts.map +1 -0
- package/lib/utils/resize-observer.js +71 -0
- package/lib/utils/resize-observer.js.map +1 -0
- package/lib/utils/scroll-lock.d.ts +79 -0
- package/lib/utils/scroll-lock.d.ts.map +1 -0
- package/lib/utils/scroll-lock.js +197 -0
- package/lib/utils/scroll-lock.js.map +1 -0
- package/lib/utils/styles.d.ts +27 -0
- package/lib/utils/styles.d.ts.map +1 -0
- package/lib/utils/styles.js +53 -0
- package/lib/utils/styles.js.map +1 -0
- package/lib/version.d.ts +8 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +11 -0
- package/lib/version.js.map +1 -0
- package/package.json +163 -0
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TyMultiselect Web Component
|
|
3
|
+
* PORTED FROM: clj/ty/components/multiselect.cljs
|
|
4
|
+
*
|
|
5
|
+
* A multiselect dropdown component using ty-tag for selections with:
|
|
6
|
+
* - Tag-only options (only ty-tag elements supported)
|
|
7
|
+
* - Multiple selection with visual tags
|
|
8
|
+
* - Desktop mode with smart positioning
|
|
9
|
+
* - Mobile mode with full-screen modal
|
|
10
|
+
* - Search and filtering capabilities
|
|
11
|
+
* - Keyboard navigation
|
|
12
|
+
* - Form association for native form submission with multiple values
|
|
13
|
+
* - Scroll locking when dropdown is open
|
|
14
|
+
* - Outside click to close
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```html
|
|
18
|
+
* <!-- Basic multiselect -->
|
|
19
|
+
* <ty-multiselect label="Skills" placeholder="Select skills" required>
|
|
20
|
+
* <ty-tag value="js">JavaScript</ty-tag>
|
|
21
|
+
* <ty-tag value="ts">TypeScript</ty-tag>
|
|
22
|
+
* <ty-tag value="py">Python</ty-tag>
|
|
23
|
+
* </ty-multiselect>
|
|
24
|
+
*
|
|
25
|
+
* <!-- With pre-selected values -->
|
|
26
|
+
* <ty-multiselect value="js,ts">
|
|
27
|
+
* <ty-tag value="js">JavaScript</ty-tag>
|
|
28
|
+
* <ty-tag value="ts">TypeScript</ty-tag>
|
|
29
|
+
* <ty-tag value="py">Python</ty-tag>
|
|
30
|
+
* </ty-multiselect>
|
|
31
|
+
*
|
|
32
|
+
* <!-- With rich tag content -->
|
|
33
|
+
* <ty-multiselect label="Team Members">
|
|
34
|
+
* <ty-tag value="1" flavor="primary">
|
|
35
|
+
* <div class="flex items-center gap-2">
|
|
36
|
+
* <img src="avatar1.jpg" class="w-6 h-6 rounded-full" />
|
|
37
|
+
* <span>John Doe</span>
|
|
38
|
+
* </div>
|
|
39
|
+
* </ty-tag>
|
|
40
|
+
* </ty-multiselect>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
import { ensureStyles } from '../utils/styles.js';
|
|
44
|
+
import { multiselectStyles } from '../styles/multiselect.js';
|
|
45
|
+
import { getLoaderSvg } from '../utils/loader-registry.js';
|
|
46
|
+
import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
|
|
47
|
+
import { isMobileTouch } from '../utils/mobile.js';
|
|
48
|
+
import { TyComponent } from '../base/ty-component.js';
|
|
49
|
+
import { CustomScrollbar, isCustomScrollbarEnabled } from '../utils/custom-scrollbar.js';
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Element Hash Utility (for consistent scroll lock IDs)
|
|
52
|
+
// ============================================================================
|
|
53
|
+
/**
|
|
54
|
+
* Counter for generating unique element IDs
|
|
55
|
+
*/
|
|
56
|
+
let elementIdCounter = 0;
|
|
57
|
+
/**
|
|
58
|
+
* WeakMap to store consistent element hashes
|
|
59
|
+
* Automatically garbage collects when element is destroyed
|
|
60
|
+
*/
|
|
61
|
+
const elementIds = new WeakMap();
|
|
62
|
+
/**
|
|
63
|
+
* Get a consistent unique ID for an element
|
|
64
|
+
* Returns the same ID for the same element across multiple calls
|
|
65
|
+
*
|
|
66
|
+
* @param element - The element to hash
|
|
67
|
+
* @returns A consistent numeric hash for the element
|
|
68
|
+
*/
|
|
69
|
+
function getElementHash(element) {
|
|
70
|
+
let id = elementIds.get(element);
|
|
71
|
+
if (id === undefined) {
|
|
72
|
+
id = ++elementIdCounter;
|
|
73
|
+
elementIds.set(element, id);
|
|
74
|
+
}
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// SVG Icons
|
|
79
|
+
// ============================================================================
|
|
80
|
+
/**
|
|
81
|
+
* Required indicator SVG icon (from Lucide)
|
|
82
|
+
*/
|
|
83
|
+
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>`;
|
|
84
|
+
/**
|
|
85
|
+
* Chevron down icon SVG
|
|
86
|
+
*/
|
|
87
|
+
const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
|
|
88
|
+
<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" />
|
|
89
|
+
</svg>`;
|
|
90
|
+
/**
|
|
91
|
+
* Ty Multiselect Component
|
|
92
|
+
*/
|
|
93
|
+
export class TyMultiselect extends TyComponent {
|
|
94
|
+
constructor() {
|
|
95
|
+
super(); // TyComponent handles attachInternals() and attachShadow()
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// INTERNAL STATE
|
|
98
|
+
// ============================================================================
|
|
99
|
+
this._name = '';
|
|
100
|
+
this._placeholder = 'Select options...';
|
|
101
|
+
this._label = '';
|
|
102
|
+
this._disabled = false;
|
|
103
|
+
this._readonly = false;
|
|
104
|
+
this._required = false;
|
|
105
|
+
this._externalSearch = false;
|
|
106
|
+
this._loading = false;
|
|
107
|
+
this._scrollLockId = null;
|
|
108
|
+
this._size = 'md';
|
|
109
|
+
this._selectedLabel = 'Selected';
|
|
110
|
+
this._availableLabel = 'Available';
|
|
111
|
+
this._noOptionsMessage = 'No options available';
|
|
112
|
+
// Component state
|
|
113
|
+
this._state = {
|
|
114
|
+
open: false,
|
|
115
|
+
search: '',
|
|
116
|
+
highlightedIndex: -1,
|
|
117
|
+
filteredTags: [],
|
|
118
|
+
selectedValues: [],
|
|
119
|
+
mode: 'desktop' // Updated dynamically on render via syncMode()
|
|
120
|
+
};
|
|
121
|
+
// Event handler references for cleanup
|
|
122
|
+
this._stubClickHandler = null;
|
|
123
|
+
this._tagClickHandler = null;
|
|
124
|
+
this._tagDismissHandler = null;
|
|
125
|
+
this._searchInputHandler = null;
|
|
126
|
+
this._blockSearchClick = null;
|
|
127
|
+
this._keyboardHandler = null;
|
|
128
|
+
// Debounce properties for search event
|
|
129
|
+
this._debounce = 0;
|
|
130
|
+
this._searchDebounceTimer = null;
|
|
131
|
+
// Custom scrollbar for options list
|
|
132
|
+
this._optionsScrollbar = null;
|
|
133
|
+
// MutationObserver for light-DOM children — re-syncs selected tags' visual
|
|
134
|
+
// state when consumers swap tag children (external-search refresh pattern).
|
|
135
|
+
this._childObserver = null;
|
|
136
|
+
const shadow = this.shadowRoot;
|
|
137
|
+
ensureStyles(shadow, { css: multiselectStyles, id: 'ty-multiselect' });
|
|
138
|
+
// DON'T render here - wait for onConnect() to initialize values first
|
|
139
|
+
// This matches dropdown.ts pattern and prevents showing empty state
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Called when component is connected to DOM
|
|
143
|
+
* TyComponent handles property capture automatically
|
|
144
|
+
*/
|
|
145
|
+
onConnect() {
|
|
146
|
+
// SAFETY: Close any open dialogs to prevent scroll locking
|
|
147
|
+
const shadow = this.shadowRoot;
|
|
148
|
+
const dialogs = shadow.querySelectorAll('dialog');
|
|
149
|
+
dialogs.forEach(dialog => {
|
|
150
|
+
if (dialog.open) {
|
|
151
|
+
console.warn('⚠️ Found open dialog on connect, closing it');
|
|
152
|
+
dialog.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// Render FIRST to create DOM structure
|
|
156
|
+
this.render();
|
|
157
|
+
// THEN initialize and sync tags (after DOM exists)
|
|
158
|
+
requestAnimationFrame(() => {
|
|
159
|
+
this.initializeState();
|
|
160
|
+
// Visual updates happen automatically via onPropertiesChanged
|
|
161
|
+
});
|
|
162
|
+
// Observe light-DOM children — re-sync selected state when consumers swap
|
|
163
|
+
// tag children (external-search refresh). syncSelectedTags is idempotent
|
|
164
|
+
// (only acts on tags whose desired-vs-actual selected state differs), so
|
|
165
|
+
// spurious firings caused by our own re-slot work are no-ops.
|
|
166
|
+
this._childObserver = new MutationObserver(() => {
|
|
167
|
+
if (this._state.selectedValues.length > 0) {
|
|
168
|
+
this.syncSelectedTags(this._state.selectedValues);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
this._childObserver.observe(this, { childList: true });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Called when component is disconnected from DOM
|
|
175
|
+
* Clean up event listeners and timers
|
|
176
|
+
*/
|
|
177
|
+
onDisconnect() {
|
|
178
|
+
// CRITICAL: Close all dialogs to prevent scroll locking
|
|
179
|
+
const shadow = this.shadowRoot;
|
|
180
|
+
const dialogs = shadow.querySelectorAll('dialog');
|
|
181
|
+
dialogs.forEach(dialog => {
|
|
182
|
+
if (dialog.open) {
|
|
183
|
+
dialog.close();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Clean up document-level listeners
|
|
187
|
+
if (this._keyboardHandler) {
|
|
188
|
+
document.removeEventListener('keydown', this._keyboardHandler);
|
|
189
|
+
this._keyboardHandler = null;
|
|
190
|
+
}
|
|
191
|
+
// Clear any pending debounce timer
|
|
192
|
+
if (this._searchDebounceTimer !== null) {
|
|
193
|
+
clearTimeout(this._searchDebounceTimer);
|
|
194
|
+
this._searchDebounceTimer = null;
|
|
195
|
+
}
|
|
196
|
+
// Cleanup custom scrollbar
|
|
197
|
+
this._destroyOptionsScrollbar();
|
|
198
|
+
// Disconnect children observer
|
|
199
|
+
if (this._childObserver) {
|
|
200
|
+
this._childObserver.disconnect();
|
|
201
|
+
this._childObserver = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Called when properties change
|
|
206
|
+
* Handle state synchronization BEFORE render
|
|
207
|
+
*/
|
|
208
|
+
onPropertiesChanged(changes) {
|
|
209
|
+
for (const { name, newValue } of changes) {
|
|
210
|
+
switch (name) {
|
|
211
|
+
case 'value':
|
|
212
|
+
const selectedValues = this.parseValue(newValue);
|
|
213
|
+
this._state.selectedValues = selectedValues;
|
|
214
|
+
// CRITICAL: Only sync tags if we're connected and tags exist
|
|
215
|
+
// During initial property setup (before onConnect), tags don't exist yet
|
|
216
|
+
if (this.isConnected && this.shadowRoot) {
|
|
217
|
+
this.syncSelectedTags(selectedValues);
|
|
218
|
+
this.updateSelectionDisplay();
|
|
219
|
+
this.updateMobileSelectedState();
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
console.warn('💜 [multiselect] onPropertiesChanged - NOT connected yet, skipping sync (will happen in initializeState)');
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case 'name':
|
|
226
|
+
this._name = newValue || '';
|
|
227
|
+
break;
|
|
228
|
+
case 'placeholder':
|
|
229
|
+
this._placeholder = newValue || 'Select options...';
|
|
230
|
+
// No need to update display - placeholder text change doesn't affect visibility
|
|
231
|
+
break;
|
|
232
|
+
case 'label':
|
|
233
|
+
this._label = newValue || '';
|
|
234
|
+
break;
|
|
235
|
+
case 'disabled':
|
|
236
|
+
this._disabled = newValue;
|
|
237
|
+
break;
|
|
238
|
+
case 'readonly':
|
|
239
|
+
this._readonly = newValue;
|
|
240
|
+
break;
|
|
241
|
+
case 'required':
|
|
242
|
+
this._required = newValue;
|
|
243
|
+
break;
|
|
244
|
+
case 'externalSearch':
|
|
245
|
+
this._externalSearch = newValue;
|
|
246
|
+
break;
|
|
247
|
+
case 'size':
|
|
248
|
+
this._size = newValue;
|
|
249
|
+
break;
|
|
250
|
+
case 'debounce':
|
|
251
|
+
this._debounce = newValue;
|
|
252
|
+
break;
|
|
253
|
+
case 'selected-label':
|
|
254
|
+
this._selectedLabel = newValue || 'Selected';
|
|
255
|
+
break;
|
|
256
|
+
case 'available-label':
|
|
257
|
+
this._availableLabel = newValue || 'Available';
|
|
258
|
+
break;
|
|
259
|
+
case 'no-options-message':
|
|
260
|
+
this._noOptionsMessage = newValue || 'No options available';
|
|
261
|
+
break;
|
|
262
|
+
case 'loading':
|
|
263
|
+
this._loading = newValue;
|
|
264
|
+
this.applyLoadingState();
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Toggle the loading visual state on the open popup.
|
|
271
|
+
* Replaces the available-options area with a centered spinner; search input stays usable.
|
|
272
|
+
* Pulls the latest registered loader SVG on each call so registry changes
|
|
273
|
+
* take effect on the next loading toggle.
|
|
274
|
+
*/
|
|
275
|
+
applyLoadingState() {
|
|
276
|
+
const shadow = this.shadowRoot;
|
|
277
|
+
if (!shadow)
|
|
278
|
+
return;
|
|
279
|
+
const svg = this._loading ? getLoaderSvg() : null;
|
|
280
|
+
shadow.querySelectorAll('.dropdown-options-wrapper').forEach((wrapper) => {
|
|
281
|
+
wrapper.classList.toggle('loading', this._loading);
|
|
282
|
+
if (this._loading) {
|
|
283
|
+
wrapper.setAttribute('aria-busy', 'true');
|
|
284
|
+
const spinner = wrapper.querySelector('.dropdown-loading-spinner');
|
|
285
|
+
if (spinner && svg)
|
|
286
|
+
spinner.innerHTML = svg;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
wrapper.removeAttribute('aria-busy');
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Get the form value for this component
|
|
295
|
+
* Returns FormData with multiple entries (HTMX standard)
|
|
296
|
+
*/
|
|
297
|
+
getFormValue() {
|
|
298
|
+
const selectedValues = this._state.selectedValues;
|
|
299
|
+
if (this._name && selectedValues.length > 0) {
|
|
300
|
+
const formData = new FormData();
|
|
301
|
+
selectedValues.forEach(value => {
|
|
302
|
+
formData.append(this._name, value);
|
|
303
|
+
});
|
|
304
|
+
return formData;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Parse multiselect value (comma-separated string to array)
|
|
310
|
+
*/
|
|
311
|
+
parseValue(value) {
|
|
312
|
+
// Defensive check: ensure value is actually a string before calling .trim()
|
|
313
|
+
if (!value || typeof value !== 'string' || value.trim() === '')
|
|
314
|
+
return [];
|
|
315
|
+
return value.split(',').map(v => v.trim()).filter(v => v !== '');
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Initialize component state from attributes
|
|
319
|
+
* Reads from both property and attribute (like ClojureScript version)
|
|
320
|
+
*/
|
|
321
|
+
initializeState() {
|
|
322
|
+
const initialValue = this.getProperty('value') || '';
|
|
323
|
+
if (initialValue) {
|
|
324
|
+
// Explicit value provided - sync tags directly
|
|
325
|
+
// DON'T use updateComponentValue() because the property is already set!
|
|
326
|
+
const selectedValues = this.parseValue(initialValue);
|
|
327
|
+
// Update internal state
|
|
328
|
+
this._state.selectedValues = selectedValues;
|
|
329
|
+
// Sync the tags to match the property value
|
|
330
|
+
this.syncSelectedTags(selectedValues);
|
|
331
|
+
// Update the visual display
|
|
332
|
+
this.updateSelectionDisplay();
|
|
333
|
+
this.updateMobileSelectedState();
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// No explicit value - check for pre-selected tags
|
|
337
|
+
const allTags = this.getTagElements();
|
|
338
|
+
const preSelectedTags = allTags
|
|
339
|
+
.filter(tag => tag.hasAttribute('selected'))
|
|
340
|
+
.map(tag => this.getTagData(tag).value);
|
|
341
|
+
if (preSelectedTags.length > 0) {
|
|
342
|
+
// Set the property value and sync (updateComponentValue handles everything)
|
|
343
|
+
this.updateComponentValue(preSelectedTags, false);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// TAG MANAGEMENT METHODS (Phase 2)
|
|
349
|
+
// ============================================================================
|
|
350
|
+
/**
|
|
351
|
+
* Get all ty-tag elements from the component (ALL slots)
|
|
352
|
+
*/
|
|
353
|
+
getTagElements() {
|
|
354
|
+
// Get ALL ty-tag children, regardless of slot assignment
|
|
355
|
+
const tags = Array.from(this.querySelectorAll('ty-tag'));
|
|
356
|
+
return tags;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Extract value and text from a ty-tag element
|
|
360
|
+
*/
|
|
361
|
+
getTagData(element) {
|
|
362
|
+
// Get value from either property or attribute
|
|
363
|
+
const value = element.value || element.getAttribute('value') || element.textContent || '';
|
|
364
|
+
const text = element.textContent || '';
|
|
365
|
+
return { value, text, element };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Select a tag - set selected state, move to selected slot, make dismissible
|
|
369
|
+
*/
|
|
370
|
+
selectTag(tag) {
|
|
371
|
+
const tagValue = this.getTagData(tag).value;
|
|
372
|
+
// Set selected attribute
|
|
373
|
+
tag.setAttribute('selected', '');
|
|
374
|
+
// Move to selected slot
|
|
375
|
+
tag.setAttribute('slot', 'selected');
|
|
376
|
+
// Make dismissible
|
|
377
|
+
tag.setAttribute('dismissible', 'true');
|
|
378
|
+
// Force re-slotting by removing and re-adding
|
|
379
|
+
const parent = tag.parentNode;
|
|
380
|
+
if (parent) {
|
|
381
|
+
parent.removeChild(tag);
|
|
382
|
+
parent.appendChild(tag);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
console.warn(`⚠️ [multiselect] - No parent for "${tagValue}"!`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Deselect a tag - remove selected state, remove from selected slot, remove dismissible
|
|
390
|
+
*/
|
|
391
|
+
deselectTag(tag) {
|
|
392
|
+
// Remove selected attribute
|
|
393
|
+
tag.removeAttribute('selected');
|
|
394
|
+
// Remove from selected slot
|
|
395
|
+
tag.removeAttribute('slot');
|
|
396
|
+
// Remove dismissible
|
|
397
|
+
tag.removeAttribute('dismissible');
|
|
398
|
+
// Force re-slotting by removing and re-adding
|
|
399
|
+
const parent = tag.parentNode;
|
|
400
|
+
if (parent) {
|
|
401
|
+
parent.removeChild(tag);
|
|
402
|
+
parent.appendChild(tag);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Get array of currently selected values from tags (ALWAYS reads from DOM)
|
|
407
|
+
*/
|
|
408
|
+
getSelectedValues() {
|
|
409
|
+
return this.getTagElements()
|
|
410
|
+
.filter(tag => tag.hasAttribute('selected'))
|
|
411
|
+
.map(tag => this.getTagData(tag).value)
|
|
412
|
+
.filter(value => value !== '');
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Check if all available tags are selected
|
|
416
|
+
*/
|
|
417
|
+
allTagsSelected() {
|
|
418
|
+
const tags = this.getTagElements();
|
|
419
|
+
if (tags.length === 0)
|
|
420
|
+
return false;
|
|
421
|
+
const selectedCount = tags.filter(tag => tag.hasAttribute('selected')).length;
|
|
422
|
+
return selectedCount === tags.length;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Sync tag selection states with desired values
|
|
426
|
+
*/
|
|
427
|
+
syncSelectedTags(selectedValues) {
|
|
428
|
+
const selectedSet = new Set(selectedValues);
|
|
429
|
+
const tags = this.getTagElements();
|
|
430
|
+
tags.forEach(tag => {
|
|
431
|
+
const tagValue = this.getTagData(tag).value;
|
|
432
|
+
const shouldBeSelected = selectedSet.has(tagValue);
|
|
433
|
+
const isSelected = tag.hasAttribute('selected');
|
|
434
|
+
if (shouldBeSelected && !isSelected) {
|
|
435
|
+
this.selectTag(tag);
|
|
436
|
+
}
|
|
437
|
+
else if (!shouldBeSelected && isSelected) {
|
|
438
|
+
this.deselectTag(tag);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Central update function - synchronizes everything
|
|
444
|
+
* Uses TyComponent's property system for proper lifecycle
|
|
445
|
+
*/
|
|
446
|
+
updateComponentValue(newValues, dispatchChange = false, action = 'set', item = null) {
|
|
447
|
+
const oldValues = this.getSelectedValues();
|
|
448
|
+
const valueStr = newValues.join(',');
|
|
449
|
+
// Only update if changed
|
|
450
|
+
if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
|
|
451
|
+
// Use TyComponent's property system - this will trigger:
|
|
452
|
+
// 1. onPropertiesChanged() → syncs tags via syncSelectedTags()
|
|
453
|
+
// 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
|
|
454
|
+
// 3. updateFormValue() → automatic (formValue: true in config)
|
|
455
|
+
// 4. render() → automatic if visual properties changed
|
|
456
|
+
this.setProperty('value', valueStr);
|
|
457
|
+
// Dispatch custom multiselect change event (with action/item details)
|
|
458
|
+
if (dispatchChange) {
|
|
459
|
+
this.dispatchChangeEvent({
|
|
460
|
+
values: newValues,
|
|
461
|
+
action,
|
|
462
|
+
item
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ============================================================================
|
|
470
|
+
// DROPDOWN METHODS (Phase 3 & 4)
|
|
471
|
+
// ============================================================================
|
|
472
|
+
/**
|
|
473
|
+
* Calculate and set dropdown position with smart direction detection
|
|
474
|
+
*/
|
|
475
|
+
calculatePosition() {
|
|
476
|
+
const shadow = this.shadowRoot;
|
|
477
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
478
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
479
|
+
if (!stub || !dialog)
|
|
480
|
+
return;
|
|
481
|
+
const stubRect = stub.getBoundingClientRect();
|
|
482
|
+
const viewportHeight = window.innerHeight;
|
|
483
|
+
const viewportWidth = window.innerWidth;
|
|
484
|
+
// Get dialog dimensions (it's already shown with showModal)
|
|
485
|
+
const dialogRect = dialog.getBoundingClientRect();
|
|
486
|
+
const estimatedHeight = dialogRect.height || 200;
|
|
487
|
+
const padding = 8;
|
|
488
|
+
const wrapPadding = 20;
|
|
489
|
+
// Available space calculations
|
|
490
|
+
const spaceBelow = viewportHeight - stubRect.bottom;
|
|
491
|
+
const spaceRight = viewportWidth - stubRect.left;
|
|
492
|
+
// Smart direction logic
|
|
493
|
+
const positionBelow = spaceBelow >= estimatedHeight + padding;
|
|
494
|
+
const fitsHorizontally = spaceRight >= stubRect.width;
|
|
495
|
+
// Calculate position coordinates
|
|
496
|
+
const x = fitsHorizontally
|
|
497
|
+
? stubRect.left - wrapPadding
|
|
498
|
+
: Math.max(padding, viewportWidth - stubRect.width - padding);
|
|
499
|
+
const y = positionBelow
|
|
500
|
+
? stubRect.top - wrapPadding
|
|
501
|
+
: viewportHeight - stubRect.bottom - wrapPadding;
|
|
502
|
+
const width = stubRect.width + wrapPadding + wrapPadding;
|
|
503
|
+
// Set CSS variables for positioning
|
|
504
|
+
this.style.setProperty('--dropdown-x', `${x}px`);
|
|
505
|
+
this.style.setProperty('--dropdown-y', `${y}px`);
|
|
506
|
+
this.style.setProperty('--dropdown-width', `${width}px`);
|
|
507
|
+
this.style.setProperty('--dropdown-offset-x', '0px');
|
|
508
|
+
this.style.setProperty('--dropdown-offset-y', '0px');
|
|
509
|
+
this.style.setProperty('--dropdown-padding', `${wrapPadding}px`);
|
|
510
|
+
// Set direction classes for CSS styling
|
|
511
|
+
if (positionBelow) {
|
|
512
|
+
dialog.classList.add('position-below');
|
|
513
|
+
dialog.classList.remove('position-above');
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
dialog.classList.add('position-above');
|
|
517
|
+
dialog.classList.remove('position-below');
|
|
518
|
+
}
|
|
519
|
+
// Optional: Store direction for debugging
|
|
520
|
+
this.style.setProperty('--dropdown-direction', positionBelow ? 'below' : 'above');
|
|
521
|
+
}
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// CUSTOM SCROLLBAR FOR OPTIONS
|
|
524
|
+
// ============================================================================
|
|
525
|
+
_setupOptionsScrollbar() {
|
|
526
|
+
if (!isCustomScrollbarEnabled())
|
|
527
|
+
return;
|
|
528
|
+
const shadow = this.shadowRoot;
|
|
529
|
+
const optionsDiv = shadow.querySelector('.dropdown-options');
|
|
530
|
+
const optionsWrapper = shadow.querySelector('.dropdown-options-wrapper');
|
|
531
|
+
if (!optionsDiv || !optionsWrapper)
|
|
532
|
+
return;
|
|
533
|
+
this._destroyOptionsScrollbar();
|
|
534
|
+
optionsDiv.classList.add('ty-custom-scroll');
|
|
535
|
+
this._optionsScrollbar = new CustomScrollbar(optionsDiv, { vertical: true });
|
|
536
|
+
if (this._optionsScrollbar.trackY) {
|
|
537
|
+
optionsWrapper.appendChild(this._optionsScrollbar.trackY);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
_destroyOptionsScrollbar() {
|
|
541
|
+
if (this._optionsScrollbar) {
|
|
542
|
+
this._optionsScrollbar.trackY?.remove();
|
|
543
|
+
this._optionsScrollbar.destroy();
|
|
544
|
+
this._optionsScrollbar = null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Open dropdown dialog (desktop mode)
|
|
549
|
+
*
|
|
550
|
+
* `<dialog>.showModal()` puts the dialog in the top layer with a backdrop, but
|
|
551
|
+
* does NOT prevent the page behind it from scrolling. We use the shared scroll
|
|
552
|
+
* lock utility (overflow:hidden on <html>) to keep wheel/touch scrolling from
|
|
553
|
+
* leaking through to the body — same behavior <ty-dropdown> and <ty-modal>
|
|
554
|
+
* implement.
|
|
555
|
+
*/
|
|
556
|
+
openDropdown() {
|
|
557
|
+
const shadow = this.shadowRoot;
|
|
558
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
559
|
+
if (!dialog)
|
|
560
|
+
return;
|
|
561
|
+
// Lock body scroll while dropdown is open
|
|
562
|
+
const lockId = `multiselect-${this.id || 'anon'}-${getElementHash(this)}`;
|
|
563
|
+
this._scrollLockId = lockId;
|
|
564
|
+
lockScroll(lockId);
|
|
565
|
+
// Show modal
|
|
566
|
+
dialog.showModal();
|
|
567
|
+
dialog.classList.add('open');
|
|
568
|
+
// Position dropdown AFTER showing modal
|
|
569
|
+
this.calculatePosition();
|
|
570
|
+
// Update component state
|
|
571
|
+
this._state.open = true;
|
|
572
|
+
// Update visual states
|
|
573
|
+
const chevron = shadow.querySelector('.dropdown-chevron');
|
|
574
|
+
if (chevron)
|
|
575
|
+
chevron.classList.add('open');
|
|
576
|
+
const searchChevron = shadow.querySelector('.dropdown-search-chevron');
|
|
577
|
+
if (searchChevron)
|
|
578
|
+
searchChevron.classList.add('open');
|
|
579
|
+
// Initialize options state
|
|
580
|
+
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
581
|
+
this._state.filteredTags = tags;
|
|
582
|
+
this._state.highlightedIndex = -1;
|
|
583
|
+
// Ensure options area is visible (may have been hidden from previous search)
|
|
584
|
+
this.updateOptionsVisibility(true);
|
|
585
|
+
// Setup custom scrollbar on options
|
|
586
|
+
this._setupOptionsScrollbar();
|
|
587
|
+
// Focus search input
|
|
588
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
589
|
+
if (searchInput) {
|
|
590
|
+
setTimeout(() => searchInput.focus(), 100);
|
|
591
|
+
}
|
|
592
|
+
// Lifecycle event (also fires empty search if external-search)
|
|
593
|
+
this.fireOpenEvent();
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Close dropdown dialog (desktop mode)
|
|
597
|
+
*/
|
|
598
|
+
closeDropdown() {
|
|
599
|
+
const shadow = this.shadowRoot;
|
|
600
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
601
|
+
if (!dialog)
|
|
602
|
+
return;
|
|
603
|
+
// Destroy custom scrollbar
|
|
604
|
+
this._destroyOptionsScrollbar();
|
|
605
|
+
// Close dialog
|
|
606
|
+
dialog.classList.remove('open');
|
|
607
|
+
dialog.classList.remove('position-above');
|
|
608
|
+
dialog.classList.remove('position-below');
|
|
609
|
+
dialog.close();
|
|
610
|
+
// Unlock body scroll (paired with the lock in openDropdown)
|
|
611
|
+
if (this._scrollLockId) {
|
|
612
|
+
unlockScroll(this._scrollLockId);
|
|
613
|
+
this._scrollLockId = null;
|
|
614
|
+
}
|
|
615
|
+
// Update state
|
|
616
|
+
this._state.open = false;
|
|
617
|
+
this._state.highlightedIndex = -1;
|
|
618
|
+
// Update visual states
|
|
619
|
+
const chevron = shadow.querySelector('.dropdown-chevron');
|
|
620
|
+
if (chevron)
|
|
621
|
+
chevron.classList.remove('open');
|
|
622
|
+
const searchChevron = shadow.querySelector('.dropdown-search-chevron');
|
|
623
|
+
if (searchChevron)
|
|
624
|
+
searchChevron.classList.remove('open');
|
|
625
|
+
// Reset search and restore all tags
|
|
626
|
+
const hadQuery = this._state.search !== '';
|
|
627
|
+
this._state.search = '';
|
|
628
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
629
|
+
if (searchInput) {
|
|
630
|
+
searchInput.value = '';
|
|
631
|
+
}
|
|
632
|
+
if (this._externalSearch) {
|
|
633
|
+
// External mode — notify consumer so it can reset its own filtered state.
|
|
634
|
+
// Bypass the debounce timer (which would delay the clear by `debounce` ms);
|
|
635
|
+
// close-time should be immediate so the consumer's filtered state syncs
|
|
636
|
+
// before the dropdown reopens. Only fire if there was actually a query.
|
|
637
|
+
if (hadQuery) {
|
|
638
|
+
if (this._searchDebounceTimer !== null) {
|
|
639
|
+
clearTimeout(this._searchDebounceTimer);
|
|
640
|
+
this._searchDebounceTimer = null;
|
|
641
|
+
}
|
|
642
|
+
this.fireSearchEvent('');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
// Internal mode — restore visibility of all tags ourselves
|
|
647
|
+
const allTags = this.getTagElements().map(el => this.getTagData(el));
|
|
648
|
+
this._state.filteredTags = allTags;
|
|
649
|
+
this.updateTagVisibility(allTags, allTags);
|
|
650
|
+
this.clearHighlights(allTags);
|
|
651
|
+
}
|
|
652
|
+
// Lifecycle event
|
|
653
|
+
this.fireCloseEvent();
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Open mobile modal (mobile mode)
|
|
657
|
+
* Now using <dialog> element for native z-index management
|
|
658
|
+
*/
|
|
659
|
+
openMobileModal() {
|
|
660
|
+
const shadow = this.shadowRoot;
|
|
661
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
662
|
+
if (!dialog)
|
|
663
|
+
return;
|
|
664
|
+
// Lock body scroll while mobile modal is open
|
|
665
|
+
const lockId = `multiselect-${this.id || 'anon'}-${getElementHash(this)}`;
|
|
666
|
+
this._scrollLockId = lockId;
|
|
667
|
+
lockScroll(lockId);
|
|
668
|
+
// Show dialog using native API (handles z-index automatically)
|
|
669
|
+
dialog.showModal();
|
|
670
|
+
dialog.classList.add('open');
|
|
671
|
+
const stub_slot = shadow.querySelector('#stub-slot');
|
|
672
|
+
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
673
|
+
stub_slot.name = "selected-blocked";
|
|
674
|
+
mobile_slot.name = "selected";
|
|
675
|
+
// Update component state
|
|
676
|
+
this._state.open = true;
|
|
677
|
+
// Initialize options state
|
|
678
|
+
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
679
|
+
this._state.filteredTags = tags;
|
|
680
|
+
// Focus search input
|
|
681
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
682
|
+
if (searchInput) {
|
|
683
|
+
// Small delay to ensure dialog is ready
|
|
684
|
+
setTimeout(() => searchInput.focus(), 100);
|
|
685
|
+
}
|
|
686
|
+
// Update state after slots are ready
|
|
687
|
+
requestAnimationFrame(() => {
|
|
688
|
+
this.updateMobileSelectedState();
|
|
689
|
+
});
|
|
690
|
+
// Lifecycle event (also fires empty search if external-search)
|
|
691
|
+
this.fireOpenEvent();
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Close mobile modal (mobile mode)
|
|
695
|
+
* Now using <dialog> element for native management
|
|
696
|
+
*/
|
|
697
|
+
closeMobileModal() {
|
|
698
|
+
const shadow = this.shadowRoot;
|
|
699
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
700
|
+
if (!dialog)
|
|
701
|
+
return;
|
|
702
|
+
// Close immediately — ::backdrop doesn't support transitions
|
|
703
|
+
dialog.classList.remove('open');
|
|
704
|
+
dialog.close();
|
|
705
|
+
// Unlock body scroll (paired with the lock in openMobileModal)
|
|
706
|
+
if (this._scrollLockId) {
|
|
707
|
+
unlockScroll(this._scrollLockId);
|
|
708
|
+
this._scrollLockId = null;
|
|
709
|
+
}
|
|
710
|
+
const stub_slot = shadow.querySelector('#stub-slot');
|
|
711
|
+
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
712
|
+
stub_slot.name = "selected";
|
|
713
|
+
mobile_slot.name = "selected-blocked";
|
|
714
|
+
// Update state
|
|
715
|
+
this._state.open = false;
|
|
716
|
+
this._state.highlightedIndex = -1;
|
|
717
|
+
// Reset search
|
|
718
|
+
const hadQuery = this._state.search !== '';
|
|
719
|
+
this._state.search = '';
|
|
720
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
721
|
+
if (searchInput) {
|
|
722
|
+
searchInput.value = '';
|
|
723
|
+
}
|
|
724
|
+
if (this._externalSearch) {
|
|
725
|
+
// External mode — notify consumer that the search was cleared on close
|
|
726
|
+
// (bypass debounce, see desktop close path for rationale).
|
|
727
|
+
if (hadQuery) {
|
|
728
|
+
if (this._searchDebounceTimer !== null) {
|
|
729
|
+
clearTimeout(this._searchDebounceTimer);
|
|
730
|
+
this._searchDebounceTimer = null;
|
|
731
|
+
}
|
|
732
|
+
this.fireSearchEvent('');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
// Internal mode — unhide all tags ourselves
|
|
737
|
+
const allTags = this.getTagElements();
|
|
738
|
+
allTags.forEach(el => el.removeAttribute('hidden'));
|
|
739
|
+
}
|
|
740
|
+
// Lifecycle event
|
|
741
|
+
this.fireCloseEvent();
|
|
742
|
+
}
|
|
743
|
+
// ============================================================================
|
|
744
|
+
// EVENT HANDLERS (Phase 5 & 6)
|
|
745
|
+
// ============================================================================
|
|
746
|
+
handleStubClick(e) {
|
|
747
|
+
e.preventDefault();
|
|
748
|
+
e.stopPropagation();
|
|
749
|
+
if (this._disabled || this._readonly) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Don't open if all tags are already selected
|
|
753
|
+
if (this.allTagsSelected()) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
this.openDropdown();
|
|
757
|
+
}
|
|
758
|
+
handleTagClick(e) {
|
|
759
|
+
const target = e.target;
|
|
760
|
+
// Find the ty-tag element
|
|
761
|
+
const tag = target.tagName === 'TY-TAG'
|
|
762
|
+
? target
|
|
763
|
+
: target.closest('ty-tag');
|
|
764
|
+
if (!tag || tag.hasAttribute('disabled'))
|
|
765
|
+
return;
|
|
766
|
+
if (tag.hasAttribute('selected'))
|
|
767
|
+
return; // Already selected
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
e.stopPropagation();
|
|
770
|
+
const tagValue = this.getTagData(tag).value;
|
|
771
|
+
const currentValues = this.getSelectedValues();
|
|
772
|
+
const newValues = [...currentValues, tagValue];
|
|
773
|
+
// Use central update function with change event
|
|
774
|
+
this.updateComponentValue(newValues, true, 'add', tagValue);
|
|
775
|
+
// Auto-close if all tags selected
|
|
776
|
+
if (this.allTagsSelected()) {
|
|
777
|
+
if (this._state.mode === 'desktop') {
|
|
778
|
+
this.closeDropdown();
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
this.closeMobileModal();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
handleTagDismiss(e) {
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
e.stopPropagation();
|
|
788
|
+
const customEvent = e;
|
|
789
|
+
const tag = customEvent.detail?.target;
|
|
790
|
+
if (!tag)
|
|
791
|
+
return;
|
|
792
|
+
const tagValue = this.getTagData(tag).value;
|
|
793
|
+
const currentValues = this.getSelectedValues();
|
|
794
|
+
const newValues = currentValues.filter(v => v !== tagValue);
|
|
795
|
+
// Use central update function with change event
|
|
796
|
+
this.updateComponentValue(newValues, true, 'remove', tagValue);
|
|
797
|
+
}
|
|
798
|
+
blockSearchClick(e) {
|
|
799
|
+
e.stopPropagation();
|
|
800
|
+
e.preventDefault();
|
|
801
|
+
}
|
|
802
|
+
handleSearchInput(e) {
|
|
803
|
+
const target = e.target;
|
|
804
|
+
const query = target.value;
|
|
805
|
+
// Update search state
|
|
806
|
+
this._state.search = query;
|
|
807
|
+
if (this._externalSearch) {
|
|
808
|
+
// External (remote) search: parent owns filtering — delegate via event.
|
|
809
|
+
// Tag visibility is left untouched; consumer is expected to update children.
|
|
810
|
+
this.dispatchSearchEvent(query);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Internal search: filter tags locally
|
|
814
|
+
const allTags = this.getTagElements().map(el => this.getTagData(el));
|
|
815
|
+
// Only filter from non-selected tags
|
|
816
|
+
const availableTags = allTags.filter(t => !t.element.hasAttribute('selected'));
|
|
817
|
+
const filtered = this.filterTags(availableTags, query);
|
|
818
|
+
// Update state
|
|
819
|
+
this._state.filteredTags = filtered;
|
|
820
|
+
this._state.highlightedIndex = -1;
|
|
821
|
+
// Update visibility
|
|
822
|
+
this.updateTagVisibility(filtered, allTags);
|
|
823
|
+
// Hide options area if no results (desktop)
|
|
824
|
+
this.updateOptionsVisibility(filtered.length > 0);
|
|
825
|
+
// Refresh mobile count + empty-state to reflect filtered visibility
|
|
826
|
+
this.updateMobileSelectedState();
|
|
827
|
+
// Clear highlights
|
|
828
|
+
this.clearHighlights(allTags);
|
|
829
|
+
}
|
|
830
|
+
handleKeyboard(e) {
|
|
831
|
+
if (!this._state.open)
|
|
832
|
+
return;
|
|
833
|
+
const shadow = this.shadowRoot;
|
|
834
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
835
|
+
const target = e.target;
|
|
836
|
+
// Only handle navigation keys when dropdown is open and either:
|
|
837
|
+
// 1. Event comes from search input, OR
|
|
838
|
+
// 2. Event comes from document but search input is not focused
|
|
839
|
+
const shouldHandle = target === searchInput ||
|
|
840
|
+
document.activeElement !== searchInput;
|
|
841
|
+
if (!shouldHandle)
|
|
842
|
+
return;
|
|
843
|
+
// Get current state values
|
|
844
|
+
const filteredTags = this._state.filteredTags;
|
|
845
|
+
const tagsCount = filteredTags.length;
|
|
846
|
+
const currentHighlightedIndex = this._state.highlightedIndex;
|
|
847
|
+
switch (e.key) {
|
|
848
|
+
case 'Escape':
|
|
849
|
+
e.preventDefault();
|
|
850
|
+
e.stopPropagation();
|
|
851
|
+
this.closeDropdown();
|
|
852
|
+
break;
|
|
853
|
+
case 'Enter':
|
|
854
|
+
e.preventDefault();
|
|
855
|
+
e.stopPropagation();
|
|
856
|
+
// Select highlighted tag if any
|
|
857
|
+
if (currentHighlightedIndex >= 0 && currentHighlightedIndex < tagsCount) {
|
|
858
|
+
const tag = filteredTags[currentHighlightedIndex];
|
|
859
|
+
this.handleTagClick({ target: tag.element });
|
|
860
|
+
}
|
|
861
|
+
break;
|
|
862
|
+
case 'ArrowUp':
|
|
863
|
+
e.preventDefault();
|
|
864
|
+
e.stopPropagation();
|
|
865
|
+
let newIndexUp;
|
|
866
|
+
if (tagsCount === 0) {
|
|
867
|
+
newIndexUp = -1;
|
|
868
|
+
}
|
|
869
|
+
else if (currentHighlightedIndex === -1) {
|
|
870
|
+
// Nothing highlighted, go to last tag
|
|
871
|
+
newIndexUp = tagsCount - 1;
|
|
872
|
+
}
|
|
873
|
+
else if (currentHighlightedIndex === 0) {
|
|
874
|
+
// At first tag, wrap to last
|
|
875
|
+
newIndexUp = tagsCount - 1;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
// Move up one
|
|
879
|
+
newIndexUp = currentHighlightedIndex - 1;
|
|
880
|
+
}
|
|
881
|
+
this._state.highlightedIndex = newIndexUp;
|
|
882
|
+
this.highlightTag(filteredTags, newIndexUp);
|
|
883
|
+
break;
|
|
884
|
+
case 'ArrowDown':
|
|
885
|
+
e.preventDefault();
|
|
886
|
+
e.stopPropagation();
|
|
887
|
+
let newIndexDown;
|
|
888
|
+
if (tagsCount === 0) {
|
|
889
|
+
newIndexDown = -1;
|
|
890
|
+
}
|
|
891
|
+
else if (currentHighlightedIndex === -1) {
|
|
892
|
+
// Nothing highlighted, go to first tag
|
|
893
|
+
newIndexDown = 0;
|
|
894
|
+
}
|
|
895
|
+
else if (currentHighlightedIndex === tagsCount - 1) {
|
|
896
|
+
// At last tag, wrap to first
|
|
897
|
+
newIndexDown = 0;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
// Move down one
|
|
901
|
+
newIndexDown = currentHighlightedIndex + 1;
|
|
902
|
+
}
|
|
903
|
+
this._state.highlightedIndex = newIndexDown;
|
|
904
|
+
this.highlightTag(filteredTags, newIndexDown);
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ============================================================================
|
|
909
|
+
// SEARCH & FILTERING HELPERS (Phase 6)
|
|
910
|
+
// ============================================================================
|
|
911
|
+
/**
|
|
912
|
+
* Filter tags based on search query
|
|
913
|
+
*/
|
|
914
|
+
filterTags(tags, query) {
|
|
915
|
+
if (!query || query.trim() === '') {
|
|
916
|
+
return tags;
|
|
917
|
+
}
|
|
918
|
+
const searchLower = query.toLowerCase();
|
|
919
|
+
return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Update visibility of tags based on filtered list
|
|
923
|
+
*/
|
|
924
|
+
updateTagVisibility(filteredTags, allTags) {
|
|
925
|
+
const visibleValues = new Set(filteredTags.map(tag => tag.value));
|
|
926
|
+
allTags.forEach(({ value, element }) => {
|
|
927
|
+
if (visibleValues.has(value)) {
|
|
928
|
+
element.removeAttribute('hidden');
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
element.setAttribute('hidden', '');
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Show/hide the dropdown options area
|
|
937
|
+
*/
|
|
938
|
+
updateOptionsVisibility(hasOptions) {
|
|
939
|
+
const shadow = this.shadowRoot;
|
|
940
|
+
const options = shadow.querySelector('.dropdown-options');
|
|
941
|
+
if (options) {
|
|
942
|
+
options.style.display = hasOptions ? '' : 'none';
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Clear all tag highlights
|
|
947
|
+
*/
|
|
948
|
+
clearHighlights(tags) {
|
|
949
|
+
tags.forEach(({ element }) => {
|
|
950
|
+
element.removeAttribute('highlighted');
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Highlight tag at specific index
|
|
955
|
+
*/
|
|
956
|
+
highlightTag(tags, index) {
|
|
957
|
+
this.clearHighlights(tags);
|
|
958
|
+
if (index >= 0 && index < tags.length) {
|
|
959
|
+
const { element } = tags[index];
|
|
960
|
+
element.setAttribute('highlighted', '');
|
|
961
|
+
// Scroll into view
|
|
962
|
+
element.scrollIntoView({
|
|
963
|
+
behavior: 'smooth',
|
|
964
|
+
block: 'nearest',
|
|
965
|
+
inline: 'nearest'
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Dispatch search event for external search handling
|
|
971
|
+
* With optional debounce support
|
|
972
|
+
*/
|
|
973
|
+
dispatchSearchEvent(query) {
|
|
974
|
+
// Clear existing timer
|
|
975
|
+
if (this._searchDebounceTimer !== null) {
|
|
976
|
+
clearTimeout(this._searchDebounceTimer);
|
|
977
|
+
this._searchDebounceTimer = null;
|
|
978
|
+
}
|
|
979
|
+
// If debounce is set, debounce the event
|
|
980
|
+
if (this._debounce > 0) {
|
|
981
|
+
this._searchDebounceTimer = window.setTimeout(() => {
|
|
982
|
+
this.fireSearchEvent(query);
|
|
983
|
+
this._searchDebounceTimer = null;
|
|
984
|
+
}, this._debounce);
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
// Fire immediately if no debounce
|
|
988
|
+
this.fireSearchEvent(query);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Fire the actual search event
|
|
993
|
+
*/
|
|
994
|
+
fireSearchEvent(query) {
|
|
995
|
+
this.dispatchEvent(new CustomEvent('search', {
|
|
996
|
+
detail: {
|
|
997
|
+
query,
|
|
998
|
+
element: this
|
|
999
|
+
},
|
|
1000
|
+
bubbles: true,
|
|
1001
|
+
composed: true
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Dispatch lifecycle events for popup open/close.
|
|
1006
|
+
* On open with external-search, also fire a `search` event with an empty
|
|
1007
|
+
* query so consumers have a clean hook to reset/refetch the option list.
|
|
1008
|
+
*/
|
|
1009
|
+
fireOpenEvent() {
|
|
1010
|
+
this.dispatchEvent(new CustomEvent('open', {
|
|
1011
|
+
detail: { mode: this._state.mode, element: this },
|
|
1012
|
+
bubbles: true,
|
|
1013
|
+
composed: true
|
|
1014
|
+
}));
|
|
1015
|
+
if (this._externalSearch) {
|
|
1016
|
+
this.fireSearchEvent('');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
fireCloseEvent() {
|
|
1020
|
+
this.dispatchEvent(new CustomEvent('close', {
|
|
1021
|
+
detail: { mode: this._state.mode, element: this },
|
|
1022
|
+
bubbles: true,
|
|
1023
|
+
composed: true
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
// ============================================================================
|
|
1027
|
+
// CHANGE EVENT DISPATCHING (Phase 5)
|
|
1028
|
+
// ============================================================================
|
|
1029
|
+
/**
|
|
1030
|
+
* Dispatch custom change event
|
|
1031
|
+
*/
|
|
1032
|
+
dispatchChangeEvent(detail) {
|
|
1033
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
1034
|
+
detail,
|
|
1035
|
+
bubbles: true,
|
|
1036
|
+
cancelable: true
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
// ============================================================================
|
|
1040
|
+
// RENDERING
|
|
1041
|
+
// ============================================================================
|
|
1042
|
+
/**
|
|
1043
|
+
* Main render method (required by TyComponent)
|
|
1044
|
+
* Delegates to mode-specific renderer
|
|
1045
|
+
*/
|
|
1046
|
+
render() {
|
|
1047
|
+
// Sync mode on every render so rotation/resize is picked up
|
|
1048
|
+
this._state.mode = isMobileTouch() ? 'mobile' : 'desktop';
|
|
1049
|
+
if (this._state.mode === 'mobile') {
|
|
1050
|
+
this.renderMobile();
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
this.renderDesktop();
|
|
1054
|
+
}
|
|
1055
|
+
// Loading wrapper is rendered dynamically — re-apply each render
|
|
1056
|
+
this.applyLoadingState();
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Setup event listeners
|
|
1060
|
+
*/
|
|
1061
|
+
setupEventListeners() {
|
|
1062
|
+
const shadow = this.shadowRoot;
|
|
1063
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
1064
|
+
const optionsSlot = shadow.querySelector('#options-slot');
|
|
1065
|
+
const searchInput = shadow.querySelector('.dropdown-search-input');
|
|
1066
|
+
if (stub) {
|
|
1067
|
+
this._stubClickHandler = this.handleStubClick.bind(this);
|
|
1068
|
+
stub.addEventListener('click', this._stubClickHandler);
|
|
1069
|
+
}
|
|
1070
|
+
// Add tag click handler to slot
|
|
1071
|
+
if (optionsSlot) {
|
|
1072
|
+
this._tagClickHandler = this.handleTagClick.bind(this);
|
|
1073
|
+
optionsSlot.addEventListener('click', this._tagClickHandler);
|
|
1074
|
+
}
|
|
1075
|
+
// Add search input handlers
|
|
1076
|
+
if (searchInput) {
|
|
1077
|
+
this._searchInputHandler = this.handleSearchInput.bind(this);
|
|
1078
|
+
this._blockSearchClick = this.blockSearchClick.bind(this);
|
|
1079
|
+
searchInput.addEventListener('input', this._searchInputHandler);
|
|
1080
|
+
searchInput.addEventListener('click', this._blockSearchClick);
|
|
1081
|
+
// searchInput.addEventListener('blur', this._searchBlurHandler)
|
|
1082
|
+
}
|
|
1083
|
+
// Setup dialog backdrop click handler
|
|
1084
|
+
const dialog = shadow.querySelector('.dropdown-dialog');
|
|
1085
|
+
if (dialog) {
|
|
1086
|
+
dialog.addEventListener('click', (e) => {
|
|
1087
|
+
// Only close if clicking directly on the dialog (backdrop), not its children
|
|
1088
|
+
if (e.target === dialog) {
|
|
1089
|
+
this.closeDropdown();
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
// Setup keyboard handler
|
|
1094
|
+
this._keyboardHandler = this.handleKeyboard.bind(this);
|
|
1095
|
+
document.addEventListener('keydown', this._keyboardHandler);
|
|
1096
|
+
// Listen for dismiss events from selected tags
|
|
1097
|
+
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
1098
|
+
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Build CSS class list for stub
|
|
1102
|
+
*/
|
|
1103
|
+
buildStubClasses() {
|
|
1104
|
+
const classes = [this._size];
|
|
1105
|
+
if (this._disabled)
|
|
1106
|
+
classes.push('disabled');
|
|
1107
|
+
return classes.join(' ');
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Render desktop mode with dialog
|
|
1111
|
+
*/
|
|
1112
|
+
renderDesktop() {
|
|
1113
|
+
const shadow = this.shadowRoot;
|
|
1114
|
+
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1115
|
+
if (!shadow.querySelector('.multiselect-container')) {
|
|
1116
|
+
const stubClasses = this.buildStubClasses();
|
|
1117
|
+
const labelHtml = this._label ? `
|
|
1118
|
+
<label class="ty-field-label">
|
|
1119
|
+
${this._label}
|
|
1120
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
|
|
1121
|
+
</label>
|
|
1122
|
+
` : '';
|
|
1123
|
+
const searchPlaceholder = 'Search...';
|
|
1124
|
+
shadow.innerHTML = `
|
|
1125
|
+
<div class="multiselect-container dropdown-mode-desktop">
|
|
1126
|
+
${labelHtml}
|
|
1127
|
+
<div class="dropdown-wrapper">
|
|
1128
|
+
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
1129
|
+
${this._disabled ? 'disabled' : ''}>
|
|
1130
|
+
<slot name="start"></slot>
|
|
1131
|
+
<div class="multiselect-chips">
|
|
1132
|
+
<slot name="selected"></slot>
|
|
1133
|
+
</div>
|
|
1134
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1135
|
+
<div class="dropdown-chevron">
|
|
1136
|
+
${CHEVRON_DOWN_SVG}
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
<dialog class="dropdown-dialog">
|
|
1140
|
+
<div class="dropdown-header">
|
|
1141
|
+
<input
|
|
1142
|
+
class="dropdown-search-input ${this._size}"
|
|
1143
|
+
type="text"
|
|
1144
|
+
placeholder="${searchPlaceholder}"
|
|
1145
|
+
${this._disabled ? 'disabled' : ''}
|
|
1146
|
+
/>
|
|
1147
|
+
<div class="dropdown-search-chevron">
|
|
1148
|
+
${CHEVRON_DOWN_SVG}
|
|
1149
|
+
</div>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div class="dropdown-options-wrapper">
|
|
1152
|
+
<div class="dropdown-options">
|
|
1153
|
+
<slot id="options-slot"></slot>
|
|
1154
|
+
</div>
|
|
1155
|
+
<div class="dropdown-loading" aria-hidden="true">
|
|
1156
|
+
<slot name="loading">
|
|
1157
|
+
<span class="dropdown-loading-spinner"></span>
|
|
1158
|
+
<span class="dropdown-loading-text">Searching…</span>
|
|
1159
|
+
</slot>
|
|
1160
|
+
</div>
|
|
1161
|
+
</div>
|
|
1162
|
+
</dialog>
|
|
1163
|
+
</div>
|
|
1164
|
+
</div>
|
|
1165
|
+
`;
|
|
1166
|
+
// Setup event listeners ONCE
|
|
1167
|
+
this.setupEventListeners();
|
|
1168
|
+
// Don't initialize here - will be done in connectedCallback
|
|
1169
|
+
// after properties are set and children are available
|
|
1170
|
+
}
|
|
1171
|
+
// Always update placeholder visibility on re-render
|
|
1172
|
+
this.updateSelectionDisplay();
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Render mobile mode with full-screen modal
|
|
1176
|
+
* Following dropdown.ts mobile structure
|
|
1177
|
+
*/
|
|
1178
|
+
renderMobile() {
|
|
1179
|
+
const shadow = this.shadowRoot;
|
|
1180
|
+
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1181
|
+
if (!shadow.querySelector('.multiselect-container')) {
|
|
1182
|
+
const stubClasses = this.buildStubClasses();
|
|
1183
|
+
const labelHtml = this._label ? `
|
|
1184
|
+
<label class="ty-field-label">
|
|
1185
|
+
${this._label}
|
|
1186
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
|
|
1187
|
+
</label>
|
|
1188
|
+
` : '';
|
|
1189
|
+
// Close button SVG (X icon)
|
|
1190
|
+
const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1191
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
1192
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
1193
|
+
</svg>`;
|
|
1194
|
+
// Search placeholder: "Search <label>..." or just "Search..."
|
|
1195
|
+
const searchPlaceholder = this._label ? `Search ${this._label}...` : 'Search...';
|
|
1196
|
+
// Search is always available — only the mode (internal vs external) varies.
|
|
1197
|
+
const searchHeaderHtml = `
|
|
1198
|
+
<div class="mobile-search-header">
|
|
1199
|
+
${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ''}
|
|
1200
|
+
<div class="mobile-header-content">
|
|
1201
|
+
<input
|
|
1202
|
+
class="mobile-search-input ${this._size}"
|
|
1203
|
+
type="text"
|
|
1204
|
+
placeholder="${searchPlaceholder}"
|
|
1205
|
+
${this._disabled ? 'disabled' : ''}
|
|
1206
|
+
/>
|
|
1207
|
+
<button class="mobile-close-button" type="button" aria-label="Close">
|
|
1208
|
+
${closeButtonSvg}
|
|
1209
|
+
</button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
`;
|
|
1213
|
+
shadow.innerHTML = `
|
|
1214
|
+
<div class="multiselect-container dropdown-mode-mobile">
|
|
1215
|
+
${labelHtml}
|
|
1216
|
+
<div class="dropdown-wrapper">
|
|
1217
|
+
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
1218
|
+
${this._disabled ? 'disabled' : ''}>
|
|
1219
|
+
<slot name="start"></slot>
|
|
1220
|
+
<div class="multiselect-chips">
|
|
1221
|
+
<slot id="stub-slot" name="selected"></slot>
|
|
1222
|
+
</div>
|
|
1223
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1224
|
+
<div class="dropdown-chevron">
|
|
1225
|
+
${CHEVRON_DOWN_SVG}
|
|
1226
|
+
</div>
|
|
1227
|
+
</div>
|
|
1228
|
+
|
|
1229
|
+
<dialog class="mobile-dialog">
|
|
1230
|
+
<div class="mobile-dialog-content">
|
|
1231
|
+
|
|
1232
|
+
<!-- HEADER (matches dropdown.ts) -->
|
|
1233
|
+
${searchHeaderHtml}
|
|
1234
|
+
|
|
1235
|
+
<!-- BODY: pinned selected strip + filter list -->
|
|
1236
|
+
<div class="mobile-body">
|
|
1237
|
+
|
|
1238
|
+
<!-- SELECTED STRIP (pinned, collapses when empty) -->
|
|
1239
|
+
<div class="mobile-selected-section" data-empty="true">
|
|
1240
|
+
<div class="section-header">
|
|
1241
|
+
<span class="section-title">${this._selectedLabel} <span class="section-count">(0)</span></span>
|
|
1242
|
+
</div>
|
|
1243
|
+
<div class="section-content">
|
|
1244
|
+
<slot id="mobile-slot" name="selected"></slot>
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
|
|
1248
|
+
<!-- AVAILABLE LIST (always visible, takes remaining space) -->
|
|
1249
|
+
<div class="mobile-available-section" data-empty="false">
|
|
1250
|
+
<div class="section-header">
|
|
1251
|
+
<span class="section-title">${this._availableLabel}</span>
|
|
1252
|
+
</div>
|
|
1253
|
+
<div class="section-content dropdown-options-wrapper">
|
|
1254
|
+
<slot id="options-slot"></slot>
|
|
1255
|
+
<div class="empty-state">${this._noOptionsMessage}</div>
|
|
1256
|
+
<div class="dropdown-loading" aria-hidden="true">
|
|
1257
|
+
<slot name="loading">
|
|
1258
|
+
<span class="dropdown-loading-spinner"></span>
|
|
1259
|
+
<span class="dropdown-loading-text">Searching…</span>
|
|
1260
|
+
</slot>
|
|
1261
|
+
</div>
|
|
1262
|
+
</div>
|
|
1263
|
+
</div>
|
|
1264
|
+
|
|
1265
|
+
</div>
|
|
1266
|
+
</div>
|
|
1267
|
+
</dialog>
|
|
1268
|
+
</div>
|
|
1269
|
+
</div>
|
|
1270
|
+
`;
|
|
1271
|
+
// Setup event listeners ONCE
|
|
1272
|
+
this.setupMobileEventListeners();
|
|
1273
|
+
}
|
|
1274
|
+
// Always update placeholder visibility
|
|
1275
|
+
this.updateSelectionDisplay();
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Setup event listeners for mobile mode
|
|
1279
|
+
* Using <dialog> element - backdrop clicks handled natively
|
|
1280
|
+
*/
|
|
1281
|
+
setupMobileEventListeners() {
|
|
1282
|
+
const shadow = this.shadowRoot;
|
|
1283
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
1284
|
+
const optionsSlot = shadow.querySelector('#options-slot');
|
|
1285
|
+
const searchInput = shadow.querySelector('.mobile-search-input');
|
|
1286
|
+
const closeButton = shadow.querySelector('.mobile-close-button');
|
|
1287
|
+
const dialog = shadow.querySelector('.mobile-dialog');
|
|
1288
|
+
if (stub) {
|
|
1289
|
+
stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
|
|
1290
|
+
}
|
|
1291
|
+
// Add tag click handler to slot
|
|
1292
|
+
if (optionsSlot) {
|
|
1293
|
+
optionsSlot.addEventListener('click', (e) => this.handleMobileTagClick(e));
|
|
1294
|
+
}
|
|
1295
|
+
// Add search input handlers (if searchable)
|
|
1296
|
+
if (searchInput) {
|
|
1297
|
+
searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
|
|
1298
|
+
}
|
|
1299
|
+
// Close button click
|
|
1300
|
+
if (closeButton) {
|
|
1301
|
+
closeButton.addEventListener('click', () => this.closeMobileModal());
|
|
1302
|
+
}
|
|
1303
|
+
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
1304
|
+
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
1305
|
+
// Backdrop click to close (native dialog behavior)
|
|
1306
|
+
if (dialog) {
|
|
1307
|
+
dialog.addEventListener('click', (e) => {
|
|
1308
|
+
// Only close if clicking directly on the dialog element (backdrop)
|
|
1309
|
+
// Not if clicking on its children (dialog-content)
|
|
1310
|
+
if (e.target === dialog) {
|
|
1311
|
+
this.closeMobileModal();
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
// Also handle Escape key via cancel event
|
|
1315
|
+
dialog.addEventListener('cancel', (e) => {
|
|
1316
|
+
e.preventDefault(); // Prevent default to handle it our way
|
|
1317
|
+
this.closeMobileModal();
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Handle mobile stub click - open modal
|
|
1323
|
+
*/
|
|
1324
|
+
handleMobileStubClick(e) {
|
|
1325
|
+
e.preventDefault();
|
|
1326
|
+
e.stopPropagation();
|
|
1327
|
+
if (this._disabled || this._readonly) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
// Don't open if all tags are already selected
|
|
1331
|
+
if (this.allTagsSelected()) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
this.openMobileModal();
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Handle mobile tag click - select and potentially close
|
|
1338
|
+
*/
|
|
1339
|
+
handleMobileTagClick(e) {
|
|
1340
|
+
// Use the same tag click handler as desktop
|
|
1341
|
+
// It already handles mobile mode for auto-close
|
|
1342
|
+
this.handleTagClick(e);
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Update mobile selected section state (collapsed view, empty states, etc.)
|
|
1346
|
+
*/
|
|
1347
|
+
updateMobileSelectedState() {
|
|
1348
|
+
if (this._state.mode !== 'mobile')
|
|
1349
|
+
return;
|
|
1350
|
+
const shadow = this.shadowRoot;
|
|
1351
|
+
const selectedSection = shadow.querySelector('.mobile-selected-section');
|
|
1352
|
+
const availableSection = shadow.querySelector('.mobile-available-section');
|
|
1353
|
+
const sectionCountSpan = shadow.querySelector('.section-count');
|
|
1354
|
+
if (selectedSection) {
|
|
1355
|
+
const selectedCount = this._state.selectedValues.length;
|
|
1356
|
+
const hasSelected = selectedCount > 0;
|
|
1357
|
+
selectedSection.setAttribute('data-empty', String(!hasSelected));
|
|
1358
|
+
// Update header count
|
|
1359
|
+
if (sectionCountSpan) {
|
|
1360
|
+
sectionCountSpan.textContent = `(${selectedCount})`;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (availableSection) {
|
|
1364
|
+
// Count *visible* available tags — tags hidden by search filtering count as 0
|
|
1365
|
+
const visibleAvailable = this.getTagElements().filter(tag => !tag.hasAttribute('selected') && !tag.hasAttribute('hidden')).length;
|
|
1366
|
+
availableSection.setAttribute('data-empty', String(visibleAvailable === 0));
|
|
1367
|
+
// Update available header count
|
|
1368
|
+
const availableTitleSpan = shadow.querySelector('.mobile-available-section .section-title');
|
|
1369
|
+
if (availableTitleSpan) {
|
|
1370
|
+
availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Update selection display (show/hide placeholder)
|
|
1376
|
+
* Matches dropdown.ts pattern - uses CSS via has-selection class
|
|
1377
|
+
*/
|
|
1378
|
+
updateSelectionDisplay() {
|
|
1379
|
+
const shadow = this.shadowRoot;
|
|
1380
|
+
const stub = shadow.querySelector('.multiselect-stub');
|
|
1381
|
+
if (!stub)
|
|
1382
|
+
return;
|
|
1383
|
+
const tags = this.getTagElements();
|
|
1384
|
+
const selectedTags = tags.filter(tag => tag.hasAttribute('selected'));
|
|
1385
|
+
const hasSelected = selectedTags.length > 0;
|
|
1386
|
+
if (hasSelected) {
|
|
1387
|
+
stub.classList.add('has-selection');
|
|
1388
|
+
}
|
|
1389
|
+
else {
|
|
1390
|
+
stub.classList.remove('has-selection');
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// ============================================================================
|
|
1394
|
+
// PUBLIC API - Getters/Setters
|
|
1395
|
+
// ============================================================================
|
|
1396
|
+
get value() {
|
|
1397
|
+
// Always read from DOM - tags with 'selected' attribute are source of truth
|
|
1398
|
+
return this.getSelectedValues().join(',');
|
|
1399
|
+
}
|
|
1400
|
+
set value(val) {
|
|
1401
|
+
this.setProperty('value', val);
|
|
1402
|
+
}
|
|
1403
|
+
get name() {
|
|
1404
|
+
return this.getProperty('name');
|
|
1405
|
+
}
|
|
1406
|
+
set name(val) {
|
|
1407
|
+
this.setProperty('name', val);
|
|
1408
|
+
}
|
|
1409
|
+
get placeholder() {
|
|
1410
|
+
return this.getProperty('placeholder');
|
|
1411
|
+
}
|
|
1412
|
+
set placeholder(val) {
|
|
1413
|
+
this.setProperty('placeholder', val);
|
|
1414
|
+
}
|
|
1415
|
+
get label() {
|
|
1416
|
+
return this.getProperty('label');
|
|
1417
|
+
}
|
|
1418
|
+
set label(val) {
|
|
1419
|
+
this.setProperty('label', val);
|
|
1420
|
+
}
|
|
1421
|
+
get disabled() {
|
|
1422
|
+
return this.getProperty('disabled');
|
|
1423
|
+
}
|
|
1424
|
+
set disabled(value) {
|
|
1425
|
+
this.setProperty('disabled', value);
|
|
1426
|
+
}
|
|
1427
|
+
get loading() {
|
|
1428
|
+
return this.getProperty('loading');
|
|
1429
|
+
}
|
|
1430
|
+
set loading(value) {
|
|
1431
|
+
this.setProperty('loading', value);
|
|
1432
|
+
}
|
|
1433
|
+
get readonly() {
|
|
1434
|
+
return this.getProperty('readonly');
|
|
1435
|
+
}
|
|
1436
|
+
set readonly(value) {
|
|
1437
|
+
this.setProperty('readonly', value);
|
|
1438
|
+
}
|
|
1439
|
+
get required() {
|
|
1440
|
+
return this.getProperty('required');
|
|
1441
|
+
}
|
|
1442
|
+
set required(value) {
|
|
1443
|
+
this.setProperty('required', value);
|
|
1444
|
+
}
|
|
1445
|
+
get externalSearch() {
|
|
1446
|
+
return this.getProperty('externalSearch');
|
|
1447
|
+
}
|
|
1448
|
+
set externalSearch(value) {
|
|
1449
|
+
this.setProperty('externalSearch', value);
|
|
1450
|
+
}
|
|
1451
|
+
get debounce() {
|
|
1452
|
+
return this.getProperty('debounce');
|
|
1453
|
+
}
|
|
1454
|
+
set debounce(value) {
|
|
1455
|
+
const numValue = typeof value === 'string' ? parseInt(value, 10) : value;
|
|
1456
|
+
this.setProperty('debounce', numValue);
|
|
1457
|
+
}
|
|
1458
|
+
get size() {
|
|
1459
|
+
return this.getProperty('size');
|
|
1460
|
+
}
|
|
1461
|
+
set size(value) {
|
|
1462
|
+
this.setProperty('size', value);
|
|
1463
|
+
}
|
|
1464
|
+
get flavor() {
|
|
1465
|
+
return this.getProperty('flavor');
|
|
1466
|
+
}
|
|
1467
|
+
set flavor(value) {
|
|
1468
|
+
this.setProperty('flavor', value);
|
|
1469
|
+
}
|
|
1470
|
+
get form() {
|
|
1471
|
+
return this._internals.form;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
// ============================================================================
|
|
1475
|
+
// PROPERTY CONFIGURATION - Declarative property lifecycle
|
|
1476
|
+
// ============================================================================
|
|
1477
|
+
TyMultiselect.properties = {
|
|
1478
|
+
value: {
|
|
1479
|
+
type: 'string',
|
|
1480
|
+
visual: true,
|
|
1481
|
+
formValue: true,
|
|
1482
|
+
emitChange: false,
|
|
1483
|
+
default: '',
|
|
1484
|
+
coerce: (v) => {
|
|
1485
|
+
// Handle array input (from React, Reagent, etc.)
|
|
1486
|
+
if (Array.isArray(v)) {
|
|
1487
|
+
return v.join(',');
|
|
1488
|
+
}
|
|
1489
|
+
// Handle null/undefined
|
|
1490
|
+
if (v === null || v === undefined) {
|
|
1491
|
+
return '';
|
|
1492
|
+
}
|
|
1493
|
+
// String already
|
|
1494
|
+
return String(v);
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
name: {
|
|
1498
|
+
type: 'string',
|
|
1499
|
+
default: ''
|
|
1500
|
+
},
|
|
1501
|
+
placeholder: {
|
|
1502
|
+
type: 'string',
|
|
1503
|
+
visual: true,
|
|
1504
|
+
default: 'Select options...'
|
|
1505
|
+
},
|
|
1506
|
+
label: {
|
|
1507
|
+
type: 'string',
|
|
1508
|
+
visual: true,
|
|
1509
|
+
default: ''
|
|
1510
|
+
},
|
|
1511
|
+
disabled: {
|
|
1512
|
+
type: 'boolean',
|
|
1513
|
+
visual: true,
|
|
1514
|
+
default: false
|
|
1515
|
+
},
|
|
1516
|
+
readonly: {
|
|
1517
|
+
type: 'boolean',
|
|
1518
|
+
visual: true,
|
|
1519
|
+
default: false
|
|
1520
|
+
},
|
|
1521
|
+
required: {
|
|
1522
|
+
type: 'boolean',
|
|
1523
|
+
visual: true,
|
|
1524
|
+
default: false
|
|
1525
|
+
},
|
|
1526
|
+
externalSearch: {
|
|
1527
|
+
type: 'boolean',
|
|
1528
|
+
visual: true,
|
|
1529
|
+
default: false,
|
|
1530
|
+
aliases: { 'external-search': true }
|
|
1531
|
+
},
|
|
1532
|
+
size: {
|
|
1533
|
+
type: 'string',
|
|
1534
|
+
visual: true,
|
|
1535
|
+
default: 'md',
|
|
1536
|
+
validate: (v) => ['sm', 'md', 'lg'].includes(v),
|
|
1537
|
+
coerce: (v) => {
|
|
1538
|
+
if (!['sm', 'md', 'lg'].includes(v)) {
|
|
1539
|
+
console.warn(`[ty-multiselect] Invalid size. Using md.`);
|
|
1540
|
+
return 'md';
|
|
1541
|
+
}
|
|
1542
|
+
return v;
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
flavor: {
|
|
1546
|
+
type: 'string',
|
|
1547
|
+
visual: true,
|
|
1548
|
+
default: 'neutral',
|
|
1549
|
+
validate: (v) => ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'].includes(v),
|
|
1550
|
+
coerce: (v) => {
|
|
1551
|
+
const valid = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
|
|
1552
|
+
if (!valid.includes(v)) {
|
|
1553
|
+
console.warn(`[ty-multiselect] Invalid flavor. Using neutral.`);
|
|
1554
|
+
return 'neutral';
|
|
1555
|
+
}
|
|
1556
|
+
return v;
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
debounce: {
|
|
1560
|
+
type: 'number',
|
|
1561
|
+
default: 0,
|
|
1562
|
+
validate: (v) => v >= 0 && v <= 5000,
|
|
1563
|
+
coerce: (v) => {
|
|
1564
|
+
const num = Number(v);
|
|
1565
|
+
if (isNaN(num))
|
|
1566
|
+
return 0;
|
|
1567
|
+
return Math.max(0, Math.min(5000, num));
|
|
1568
|
+
}
|
|
1569
|
+
},
|
|
1570
|
+
'selected-label': {
|
|
1571
|
+
type: 'string',
|
|
1572
|
+
visual: true,
|
|
1573
|
+
default: 'Selected'
|
|
1574
|
+
},
|
|
1575
|
+
'available-label': {
|
|
1576
|
+
type: 'string',
|
|
1577
|
+
visual: true,
|
|
1578
|
+
default: 'Available'
|
|
1579
|
+
},
|
|
1580
|
+
'no-options-message': {
|
|
1581
|
+
type: 'string',
|
|
1582
|
+
visual: true,
|
|
1583
|
+
default: 'No options available'
|
|
1584
|
+
},
|
|
1585
|
+
loading: {
|
|
1586
|
+
type: 'boolean',
|
|
1587
|
+
visual: true,
|
|
1588
|
+
default: false
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
// Register the custom element
|
|
1592
|
+
if (!customElements.get('ty-multiselect')) {
|
|
1593
|
+
customElements.define('ty-multiselect', TyMultiselect);
|
|
1594
|
+
}
|
|
1595
|
+
//# sourceMappingURL=multiselect.js.map
|