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