tyrell-components 1.0.0-RC13 → 1.0.0-RC14
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 +1 -1
- package/css/tyrell-theme.css +167 -178
- package/css/tyrell.css +19 -0
- package/dist/tyrell-theme.css +167 -178
- package/dist/tyrell.css +19 -0
- package/dist/tyrell.js +1 -1
- package/lib/base/ty-component.d.ts +20 -10
- package/lib/base/ty-component.js +21 -144
- package/lib/components/button.d.ts +2 -16
- package/lib/components/button.js +0 -36
- package/lib/components/calendar-month.d.ts +2 -48
- package/lib/components/calendar-month.js +4 -166
- package/lib/components/calendar-navigation.d.ts +3 -48
- package/lib/components/calendar-navigation.js +3 -126
- package/lib/components/calendar.d.ts +3 -80
- package/lib/components/calendar.js +8 -230
- package/lib/components/checkbox.d.ts +4 -71
- package/lib/components/checkbox.js +7 -151
- package/lib/components/copy.d.ts +3 -56
- package/lib/components/copy.js +1 -120
- package/lib/components/date-picker.d.ts +13 -162
- package/lib/components/date-picker.js +41 -592
- package/lib/components/file-upload.d.ts +9 -15
- package/lib/components/file-upload.js +19 -41
- package/lib/components/icon.d.ts +3 -24
- package/lib/components/icon.js +1 -90
- package/lib/components/input.d.ts +7 -107
- package/lib/components/input.js +5 -193
- package/lib/components/modal.d.ts +17 -26
- package/lib/components/modal.js +57 -201
- package/lib/components/option.d.ts +2 -9
- package/lib/components/option.js +0 -48
- package/lib/components/popup.d.ts +2 -17
- package/lib/components/popup.js +8 -139
- package/lib/components/radio.d.ts +3 -23
- package/lib/components/radio.js +10 -65
- package/lib/components/resize-observer.d.ts +3 -30
- package/lib/components/resize-observer.js +12 -46
- package/lib/components/scroll-container.d.ts +2 -12
- package/lib/components/scroll-container.js +3 -46
- package/lib/components/select.d.ts +22 -153
- package/lib/components/select.js +33 -590
- package/lib/components/selected-tags.d.ts +4 -47
- package/lib/components/selected-tags.js +1 -95
- package/lib/components/step.d.ts +1 -6
- package/lib/components/step.js +0 -6
- package/lib/components/switch.d.ts +0 -11
- package/lib/components/switch.js +2 -36
- package/lib/components/tab.d.ts +1 -6
- package/lib/components/tab.js +0 -7
- package/lib/components/tabs.d.ts +5 -63
- package/lib/components/tabs.js +20 -283
- package/lib/components/tag.d.ts +2 -36
- package/lib/components/tag.js +5 -105
- package/lib/components/textarea.d.ts +6 -86
- package/lib/components/textarea.js +2 -173
- package/lib/components/tooltip.d.ts +3 -11
- package/lib/components/tooltip.js +24 -160
- package/lib/components/wizard.d.ts +6 -54
- package/lib/components/wizard.js +15 -228
- package/lib/icons/fontawesome/brands.d.ts +0 -1
- package/lib/icons/fontawesome/brands.js +0 -8
- package/lib/icons/fontawesome/regular.d.ts +0 -1
- package/lib/icons/fontawesome/regular.js +0 -8
- package/lib/icons/fontawesome/solid.d.ts +0 -1
- package/lib/icons/fontawesome/solid.js +0 -8
- package/lib/icons/heroicons/micro.d.ts +0 -1
- package/lib/icons/heroicons/micro.js +0 -8
- package/lib/icons/heroicons/mini.d.ts +0 -1
- package/lib/icons/heroicons/mini.js +0 -8
- package/lib/icons/heroicons/outline.d.ts +0 -1
- package/lib/icons/heroicons/outline.js +0 -8
- package/lib/icons/heroicons/solid.d.ts +0 -1
- package/lib/icons/heroicons/solid.js +0 -8
- package/lib/icons/lucide.d.ts +0 -1
- package/lib/icons/lucide.js +0 -1872
- package/lib/icons/material/filled.d.ts +0 -1
- package/lib/icons/material/filled.js +0 -10
- package/lib/icons/material/outlined.d.ts +0 -1
- package/lib/icons/material/outlined.js +0 -10
- package/lib/icons/material/round.d.ts +0 -1
- package/lib/icons/material/round.js +0 -10
- package/lib/icons/material/sharp.d.ts +0 -1
- package/lib/icons/material/sharp.js +0 -10
- package/lib/icons/material/two-tone.d.ts +0 -1
- package/lib/icons/material/two-tone.js +0 -10
- package/lib/index.d.ts +0 -1
- package/lib/index.js +0 -20
- package/lib/styles/button.d.ts +2 -20
- package/lib/styles/button.js +22 -122
- package/lib/styles/calendar-month.d.ts +1 -5
- package/lib/styles/calendar-month.js +4 -61
- package/lib/styles/calendar-navigation.d.ts +2 -6
- package/lib/styles/calendar-navigation.js +2 -27
- package/lib/styles/calendar.d.ts +1 -2
- package/lib/styles/calendar.js +1 -14
- package/lib/styles/checkbox.d.ts +0 -1
- package/lib/styles/checkbox.js +1 -23
- package/lib/styles/copy.d.ts +0 -1
- package/lib/styles/copy.js +2 -17
- package/lib/styles/custom-scrollbar.d.ts +1 -2
- package/lib/styles/custom-scrollbar.js +3 -18
- package/lib/styles/date-picker.d.ts +1 -5
- package/lib/styles/date-picker.js +35 -39
- package/lib/styles/file-upload.d.ts +1 -2
- package/lib/styles/file-upload.js +1 -16
- package/lib/styles/icon.d.ts +2 -6
- package/lib/styles/icon.js +7 -21
- package/lib/styles/input.d.ts +1 -6
- package/lib/styles/input.js +5 -54
- package/lib/styles/modal.d.ts +1 -2
- package/lib/styles/modal.js +21 -23
- package/lib/styles/option.d.ts +2 -6
- package/lib/styles/option.js +17 -12
- package/lib/styles/popup.d.ts +1 -2
- package/lib/styles/popup.js +26 -30
- package/lib/styles/radio.d.ts +0 -1
- package/lib/styles/radio.js +2 -16
- package/lib/styles/resize-observer.d.ts +1 -2
- package/lib/styles/resize-observer.js +0 -7
- package/lib/styles/scroll-container.d.ts +1 -2
- package/lib/styles/scroll-container.js +0 -19
- package/lib/styles/select-base.d.ts +0 -1
- package/lib/styles/select-base.js +68 -79
- package/lib/styles/select.d.ts +1 -2
- package/lib/styles/select.js +7 -15
- package/lib/styles/step.d.ts +1 -2
- package/lib/styles/step.js +0 -4
- package/lib/styles/switch.d.ts +0 -1
- package/lib/styles/switch.js +2 -20
- package/lib/styles/tab.d.ts +1 -2
- package/lib/styles/tab.js +0 -5
- package/lib/styles/tabs.d.ts +3 -11
- package/lib/styles/tabs.js +10 -48
- package/lib/styles/tag.d.ts +1 -5
- package/lib/styles/tag.js +2 -31
- package/lib/styles/textarea.d.ts +1 -2
- package/lib/styles/textarea.js +2 -45
- package/lib/styles/tooltip.d.ts +0 -1
- package/lib/styles/tooltip.js +0 -10
- package/lib/styles/wizard.d.ts +5 -21
- package/lib/styles/wizard.js +8 -88
- package/lib/types/common.d.ts +0 -2
- package/lib/types/common.js +0 -12
- package/lib/utils/calendar-utils.d.ts +4 -21
- package/lib/utils/calendar-utils.js +4 -177
- package/lib/utils/custom-scrollbar.d.ts +6 -20
- package/lib/utils/custom-scrollbar.js +1 -45
- package/lib/utils/flavor-sheet.d.ts +0 -1
- package/lib/utils/flavor-sheet.js +0 -23
- package/lib/utils/icon-registry.d.ts +0 -2
- package/lib/utils/icon-registry.js +1 -107
- package/lib/utils/icons.d.ts +0 -1
- package/lib/utils/icons.js +0 -9
- package/lib/utils/loader-registry.d.ts +0 -1
- package/lib/utils/loader-registry.js +0 -32
- package/lib/utils/locale.d.ts +2 -23
- package/lib/utils/locale.js +0 -86
- package/lib/utils/mobile.d.ts +0 -1
- package/lib/utils/mobile.js +0 -17
- package/lib/utils/number-format.d.ts +6 -3
- package/lib/utils/number-format.js +11 -67
- package/lib/utils/positioning.d.ts +50 -7
- package/lib/utils/positioning.js +52 -62
- package/lib/utils/property-capture.d.ts +5 -27
- package/lib/utils/property-capture.js +0 -108
- package/lib/utils/property-manager.d.ts +0 -1
- package/lib/utils/property-manager.js +0 -70
- package/lib/utils/resize-observer.d.ts +0 -1
- package/lib/utils/resize-observer.js +0 -36
- package/lib/utils/scroll-lock.d.ts +4 -11
- package/lib/utils/scroll-lock.js +0 -107
- package/lib/utils/styles.d.ts +0 -1
- package/lib/utils/styles.js +0 -24
- package/lib/version.d.ts +1 -2
- package/lib/version.js +1 -11
- package/package.json +2 -2
- package/lib/base/ty-component.d.ts.map +0 -1
- package/lib/base/ty-component.js.map +0 -1
- package/lib/components/button.d.ts.map +0 -1
- package/lib/components/button.js.map +0 -1
- package/lib/components/calendar-month.d.ts.map +0 -1
- package/lib/components/calendar-month.js.map +0 -1
- package/lib/components/calendar-navigation.d.ts.map +0 -1
- package/lib/components/calendar-navigation.js.map +0 -1
- package/lib/components/calendar.d.ts.map +0 -1
- package/lib/components/calendar.js.map +0 -1
- package/lib/components/checkbox.d.ts.map +0 -1
- package/lib/components/checkbox.js.map +0 -1
- package/lib/components/copy.d.ts.map +0 -1
- package/lib/components/copy.js.map +0 -1
- package/lib/components/date-picker.d.ts.map +0 -1
- package/lib/components/date-picker.js.map +0 -1
- package/lib/components/file-upload.d.ts.map +0 -1
- package/lib/components/file-upload.js.map +0 -1
- package/lib/components/icon.d.ts.map +0 -1
- package/lib/components/icon.js.map +0 -1
- package/lib/components/input.d.ts.map +0 -1
- package/lib/components/input.js.map +0 -1
- package/lib/components/modal.d.ts.map +0 -1
- package/lib/components/modal.js.map +0 -1
- package/lib/components/option.d.ts.map +0 -1
- package/lib/components/option.js.map +0 -1
- package/lib/components/popup.d.ts.map +0 -1
- package/lib/components/popup.js.map +0 -1
- package/lib/components/radio.d.ts.map +0 -1
- package/lib/components/radio.js.map +0 -1
- package/lib/components/resize-observer.d.ts.map +0 -1
- package/lib/components/resize-observer.js.map +0 -1
- package/lib/components/scroll-container.d.ts.map +0 -1
- package/lib/components/scroll-container.js.map +0 -1
- package/lib/components/select.d.ts.map +0 -1
- package/lib/components/select.js.map +0 -1
- package/lib/components/selected-tags.d.ts.map +0 -1
- package/lib/components/selected-tags.js.map +0 -1
- package/lib/components/step.d.ts.map +0 -1
- package/lib/components/step.js.map +0 -1
- package/lib/components/switch.d.ts.map +0 -1
- package/lib/components/switch.js.map +0 -1
- package/lib/components/tab.d.ts.map +0 -1
- package/lib/components/tab.js.map +0 -1
- package/lib/components/tabs.d.ts.map +0 -1
- package/lib/components/tabs.js.map +0 -1
- package/lib/components/tag.d.ts.map +0 -1
- package/lib/components/tag.js.map +0 -1
- package/lib/components/textarea.d.ts.map +0 -1
- package/lib/components/textarea.js.map +0 -1
- package/lib/components/tooltip.d.ts.map +0 -1
- package/lib/components/tooltip.js.map +0 -1
- package/lib/components/wizard.d.ts.map +0 -1
- package/lib/components/wizard.js.map +0 -1
- package/lib/icons/fontawesome/brands.d.ts.map +0 -1
- package/lib/icons/fontawesome/brands.js.map +0 -1
- package/lib/icons/fontawesome/regular.d.ts.map +0 -1
- package/lib/icons/fontawesome/regular.js.map +0 -1
- package/lib/icons/fontawesome/solid.d.ts.map +0 -1
- package/lib/icons/fontawesome/solid.js.map +0 -1
- package/lib/icons/heroicons/micro.d.ts.map +0 -1
- package/lib/icons/heroicons/micro.js.map +0 -1
- package/lib/icons/heroicons/mini.d.ts.map +0 -1
- package/lib/icons/heroicons/mini.js.map +0 -1
- package/lib/icons/heroicons/outline.d.ts.map +0 -1
- package/lib/icons/heroicons/outline.js.map +0 -1
- package/lib/icons/heroicons/solid.d.ts.map +0 -1
- package/lib/icons/heroicons/solid.js.map +0 -1
- package/lib/icons/lucide.d.ts.map +0 -1
- package/lib/icons/lucide.js.map +0 -1
- package/lib/icons/material/filled.d.ts.map +0 -1
- package/lib/icons/material/filled.js.map +0 -1
- package/lib/icons/material/outlined.d.ts.map +0 -1
- package/lib/icons/material/outlined.js.map +0 -1
- package/lib/icons/material/round.d.ts.map +0 -1
- package/lib/icons/material/round.js.map +0 -1
- package/lib/icons/material/sharp.d.ts.map +0 -1
- package/lib/icons/material/sharp.js.map +0 -1
- package/lib/icons/material/two-tone.d.ts.map +0 -1
- package/lib/icons/material/two-tone.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/styles/button.d.ts.map +0 -1
- package/lib/styles/button.js.map +0 -1
- package/lib/styles/calendar-month.d.ts.map +0 -1
- package/lib/styles/calendar-month.js.map +0 -1
- package/lib/styles/calendar-navigation.d.ts.map +0 -1
- package/lib/styles/calendar-navigation.js.map +0 -1
- package/lib/styles/calendar.d.ts.map +0 -1
- package/lib/styles/calendar.js.map +0 -1
- package/lib/styles/checkbox.d.ts.map +0 -1
- package/lib/styles/checkbox.js.map +0 -1
- package/lib/styles/copy.d.ts.map +0 -1
- package/lib/styles/copy.js.map +0 -1
- package/lib/styles/custom-scrollbar.d.ts.map +0 -1
- package/lib/styles/custom-scrollbar.js.map +0 -1
- package/lib/styles/date-picker.d.ts.map +0 -1
- package/lib/styles/date-picker.js.map +0 -1
- package/lib/styles/file-upload.d.ts.map +0 -1
- package/lib/styles/file-upload.js.map +0 -1
- package/lib/styles/icon.d.ts.map +0 -1
- package/lib/styles/icon.js.map +0 -1
- package/lib/styles/input.d.ts.map +0 -1
- package/lib/styles/input.js.map +0 -1
- package/lib/styles/modal.d.ts.map +0 -1
- package/lib/styles/modal.js.map +0 -1
- package/lib/styles/multiselect.d.ts +0 -8
- package/lib/styles/multiselect.d.ts.map +0 -1
- package/lib/styles/multiselect.js +0 -849
- package/lib/styles/multiselect.js.map +0 -1
- package/lib/styles/option.d.ts.map +0 -1
- package/lib/styles/option.js.map +0 -1
- package/lib/styles/popup.d.ts.map +0 -1
- package/lib/styles/popup.js.map +0 -1
- package/lib/styles/radio.d.ts.map +0 -1
- package/lib/styles/radio.js.map +0 -1
- package/lib/styles/resize-observer.d.ts.map +0 -1
- package/lib/styles/resize-observer.js.map +0 -1
- package/lib/styles/scroll-container.d.ts.map +0 -1
- package/lib/styles/scroll-container.js.map +0 -1
- package/lib/styles/select-base.d.ts.map +0 -1
- package/lib/styles/select-base.js.map +0 -1
- package/lib/styles/select.d.ts.map +0 -1
- package/lib/styles/select.js.map +0 -1
- package/lib/styles/step.d.ts.map +0 -1
- package/lib/styles/step.js.map +0 -1
- package/lib/styles/switch.d.ts.map +0 -1
- package/lib/styles/switch.js.map +0 -1
- package/lib/styles/tab.d.ts.map +0 -1
- package/lib/styles/tab.js.map +0 -1
- package/lib/styles/tabs.d.ts.map +0 -1
- package/lib/styles/tabs.js.map +0 -1
- package/lib/styles/tag.d.ts.map +0 -1
- package/lib/styles/tag.js.map +0 -1
- package/lib/styles/textarea.d.ts.map +0 -1
- package/lib/styles/textarea.js.map +0 -1
- package/lib/styles/tooltip.d.ts.map +0 -1
- package/lib/styles/tooltip.js.map +0 -1
- package/lib/styles/wizard.d.ts.map +0 -1
- package/lib/styles/wizard.js.map +0 -1
- package/lib/types/common.d.ts.map +0 -1
- package/lib/types/common.js.map +0 -1
- package/lib/utils/calendar-utils.d.ts.map +0 -1
- package/lib/utils/calendar-utils.js.map +0 -1
- package/lib/utils/custom-scrollbar.d.ts.map +0 -1
- package/lib/utils/custom-scrollbar.js.map +0 -1
- package/lib/utils/flavor-sheet.d.ts.map +0 -1
- package/lib/utils/flavor-sheet.js.map +0 -1
- package/lib/utils/icon-registry.d.ts.map +0 -1
- package/lib/utils/icon-registry.js.map +0 -1
- package/lib/utils/icons.d.ts.map +0 -1
- package/lib/utils/icons.js.map +0 -1
- package/lib/utils/loader-registry.d.ts.map +0 -1
- package/lib/utils/loader-registry.js.map +0 -1
- package/lib/utils/locale.d.ts.map +0 -1
- package/lib/utils/locale.js.map +0 -1
- package/lib/utils/mobile.d.ts.map +0 -1
- package/lib/utils/mobile.js.map +0 -1
- package/lib/utils/number-format.d.ts.map +0 -1
- package/lib/utils/number-format.js.map +0 -1
- package/lib/utils/parse-boolean.d.ts +0 -39
- package/lib/utils/parse-boolean.d.ts.map +0 -1
- package/lib/utils/parse-boolean.js +0 -58
- package/lib/utils/parse-boolean.js.map +0 -1
- package/lib/utils/positioning.d.ts.map +0 -1
- package/lib/utils/positioning.js.map +0 -1
- package/lib/utils/property-capture.d.ts.map +0 -1
- package/lib/utils/property-capture.js.map +0 -1
- package/lib/utils/property-manager.d.ts.map +0 -1
- package/lib/utils/property-manager.js.map +0 -1
- package/lib/utils/resize-observer.d.ts.map +0 -1
- package/lib/utils/resize-observer.js.map +0 -1
- package/lib/utils/scroll-lock.d.ts.map +0 -1
- package/lib/utils/scroll-lock.js.map +0 -1
- package/lib/utils/styles.d.ts.map +0 -1
- package/lib/utils/styles.js.map +0 -1
- package/lib/version.d.ts.map +0 -1
- package/lib/version.js.map +0 -1
package/lib/components/select.js
CHANGED
|
@@ -1,72 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TySelect Web Component
|
|
3
|
-
*
|
|
4
|
-
* The select control — replaces ty-dropdown and ty-multiselect.
|
|
5
|
-
*
|
|
6
|
-
* Cardinality (native <select> semantics):
|
|
7
|
-
* - default: SINGLE select — scalar value, picking an option closes the popup
|
|
8
|
-
* - `multiple`: multi select — comma value / repeated FormData entries,
|
|
9
|
-
* options toggle and the popup stays open
|
|
10
|
-
*
|
|
11
|
-
* Skins:
|
|
12
|
-
* - default: form FIELD — full width, --ty-input-* tokens, matches ty-input;
|
|
13
|
-
* shows the selected label(s) inline
|
|
14
|
-
* - `compact`: content-hugging trigger for toolbars/filter bars; single shows
|
|
15
|
-
* the selected label, multiple shows placeholder + count badge (pair with
|
|
16
|
-
* <ty-selected-options>)
|
|
17
|
-
* - slot="trigger": consumer skin — replaces field/compact chrome entirely,
|
|
18
|
-
* behavior/ARIA/form participation unchanged
|
|
19
|
-
*
|
|
20
|
-
* - ty-option children (ty-tag also accepted)
|
|
21
|
-
* - Desktop popup with smart positioning, mobile full-screen modal
|
|
22
|
-
* - Search / external-search / debounce, keyboard navigation
|
|
23
|
-
* - Form association: single submits one entry, multiple submits repeated
|
|
24
|
-
* `name=` entries (HTMX-ready)
|
|
25
|
-
* - change event detail: { value, values, items: [{value, label, flavor}], action, item }
|
|
26
|
-
* (`value` is a scalar for single, array for multiple)
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```html
|
|
30
|
-
* <!-- Single select, field skin -->
|
|
31
|
-
* <ty-select label="Robot" name="robot">
|
|
32
|
-
* <ty-option value="bobo">Bobo Robot</ty-option>
|
|
33
|
-
* <ty-option value="eywa">EYWA Dataset Example</ty-option>
|
|
34
|
-
* </ty-select>
|
|
35
|
-
*
|
|
36
|
-
* <!-- Multi select, compact skin + out-of-band chips -->
|
|
37
|
-
* <ty-select multiple compact label="Robots" name="robots" id="robots">...</ty-select>
|
|
38
|
-
* <ty-selected-options for="robots"></ty-selected-options>
|
|
39
|
-
* ```
|
|
40
|
-
*/
|
|
41
1
|
import { ensureStyles } from "../utils/styles.js";
|
|
42
2
|
import { syncCustomFlavorSheet } from "../utils/flavor-sheet.js";
|
|
43
3
|
import { selectBaseStyles, selectCustomFlavorCss } from "../styles/select-base.js";
|
|
44
4
|
import { selectStyles } from "../styles/select.js";
|
|
45
5
|
import { getLoaderSvg } from "../utils/loader-registry.js";
|
|
46
6
|
import { lockScroll, unlockScroll } from "../utils/scroll-lock.js";
|
|
47
|
-
import { computeAnchoredPosition } from "../utils/positioning.js";
|
|
7
|
+
import { computeAnchoredPosition, placementToAnchored } from "../utils/positioning.js";
|
|
48
8
|
import { isMobileTouch } from "../utils/mobile.js";
|
|
49
9
|
import { TyComponent } from "../base/ty-component.js";
|
|
50
10
|
import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scrollbar.js";
|
|
51
|
-
// ============================================================================
|
|
52
|
-
// Element Hash Utility (for consistent scroll lock IDs)
|
|
53
|
-
// ============================================================================
|
|
54
|
-
/**
|
|
55
|
-
* Counter for generating unique element IDs
|
|
56
|
-
*/
|
|
57
11
|
let elementIdCounter = 0;
|
|
58
|
-
/**
|
|
59
|
-
* WeakMap to store consistent element hashes
|
|
60
|
-
* Automatically garbage collects when element is destroyed
|
|
61
|
-
*/
|
|
62
12
|
const elementIds = new WeakMap();
|
|
63
|
-
/**
|
|
64
|
-
* Get a consistent unique ID for an element
|
|
65
|
-
* Returns the same ID for the same element across multiple calls
|
|
66
|
-
*
|
|
67
|
-
* @param element - The element to hash
|
|
68
|
-
* @returns A consistent numeric hash for the element
|
|
69
|
-
*/
|
|
70
13
|
function getElementHash(element) {
|
|
71
14
|
let id = elementIds.get(element);
|
|
72
15
|
if (id === undefined) {
|
|
@@ -75,25 +18,11 @@ function getElementHash(element) {
|
|
|
75
18
|
}
|
|
76
19
|
return id;
|
|
77
20
|
}
|
|
78
|
-
// ============================================================================
|
|
79
|
-
// SVG Icons
|
|
80
|
-
// ============================================================================
|
|
81
21
|
import { REQUIRED_ICON_SVG } from "../utils/icons.js";
|
|
82
|
-
/**
|
|
83
|
-
* Chevron down icon SVG
|
|
84
|
-
*/
|
|
85
22
|
const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
|
|
86
23
|
<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" />
|
|
87
24
|
</svg>`;
|
|
88
|
-
/**
|
|
89
|
-
* 'auto' searchable mode: option count above which the search row appears.
|
|
90
|
-
* Short lists scan faster than they search — native <select> has no search.
|
|
91
|
-
*/
|
|
92
25
|
const SEARCH_AUTO_THRESHOLD = 7;
|
|
93
|
-
/**
|
|
94
|
-
* create-transform="slug": lowercase, spaces → '_', strip anything else
|
|
95
|
-
* non-alphanumeric, collapse/trim stray underscores.
|
|
96
|
-
*/
|
|
97
26
|
function slugify(text) {
|
|
98
27
|
return text
|
|
99
28
|
.trim()
|
|
@@ -103,29 +32,16 @@ function slugify(text) {
|
|
|
103
32
|
.replace(/_+/g, "_")
|
|
104
33
|
.replace(/^_|_$/g, "");
|
|
105
34
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Search (magnifier) icon SVG — popup search input adornment (from Lucide)
|
|
108
|
-
*/
|
|
109
35
|
const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
110
36
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
111
37
|
</svg>`;
|
|
112
|
-
/**
|
|
113
|
-
* Clear (X) icon SVG — .select-clear button, shown in the trigger's fallback
|
|
114
|
-
* chrome (default/compact skins) when clearable and something is selected.
|
|
115
|
-
*/
|
|
116
38
|
const CLEAR_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
117
39
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
118
40
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
119
41
|
</svg>`;
|
|
120
|
-
/**
|
|
121
|
-
* Ty Select Component
|
|
122
|
-
*/
|
|
123
42
|
export class TySelect extends TyComponent {
|
|
124
43
|
constructor() {
|
|
125
|
-
super();
|
|
126
|
-
// ============================================================================
|
|
127
|
-
// INTERNAL STATE
|
|
128
|
-
// ============================================================================
|
|
44
|
+
super();
|
|
129
45
|
this._name = "";
|
|
130
46
|
this._multiple = false;
|
|
131
47
|
this._compact = false;
|
|
@@ -144,45 +60,30 @@ export class TySelect extends TyComponent {
|
|
|
144
60
|
this._size = "md";
|
|
145
61
|
this._availableLabel = "Available";
|
|
146
62
|
this._noOptionsMessage = "No options available";
|
|
147
|
-
// Component state
|
|
148
63
|
this._state = {
|
|
149
64
|
open: false,
|
|
150
65
|
search: "",
|
|
151
66
|
highlightedIndex: -1,
|
|
152
67
|
filteredTags: [],
|
|
153
68
|
selectedValues: [],
|
|
154
|
-
mode: "desktop",
|
|
69
|
+
mode: "desktop",
|
|
155
70
|
};
|
|
156
|
-
// Event handler references for cleanup
|
|
157
71
|
this._stubClickHandler = null;
|
|
158
72
|
this._tagClickHandler = null;
|
|
159
73
|
this._searchInputHandler = null;
|
|
160
74
|
this._blockSearchClick = null;
|
|
161
75
|
this._keyboardHandler = null;
|
|
162
|
-
// Debounce properties for search event
|
|
163
76
|
this._debounce = 0;
|
|
164
77
|
this._searchDebounceTimer = null;
|
|
165
|
-
// Custom scrollbar for options list
|
|
166
78
|
this._optionsScrollbar = null;
|
|
167
|
-
// MutationObserver for light-DOM children — re-syncs selected tags' visual
|
|
168
|
-
// state when consumers swap tag children (external-search refresh pattern).
|
|
169
79
|
this._childObserver = null;
|
|
170
|
-
// Last single-select display clone — survives external-search option swaps
|
|
171
|
-
// that remove the matching option (and often the clone itself) from the DOM.
|
|
172
80
|
this._selectedClone = null;
|
|
173
81
|
this._customFlavorSheet = null;
|
|
174
82
|
const shadow = this.shadowRoot;
|
|
175
83
|
ensureStyles(shadow, { css: selectBaseStyles, id: "ty-select-base" });
|
|
176
84
|
ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
|
|
177
|
-
// DON'T render here - wait for onConnect() to initialize values first
|
|
178
|
-
// This matches dropdown.ts pattern and prevents showing empty state
|
|
179
85
|
}
|
|
180
|
-
/**
|
|
181
|
-
* Called when component is connected to DOM
|
|
182
|
-
* TyComponent handles property capture automatically
|
|
183
|
-
*/
|
|
184
86
|
onConnect() {
|
|
185
|
-
// SAFETY: Close any open dialogs to prevent scroll locking
|
|
186
87
|
const shadow = this.shadowRoot;
|
|
187
88
|
const dialogs = shadow.querySelectorAll("dialog");
|
|
188
89
|
dialogs.forEach((dialog) => {
|
|
@@ -191,54 +92,27 @@ export class TySelect extends TyComponent {
|
|
|
191
92
|
dialog.close();
|
|
192
93
|
}
|
|
193
94
|
});
|
|
194
|
-
// Render FIRST to create DOM structure
|
|
195
95
|
this.render();
|
|
196
|
-
// THEN initialize and sync tags (after DOM exists)
|
|
197
96
|
requestAnimationFrame(() => {
|
|
198
97
|
this.initializeState();
|
|
199
|
-
// Visual updates happen automatically via onPropertiesChanged
|
|
200
98
|
});
|
|
201
|
-
// Observe light-DOM children — re-sync selected state when consumers swap
|
|
202
|
-
// tag children (external-search refresh). syncSelectedTags is idempotent
|
|
203
|
-
// (only acts on tags whose desired-vs-actual selected state differs), so
|
|
204
|
-
// spurious firings caused by our own re-slot work are no-ops.
|
|
205
99
|
this._childObserver = new MutationObserver(() => {
|
|
206
100
|
this.ensureOptionRoles();
|
|
207
101
|
if (this._state.selectedValues.length > 0) {
|
|
208
102
|
this.syncSelectedTags(this._state.selectedValues);
|
|
209
103
|
}
|
|
210
|
-
// Option count may have crossed the 'auto' searchable threshold
|
|
211
104
|
this.updateSearchVisibility();
|
|
212
|
-
// External-search consumers replace ALL ty-option children — often
|
|
213
|
-
// including our display clone. Rebuild the selection display (idempotent:
|
|
214
|
-
// a clone that already matches is left alone, so no observer loop).
|
|
215
105
|
this.updateSelectionDisplay();
|
|
216
|
-
// Mobile's "Available (N)" count and empty-state come from
|
|
217
|
-
// updateMobileSelectedState(), which internal search filtering already
|
|
218
|
-
// triggers — but external-search bypasses internal filtering entirely
|
|
219
|
-
// (handleSearchInput returns early for it), so this was the only place
|
|
220
|
-
// left that could catch a child-list change and it didn't. Without
|
|
221
|
-
// this, .mobile-available-section[data-empty="true"] slot { display:
|
|
222
|
-
// none } stays stuck hidden after a consumer replaces the options —
|
|
223
|
-
// the new options are correctly in the DOM, just invisible.
|
|
224
106
|
this.updateMobileSelectedState();
|
|
225
107
|
});
|
|
226
108
|
this._childObserver.observe(this, { childList: true });
|
|
227
|
-
// MutationObserver only fires on FUTURE mutations — cover the initial
|
|
228
|
-
// set of options too (the common case: none selected yet at connect).
|
|
229
109
|
this.ensureOptionRoles();
|
|
230
110
|
this._syncCustomFlavor();
|
|
231
111
|
}
|
|
232
|
-
/** Custom (non-built-in) flavors — see utils/flavor-sheet.ts. */
|
|
233
112
|
_syncCustomFlavor() {
|
|
234
113
|
this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.getProperty("flavor"), ({ base }) => selectCustomFlavorCss(base));
|
|
235
114
|
}
|
|
236
|
-
/**
|
|
237
|
-
* Called when component is disconnected from DOM
|
|
238
|
-
* Clean up event listeners and timers
|
|
239
|
-
*/
|
|
240
115
|
onDisconnect() {
|
|
241
|
-
// CRITICAL: Close all dialogs to prevent scroll locking
|
|
242
116
|
const shadow = this.shadowRoot;
|
|
243
117
|
const dialogs = shadow.querySelectorAll("dialog");
|
|
244
118
|
dialogs.forEach((dialog) => {
|
|
@@ -246,42 +120,31 @@ export class TySelect extends TyComponent {
|
|
|
246
120
|
dialog.close();
|
|
247
121
|
}
|
|
248
122
|
});
|
|
249
|
-
// Clean up document-level listeners
|
|
250
123
|
if (this._keyboardHandler) {
|
|
251
124
|
document.removeEventListener("keydown", this._keyboardHandler);
|
|
252
125
|
this._keyboardHandler = null;
|
|
253
126
|
}
|
|
254
|
-
// Clear any pending debounce timer
|
|
255
127
|
if (this._searchDebounceTimer !== null) {
|
|
256
128
|
clearTimeout(this._searchDebounceTimer);
|
|
257
129
|
this._searchDebounceTimer = null;
|
|
258
130
|
}
|
|
259
|
-
// Cleanup custom scrollbar
|
|
260
131
|
this._destroyOptionsScrollbar();
|
|
261
|
-
// Disconnect children observer
|
|
262
132
|
if (this._childObserver) {
|
|
263
133
|
this._childObserver.disconnect();
|
|
264
134
|
this._childObserver = null;
|
|
265
135
|
}
|
|
266
136
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Called when properties change
|
|
269
|
-
* Handle state synchronization BEFORE render
|
|
270
|
-
*/
|
|
271
137
|
onPropertiesChanged(changes) {
|
|
272
138
|
for (const { name, newValue } of changes) {
|
|
273
139
|
switch (name) {
|
|
274
140
|
case "value":
|
|
275
141
|
const selectedValues = this.parseValue(newValue);
|
|
276
142
|
this._state.selectedValues = selectedValues;
|
|
277
|
-
// CRITICAL: Only sync tags if we're connected and tags exist
|
|
278
|
-
// During initial property setup (before onConnect), tags don't exist yet
|
|
279
143
|
if (this.isConnected && this.shadowRoot) {
|
|
280
144
|
this.syncSelectedTags(selectedValues);
|
|
281
145
|
this.updateSelectionDisplay();
|
|
282
146
|
this.updateMobileSelectedState();
|
|
283
147
|
}
|
|
284
|
-
// (not-yet-connected: sync deferred to initializeState)
|
|
285
148
|
break;
|
|
286
149
|
case "name":
|
|
287
150
|
this._name = newValue || "";
|
|
@@ -291,7 +154,6 @@ export class TySelect extends TyComponent {
|
|
|
291
154
|
break;
|
|
292
155
|
case "multiple":
|
|
293
156
|
this._multiple = newValue;
|
|
294
|
-
// Dropping to single with a multi selection: keep only the first
|
|
295
157
|
if (!newValue && this._state.selectedValues.length > 1) {
|
|
296
158
|
this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
|
|
297
159
|
}
|
|
@@ -356,12 +218,6 @@ export class TySelect extends TyComponent {
|
|
|
356
218
|
}
|
|
357
219
|
}
|
|
358
220
|
}
|
|
359
|
-
/**
|
|
360
|
-
* Toggle the loading visual state on the open popup.
|
|
361
|
-
* Replaces the available-options area with a centered spinner; search input stays usable.
|
|
362
|
-
* Pulls the latest registered loader SVG on each call so registry changes
|
|
363
|
-
* take effect on the next loading toggle.
|
|
364
|
-
*/
|
|
365
221
|
applyLoadingState() {
|
|
366
222
|
const shadow = this.shadowRoot;
|
|
367
223
|
if (!shadow)
|
|
@@ -380,17 +236,11 @@ export class TySelect extends TyComponent {
|
|
|
380
236
|
}
|
|
381
237
|
});
|
|
382
238
|
}
|
|
383
|
-
/**
|
|
384
|
-
* Get the form value for this component
|
|
385
|
-
* Returns FormData with multiple entries (HTMX standard)
|
|
386
|
-
*/
|
|
387
239
|
getFormValue() {
|
|
388
240
|
const selectedValues = this._state.selectedValues;
|
|
389
241
|
if (this._name && selectedValues.length > 0) {
|
|
390
|
-
// Single select submits one plain entry
|
|
391
242
|
if (!this._multiple)
|
|
392
243
|
return selectedValues[0];
|
|
393
|
-
// Multiple submits repeated `name=` entries (HTMX standard)
|
|
394
244
|
const formData = new FormData();
|
|
395
245
|
selectedValues.forEach((value) => {
|
|
396
246
|
formData.append(this._name, value);
|
|
@@ -399,11 +249,7 @@ export class TySelect extends TyComponent {
|
|
|
399
249
|
}
|
|
400
250
|
return null;
|
|
401
251
|
}
|
|
402
|
-
/**
|
|
403
|
-
* Parse select value (comma-separated string to array)
|
|
404
|
-
*/
|
|
405
252
|
parseValue(value) {
|
|
406
|
-
// Defensive check: ensure value is actually a string before calling .trim()
|
|
407
253
|
if (!value || typeof value !== "string" || value.trim() === "")
|
|
408
254
|
return [];
|
|
409
255
|
return value
|
|
@@ -411,86 +257,48 @@ export class TySelect extends TyComponent {
|
|
|
411
257
|
.map((v) => v.trim())
|
|
412
258
|
.filter((v) => v !== "");
|
|
413
259
|
}
|
|
414
|
-
/**
|
|
415
|
-
* Initialize component state from attributes
|
|
416
|
-
* Reads from both property and attribute (like ClojureScript version)
|
|
417
|
-
*/
|
|
418
260
|
initializeState() {
|
|
419
261
|
const initialValue = this.getProperty("value") || "";
|
|
420
262
|
if (initialValue) {
|
|
421
|
-
// Explicit value provided - sync tags directly
|
|
422
|
-
// DON'T use updateComponentValue() because the property is already set!
|
|
423
263
|
const selectedValues = this.parseValue(initialValue);
|
|
424
|
-
// Update internal state
|
|
425
264
|
this._state.selectedValues = selectedValues;
|
|
426
|
-
// Sync the tags to match the property value
|
|
427
265
|
this.syncSelectedTags(selectedValues);
|
|
428
|
-
// Update the visual display
|
|
429
266
|
this.updateSelectionDisplay();
|
|
430
267
|
this.updateMobileSelectedState();
|
|
431
268
|
}
|
|
432
269
|
else {
|
|
433
|
-
// No explicit value - check for pre-selected tags
|
|
434
270
|
const allTags = this.getTagElements();
|
|
435
271
|
const preSelectedTags = allTags
|
|
436
272
|
.filter((tag) => tag.hasAttribute("selected"))
|
|
437
273
|
.map((tag) => this.getTagData(tag).value);
|
|
438
274
|
if (preSelectedTags.length > 0) {
|
|
439
|
-
// Set the property value and sync (updateComponentValue handles everything)
|
|
440
275
|
this.updateComponentValue(preSelectedTags, false);
|
|
441
276
|
}
|
|
442
277
|
}
|
|
443
278
|
}
|
|
444
|
-
// ============================================================================
|
|
445
|
-
// TAG MANAGEMENT METHODS (Phase 2)
|
|
446
|
-
// ============================================================================
|
|
447
|
-
/**
|
|
448
|
-
* Get all option elements from the component (ty-option preferred, ty-tag accepted)
|
|
449
|
-
*/
|
|
450
279
|
getTagElements() {
|
|
451
|
-
// :not([cloned]) — the single-select display clone (slot="selected")
|
|
452
|
-
// lives in our light DOM too and must never count as an option
|
|
453
280
|
return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
|
|
454
281
|
}
|
|
455
|
-
/**
|
|
456
|
-
* Extract value and text from a ty-tag element
|
|
457
|
-
*/
|
|
458
282
|
getTagData(element) {
|
|
459
|
-
// Get value from either property or attribute
|
|
460
283
|
const value = element.value ||
|
|
461
284
|
element.getAttribute("value") ||
|
|
462
285
|
element.textContent ||
|
|
463
286
|
"";
|
|
464
|
-
// Display label: explicit label attribute wins over textContent —
|
|
465
|
-
// native <option label> semantics; lets rich options declare clean text
|
|
466
287
|
const text = element.getAttribute("label") || element.textContent || "";
|
|
467
288
|
return { value, text, element };
|
|
468
289
|
}
|
|
469
|
-
/**
|
|
470
|
-
* Select an option - attribute only. The option stays in the list with
|
|
471
|
-
* selected styling; chips are rendered out-of-band (ty-selected-options).
|
|
472
|
-
*/
|
|
473
290
|
selectTag(tag) {
|
|
474
291
|
tag.setAttribute("selected", "");
|
|
475
292
|
}
|
|
476
|
-
/**
|
|
477
|
-
* Deselect an option - attribute only.
|
|
478
|
-
*/
|
|
479
293
|
deselectTag(tag) {
|
|
480
294
|
tag.removeAttribute("selected");
|
|
481
295
|
}
|
|
482
|
-
/**
|
|
483
|
-
* Get array of currently selected values from tags (ALWAYS reads from DOM)
|
|
484
|
-
*/
|
|
485
296
|
getSelectedValues() {
|
|
486
297
|
return this.getTagElements()
|
|
487
298
|
.filter((tag) => tag.hasAttribute("selected"))
|
|
488
299
|
.map((tag) => this.getTagData(tag).value)
|
|
489
300
|
.filter((value) => value !== "");
|
|
490
301
|
}
|
|
491
|
-
/**
|
|
492
|
-
* Sync tag selection states with desired values
|
|
493
|
-
*/
|
|
494
302
|
syncSelectedTags(selectedValues) {
|
|
495
303
|
const selectedSet = new Set(selectedValues);
|
|
496
304
|
const tags = this.getTagElements();
|
|
@@ -504,18 +312,9 @@ export class TySelect extends TyComponent {
|
|
|
504
312
|
else if (!shouldBeSelected && isSelected) {
|
|
505
313
|
this.deselectTag(tag);
|
|
506
314
|
}
|
|
507
|
-
// ARIA listbox pattern: every option reports its live selected state,
|
|
508
|
-
// not just ones that changed this pass — cheap (idempotent setAttribute).
|
|
509
315
|
tag.setAttribute("aria-selected", String(shouldBeSelected));
|
|
510
316
|
});
|
|
511
317
|
}
|
|
512
|
-
/**
|
|
513
|
-
* role="option" for every current option/tag — separate from
|
|
514
|
-
* syncSelectedTags (which only runs when the `value` property changes)
|
|
515
|
-
* so it also covers the "nothing selected yet, value never touched"
|
|
516
|
-
* startup case. Idempotent; cheap to call from the child MutationObserver
|
|
517
|
-
* on every light-DOM change.
|
|
518
|
-
*/
|
|
519
318
|
ensureOptionRoles() {
|
|
520
319
|
this.getTagElements().forEach((tag) => {
|
|
521
320
|
if (!tag.hasAttribute("role"))
|
|
@@ -524,29 +323,14 @@ export class TySelect extends TyComponent {
|
|
|
524
323
|
tag.setAttribute("aria-selected", "false");
|
|
525
324
|
});
|
|
526
325
|
}
|
|
527
|
-
/**
|
|
528
|
-
* Central update function - synchronizes everything
|
|
529
|
-
* Uses TyComponent's property system for proper lifecycle
|
|
530
|
-
*/
|
|
531
326
|
updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
|
|
532
327
|
const oldValues = this.getSelectedValues();
|
|
533
328
|
const valueStr = newValues.join(",");
|
|
534
|
-
// Only update if changed
|
|
535
329
|
if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
|
|
536
|
-
// Use TyComponent's property system - this will trigger:
|
|
537
|
-
// 1. onPropertiesChanged() → syncs tags via syncSelectedTags()
|
|
538
|
-
// 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
|
|
539
|
-
// 3. updateFormValue() → automatic (formValue: true in config)
|
|
540
|
-
// 4. render() → automatic if visual properties changed
|
|
541
330
|
this.setProperty("value", valueStr);
|
|
542
|
-
// Explicit call, not just relying on render()'s cascade — `value` may
|
|
543
|
-
// not always be flagged as a "visual" property requiring a full
|
|
544
|
-
// re-render, and validity must update the moment selection changes.
|
|
545
331
|
this.updateValidity();
|
|
546
|
-
// Dispatch change event (with rich items for out-of-band chip rendering)
|
|
547
332
|
if (dispatchChange) {
|
|
548
333
|
this.dispatchChangeEvent({
|
|
549
|
-
// Scalar for single select, array for multiple
|
|
550
334
|
value: this._multiple ? [...newValues] : (newValues[0] ?? null),
|
|
551
335
|
values: newValues,
|
|
552
336
|
items: this.getSelectedItems(newValues),
|
|
@@ -556,27 +340,17 @@ export class TySelect extends TyComponent {
|
|
|
556
340
|
}
|
|
557
341
|
}
|
|
558
342
|
}
|
|
559
|
-
/**
|
|
560
|
-
* Build rich item info for values, read off the matching option elements
|
|
561
|
-
*/
|
|
562
343
|
getSelectedItems(values) {
|
|
563
344
|
const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
|
|
564
345
|
return values.map((value) => {
|
|
565
346
|
const el = byValue.get(value);
|
|
566
347
|
return {
|
|
567
348
|
value,
|
|
568
|
-
// label attribute wins (native <option label> semantics)
|
|
569
349
|
label: el?.getAttribute("label") || el?.textContent?.trim() || value,
|
|
570
350
|
flavor: el?.getAttribute("flavor") ?? null,
|
|
571
351
|
};
|
|
572
352
|
});
|
|
573
353
|
}
|
|
574
|
-
// ============================================================================
|
|
575
|
-
// DROPDOWN METHODS (Phase 3 & 4)
|
|
576
|
-
// ============================================================================
|
|
577
|
-
/**
|
|
578
|
-
* Calculate and set dropdown position with smart direction detection
|
|
579
|
-
*/
|
|
580
354
|
calculatePosition() {
|
|
581
355
|
const shadow = this.shadowRoot;
|
|
582
356
|
const stub = shadow.querySelector(".select-stub");
|
|
@@ -584,39 +358,39 @@ export class TySelect extends TyComponent {
|
|
|
584
358
|
if (!stub || !dialog)
|
|
585
359
|
return;
|
|
586
360
|
const stubRect = stub.getBoundingClientRect();
|
|
587
|
-
// Get dialog dimensions (it's already shown with showModal).
|
|
588
|
-
// Cap the estimate: with hundreds of options the raw measurement can be
|
|
589
|
-
// the uncapped content height, which made "fits below?" always false.
|
|
590
|
-
// 440px ≈ options max-height (24rem) + search header.
|
|
591
361
|
const MAX_POPUP_ESTIMATE = 440;
|
|
592
362
|
const dialogRect = dialog.getBoundingClientRect();
|
|
593
363
|
const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
|
|
594
364
|
const wrapPadding = 20;
|
|
595
|
-
// Popup width is independent of the trigger — a small button trigger still
|
|
596
|
-
// gets a usable list. Overridable via --ty-select-popup-width.
|
|
597
365
|
const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
|
|
598
366
|
const popupWidth = !isNaN(cssWidth)
|
|
599
367
|
? cssWidth
|
|
600
368
|
: Math.max(stubRect.width, 320);
|
|
369
|
+
const requested = this.getProperty("placement");
|
|
370
|
+
const anchored = requested
|
|
371
|
+
? placementToAnchored(requested)
|
|
372
|
+
: {
|
|
373
|
+
side: "auto",
|
|
374
|
+
align: this.getProperty("align"),
|
|
375
|
+
};
|
|
601
376
|
const pos = computeAnchoredPosition({
|
|
602
377
|
anchorRect: stubRect,
|
|
603
378
|
popupWidth,
|
|
604
379
|
popupHeight: estimatedHeight,
|
|
605
|
-
align:
|
|
380
|
+
align: anchored.align,
|
|
381
|
+
side: anchored.side,
|
|
382
|
+
gap: 8,
|
|
606
383
|
});
|
|
607
384
|
const positionBelow = pos.below;
|
|
608
|
-
// Shift by the dialog's transparent wrap (room for shadows)
|
|
609
385
|
const x = pos.x - wrapPadding;
|
|
610
386
|
const y = (positionBelow ? pos.topY : pos.bottomY) - wrapPadding;
|
|
611
387
|
const width = popupWidth + wrapPadding + wrapPadding;
|
|
612
|
-
// Set CSS variables for positioning
|
|
613
388
|
this.style.setProperty("--dropdown-x", `${x}px`);
|
|
614
389
|
this.style.setProperty("--dropdown-y", `${y}px`);
|
|
615
390
|
this.style.setProperty("--dropdown-width", `${width}px`);
|
|
616
391
|
this.style.setProperty("--dropdown-offset-x", "0px");
|
|
617
392
|
this.style.setProperty("--dropdown-offset-y", "0px");
|
|
618
393
|
this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
|
|
619
|
-
// Set direction classes for CSS styling
|
|
620
394
|
if (positionBelow) {
|
|
621
395
|
dialog.classList.add("position-below");
|
|
622
396
|
dialog.classList.remove("position-above");
|
|
@@ -625,12 +399,8 @@ export class TySelect extends TyComponent {
|
|
|
625
399
|
dialog.classList.add("position-above");
|
|
626
400
|
dialog.classList.remove("position-below");
|
|
627
401
|
}
|
|
628
|
-
// Optional: Store direction for debugging
|
|
629
402
|
this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
|
|
630
403
|
}
|
|
631
|
-
// ============================================================================
|
|
632
|
-
// CUSTOM SCROLLBAR FOR OPTIONS
|
|
633
|
-
// ============================================================================
|
|
634
404
|
_setupOptionsScrollbar() {
|
|
635
405
|
if (!isCustomScrollbarEnabled())
|
|
636
406
|
return;
|
|
@@ -655,15 +425,6 @@ export class TySelect extends TyComponent {
|
|
|
655
425
|
this._optionsScrollbar = null;
|
|
656
426
|
}
|
|
657
427
|
}
|
|
658
|
-
/**
|
|
659
|
-
* Open dropdown dialog (desktop mode)
|
|
660
|
-
*
|
|
661
|
-
* `<dialog>.showModal()` puts the dialog in the top layer with a backdrop, but
|
|
662
|
-
* does NOT prevent the page behind it from scrolling. We use the shared scroll
|
|
663
|
-
* lock utility (overflow:hidden on <html>) to keep wheel/touch scrolling from
|
|
664
|
-
* leaking through to the body — same behavior <ty-dropdown> and <ty-modal>
|
|
665
|
-
* implement.
|
|
666
|
-
*/
|
|
667
428
|
openDropdown() {
|
|
668
429
|
const shadow = this.shadowRoot;
|
|
669
430
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
@@ -672,43 +433,30 @@ export class TySelect extends TyComponent {
|
|
|
672
433
|
const stubEl = shadow.querySelector(".select-stub");
|
|
673
434
|
if (stubEl)
|
|
674
435
|
stubEl.setAttribute("aria-expanded", "true");
|
|
675
|
-
// Lock body scroll while dropdown is open
|
|
676
436
|
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
677
437
|
this._scrollLockId = lockId;
|
|
678
438
|
lockScroll(lockId);
|
|
679
|
-
// Show modal
|
|
680
439
|
dialog.showModal();
|
|
681
|
-
dialog.classList.add("open");
|
|
682
|
-
// Position dropdown AFTER showing modal
|
|
683
440
|
this.calculatePosition();
|
|
684
|
-
|
|
441
|
+
dialog.classList.add("open");
|
|
685
442
|
this._state.open = true;
|
|
686
|
-
// Update visual states
|
|
687
443
|
const chevron = shadow.querySelector(".dropdown-chevron");
|
|
688
444
|
if (chevron)
|
|
689
445
|
chevron.classList.add("open");
|
|
690
446
|
const searchChevron = shadow.querySelector(".dropdown-search-chevron");
|
|
691
447
|
if (searchChevron)
|
|
692
448
|
searchChevron.classList.add("open");
|
|
693
|
-
// Initialize options state
|
|
694
449
|
const tags = this.getTagElements().map((el) => this.getTagData(el));
|
|
695
450
|
this._state.filteredTags = tags;
|
|
696
451
|
this._state.highlightedIndex = -1;
|
|
697
|
-
// Ensure options area is visible (may have been hidden from previous search)
|
|
698
452
|
this.updateOptionsVisibility(true);
|
|
699
|
-
// Setup custom scrollbar on options
|
|
700
453
|
this._setupOptionsScrollbar();
|
|
701
|
-
// Focus search input (when the search row is shown)
|
|
702
454
|
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
703
455
|
if (searchInput && this.shouldShowSearch()) {
|
|
704
456
|
setTimeout(() => searchInput.focus(), 100);
|
|
705
457
|
}
|
|
706
|
-
// Lifecycle event (also fires empty search if external-search)
|
|
707
458
|
this.fireOpenEvent();
|
|
708
459
|
}
|
|
709
|
-
/**
|
|
710
|
-
* Close dropdown dialog (desktop mode)
|
|
711
|
-
*/
|
|
712
460
|
closeDropdown() {
|
|
713
461
|
const shadow = this.shadowRoot;
|
|
714
462
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
@@ -717,29 +465,23 @@ export class TySelect extends TyComponent {
|
|
|
717
465
|
const stubEl = shadow.querySelector(".select-stub");
|
|
718
466
|
if (stubEl)
|
|
719
467
|
stubEl.setAttribute("aria-expanded", "false");
|
|
720
|
-
// Destroy custom scrollbar
|
|
721
468
|
this._destroyOptionsScrollbar();
|
|
722
|
-
// Close dialog
|
|
723
469
|
dialog.classList.remove("open");
|
|
724
470
|
dialog.classList.remove("position-above");
|
|
725
471
|
dialog.classList.remove("position-below");
|
|
726
472
|
dialog.close();
|
|
727
|
-
// Unlock body scroll (paired with the lock in openDropdown)
|
|
728
473
|
if (this._scrollLockId) {
|
|
729
474
|
unlockScroll(this._scrollLockId);
|
|
730
475
|
this._scrollLockId = null;
|
|
731
476
|
}
|
|
732
|
-
// Update state
|
|
733
477
|
this._state.open = false;
|
|
734
478
|
this._state.highlightedIndex = -1;
|
|
735
|
-
// Update visual states
|
|
736
479
|
const chevron = shadow.querySelector(".dropdown-chevron");
|
|
737
480
|
if (chevron)
|
|
738
481
|
chevron.classList.remove("open");
|
|
739
482
|
const searchChevron = shadow.querySelector(".dropdown-search-chevron");
|
|
740
483
|
if (searchChevron)
|
|
741
484
|
searchChevron.classList.remove("open");
|
|
742
|
-
// Reset search and restore all tags
|
|
743
485
|
const hadQuery = this._state.search !== "";
|
|
744
486
|
this._state.search = "";
|
|
745
487
|
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
@@ -747,10 +489,6 @@ export class TySelect extends TyComponent {
|
|
|
747
489
|
searchInput.value = "";
|
|
748
490
|
}
|
|
749
491
|
if (this._externalSearch) {
|
|
750
|
-
// External mode — notify consumer so it can reset its own filtered state.
|
|
751
|
-
// Bypass the debounce timer (which would delay the clear by `debounce` ms);
|
|
752
|
-
// close-time should be immediate so the consumer's filtered state syncs
|
|
753
|
-
// before the dropdown reopens. Only fire if there was actually a query.
|
|
754
492
|
if (hadQuery) {
|
|
755
493
|
if (this._searchDebounceTimer !== null) {
|
|
756
494
|
clearTimeout(this._searchDebounceTimer);
|
|
@@ -760,19 +498,13 @@ export class TySelect extends TyComponent {
|
|
|
760
498
|
}
|
|
761
499
|
}
|
|
762
500
|
else {
|
|
763
|
-
// Internal mode — restore visibility of all tags ourselves
|
|
764
501
|
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
765
502
|
this._state.filteredTags = allTags;
|
|
766
503
|
this.updateTagVisibility(allTags, allTags);
|
|
767
504
|
this.clearHighlights(allTags);
|
|
768
505
|
}
|
|
769
|
-
// Lifecycle event
|
|
770
506
|
this.fireCloseEvent();
|
|
771
507
|
}
|
|
772
|
-
/**
|
|
773
|
-
* Open mobile modal (mobile mode)
|
|
774
|
-
* Now using <dialog> element for native z-index management
|
|
775
|
-
*/
|
|
776
508
|
openMobileModal() {
|
|
777
509
|
const shadow = this.shadowRoot;
|
|
778
510
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
@@ -781,48 +513,21 @@ export class TySelect extends TyComponent {
|
|
|
781
513
|
const stubEl = shadow.querySelector(".select-stub");
|
|
782
514
|
if (stubEl)
|
|
783
515
|
stubEl.setAttribute("aria-expanded", "true");
|
|
784
|
-
// Lock body scroll while mobile modal is open
|
|
785
516
|
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
786
517
|
this._scrollLockId = lockId;
|
|
787
518
|
lockScroll(lockId);
|
|
788
|
-
// Show dialog using native API (handles z-index automatically)
|
|
789
519
|
dialog.showModal();
|
|
520
|
+
dialog.getBoundingClientRect();
|
|
790
521
|
dialog.classList.add("open");
|
|
791
|
-
// Update component state
|
|
792
522
|
this._state.open = true;
|
|
793
|
-
// Initialize options state. Also ensure nothing stays hidden from a
|
|
794
|
-
// previous search — mirrors openDropdown()'s updateOptionsVisibility(true)
|
|
795
|
-
// ("may have been hidden from previous search"), which openMobileModal()
|
|
796
|
-
// never had a mobile-appropriate equivalent for: desktop hides the whole
|
|
797
|
-
// options container (a single display:none toggle, harmless to reset
|
|
798
|
-
// unconditionally), mobile hides individual options via their `hidden`
|
|
799
|
-
// attribute (updateTagVisibility), so the reset has to happen here.
|
|
800
523
|
const tags = this.getTagElements().map((el) => this.getTagData(el));
|
|
801
524
|
this._state.filteredTags = tags;
|
|
802
525
|
this.updateTagVisibility(tags, tags);
|
|
803
|
-
// Focus the search input: the `autofocus` attribute on
|
|
804
|
-
// .mobile-search-input (see render template) handles this — showModal()
|
|
805
|
-
// itself runs the spec'd "dialog focusing steps" on every call, which
|
|
806
|
-
// look for an autofocus descendant and focus it natively, skipping it
|
|
807
|
-
// automatically if it's hidden (search disabled) in favor of the next
|
|
808
|
-
// focusable element. A manual searchInput.focus() call here, AFTER
|
|
809
|
-
// showModal() already ran its own focusing steps in the same task, was
|
|
810
|
-
// racing that native behavior — document.activeElement ended up back on
|
|
811
|
-
// the host element, not the input, which is why typing did nothing:
|
|
812
|
-
// keystrokes weren't reaching the search box at all despite it visually
|
|
813
|
-
// looking focused (cursor showing from the browser's own caret paint,
|
|
814
|
-
// not actual DOM focus).
|
|
815
|
-
// Update state after slots are ready
|
|
816
526
|
requestAnimationFrame(() => {
|
|
817
527
|
this.updateMobileSelectedState();
|
|
818
528
|
});
|
|
819
|
-
// Lifecycle event (also fires empty search if external-search)
|
|
820
529
|
this.fireOpenEvent();
|
|
821
530
|
}
|
|
822
|
-
/**
|
|
823
|
-
* Close mobile modal (mobile mode)
|
|
824
|
-
* Now using <dialog> element for native management
|
|
825
|
-
*/
|
|
826
531
|
closeMobileModal() {
|
|
827
532
|
const shadow = this.shadowRoot;
|
|
828
533
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
@@ -831,18 +536,14 @@ export class TySelect extends TyComponent {
|
|
|
831
536
|
const stubEl = shadow.querySelector(".select-stub");
|
|
832
537
|
if (stubEl)
|
|
833
538
|
stubEl.setAttribute("aria-expanded", "false");
|
|
834
|
-
// Close immediately — ::backdrop doesn't support transitions
|
|
835
539
|
dialog.classList.remove("open");
|
|
836
540
|
dialog.close();
|
|
837
|
-
// Unlock body scroll (paired with the lock in openMobileModal)
|
|
838
541
|
if (this._scrollLockId) {
|
|
839
542
|
unlockScroll(this._scrollLockId);
|
|
840
543
|
this._scrollLockId = null;
|
|
841
544
|
}
|
|
842
|
-
// Update state
|
|
843
545
|
this._state.open = false;
|
|
844
546
|
this._state.highlightedIndex = -1;
|
|
845
|
-
// Reset search
|
|
846
547
|
const hadQuery = this._state.search !== "";
|
|
847
548
|
this._state.search = "";
|
|
848
549
|
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
@@ -850,8 +551,6 @@ export class TySelect extends TyComponent {
|
|
|
850
551
|
searchInput.value = "";
|
|
851
552
|
}
|
|
852
553
|
if (this._externalSearch) {
|
|
853
|
-
// External mode — notify consumer that the search was cleared on close
|
|
854
|
-
// (bypass debounce, see desktop close path for rationale).
|
|
855
554
|
if (hadQuery) {
|
|
856
555
|
if (this._searchDebounceTimer !== null) {
|
|
857
556
|
clearTimeout(this._searchDebounceTimer);
|
|
@@ -861,16 +560,11 @@ export class TySelect extends TyComponent {
|
|
|
861
560
|
}
|
|
862
561
|
}
|
|
863
562
|
else {
|
|
864
|
-
// Internal mode — unhide all tags ourselves
|
|
865
563
|
const allTags = this.getTagElements();
|
|
866
564
|
allTags.forEach((el) => el.removeAttribute("hidden"));
|
|
867
565
|
}
|
|
868
|
-
// Lifecycle event
|
|
869
566
|
this.fireCloseEvent();
|
|
870
567
|
}
|
|
871
|
-
// ============================================================================
|
|
872
|
-
// EVENT HANDLERS (Phase 5 & 6)
|
|
873
|
-
// ============================================================================
|
|
874
568
|
handleStubClick(e) {
|
|
875
569
|
e.preventDefault();
|
|
876
570
|
e.stopPropagation();
|
|
@@ -879,10 +573,6 @@ export class TySelect extends TyComponent {
|
|
|
879
573
|
}
|
|
880
574
|
this.openDropdown();
|
|
881
575
|
}
|
|
882
|
-
/**
|
|
883
|
-
* .select-clear click — must stop propagation before it bubbles to the
|
|
884
|
-
* stub's own click handler (which would otherwise also open the dropdown).
|
|
885
|
-
*/
|
|
886
576
|
handleClearClick(e) {
|
|
887
577
|
e.preventDefault();
|
|
888
578
|
e.stopPropagation();
|
|
@@ -891,14 +581,6 @@ export class TySelect extends TyComponent {
|
|
|
891
581
|
}
|
|
892
582
|
this.clear();
|
|
893
583
|
}
|
|
894
|
-
/**
|
|
895
|
-
* Keyboard-open the (closed) stub. The stub is a plain div with
|
|
896
|
-
* tabindex="0", not a native <button> — Enter/Space don't turn into
|
|
897
|
-
* click events on their own the way they do for real form controls, and
|
|
898
|
-
* without this a keyboard-only user could Tab to the field but never
|
|
899
|
-
* open it. document-level handleKeyboard() only runs once already open
|
|
900
|
-
* (it early-returns on !this._state.open), so this is the other half.
|
|
901
|
-
*/
|
|
902
584
|
handleStubKeydown(e) {
|
|
903
585
|
if (this._disabled || this._readonly || this._state.open)
|
|
904
586
|
return;
|
|
@@ -909,7 +591,6 @@ export class TySelect extends TyComponent {
|
|
|
909
591
|
}
|
|
910
592
|
handleTagClick(e) {
|
|
911
593
|
const target = e.target;
|
|
912
|
-
// Find the option element (ty-option or ty-tag)
|
|
913
594
|
const tag = target.closest("ty-option, ty-tag");
|
|
914
595
|
if (!tag || tag.hasAttribute("disabled"))
|
|
915
596
|
return;
|
|
@@ -917,18 +598,9 @@ export class TySelect extends TyComponent {
|
|
|
917
598
|
e.stopPropagation();
|
|
918
599
|
this.selectByTag(tag);
|
|
919
600
|
}
|
|
920
|
-
/**
|
|
921
|
-
* Select/toggle a tag by element — the part of handleTagClick that has no
|
|
922
|
-
* dependency on a real DOM event, so keyboard/programmatic callers (Enter
|
|
923
|
-
* on a highlighted option, allow-create's exact-match dedup) can call it
|
|
924
|
-
* directly instead of faking an Event object just to satisfy
|
|
925
|
-
* preventDefault()/stopPropagation().
|
|
926
|
-
*/
|
|
927
601
|
selectByTag(tag) {
|
|
928
602
|
const tagValue = this.getTagData(tag).value;
|
|
929
603
|
const currentValues = this.getSelectedValues();
|
|
930
|
-
// Single select: picking replaces the selection and closes the popup
|
|
931
|
-
// (native <select> semantics — re-picking the same option just closes)
|
|
932
604
|
if (!this._multiple) {
|
|
933
605
|
this.updateComponentValue([tagValue], true, "set", tagValue);
|
|
934
606
|
if (this._state.mode === "mobile") {
|
|
@@ -939,7 +611,6 @@ export class TySelect extends TyComponent {
|
|
|
939
611
|
}
|
|
940
612
|
return;
|
|
941
613
|
}
|
|
942
|
-
// Multiple: toggle — clicking a selected option deselects it
|
|
943
614
|
if (tag.hasAttribute("selected")) {
|
|
944
615
|
const newValues = currentValues.filter((v) => v !== tagValue);
|
|
945
616
|
this.updateComponentValue(newValues, true, "remove", tagValue);
|
|
@@ -948,13 +619,6 @@ export class TySelect extends TyComponent {
|
|
|
948
619
|
this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
|
|
949
620
|
}
|
|
950
621
|
}
|
|
951
|
-
/**
|
|
952
|
-
* allow-create: Enter with unmatched search text mints a new option.
|
|
953
|
-
* Returns true if it handled the key (created, or selected an existing
|
|
954
|
-
* exact match instead of duplicating) — false if there's nothing to do
|
|
955
|
-
* (blank query, or allow-create is off), so the caller's normal Enter
|
|
956
|
-
* handling (select highlighted, or no-op) proceeds.
|
|
957
|
-
*/
|
|
958
622
|
tryCreateFromSearch() {
|
|
959
623
|
if (!this._allowCreate)
|
|
960
624
|
return false;
|
|
@@ -963,8 +627,6 @@ export class TySelect extends TyComponent {
|
|
|
963
627
|
return false;
|
|
964
628
|
const queryLower = query.toLowerCase();
|
|
965
629
|
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
966
|
-
// Dedup: typed text exactly matches an existing option (by value or
|
|
967
|
-
// display text) — select it instead of minting a duplicate.
|
|
968
630
|
const existing = allTags.find((t) => t.value.toLowerCase() === queryLower ||
|
|
969
631
|
t.text.toLowerCase() === queryLower);
|
|
970
632
|
if (existing) {
|
|
@@ -974,7 +636,7 @@ export class TySelect extends TyComponent {
|
|
|
974
636
|
const rawLabel = query;
|
|
975
637
|
const defaultValue = this._createTransform === "slug" ? slugify(rawLabel) : rawLabel;
|
|
976
638
|
if (!defaultValue)
|
|
977
|
-
return true;
|
|
639
|
+
return true;
|
|
978
640
|
const detail = { value: defaultValue, label: rawLabel };
|
|
979
641
|
const event = new CustomEvent("create", {
|
|
980
642
|
detail,
|
|
@@ -984,7 +646,7 @@ export class TySelect extends TyComponent {
|
|
|
984
646
|
});
|
|
985
647
|
this.dispatchEvent(event);
|
|
986
648
|
if (event.defaultPrevented)
|
|
987
|
-
return true;
|
|
649
|
+
return true;
|
|
988
650
|
const finalValue = detail.value;
|
|
989
651
|
const option = document.createElement("ty-option");
|
|
990
652
|
option.setAttribute("value", finalValue);
|
|
@@ -1005,11 +667,6 @@ export class TySelect extends TyComponent {
|
|
|
1005
667
|
this.clearSearchAfterCreate();
|
|
1006
668
|
return true;
|
|
1007
669
|
}
|
|
1008
|
-
/**
|
|
1009
|
-
* Multiple + allow-create: clear the search box and re-show the full
|
|
1010
|
-
* (now-longer) option list so the next tag can be typed immediately —
|
|
1011
|
-
* the tags-input pattern.
|
|
1012
|
-
*/
|
|
1013
670
|
clearSearchAfterCreate() {
|
|
1014
671
|
this._state.search = "";
|
|
1015
672
|
this._state.highlightedIndex = -1;
|
|
@@ -1037,28 +694,18 @@ export class TySelect extends TyComponent {
|
|
|
1037
694
|
handleSearchInput(e) {
|
|
1038
695
|
const target = e.target;
|
|
1039
696
|
const query = target.value;
|
|
1040
|
-
// Update search state
|
|
1041
697
|
this._state.search = query;
|
|
1042
698
|
if (this._externalSearch) {
|
|
1043
|
-
// External (remote) search: parent owns filtering — delegate via event.
|
|
1044
|
-
// Tag visibility is left untouched; consumer is expected to update children.
|
|
1045
699
|
this.dispatchSearchEvent(query);
|
|
1046
700
|
return;
|
|
1047
701
|
}
|
|
1048
|
-
// Internal search: filter ALL options locally — selected ones stay visible
|
|
1049
|
-
// (with selected styling) so they can be toggled off while searching
|
|
1050
702
|
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
1051
703
|
const filtered = this.filterTags(allTags, query);
|
|
1052
|
-
// Update state
|
|
1053
704
|
this._state.filteredTags = filtered;
|
|
1054
705
|
this._state.highlightedIndex = -1;
|
|
1055
|
-
// Update visibility
|
|
1056
706
|
this.updateTagVisibility(filtered, allTags);
|
|
1057
|
-
// Hide options area if no results (desktop)
|
|
1058
707
|
this.updateOptionsVisibility(filtered.length > 0);
|
|
1059
|
-
// Refresh mobile count + empty-state to reflect filtered visibility
|
|
1060
708
|
this.updateMobileSelectedState();
|
|
1061
|
-
// Clear highlights
|
|
1062
709
|
this.clearHighlights(allTags);
|
|
1063
710
|
}
|
|
1064
711
|
handleKeyboard(e) {
|
|
@@ -1067,13 +714,9 @@ export class TySelect extends TyComponent {
|
|
|
1067
714
|
const shadow = this.shadowRoot;
|
|
1068
715
|
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
1069
716
|
const target = e.target;
|
|
1070
|
-
// Only handle navigation keys when dropdown is open and either:
|
|
1071
|
-
// 1. Event comes from search input, OR
|
|
1072
|
-
// 2. Event comes from document but search input is not focused
|
|
1073
717
|
const shouldHandle = target === searchInput || document.activeElement !== searchInput;
|
|
1074
718
|
if (!shouldHandle)
|
|
1075
719
|
return;
|
|
1076
|
-
// Get current state values
|
|
1077
720
|
const filteredTags = this._state.filteredTags;
|
|
1078
721
|
const tagsCount = filteredTags.length;
|
|
1079
722
|
const currentHighlightedIndex = this._state.highlightedIndex;
|
|
@@ -1086,8 +729,6 @@ export class TySelect extends TyComponent {
|
|
|
1086
729
|
case "Enter":
|
|
1087
730
|
e.preventDefault();
|
|
1088
731
|
e.stopPropagation();
|
|
1089
|
-
// A highlighted (arrowed-to) option always wins — the user picked
|
|
1090
|
-
// it explicitly, so allow-create must not shadow it with a new one.
|
|
1091
732
|
if (currentHighlightedIndex >= 0 &&
|
|
1092
733
|
currentHighlightedIndex < tagsCount) {
|
|
1093
734
|
this.selectByTag(filteredTags[currentHighlightedIndex].element);
|
|
@@ -1104,15 +745,12 @@ export class TySelect extends TyComponent {
|
|
|
1104
745
|
newIndexUp = -1;
|
|
1105
746
|
}
|
|
1106
747
|
else if (currentHighlightedIndex === -1) {
|
|
1107
|
-
// Nothing highlighted, go to last tag
|
|
1108
748
|
newIndexUp = tagsCount - 1;
|
|
1109
749
|
}
|
|
1110
750
|
else if (currentHighlightedIndex === 0) {
|
|
1111
|
-
// At first tag, wrap to last
|
|
1112
751
|
newIndexUp = tagsCount - 1;
|
|
1113
752
|
}
|
|
1114
753
|
else {
|
|
1115
|
-
// Move up one
|
|
1116
754
|
newIndexUp = currentHighlightedIndex - 1;
|
|
1117
755
|
}
|
|
1118
756
|
this._state.highlightedIndex = newIndexUp;
|
|
@@ -1126,15 +764,12 @@ export class TySelect extends TyComponent {
|
|
|
1126
764
|
newIndexDown = -1;
|
|
1127
765
|
}
|
|
1128
766
|
else if (currentHighlightedIndex === -1) {
|
|
1129
|
-
// Nothing highlighted, go to first tag
|
|
1130
767
|
newIndexDown = 0;
|
|
1131
768
|
}
|
|
1132
769
|
else if (currentHighlightedIndex === tagsCount - 1) {
|
|
1133
|
-
// At last tag, wrap to first
|
|
1134
770
|
newIndexDown = 0;
|
|
1135
771
|
}
|
|
1136
772
|
else {
|
|
1137
|
-
// Move down one
|
|
1138
773
|
newIndexDown = currentHighlightedIndex + 1;
|
|
1139
774
|
}
|
|
1140
775
|
this._state.highlightedIndex = newIndexDown;
|
|
@@ -1142,12 +777,6 @@ export class TySelect extends TyComponent {
|
|
|
1142
777
|
break;
|
|
1143
778
|
}
|
|
1144
779
|
}
|
|
1145
|
-
// ============================================================================
|
|
1146
|
-
// SEARCH & FILTERING HELPERS (Phase 6)
|
|
1147
|
-
// ============================================================================
|
|
1148
|
-
/**
|
|
1149
|
-
* Filter tags based on search query
|
|
1150
|
-
*/
|
|
1151
780
|
filterTags(tags, query) {
|
|
1152
781
|
if (!query || query.trim() === "") {
|
|
1153
782
|
return tags;
|
|
@@ -1155,9 +784,6 @@ export class TySelect extends TyComponent {
|
|
|
1155
784
|
const searchLower = query.toLowerCase();
|
|
1156
785
|
return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
|
|
1157
786
|
}
|
|
1158
|
-
/**
|
|
1159
|
-
* Update visibility of tags based on filtered list
|
|
1160
|
-
*/
|
|
1161
787
|
updateTagVisibility(filteredTags, allTags) {
|
|
1162
788
|
const visibleValues = new Set(filteredTags.map((tag) => tag.value));
|
|
1163
789
|
allTags.forEach(({ value, element }) => {
|
|
@@ -1169,9 +795,6 @@ export class TySelect extends TyComponent {
|
|
|
1169
795
|
}
|
|
1170
796
|
});
|
|
1171
797
|
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Show/hide the dropdown options area
|
|
1174
|
-
*/
|
|
1175
798
|
updateOptionsVisibility(hasOptions) {
|
|
1176
799
|
const shadow = this.shadowRoot;
|
|
1177
800
|
const options = shadow.querySelector(".dropdown-options");
|
|
@@ -1179,23 +802,16 @@ export class TySelect extends TyComponent {
|
|
|
1179
802
|
options.style.display = hasOptions ? "" : "none";
|
|
1180
803
|
}
|
|
1181
804
|
}
|
|
1182
|
-
/**
|
|
1183
|
-
* Clear all tag highlights
|
|
1184
|
-
*/
|
|
1185
805
|
clearHighlights(tags) {
|
|
1186
806
|
tags.forEach(({ element }) => {
|
|
1187
807
|
element.removeAttribute("highlighted");
|
|
1188
808
|
});
|
|
1189
809
|
}
|
|
1190
|
-
/**
|
|
1191
|
-
* Highlight tag at specific index
|
|
1192
|
-
*/
|
|
1193
810
|
highlightTag(tags, index) {
|
|
1194
811
|
this.clearHighlights(tags);
|
|
1195
812
|
if (index >= 0 && index < tags.length) {
|
|
1196
813
|
const { element } = tags[index];
|
|
1197
814
|
element.setAttribute("highlighted", "");
|
|
1198
|
-
// Scroll into view
|
|
1199
815
|
element.scrollIntoView({
|
|
1200
816
|
behavior: "smooth",
|
|
1201
817
|
block: "nearest",
|
|
@@ -1203,17 +819,11 @@ export class TySelect extends TyComponent {
|
|
|
1203
819
|
});
|
|
1204
820
|
}
|
|
1205
821
|
}
|
|
1206
|
-
/**
|
|
1207
|
-
* Dispatch search event for external search handling
|
|
1208
|
-
* With optional debounce support
|
|
1209
|
-
*/
|
|
1210
822
|
dispatchSearchEvent(query) {
|
|
1211
|
-
// Clear existing timer
|
|
1212
823
|
if (this._searchDebounceTimer !== null) {
|
|
1213
824
|
clearTimeout(this._searchDebounceTimer);
|
|
1214
825
|
this._searchDebounceTimer = null;
|
|
1215
826
|
}
|
|
1216
|
-
// If debounce is set, debounce the event
|
|
1217
827
|
if (this._debounce > 0) {
|
|
1218
828
|
this._searchDebounceTimer = window.setTimeout(() => {
|
|
1219
829
|
this.fireSearchEvent(query);
|
|
@@ -1221,13 +831,9 @@ export class TySelect extends TyComponent {
|
|
|
1221
831
|
}, this._debounce);
|
|
1222
832
|
}
|
|
1223
833
|
else {
|
|
1224
|
-
// Fire immediately if no debounce
|
|
1225
834
|
this.fireSearchEvent(query);
|
|
1226
835
|
}
|
|
1227
836
|
}
|
|
1228
|
-
/**
|
|
1229
|
-
* Fire the actual search event
|
|
1230
|
-
*/
|
|
1231
837
|
fireSearchEvent(query) {
|
|
1232
838
|
this.dispatchEvent(new CustomEvent("search", {
|
|
1233
839
|
detail: {
|
|
@@ -1238,16 +844,9 @@ export class TySelect extends TyComponent {
|
|
|
1238
844
|
composed: true,
|
|
1239
845
|
}));
|
|
1240
846
|
}
|
|
1241
|
-
/**
|
|
1242
|
-
* Dispatch lifecycle events for popup open/close.
|
|
1243
|
-
* On open with external-search, also fire a `search` event with an empty
|
|
1244
|
-
* query so consumers have a clean hook to reset/refetch the option list.
|
|
1245
|
-
*/
|
|
1246
847
|
fireOpenEvent() {
|
|
1247
848
|
this.dispatchEvent(new CustomEvent("open", {
|
|
1248
849
|
detail: { mode: this._state.mode, element: this },
|
|
1249
|
-
bubbles: true,
|
|
1250
|
-
composed: true,
|
|
1251
850
|
}));
|
|
1252
851
|
if (this._externalSearch) {
|
|
1253
852
|
this.fireSearchEvent("");
|
|
@@ -1256,16 +855,8 @@ export class TySelect extends TyComponent {
|
|
|
1256
855
|
fireCloseEvent() {
|
|
1257
856
|
this.dispatchEvent(new CustomEvent("close", {
|
|
1258
857
|
detail: { mode: this._state.mode, element: this },
|
|
1259
|
-
bubbles: true,
|
|
1260
|
-
composed: true,
|
|
1261
858
|
}));
|
|
1262
859
|
}
|
|
1263
|
-
// ============================================================================
|
|
1264
|
-
// CHANGE EVENT DISPATCHING (Phase 5)
|
|
1265
|
-
// ============================================================================
|
|
1266
|
-
/**
|
|
1267
|
-
* Dispatch custom change event
|
|
1268
|
-
*/
|
|
1269
860
|
dispatchChangeEvent(detail) {
|
|
1270
861
|
this.dispatchEvent(new CustomEvent("change", {
|
|
1271
862
|
detail,
|
|
@@ -1273,15 +864,7 @@ export class TySelect extends TyComponent {
|
|
|
1273
864
|
cancelable: true,
|
|
1274
865
|
}));
|
|
1275
866
|
}
|
|
1276
|
-
// ============================================================================
|
|
1277
|
-
// RENDERING
|
|
1278
|
-
// ============================================================================
|
|
1279
|
-
/**
|
|
1280
|
-
* Main render method (required by TyComponent)
|
|
1281
|
-
* Delegates to mode-specific renderer
|
|
1282
|
-
*/
|
|
1283
867
|
render() {
|
|
1284
|
-
// Sync mode on every render so rotation/resize is picked up
|
|
1285
868
|
this._state.mode = isMobileTouch() ? "mobile" : "desktop";
|
|
1286
869
|
if (this._state.mode === "mobile") {
|
|
1287
870
|
this.renderMobile();
|
|
@@ -1290,18 +873,9 @@ export class TySelect extends TyComponent {
|
|
|
1290
873
|
this.renderDesktop();
|
|
1291
874
|
}
|
|
1292
875
|
this.updateSearchVisibility();
|
|
1293
|
-
// Loading wrapper is rendered dynamically — re-apply each render
|
|
1294
876
|
this.applyLoadingState();
|
|
1295
|
-
// Reflect constraint validation (required) to the owning <form>.
|
|
1296
877
|
this.updateValidity();
|
|
1297
878
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
* Reflect constraint validation to the owning <form> via ElementInternals.
|
|
1300
|
-
* Without this `required` is only cosmetic — form.checkValidity() (and a
|
|
1301
|
-
* real submit) never actually blocks on an empty required select. Mirrors
|
|
1302
|
-
* ty-input's updateValidity(): empty + required => valueMissing; disabled
|
|
1303
|
-
* fields are barred from constraint validation.
|
|
1304
|
-
*/
|
|
1305
879
|
updateValidity() {
|
|
1306
880
|
if (this.disabled) {
|
|
1307
881
|
this._internals.setValidity({});
|
|
@@ -1315,10 +889,6 @@ export class TySelect extends TyComponent {
|
|
|
1315
889
|
this._internals.setValidity({});
|
|
1316
890
|
}
|
|
1317
891
|
}
|
|
1318
|
-
/**
|
|
1319
|
-
* When a consumer slots their own trigger (slot="trigger"), strip the
|
|
1320
|
-
* default field chrome so the trigger is styled entirely by the consumer.
|
|
1321
|
-
*/
|
|
1322
892
|
setupTriggerSlot() {
|
|
1323
893
|
const shadow = this.shadowRoot;
|
|
1324
894
|
const stub = shadow.querySelector(".select-stub");
|
|
@@ -1329,9 +899,6 @@ export class TySelect extends TyComponent {
|
|
|
1329
899
|
slot.addEventListener("slotchange", sync);
|
|
1330
900
|
sync();
|
|
1331
901
|
}
|
|
1332
|
-
/**
|
|
1333
|
-
* Setup event listeners
|
|
1334
|
-
*/
|
|
1335
902
|
setupEventListeners() {
|
|
1336
903
|
const shadow = this.shadowRoot;
|
|
1337
904
|
const stub = shadow.querySelector(".select-stub");
|
|
@@ -1345,36 +912,27 @@ export class TySelect extends TyComponent {
|
|
|
1345
912
|
const clearBtn = shadow.querySelector(".select-clear");
|
|
1346
913
|
clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
|
|
1347
914
|
this.setupTriggerSlot();
|
|
1348
|
-
// Add tag click handler to slot
|
|
1349
915
|
if (optionsSlot) {
|
|
1350
916
|
this._tagClickHandler = this.handleTagClick.bind(this);
|
|
1351
917
|
optionsSlot.addEventListener("click", this._tagClickHandler);
|
|
1352
918
|
}
|
|
1353
|
-
// Add search input handlers
|
|
1354
919
|
if (searchInput) {
|
|
1355
920
|
this._searchInputHandler = this.handleSearchInput.bind(this);
|
|
1356
921
|
this._blockSearchClick = this.blockSearchClick.bind(this);
|
|
1357
922
|
searchInput.addEventListener("input", this._searchInputHandler);
|
|
1358
923
|
searchInput.addEventListener("click", this._blockSearchClick);
|
|
1359
|
-
// searchInput.addEventListener('blur', this._searchBlurHandler)
|
|
1360
924
|
}
|
|
1361
|
-
// Setup dialog backdrop click handler
|
|
1362
925
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
1363
926
|
if (dialog) {
|
|
1364
927
|
dialog.addEventListener("click", (e) => {
|
|
1365
|
-
// Only close if clicking directly on the dialog (backdrop), not its children
|
|
1366
928
|
if (e.target === dialog) {
|
|
1367
929
|
this.closeDropdown();
|
|
1368
930
|
}
|
|
1369
931
|
});
|
|
1370
932
|
}
|
|
1371
|
-
// Setup keyboard handler
|
|
1372
933
|
this._keyboardHandler = this.handleKeyboard.bind(this);
|
|
1373
934
|
document.addEventListener("keydown", this._keyboardHandler);
|
|
1374
935
|
}
|
|
1375
|
-
/**
|
|
1376
|
-
* Build CSS class list for stub
|
|
1377
|
-
*/
|
|
1378
936
|
buildStubClasses() {
|
|
1379
937
|
const classes = [this._size];
|
|
1380
938
|
if (this._disabled)
|
|
@@ -1383,12 +941,8 @@ export class TySelect extends TyComponent {
|
|
|
1383
941
|
classes.push("compact");
|
|
1384
942
|
return classes.join(" ");
|
|
1385
943
|
}
|
|
1386
|
-
/**
|
|
1387
|
-
* Render desktop mode with dialog
|
|
1388
|
-
*/
|
|
1389
944
|
renderDesktop() {
|
|
1390
945
|
const shadow = this.shadowRoot;
|
|
1391
|
-
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1392
946
|
if (!shadow.querySelector(".select-container")) {
|
|
1393
947
|
const stubClasses = this.buildStubClasses();
|
|
1394
948
|
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
@@ -1458,21 +1012,12 @@ export class TySelect extends TyComponent {
|
|
|
1458
1012
|
</div>
|
|
1459
1013
|
</div>
|
|
1460
1014
|
`;
|
|
1461
|
-
// Setup event listeners ONCE
|
|
1462
1015
|
this.setupEventListeners();
|
|
1463
|
-
// Don't initialize here - will be done in connectedCallback
|
|
1464
|
-
// after properties are set and children are available
|
|
1465
1016
|
}
|
|
1466
|
-
// Always update placeholder visibility on re-render
|
|
1467
1017
|
this.updateSelectionDisplay();
|
|
1468
1018
|
}
|
|
1469
|
-
/**
|
|
1470
|
-
* Render mobile mode with full-screen modal
|
|
1471
|
-
* Following dropdown.ts mobile structure
|
|
1472
|
-
*/
|
|
1473
1019
|
renderMobile() {
|
|
1474
1020
|
const shadow = this.shadowRoot;
|
|
1475
|
-
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1476
1021
|
if (!shadow.querySelector(".select-container")) {
|
|
1477
1022
|
const stubClasses = this.buildStubClasses();
|
|
1478
1023
|
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
@@ -1485,16 +1030,13 @@ export class TySelect extends TyComponent {
|
|
|
1485
1030
|
</label>
|
|
1486
1031
|
`
|
|
1487
1032
|
: "";
|
|
1488
|
-
// Close button SVG (X icon)
|
|
1489
1033
|
const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1490
1034
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
1491
1035
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
1492
1036
|
</svg>`;
|
|
1493
|
-
// Search placeholder: "Search <label>..." or just "Search..."
|
|
1494
1037
|
const searchPlaceholder = this._label
|
|
1495
1038
|
? `Search ${this._label}...`
|
|
1496
1039
|
: "Search...";
|
|
1497
|
-
// Search is always available — only the mode (internal vs external) varies.
|
|
1498
1040
|
const searchHeaderHtml = `
|
|
1499
1041
|
<div class="mobile-search-header">
|
|
1500
1042
|
${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
|
|
@@ -1574,16 +1116,10 @@ export class TySelect extends TyComponent {
|
|
|
1574
1116
|
</div>
|
|
1575
1117
|
</div>
|
|
1576
1118
|
`;
|
|
1577
|
-
// Setup event listeners ONCE
|
|
1578
1119
|
this.setupMobileEventListeners();
|
|
1579
1120
|
}
|
|
1580
|
-
// Always update placeholder visibility
|
|
1581
1121
|
this.updateSelectionDisplay();
|
|
1582
1122
|
}
|
|
1583
|
-
/**
|
|
1584
|
-
* Setup event listeners for mobile mode
|
|
1585
|
-
* Using <dialog> element - backdrop clicks handled natively
|
|
1586
|
-
*/
|
|
1587
1123
|
setupMobileEventListeners() {
|
|
1588
1124
|
const shadow = this.shadowRoot;
|
|
1589
1125
|
const stub = shadow.querySelector(".select-stub");
|
|
@@ -1598,37 +1134,27 @@ export class TySelect extends TyComponent {
|
|
|
1598
1134
|
const clearBtn = shadow.querySelector(".select-clear");
|
|
1599
1135
|
clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
|
|
1600
1136
|
this.setupTriggerSlot();
|
|
1601
|
-
// Add tag click handler to slot
|
|
1602
1137
|
if (optionsSlot) {
|
|
1603
1138
|
optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
|
|
1604
1139
|
}
|
|
1605
|
-
// Add search input handlers (if searchable)
|
|
1606
1140
|
if (searchInput) {
|
|
1607
1141
|
searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
|
|
1608
1142
|
}
|
|
1609
|
-
// Close button click
|
|
1610
1143
|
if (closeButton) {
|
|
1611
1144
|
closeButton.addEventListener("click", () => this.closeMobileModal());
|
|
1612
1145
|
}
|
|
1613
|
-
// Backdrop click to close (native dialog behavior)
|
|
1614
1146
|
if (dialog) {
|
|
1615
1147
|
dialog.addEventListener("click", (e) => {
|
|
1616
|
-
// Only close if clicking directly on the dialog element (backdrop)
|
|
1617
|
-
// Not if clicking on its children (dialog-content)
|
|
1618
1148
|
if (e.target === dialog) {
|
|
1619
1149
|
this.closeMobileModal();
|
|
1620
1150
|
}
|
|
1621
1151
|
});
|
|
1622
|
-
// Also handle Escape key via cancel event
|
|
1623
1152
|
dialog.addEventListener("cancel", (e) => {
|
|
1624
|
-
e.preventDefault();
|
|
1153
|
+
e.preventDefault();
|
|
1625
1154
|
this.closeMobileModal();
|
|
1626
1155
|
});
|
|
1627
1156
|
}
|
|
1628
1157
|
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Handle mobile stub click - open modal
|
|
1631
|
-
*/
|
|
1632
1158
|
handleMobileStubClick(e) {
|
|
1633
1159
|
e.preventDefault();
|
|
1634
1160
|
e.stopPropagation();
|
|
@@ -1637,7 +1163,6 @@ export class TySelect extends TyComponent {
|
|
|
1637
1163
|
}
|
|
1638
1164
|
this.openMobileModal();
|
|
1639
1165
|
}
|
|
1640
|
-
/** Keyboard-open the (closed) mobile stub — see handleStubKeydown for why this is needed. */
|
|
1641
1166
|
handleMobileStubKeydown(e) {
|
|
1642
1167
|
if (this._disabled || this._readonly || this._state.open)
|
|
1643
1168
|
return;
|
|
@@ -1646,45 +1171,26 @@ export class TySelect extends TyComponent {
|
|
|
1646
1171
|
this.openMobileModal();
|
|
1647
1172
|
}
|
|
1648
1173
|
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Handle mobile tag click - select and potentially close
|
|
1651
|
-
*/
|
|
1652
1174
|
handleMobileTagClick(e) {
|
|
1653
|
-
// Use the same tag click handler as desktop
|
|
1654
|
-
// It already handles mobile mode for auto-close
|
|
1655
1175
|
this.handleTagClick(e);
|
|
1656
1176
|
}
|
|
1657
|
-
/**
|
|
1658
|
-
* Update mobile options section state (count, empty state)
|
|
1659
|
-
*/
|
|
1660
1177
|
updateMobileSelectedState() {
|
|
1661
1178
|
if (this._state.mode !== "mobile")
|
|
1662
1179
|
return;
|
|
1663
1180
|
const shadow = this.shadowRoot;
|
|
1664
1181
|
const availableSection = shadow.querySelector(".mobile-available-section");
|
|
1665
1182
|
if (availableSection) {
|
|
1666
|
-
// Count *visible* options — options hidden by search filtering count as 0
|
|
1667
1183
|
const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
|
|
1668
1184
|
availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
|
|
1669
|
-
// Update available header count
|
|
1670
1185
|
const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
|
|
1671
1186
|
if (availableTitleSpan) {
|
|
1672
1187
|
availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
|
|
1673
1188
|
}
|
|
1674
1189
|
}
|
|
1675
1190
|
}
|
|
1676
|
-
/**
|
|
1677
|
-
* Update selection display: count badge next to the placeholder
|
|
1678
|
-
*/
|
|
1679
|
-
/**
|
|
1680
|
-
* Should the popup show a search row? external-search always (the input is
|
|
1681
|
-
* the mechanism); searchable always/never wins otherwise; 'auto' shows it
|
|
1682
|
-
* only when the option list is long enough to need filtering.
|
|
1683
|
-
*/
|
|
1684
1191
|
shouldShowSearch() {
|
|
1685
1192
|
if (this._externalSearch)
|
|
1686
1193
|
return true;
|
|
1687
|
-
// allow-create's only input mechanism IS the search box.
|
|
1688
1194
|
if (this._allowCreate)
|
|
1689
1195
|
return true;
|
|
1690
1196
|
if (this._searchable === "always")
|
|
@@ -1693,9 +1199,6 @@ export class TySelect extends TyComponent {
|
|
|
1693
1199
|
return false;
|
|
1694
1200
|
return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
|
|
1695
1201
|
}
|
|
1696
|
-
/**
|
|
1697
|
-
* Toggle the search row (desktop header / mobile input) per shouldShowSearch.
|
|
1698
|
-
*/
|
|
1699
1202
|
updateSearchVisibility() {
|
|
1700
1203
|
const shadow = this.shadowRoot;
|
|
1701
1204
|
if (!shadow)
|
|
@@ -1704,7 +1207,6 @@ export class TySelect extends TyComponent {
|
|
|
1704
1207
|
const header = shadow.querySelector(".dropdown-header");
|
|
1705
1208
|
if (header)
|
|
1706
1209
|
header.hidden = !show;
|
|
1707
|
-
// Mobile keeps its header (label + close button); only the input+icon hide
|
|
1708
1210
|
const mobileInput = shadow.querySelector(".mobile-search-input");
|
|
1709
1211
|
if (mobileInput)
|
|
1710
1212
|
mobileInput.hidden = !show;
|
|
@@ -1712,22 +1214,11 @@ export class TySelect extends TyComponent {
|
|
|
1712
1214
|
if (mobileIcon)
|
|
1713
1215
|
mobileIcon.hidden = !show;
|
|
1714
1216
|
}
|
|
1715
|
-
/**
|
|
1716
|
-
* Selection summary appropriate to cardinality × skin:
|
|
1717
|
-
* - single (field or compact): the selected OPTION itself — cloned into
|
|
1718
|
-
* slot="selected" so rich HTML options display intact (dropdown parity);
|
|
1719
|
-
* placeholder when empty
|
|
1720
|
-
* - multiple + field: joined labels (ellipsis via CSS), or placeholder
|
|
1721
|
-
* - multiple + compact: placeholder + count badge
|
|
1722
|
-
*/
|
|
1723
1217
|
updateSelectionDisplay() {
|
|
1724
1218
|
const shadow = this.shadowRoot;
|
|
1725
1219
|
const stub = shadow.querySelector(".select-stub");
|
|
1726
1220
|
if (!stub)
|
|
1727
1221
|
return;
|
|
1728
|
-
// Selection comes from STATE, not from scanning option elements —
|
|
1729
|
-
// external search replaces the option children, and the selection must
|
|
1730
|
-
// survive even when the current results don't include the selected value.
|
|
1731
1222
|
const values = this._state.selectedValues;
|
|
1732
1223
|
const count = values.length;
|
|
1733
1224
|
stub.classList.toggle("has-selection", count > 0);
|
|
@@ -1741,14 +1232,11 @@ export class TySelect extends TyComponent {
|
|
|
1741
1232
|
if (clearBtn) {
|
|
1742
1233
|
clearBtn.hidden = !(count > 0 && this._clearable && !this._disabled && !this._readonly);
|
|
1743
1234
|
}
|
|
1744
|
-
// Single select: project the selected option into the stub as a clone
|
|
1745
|
-
// (rich HTML content survives — same mechanism as ty-dropdown).
|
|
1746
1235
|
const wantClone = !this._multiple && count > 0 ? values[0] : null;
|
|
1747
1236
|
this.syncSelectedClone(wantClone);
|
|
1748
1237
|
stub.classList.toggle("has-clone", wantClone !== null);
|
|
1749
1238
|
const textEl = stub.querySelector(".dropdown-placeholder");
|
|
1750
1239
|
if (textEl) {
|
|
1751
|
-
// The clone replaces the text display entirely in single mode
|
|
1752
1240
|
textEl.hidden = wantClone !== null;
|
|
1753
1241
|
const showLabels = count > 0 && this._multiple && !this._compact;
|
|
1754
1242
|
if (showLabels) {
|
|
@@ -1763,17 +1251,11 @@ export class TySelect extends TyComponent {
|
|
|
1763
1251
|
}
|
|
1764
1252
|
}
|
|
1765
1253
|
}
|
|
1766
|
-
/**
|
|
1767
|
-
* Keep exactly one display clone (slot="selected", marked `cloned`) in our
|
|
1768
|
-
* light DOM matching the given value — or none. The clone never counts as
|
|
1769
|
-
* an option (getTagElements filters [cloned]) and ty-option's own
|
|
1770
|
-
* :host([cloned]) styling strips its list-row chrome.
|
|
1771
|
-
*/
|
|
1772
1254
|
syncSelectedClone(value) {
|
|
1773
1255
|
const existing = this.querySelector(':scope > [cloned][slot="selected"]');
|
|
1774
1256
|
if (existing?.getAttribute("value") === value) {
|
|
1775
1257
|
this._selectedClone = existing;
|
|
1776
|
-
return;
|
|
1258
|
+
return;
|
|
1777
1259
|
}
|
|
1778
1260
|
existing?.remove();
|
|
1779
1261
|
if (value === null) {
|
|
@@ -1783,46 +1265,28 @@ export class TySelect extends TyComponent {
|
|
|
1783
1265
|
const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
|
|
1784
1266
|
if (option) {
|
|
1785
1267
|
const clone = option.cloneNode(true);
|
|
1786
|
-
clone.removeAttribute("selected");
|
|
1268
|
+
clone.removeAttribute("selected");
|
|
1787
1269
|
clone.setAttribute("slot", "selected");
|
|
1788
1270
|
clone.setAttribute("cloned", "true");
|
|
1789
1271
|
this.appendChild(clone);
|
|
1790
1272
|
this._selectedClone = clone;
|
|
1791
1273
|
}
|
|
1792
1274
|
else if (this._selectedClone?.getAttribute("value") === value) {
|
|
1793
|
-
// The option list no longer contains the selection (external search
|
|
1794
|
-
// swapped the children — possibly wiping our clone with them). Restore
|
|
1795
|
-
// the saved display clone so the selection stays visible.
|
|
1796
1275
|
const restored = this._selectedClone.cloneNode(true);
|
|
1797
1276
|
this.appendChild(restored);
|
|
1798
1277
|
this._selectedClone = restored;
|
|
1799
1278
|
}
|
|
1800
1279
|
}
|
|
1801
|
-
// ============================================================================
|
|
1802
|
-
// PUBLIC API - Getters/Setters
|
|
1803
|
-
// ============================================================================
|
|
1804
|
-
/**
|
|
1805
|
-
* Deselect a value programmatically WITH a change event.
|
|
1806
|
-
* Used by out-of-band chip displays (ty-selected-options) so dismissing a chip
|
|
1807
|
-
* fires change like any in-popup interaction (HTMX hx-trigger="change" etc.).
|
|
1808
|
-
*/
|
|
1809
1280
|
deselectValue(value) {
|
|
1810
1281
|
const current = this.getSelectedValues();
|
|
1811
1282
|
if (!current.includes(value))
|
|
1812
1283
|
return;
|
|
1813
1284
|
this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
|
|
1814
1285
|
}
|
|
1815
|
-
/**
|
|
1816
|
-
* Clear the entire selection programmatically WITH a change event
|
|
1817
|
-
* (action: 'clear'). No-op if nothing is selected. Exists so slot="trigger"
|
|
1818
|
-
* consumers (custom chrome, built-in clear UI hidden by design) can wire
|
|
1819
|
-
* their own clear icon's click handler to it.
|
|
1820
|
-
*/
|
|
1821
1286
|
clear() {
|
|
1822
1287
|
this.updateComponentValue([], true, "clear", null);
|
|
1823
1288
|
}
|
|
1824
1289
|
get value() {
|
|
1825
|
-
// Always read from DOM - tags with 'selected' attribute are source of truth
|
|
1826
1290
|
return this.getSelectedValues().join(",");
|
|
1827
1291
|
}
|
|
1828
1292
|
set value(val) {
|
|
@@ -1919,6 +1383,12 @@ export class TySelect extends TyComponent {
|
|
|
1919
1383
|
set align(value) {
|
|
1920
1384
|
this.setProperty("align", value);
|
|
1921
1385
|
}
|
|
1386
|
+
get placement() {
|
|
1387
|
+
return this.getProperty("placement");
|
|
1388
|
+
}
|
|
1389
|
+
set placement(value) {
|
|
1390
|
+
this.setProperty("placement", value);
|
|
1391
|
+
}
|
|
1922
1392
|
get flavor() {
|
|
1923
1393
|
return this.getProperty("flavor");
|
|
1924
1394
|
}
|
|
@@ -1929,9 +1399,6 @@ export class TySelect extends TyComponent {
|
|
|
1929
1399
|
return this._internals.form;
|
|
1930
1400
|
}
|
|
1931
1401
|
}
|
|
1932
|
-
// ============================================================================
|
|
1933
|
-
// PROPERTY CONFIGURATION - Declarative property lifecycle
|
|
1934
|
-
// ============================================================================
|
|
1935
1402
|
TySelect.properties = {
|
|
1936
1403
|
value: {
|
|
1937
1404
|
type: "string",
|
|
@@ -1940,15 +1407,12 @@ TySelect.properties = {
|
|
|
1940
1407
|
emitChange: false,
|
|
1941
1408
|
default: "",
|
|
1942
1409
|
coerce: (v) => {
|
|
1943
|
-
// Handle array input (from React, Reagent, etc.)
|
|
1944
1410
|
if (Array.isArray(v)) {
|
|
1945
1411
|
return v.join(",");
|
|
1946
1412
|
}
|
|
1947
|
-
// Handle null/undefined
|
|
1948
1413
|
if (v === null || v === undefined) {
|
|
1949
1414
|
return "";
|
|
1950
1415
|
}
|
|
1951
|
-
// String already
|
|
1952
1416
|
return String(v);
|
|
1953
1417
|
},
|
|
1954
1418
|
},
|
|
@@ -1956,14 +1420,11 @@ TySelect.properties = {
|
|
|
1956
1420
|
type: "string",
|
|
1957
1421
|
default: "",
|
|
1958
1422
|
},
|
|
1959
|
-
// Cardinality — native <select> semantics: absent = single select
|
|
1960
1423
|
multiple: {
|
|
1961
1424
|
type: "boolean",
|
|
1962
1425
|
visual: true,
|
|
1963
1426
|
default: false,
|
|
1964
1427
|
},
|
|
1965
|
-
// Skin — absent = form field (matches ty-input); present = compact
|
|
1966
|
-
// content-hugging trigger (toolbars, filter bars)
|
|
1967
1428
|
compact: {
|
|
1968
1429
|
type: "boolean",
|
|
1969
1430
|
visual: true,
|
|
@@ -1994,10 +1455,6 @@ TySelect.properties = {
|
|
|
1994
1455
|
visual: true,
|
|
1995
1456
|
default: false,
|
|
1996
1457
|
},
|
|
1997
|
-
// Built-in × clear button in the default/compact trigger, shown once
|
|
1998
|
-
// something is selected. Automatically absent under slot="trigger" —
|
|
1999
|
-
// it lives in the slot's fallback content, same mechanism that already
|
|
2000
|
-
// hides the chevron there. Default true matches ty-date-picker.
|
|
2001
1458
|
clearable: {
|
|
2002
1459
|
type: "boolean",
|
|
2003
1460
|
visual: true,
|
|
@@ -2010,17 +1467,12 @@ TySelect.properties = {
|
|
|
2010
1467
|
default: false,
|
|
2011
1468
|
aliases: { "external-search": true },
|
|
2012
1469
|
},
|
|
2013
|
-
// Enter on unmatched search text mints a new <ty-option> — forces the
|
|
2014
|
-
// search row on (same as external-search: the input IS the mechanism).
|
|
2015
1470
|
allowCreate: {
|
|
2016
1471
|
type: "boolean",
|
|
2017
1472
|
visual: true,
|
|
2018
1473
|
default: false,
|
|
2019
1474
|
aliases: { "allow-create": true },
|
|
2020
1475
|
},
|
|
2021
|
-
// Opt-in normalizer for the value minted by allow-create (label/display
|
|
2022
|
-
// text is always kept verbatim). 'none' (default): value === typed text.
|
|
2023
|
-
// 'slug': lowercase, spaces → '_', strips anything else non-alphanumeric.
|
|
2024
1476
|
createTransform: {
|
|
2025
1477
|
type: "string",
|
|
2026
1478
|
visual: false,
|
|
@@ -2028,9 +1480,6 @@ TySelect.properties = {
|
|
|
2028
1480
|
aliases: { "create-transform": true },
|
|
2029
1481
|
coerce: (v) => (v === "slug" ? "slug" : "none"),
|
|
2030
1482
|
},
|
|
2031
|
-
// Search row visibility: 'auto' (default) shows it only for long option
|
|
2032
|
-
// lists; searchable / searchable="true" forces it on, searchable="false"
|
|
2033
|
-
// off. external-search always shows it (the input IS the mechanism).
|
|
2034
1483
|
searchable: {
|
|
2035
1484
|
type: "string",
|
|
2036
1485
|
visual: true,
|
|
@@ -2043,23 +1492,22 @@ TySelect.properties = {
|
|
|
2043
1492
|
return "auto";
|
|
2044
1493
|
},
|
|
2045
1494
|
},
|
|
2046
|
-
// Horizontal anchor for the popup: "start" (default) matches the
|
|
2047
|
-
// trigger's left edge, "end" matches its right edge — either way
|
|
2048
|
-
// clamped into the viewport. Useful with slot="trigger" custom
|
|
2049
|
-
// triggers whose own position makes left-anchoring look wrong (e.g.
|
|
2050
|
-
// an icon button at the right edge of a toolbar).
|
|
2051
1495
|
align: {
|
|
2052
1496
|
type: "string",
|
|
2053
1497
|
visual: true,
|
|
2054
1498
|
default: "start",
|
|
2055
|
-
validate: (v) => ["start", "end"].includes(v),
|
|
2056
|
-
coerce: (v) =>
|
|
1499
|
+
validate: (v) => ["start", "center", "end"].includes(v),
|
|
1500
|
+
coerce: (v) => v === "end" ? "end" : v === "center" ? "center" : "start",
|
|
1501
|
+
},
|
|
1502
|
+
placement: {
|
|
1503
|
+
type: "string",
|
|
1504
|
+
visual: true,
|
|
1505
|
+
default: "",
|
|
2057
1506
|
},
|
|
2058
1507
|
size: {
|
|
2059
1508
|
type: "string",
|
|
2060
1509
|
visual: true,
|
|
2061
1510
|
default: "md",
|
|
2062
|
-
// Fields come in exactly three sizes; legacy xs/xl map to sm/lg.
|
|
2063
1511
|
validate: (v) => ["sm", "md", "lg"].includes(v),
|
|
2064
1512
|
coerce: (v) => {
|
|
2065
1513
|
if (v === "xs")
|
|
@@ -2099,17 +1547,12 @@ TySelect.properties = {
|
|
|
2099
1547
|
visual: true,
|
|
2100
1548
|
default: false,
|
|
2101
1549
|
},
|
|
2102
|
-
// Any string is accepted: built-ins get static CSS, other identifiers
|
|
2103
|
-
// become custom flavors (see _syncCustomFlavor), garbage degrades to
|
|
2104
|
-
// the default look.
|
|
2105
1550
|
flavor: {
|
|
2106
1551
|
type: "string",
|
|
2107
1552
|
visual: true,
|
|
2108
1553
|
default: "neutral",
|
|
2109
1554
|
},
|
|
2110
1555
|
};
|
|
2111
|
-
// Register the custom element
|
|
2112
1556
|
if (!customElements.get("ty-select")) {
|
|
2113
1557
|
customElements.define("ty-select", TySelect);
|
|
2114
1558
|
}
|
|
2115
|
-
//# sourceMappingURL=select.js.map
|