tyrell-components 1.0.0-TC25 → 1.0.0-TC27
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/css/tyrell-brand.css +15 -11
- package/css/tyrell.css +4 -4
- package/dist/tyrell-brand.css +15 -11
- package/dist/tyrell.css +4 -4
- package/dist/tyrell.js +1 -1
- package/lib/components/calendar-month.d.ts +6 -0
- package/lib/components/calendar-month.d.ts.map +1 -1
- package/lib/components/calendar-month.js +52 -9
- package/lib/components/calendar-month.js.map +1 -1
- package/lib/components/calendar-navigation.d.ts +31 -5
- package/lib/components/calendar-navigation.d.ts.map +1 -1
- package/lib/components/calendar-navigation.js +71 -31
- package/lib/components/calendar-navigation.js.map +1 -1
- package/lib/components/calendar.d.ts +14 -1
- package/lib/components/calendar.d.ts.map +1 -1
- package/lib/components/calendar.js +65 -4
- package/lib/components/calendar.js.map +1 -1
- package/lib/components/checkbox.d.ts +8 -0
- package/lib/components/checkbox.d.ts.map +1 -1
- package/lib/components/checkbox.js +48 -27
- package/lib/components/checkbox.js.map +1 -1
- package/lib/components/copy.d.ts +10 -0
- package/lib/components/copy.d.ts.map +1 -1
- package/lib/components/copy.js +25 -4
- package/lib/components/copy.js.map +1 -1
- package/lib/components/date-picker.d.ts +14 -0
- package/lib/components/date-picker.d.ts.map +1 -1
- package/lib/components/date-picker.js +29 -0
- package/lib/components/date-picker.js.map +1 -1
- package/lib/components/input.d.ts +6 -0
- package/lib/components/input.d.ts.map +1 -1
- package/lib/components/input.js +30 -0
- package/lib/components/input.js.map +1 -1
- package/lib/components/modal.js +6 -0
- package/lib/components/modal.js.map +1 -1
- package/lib/components/option.d.ts +0 -16
- package/lib/components/option.d.ts.map +1 -1
- package/lib/components/option.js +9 -1
- package/lib/components/option.js.map +1 -1
- package/lib/components/radio.d.ts +1 -0
- package/lib/components/radio.d.ts.map +1 -1
- package/lib/components/radio.js +37 -8
- package/lib/components/radio.js.map +1 -1
- package/lib/components/{multiselect.d.ts → select.d.ts} +110 -67
- package/lib/components/select.d.ts.map +1 -0
- package/lib/components/{multiselect.js → select.js} +642 -490
- package/lib/components/select.js.map +1 -0
- package/lib/components/selected-tags.d.ts +80 -0
- package/lib/components/selected-tags.d.ts.map +1 -0
- package/lib/components/selected-tags.js +250 -0
- package/lib/components/selected-tags.js.map +1 -0
- package/lib/components/switch.d.ts.map +1 -1
- package/lib/components/switch.js +19 -7
- package/lib/components/switch.js.map +1 -1
- package/lib/components/tabs.d.ts.map +1 -1
- package/lib/components/tabs.js +10 -0
- package/lib/components/tabs.js.map +1 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/styles/calendar-month.d.ts +1 -1
- package/lib/styles/calendar-month.d.ts.map +1 -1
- package/lib/styles/calendar-month.js +9 -1
- package/lib/styles/calendar-month.js.map +1 -1
- package/lib/styles/calendar-navigation.d.ts +1 -1
- package/lib/styles/calendar-navigation.d.ts.map +1 -1
- package/lib/styles/calendar-navigation.js +7 -1
- package/lib/styles/calendar-navigation.js.map +1 -1
- package/lib/styles/checkbox.d.ts +5 -5
- package/lib/styles/checkbox.d.ts.map +1 -1
- package/lib/styles/checkbox.js +147 -8
- package/lib/styles/checkbox.js.map +1 -1
- package/lib/styles/copy.d.ts +1 -1
- package/lib/styles/copy.d.ts.map +1 -1
- package/lib/styles/copy.js +32 -0
- package/lib/styles/copy.js.map +1 -1
- package/lib/styles/input.d.ts +1 -1
- package/lib/styles/input.d.ts.map +1 -1
- package/lib/styles/input.js +28 -298
- package/lib/styles/input.js.map +1 -1
- package/lib/styles/option.d.ts +1 -1
- package/lib/styles/option.d.ts.map +1 -1
- package/lib/styles/option.js +29 -5
- package/lib/styles/option.js.map +1 -1
- package/lib/styles/radio.d.ts +1 -1
- package/lib/styles/radio.d.ts.map +1 -1
- package/lib/styles/select.d.ts +12 -0
- package/lib/styles/select.d.ts.map +1 -0
- package/lib/styles/select.js +245 -0
- package/lib/styles/select.js.map +1 -0
- package/lib/styles/switch.d.ts +1 -1
- package/lib/styles/switch.d.ts.map +1 -1
- package/lib/styles/textarea.d.ts +1 -1
- package/lib/styles/textarea.d.ts.map +1 -1
- package/lib/styles/textarea.js +1 -1
- package/lib/utils/calendar-utils.d.ts +0 -48
- package/lib/utils/calendar-utils.d.ts.map +1 -1
- package/lib/utils/calendar-utils.js +0 -69
- package/lib/utils/calendar-utils.js.map +1 -1
- package/lib/utils/icon-registry.d.ts +0 -13
- package/lib/utils/icon-registry.d.ts.map +1 -1
- package/lib/utils/icon-registry.js +0 -35
- package/lib/utils/icon-registry.js.map +1 -1
- package/lib/utils/locale.d.ts +0 -57
- package/lib/utils/locale.d.ts.map +1 -1
- package/lib/utils/locale.js +0 -89
- package/lib/utils/locale.js.map +1 -1
- package/lib/utils/number-format.d.ts +0 -21
- package/lib/utils/number-format.d.ts.map +1 -1
- package/lib/utils/number-format.js +0 -27
- package/lib/utils/number-format.js.map +1 -1
- package/lib/utils/positioning.d.ts +0 -24
- package/lib/utils/positioning.d.ts.map +1 -1
- package/lib/utils/positioning.js +3 -86
- package/lib/utils/positioning.js.map +1 -1
- package/lib/utils/scroll-lock.d.ts.map +1 -1
- package/lib/utils/scroll-lock.js +0 -1
- package/lib/utils/scroll-lock.js.map +1 -1
- package/lib/utils/styles.d.ts +0 -5
- package/lib/utils/styles.d.ts.map +1 -1
- package/lib/utils/styles.js +0 -7
- package/lib/utils/styles.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +8 -8
- package/css/tyrell-simple.css +0 -257
- package/lib/components/dropdown.d.ts +0 -424
- package/lib/components/dropdown.d.ts.map +0 -1
- package/lib/components/dropdown.js +0 -1640
- package/lib/components/dropdown.js.map +0 -1
- package/lib/components/multiselect.d.ts.map +0 -1
- package/lib/components/multiselect.js.map +0 -1
- package/lib/styles/dropdown.d.ts +0 -12
- package/lib/styles/dropdown.d.ts.map +0 -1
- package/lib/styles/dropdown.js +0 -1081
- package/lib/styles/dropdown.js.map +0 -1
|
@@ -1,52 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* PORTED FROM: clj/ty/components/multiselect.cljs
|
|
2
|
+
* TySelect Web Component
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
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-tags>)
|
|
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)
|
|
15
27
|
*
|
|
16
28
|
* @example
|
|
17
29
|
* ```html
|
|
18
|
-
* <!--
|
|
19
|
-
* <ty-
|
|
20
|
-
* <ty-
|
|
21
|
-
* <ty-
|
|
22
|
-
*
|
|
23
|
-
* </ty-multiselect>
|
|
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>
|
|
24
35
|
*
|
|
25
|
-
* <!--
|
|
26
|
-
* <ty-
|
|
27
|
-
*
|
|
28
|
-
* <ty-tag value="ts">TypeScript</ty-tag>
|
|
29
|
-
* <ty-tag value="py">Python</ty-tag>
|
|
30
|
-
* </ty-multiselect>
|
|
31
|
-
*
|
|
32
|
-
* <!-- With rich tag content -->
|
|
33
|
-
* <ty-multiselect label="Team Members">
|
|
34
|
-
* <ty-tag value="1" flavor="primary">
|
|
35
|
-
* <div class="flex items-center gap-2">
|
|
36
|
-
* <img src="avatar1.jpg" class="w-6 h-6 rounded-full" />
|
|
37
|
-
* <span>John Doe</span>
|
|
38
|
-
* </div>
|
|
39
|
-
* </ty-tag>
|
|
40
|
-
* </ty-multiselect>
|
|
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-tags for="robots"></ty-selected-tags>
|
|
41
39
|
* ```
|
|
42
40
|
*/
|
|
43
|
-
import { ensureStyles } from
|
|
44
|
-
import { multiselectStyles } from
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
41
|
+
import { ensureStyles } from "../utils/styles.js";
|
|
42
|
+
import { multiselectStyles } from "../styles/multiselect.js";
|
|
43
|
+
import { selectStyles } from "../styles/select.js";
|
|
44
|
+
import { getLoaderSvg } from "../utils/loader-registry.js";
|
|
45
|
+
import { lockScroll, unlockScroll } from "../utils/scroll-lock.js";
|
|
46
|
+
import { isMobileTouch } from "../utils/mobile.js";
|
|
47
|
+
import { TyComponent } from "../base/ty-component.js";
|
|
48
|
+
import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scrollbar.js";
|
|
50
49
|
// ============================================================================
|
|
51
50
|
// Element Hash Utility (for consistent scroll lock IDs)
|
|
52
51
|
// ============================================================================
|
|
@@ -87,41 +86,53 @@ const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" he
|
|
|
87
86
|
const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
|
|
88
87
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
89
88
|
</svg>`;
|
|
89
|
+
/**
|
|
90
|
+
* 'auto' searchable mode: option count above which the search row appears.
|
|
91
|
+
* Short lists scan faster than they search — native <select> has no search.
|
|
92
|
+
*/
|
|
93
|
+
const SEARCH_AUTO_THRESHOLD = 7;
|
|
94
|
+
/**
|
|
95
|
+
* Search (magnifier) icon SVG — popup search input adornment (from Lucide)
|
|
96
|
+
*/
|
|
97
|
+
const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
98
|
+
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
99
|
+
</svg>`;
|
|
90
100
|
/**
|
|
91
101
|
* Ty Multiselect Component
|
|
92
102
|
*/
|
|
93
|
-
export class
|
|
103
|
+
export class TySelect extends TyComponent {
|
|
94
104
|
constructor() {
|
|
95
105
|
super(); // TyComponent handles attachInternals() and attachShadow()
|
|
96
106
|
// ============================================================================
|
|
97
107
|
// INTERNAL STATE
|
|
98
108
|
// ============================================================================
|
|
99
|
-
this._name =
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
109
|
+
this._name = "";
|
|
110
|
+
this._multiple = false;
|
|
111
|
+
this._compact = false;
|
|
112
|
+
this._placeholder = "Select...";
|
|
113
|
+
this._label = "";
|
|
102
114
|
this._disabled = false;
|
|
103
115
|
this._readonly = false;
|
|
104
116
|
this._required = false;
|
|
105
117
|
this._externalSearch = false;
|
|
118
|
+
this._searchable = "auto";
|
|
106
119
|
this._loading = false;
|
|
107
120
|
this._scrollLockId = null;
|
|
108
|
-
this._size =
|
|
109
|
-
this.
|
|
110
|
-
this.
|
|
111
|
-
this._noOptionsMessage = 'No options available';
|
|
121
|
+
this._size = "md";
|
|
122
|
+
this._availableLabel = "Available";
|
|
123
|
+
this._noOptionsMessage = "No options available";
|
|
112
124
|
// Component state
|
|
113
125
|
this._state = {
|
|
114
126
|
open: false,
|
|
115
|
-
search:
|
|
127
|
+
search: "",
|
|
116
128
|
highlightedIndex: -1,
|
|
117
129
|
filteredTags: [],
|
|
118
130
|
selectedValues: [],
|
|
119
|
-
mode:
|
|
131
|
+
mode: "desktop", // Updated dynamically on render via syncMode()
|
|
120
132
|
};
|
|
121
133
|
// Event handler references for cleanup
|
|
122
134
|
this._stubClickHandler = null;
|
|
123
135
|
this._tagClickHandler = null;
|
|
124
|
-
this._tagDismissHandler = null;
|
|
125
136
|
this._searchInputHandler = null;
|
|
126
137
|
this._blockSearchClick = null;
|
|
127
138
|
this._keyboardHandler = null;
|
|
@@ -133,8 +144,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
133
144
|
// MutationObserver for light-DOM children — re-syncs selected tags' visual
|
|
134
145
|
// state when consumers swap tag children (external-search refresh pattern).
|
|
135
146
|
this._childObserver = null;
|
|
147
|
+
// Last single-select display clone — survives external-search option swaps
|
|
148
|
+
// that remove the matching option (and often the clone itself) from the DOM.
|
|
149
|
+
this._selectedClone = null;
|
|
136
150
|
const shadow = this.shadowRoot;
|
|
137
|
-
ensureStyles(shadow, { css: multiselectStyles, id:
|
|
151
|
+
ensureStyles(shadow, { css: multiselectStyles, id: "ty-select-base" });
|
|
152
|
+
ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
|
|
138
153
|
// DON'T render here - wait for onConnect() to initialize values first
|
|
139
154
|
// This matches dropdown.ts pattern and prevents showing empty state
|
|
140
155
|
}
|
|
@@ -145,10 +160,10 @@ export class TyMultiselect extends TyComponent {
|
|
|
145
160
|
onConnect() {
|
|
146
161
|
// SAFETY: Close any open dialogs to prevent scroll locking
|
|
147
162
|
const shadow = this.shadowRoot;
|
|
148
|
-
const dialogs = shadow.querySelectorAll(
|
|
149
|
-
dialogs.forEach(dialog => {
|
|
163
|
+
const dialogs = shadow.querySelectorAll("dialog");
|
|
164
|
+
dialogs.forEach((dialog) => {
|
|
150
165
|
if (dialog.open) {
|
|
151
|
-
console.warn(
|
|
166
|
+
console.warn("⚠️ Found open dialog on connect, closing it");
|
|
152
167
|
dialog.close();
|
|
153
168
|
}
|
|
154
169
|
});
|
|
@@ -167,6 +182,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
167
182
|
if (this._state.selectedValues.length > 0) {
|
|
168
183
|
this.syncSelectedTags(this._state.selectedValues);
|
|
169
184
|
}
|
|
185
|
+
// Option count may have crossed the 'auto' searchable threshold
|
|
186
|
+
this.updateSearchVisibility();
|
|
187
|
+
// External-search consumers replace ALL ty-option children — often
|
|
188
|
+
// including our display clone. Rebuild the selection display (idempotent:
|
|
189
|
+
// a clone that already matches is left alone, so no observer loop).
|
|
190
|
+
this.updateSelectionDisplay();
|
|
170
191
|
});
|
|
171
192
|
this._childObserver.observe(this, { childList: true });
|
|
172
193
|
}
|
|
@@ -177,15 +198,15 @@ export class TyMultiselect extends TyComponent {
|
|
|
177
198
|
onDisconnect() {
|
|
178
199
|
// CRITICAL: Close all dialogs to prevent scroll locking
|
|
179
200
|
const shadow = this.shadowRoot;
|
|
180
|
-
const dialogs = shadow.querySelectorAll(
|
|
181
|
-
dialogs.forEach(dialog => {
|
|
201
|
+
const dialogs = shadow.querySelectorAll("dialog");
|
|
202
|
+
dialogs.forEach((dialog) => {
|
|
182
203
|
if (dialog.open) {
|
|
183
204
|
dialog.close();
|
|
184
205
|
}
|
|
185
206
|
});
|
|
186
207
|
// Clean up document-level listeners
|
|
187
208
|
if (this._keyboardHandler) {
|
|
188
|
-
document.removeEventListener(
|
|
209
|
+
document.removeEventListener("keydown", this._keyboardHandler);
|
|
189
210
|
this._keyboardHandler = null;
|
|
190
211
|
}
|
|
191
212
|
// Clear any pending debounce timer
|
|
@@ -208,7 +229,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
208
229
|
onPropertiesChanged(changes) {
|
|
209
230
|
for (const { name, newValue } of changes) {
|
|
210
231
|
switch (name) {
|
|
211
|
-
case
|
|
232
|
+
case "value":
|
|
212
233
|
const selectedValues = this.parseValue(newValue);
|
|
213
234
|
this._state.selectedValues = selectedValues;
|
|
214
235
|
// CRITICAL: Only sync tags if we're connected and tags exist
|
|
@@ -218,48 +239,59 @@ export class TyMultiselect extends TyComponent {
|
|
|
218
239
|
this.updateSelectionDisplay();
|
|
219
240
|
this.updateMobileSelectedState();
|
|
220
241
|
}
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
// (not-yet-connected: sync deferred to initializeState)
|
|
243
|
+
break;
|
|
244
|
+
case "name":
|
|
245
|
+
this._name = newValue || "";
|
|
246
|
+
break;
|
|
247
|
+
case "multiple":
|
|
248
|
+
this._multiple = newValue;
|
|
249
|
+
// Dropping to single with a multi selection: keep only the first
|
|
250
|
+
if (!newValue && this._state.selectedValues.length > 1) {
|
|
251
|
+
this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
|
|
223
252
|
}
|
|
224
253
|
break;
|
|
225
|
-
case
|
|
226
|
-
this.
|
|
254
|
+
case "compact":
|
|
255
|
+
this._compact = newValue;
|
|
227
256
|
break;
|
|
228
|
-
case
|
|
229
|
-
this._placeholder = newValue ||
|
|
230
|
-
|
|
257
|
+
case "placeholder":
|
|
258
|
+
this._placeholder = newValue || "Select...";
|
|
259
|
+
if (this.isConnected && this.shadowRoot)
|
|
260
|
+
this.updateSelectionDisplay();
|
|
231
261
|
break;
|
|
232
|
-
case
|
|
233
|
-
this._label = newValue ||
|
|
262
|
+
case "label":
|
|
263
|
+
this._label = newValue || "";
|
|
234
264
|
break;
|
|
235
|
-
case
|
|
265
|
+
case "disabled":
|
|
236
266
|
this._disabled = newValue;
|
|
237
267
|
break;
|
|
238
|
-
case
|
|
268
|
+
case "readonly":
|
|
239
269
|
this._readonly = newValue;
|
|
240
270
|
break;
|
|
241
|
-
case
|
|
271
|
+
case "required":
|
|
242
272
|
this._required = newValue;
|
|
243
273
|
break;
|
|
244
|
-
case
|
|
274
|
+
case "externalSearch":
|
|
245
275
|
this._externalSearch = newValue;
|
|
246
276
|
break;
|
|
247
|
-
case
|
|
277
|
+
case "searchable":
|
|
278
|
+
this._searchable = newValue;
|
|
279
|
+
if (this.isConnected && this.shadowRoot)
|
|
280
|
+
this.updateSearchVisibility();
|
|
281
|
+
break;
|
|
282
|
+
case "size":
|
|
248
283
|
this._size = newValue;
|
|
249
284
|
break;
|
|
250
|
-
case
|
|
285
|
+
case "debounce":
|
|
251
286
|
this._debounce = newValue;
|
|
252
287
|
break;
|
|
253
|
-
case
|
|
254
|
-
this.
|
|
255
|
-
break;
|
|
256
|
-
case 'available-label':
|
|
257
|
-
this._availableLabel = newValue || 'Available';
|
|
288
|
+
case "available-label":
|
|
289
|
+
this._availableLabel = newValue || "Available";
|
|
258
290
|
break;
|
|
259
|
-
case
|
|
260
|
-
this._noOptionsMessage = newValue ||
|
|
291
|
+
case "no-options-message":
|
|
292
|
+
this._noOptionsMessage = newValue || "No options available";
|
|
261
293
|
break;
|
|
262
|
-
case
|
|
294
|
+
case "loading":
|
|
263
295
|
this._loading = newValue;
|
|
264
296
|
this.applyLoadingState();
|
|
265
297
|
break;
|
|
@@ -277,16 +309,16 @@ export class TyMultiselect extends TyComponent {
|
|
|
277
309
|
if (!shadow)
|
|
278
310
|
return;
|
|
279
311
|
const svg = this._loading ? getLoaderSvg() : null;
|
|
280
|
-
shadow.querySelectorAll(
|
|
281
|
-
wrapper.classList.toggle(
|
|
312
|
+
shadow.querySelectorAll(".dropdown-options-wrapper").forEach((wrapper) => {
|
|
313
|
+
wrapper.classList.toggle("loading", this._loading);
|
|
282
314
|
if (this._loading) {
|
|
283
|
-
wrapper.setAttribute(
|
|
284
|
-
const spinner = wrapper.querySelector(
|
|
315
|
+
wrapper.setAttribute("aria-busy", "true");
|
|
316
|
+
const spinner = wrapper.querySelector(".dropdown-loading-spinner");
|
|
285
317
|
if (spinner && svg)
|
|
286
318
|
spinner.innerHTML = svg;
|
|
287
319
|
}
|
|
288
320
|
else {
|
|
289
|
-
wrapper.removeAttribute(
|
|
321
|
+
wrapper.removeAttribute("aria-busy");
|
|
290
322
|
}
|
|
291
323
|
});
|
|
292
324
|
}
|
|
@@ -297,8 +329,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
297
329
|
getFormValue() {
|
|
298
330
|
const selectedValues = this._state.selectedValues;
|
|
299
331
|
if (this._name && selectedValues.length > 0) {
|
|
332
|
+
// Single select submits one plain entry
|
|
333
|
+
if (!this._multiple)
|
|
334
|
+
return selectedValues[0];
|
|
335
|
+
// Multiple submits repeated `name=` entries (HTMX standard)
|
|
300
336
|
const formData = new FormData();
|
|
301
|
-
selectedValues.forEach(value => {
|
|
337
|
+
selectedValues.forEach((value) => {
|
|
302
338
|
formData.append(this._name, value);
|
|
303
339
|
});
|
|
304
340
|
return formData;
|
|
@@ -310,16 +346,19 @@ export class TyMultiselect extends TyComponent {
|
|
|
310
346
|
*/
|
|
311
347
|
parseValue(value) {
|
|
312
348
|
// Defensive check: ensure value is actually a string before calling .trim()
|
|
313
|
-
if (!value || typeof value !==
|
|
349
|
+
if (!value || typeof value !== "string" || value.trim() === "")
|
|
314
350
|
return [];
|
|
315
|
-
return value
|
|
351
|
+
return value
|
|
352
|
+
.split(",")
|
|
353
|
+
.map((v) => v.trim())
|
|
354
|
+
.filter((v) => v !== "");
|
|
316
355
|
}
|
|
317
356
|
/**
|
|
318
357
|
* Initialize component state from attributes
|
|
319
358
|
* Reads from both property and attribute (like ClojureScript version)
|
|
320
359
|
*/
|
|
321
360
|
initializeState() {
|
|
322
|
-
const initialValue = this.getProperty(
|
|
361
|
+
const initialValue = this.getProperty("value") || "";
|
|
323
362
|
if (initialValue) {
|
|
324
363
|
// Explicit value provided - sync tags directly
|
|
325
364
|
// DON'T use updateComponentValue() because the property is already set!
|
|
@@ -336,8 +375,8 @@ export class TyMultiselect extends TyComponent {
|
|
|
336
375
|
// No explicit value - check for pre-selected tags
|
|
337
376
|
const allTags = this.getTagElements();
|
|
338
377
|
const preSelectedTags = allTags
|
|
339
|
-
.filter(tag => tag.hasAttribute(
|
|
340
|
-
.map(tag => this.getTagData(tag).value);
|
|
378
|
+
.filter((tag) => tag.hasAttribute("selected"))
|
|
379
|
+
.map((tag) => this.getTagData(tag).value);
|
|
341
380
|
if (preSelectedTags.length > 0) {
|
|
342
381
|
// Set the property value and sync (updateComponentValue handles everything)
|
|
343
382
|
this.updateComponentValue(preSelectedTags, false);
|
|
@@ -348,78 +387,48 @@ export class TyMultiselect extends TyComponent {
|
|
|
348
387
|
// TAG MANAGEMENT METHODS (Phase 2)
|
|
349
388
|
// ============================================================================
|
|
350
389
|
/**
|
|
351
|
-
* Get all
|
|
390
|
+
* Get all option elements from the component (ty-option preferred, ty-tag accepted)
|
|
352
391
|
*/
|
|
353
392
|
getTagElements() {
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
return
|
|
393
|
+
// :not([cloned]) — the single-select display clone (slot="selected")
|
|
394
|
+
// lives in our light DOM too and must never count as an option
|
|
395
|
+
return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
|
|
357
396
|
}
|
|
358
397
|
/**
|
|
359
398
|
* Extract value and text from a ty-tag element
|
|
360
399
|
*/
|
|
361
400
|
getTagData(element) {
|
|
362
401
|
// Get value from either property or attribute
|
|
363
|
-
const value = element.value ||
|
|
364
|
-
|
|
402
|
+
const value = element.value ||
|
|
403
|
+
element.getAttribute("value") ||
|
|
404
|
+
element.textContent ||
|
|
405
|
+
"";
|
|
406
|
+
// Display label: explicit label attribute wins over textContent —
|
|
407
|
+
// native <option label> semantics; lets rich options declare clean text
|
|
408
|
+
const text = element.getAttribute("label") || element.textContent || "";
|
|
365
409
|
return { value, text, element };
|
|
366
410
|
}
|
|
367
411
|
/**
|
|
368
|
-
* Select
|
|
412
|
+
* Select an option - attribute only. The option stays in the list with
|
|
413
|
+
* selected styling; chips are rendered out-of-band (ty-selected-tags).
|
|
369
414
|
*/
|
|
370
415
|
selectTag(tag) {
|
|
371
|
-
|
|
372
|
-
// Set selected attribute
|
|
373
|
-
tag.setAttribute('selected', '');
|
|
374
|
-
// Move to selected slot
|
|
375
|
-
tag.setAttribute('slot', 'selected');
|
|
376
|
-
// Make dismissible
|
|
377
|
-
tag.setAttribute('dismissible', 'true');
|
|
378
|
-
// Force re-slotting by removing and re-adding
|
|
379
|
-
const parent = tag.parentNode;
|
|
380
|
-
if (parent) {
|
|
381
|
-
parent.removeChild(tag);
|
|
382
|
-
parent.appendChild(tag);
|
|
383
|
-
}
|
|
384
|
-
else {
|
|
385
|
-
console.warn(`⚠️ [multiselect] - No parent for "${tagValue}"!`);
|
|
386
|
-
}
|
|
416
|
+
tag.setAttribute("selected", "");
|
|
387
417
|
}
|
|
388
418
|
/**
|
|
389
|
-
* Deselect
|
|
419
|
+
* Deselect an option - attribute only.
|
|
390
420
|
*/
|
|
391
421
|
deselectTag(tag) {
|
|
392
|
-
|
|
393
|
-
tag.removeAttribute('selected');
|
|
394
|
-
// Remove from selected slot
|
|
395
|
-
tag.removeAttribute('slot');
|
|
396
|
-
// Remove dismissible
|
|
397
|
-
tag.removeAttribute('dismissible');
|
|
398
|
-
// Force re-slotting by removing and re-adding
|
|
399
|
-
const parent = tag.parentNode;
|
|
400
|
-
if (parent) {
|
|
401
|
-
parent.removeChild(tag);
|
|
402
|
-
parent.appendChild(tag);
|
|
403
|
-
}
|
|
422
|
+
tag.removeAttribute("selected");
|
|
404
423
|
}
|
|
405
424
|
/**
|
|
406
425
|
* Get array of currently selected values from tags (ALWAYS reads from DOM)
|
|
407
426
|
*/
|
|
408
427
|
getSelectedValues() {
|
|
409
428
|
return this.getTagElements()
|
|
410
|
-
.filter(tag => tag.hasAttribute(
|
|
411
|
-
.map(tag => this.getTagData(tag).value)
|
|
412
|
-
.filter(value => value !==
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Check if all available tags are selected
|
|
416
|
-
*/
|
|
417
|
-
allTagsSelected() {
|
|
418
|
-
const tags = this.getTagElements();
|
|
419
|
-
if (tags.length === 0)
|
|
420
|
-
return false;
|
|
421
|
-
const selectedCount = tags.filter(tag => tag.hasAttribute('selected')).length;
|
|
422
|
-
return selectedCount === tags.length;
|
|
429
|
+
.filter((tag) => tag.hasAttribute("selected"))
|
|
430
|
+
.map((tag) => this.getTagData(tag).value)
|
|
431
|
+
.filter((value) => value !== "");
|
|
423
432
|
}
|
|
424
433
|
/**
|
|
425
434
|
* Sync tag selection states with desired values
|
|
@@ -427,10 +436,10 @@ export class TyMultiselect extends TyComponent {
|
|
|
427
436
|
syncSelectedTags(selectedValues) {
|
|
428
437
|
const selectedSet = new Set(selectedValues);
|
|
429
438
|
const tags = this.getTagElements();
|
|
430
|
-
tags.forEach(tag => {
|
|
439
|
+
tags.forEach((tag) => {
|
|
431
440
|
const tagValue = this.getTagData(tag).value;
|
|
432
441
|
const shouldBeSelected = selectedSet.has(tagValue);
|
|
433
|
-
const isSelected = tag.hasAttribute(
|
|
442
|
+
const isSelected = tag.hasAttribute("selected");
|
|
434
443
|
if (shouldBeSelected && !isSelected) {
|
|
435
444
|
this.selectTag(tag);
|
|
436
445
|
}
|
|
@@ -443,9 +452,9 @@ export class TyMultiselect extends TyComponent {
|
|
|
443
452
|
* Central update function - synchronizes everything
|
|
444
453
|
* Uses TyComponent's property system for proper lifecycle
|
|
445
454
|
*/
|
|
446
|
-
updateComponentValue(newValues, dispatchChange = false, action =
|
|
455
|
+
updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
|
|
447
456
|
const oldValues = this.getSelectedValues();
|
|
448
|
-
const valueStr = newValues.join(
|
|
457
|
+
const valueStr = newValues.join(",");
|
|
449
458
|
// Only update if changed
|
|
450
459
|
if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
|
|
451
460
|
// Use TyComponent's property system - this will trigger:
|
|
@@ -453,18 +462,34 @@ export class TyMultiselect extends TyComponent {
|
|
|
453
462
|
// 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
|
|
454
463
|
// 3. updateFormValue() → automatic (formValue: true in config)
|
|
455
464
|
// 4. render() → automatic if visual properties changed
|
|
456
|
-
this.setProperty(
|
|
457
|
-
// Dispatch
|
|
465
|
+
this.setProperty("value", valueStr);
|
|
466
|
+
// Dispatch change event (with rich items for out-of-band chip rendering)
|
|
458
467
|
if (dispatchChange) {
|
|
459
468
|
this.dispatchChangeEvent({
|
|
469
|
+
// Scalar for single select, array for multiple
|
|
470
|
+
value: this._multiple ? [...newValues] : (newValues[0] ?? null),
|
|
460
471
|
values: newValues,
|
|
472
|
+
items: this.getSelectedItems(newValues),
|
|
461
473
|
action,
|
|
462
|
-
item
|
|
474
|
+
item,
|
|
463
475
|
});
|
|
464
476
|
}
|
|
465
477
|
}
|
|
466
|
-
|
|
467
|
-
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Build rich item info for values, read off the matching option elements
|
|
481
|
+
*/
|
|
482
|
+
getSelectedItems(values) {
|
|
483
|
+
const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
|
|
484
|
+
return values.map((value) => {
|
|
485
|
+
const el = byValue.get(value);
|
|
486
|
+
return {
|
|
487
|
+
value,
|
|
488
|
+
// label attribute wins (native <option label> semantics)
|
|
489
|
+
label: el?.getAttribute("label") || el?.textContent?.trim() || value,
|
|
490
|
+
flavor: el?.getAttribute("flavor") ?? null,
|
|
491
|
+
};
|
|
492
|
+
});
|
|
468
493
|
}
|
|
469
494
|
// ============================================================================
|
|
470
495
|
// DROPDOWN METHODS (Phase 3 & 4)
|
|
@@ -474,50 +499,61 @@ export class TyMultiselect extends TyComponent {
|
|
|
474
499
|
*/
|
|
475
500
|
calculatePosition() {
|
|
476
501
|
const shadow = this.shadowRoot;
|
|
477
|
-
const stub = shadow.querySelector(
|
|
478
|
-
const dialog = shadow.querySelector(
|
|
502
|
+
const stub = shadow.querySelector(".multiselect-stub");
|
|
503
|
+
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
479
504
|
if (!stub || !dialog)
|
|
480
505
|
return;
|
|
481
506
|
const stubRect = stub.getBoundingClientRect();
|
|
482
507
|
const viewportHeight = window.innerHeight;
|
|
483
508
|
const viewportWidth = window.innerWidth;
|
|
484
|
-
// Get dialog dimensions (it's already shown with showModal)
|
|
509
|
+
// Get dialog dimensions (it's already shown with showModal).
|
|
510
|
+
// Cap the estimate: with hundreds of options the raw measurement can be
|
|
511
|
+
// the uncapped content height, which made "fits below?" always false.
|
|
512
|
+
// 440px ≈ options max-height (24rem) + search header.
|
|
513
|
+
const MAX_POPUP_ESTIMATE = 440;
|
|
485
514
|
const dialogRect = dialog.getBoundingClientRect();
|
|
486
|
-
const estimatedHeight = dialogRect.height || 200;
|
|
515
|
+
const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
|
|
487
516
|
const padding = 8;
|
|
488
517
|
const wrapPadding = 20;
|
|
518
|
+
// Popup width is independent of the trigger — a small button trigger still
|
|
519
|
+
// gets a usable list. Overridable via --ty-select-popup-width.
|
|
520
|
+
const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
|
|
521
|
+
const popupWidth = !isNaN(cssWidth)
|
|
522
|
+
? cssWidth
|
|
523
|
+
: Math.max(stubRect.width, 320);
|
|
489
524
|
// Available space calculations
|
|
490
525
|
const spaceBelow = viewportHeight - stubRect.bottom;
|
|
491
|
-
const
|
|
492
|
-
// Smart direction logic
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
526
|
+
const spaceAbove = stubRect.top;
|
|
527
|
+
// Smart direction logic: below when it fits — and when neither side
|
|
528
|
+
// fits, take the side with MORE room instead of blindly flipping up
|
|
529
|
+
// (a field near the viewport top used to clip the popup off-screen).
|
|
530
|
+
const positionBelow = spaceBelow >= estimatedHeight + padding || spaceBelow >= spaceAbove;
|
|
531
|
+
// Anchor to the trigger's left edge, clamped into the viewport
|
|
532
|
+
const x = Math.max(padding - wrapPadding, Math.min(stubRect.left - wrapPadding, viewportWidth - popupWidth - wrapPadding - padding));
|
|
533
|
+
// Open below (or above) the trigger — never covering it
|
|
534
|
+
const gap = 4;
|
|
499
535
|
const y = positionBelow
|
|
500
|
-
? stubRect.
|
|
501
|
-
: viewportHeight - stubRect.
|
|
502
|
-
const width =
|
|
536
|
+
? stubRect.bottom + gap - wrapPadding
|
|
537
|
+
: viewportHeight - stubRect.top + gap - wrapPadding;
|
|
538
|
+
const width = popupWidth + wrapPadding + wrapPadding;
|
|
503
539
|
// Set CSS variables for positioning
|
|
504
|
-
this.style.setProperty(
|
|
505
|
-
this.style.setProperty(
|
|
506
|
-
this.style.setProperty(
|
|
507
|
-
this.style.setProperty(
|
|
508
|
-
this.style.setProperty(
|
|
509
|
-
this.style.setProperty(
|
|
540
|
+
this.style.setProperty("--dropdown-x", `${x}px`);
|
|
541
|
+
this.style.setProperty("--dropdown-y", `${y}px`);
|
|
542
|
+
this.style.setProperty("--dropdown-width", `${width}px`);
|
|
543
|
+
this.style.setProperty("--dropdown-offset-x", "0px");
|
|
544
|
+
this.style.setProperty("--dropdown-offset-y", "0px");
|
|
545
|
+
this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
|
|
510
546
|
// Set direction classes for CSS styling
|
|
511
547
|
if (positionBelow) {
|
|
512
|
-
dialog.classList.add(
|
|
513
|
-
dialog.classList.remove(
|
|
548
|
+
dialog.classList.add("position-below");
|
|
549
|
+
dialog.classList.remove("position-above");
|
|
514
550
|
}
|
|
515
551
|
else {
|
|
516
|
-
dialog.classList.add(
|
|
517
|
-
dialog.classList.remove(
|
|
552
|
+
dialog.classList.add("position-above");
|
|
553
|
+
dialog.classList.remove("position-below");
|
|
518
554
|
}
|
|
519
555
|
// Optional: Store direction for debugging
|
|
520
|
-
this.style.setProperty(
|
|
556
|
+
this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
|
|
521
557
|
}
|
|
522
558
|
// ============================================================================
|
|
523
559
|
// CUSTOM SCROLLBAR FOR OPTIONS
|
|
@@ -526,13 +562,15 @@ export class TyMultiselect extends TyComponent {
|
|
|
526
562
|
if (!isCustomScrollbarEnabled())
|
|
527
563
|
return;
|
|
528
564
|
const shadow = this.shadowRoot;
|
|
529
|
-
const optionsDiv = shadow.querySelector(
|
|
530
|
-
const optionsWrapper = shadow.querySelector(
|
|
565
|
+
const optionsDiv = shadow.querySelector(".dropdown-options");
|
|
566
|
+
const optionsWrapper = shadow.querySelector(".dropdown-options-wrapper");
|
|
531
567
|
if (!optionsDiv || !optionsWrapper)
|
|
532
568
|
return;
|
|
533
569
|
this._destroyOptionsScrollbar();
|
|
534
|
-
optionsDiv.classList.add(
|
|
535
|
-
this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
|
|
570
|
+
optionsDiv.classList.add("ty-custom-scroll");
|
|
571
|
+
this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
|
|
572
|
+
vertical: true,
|
|
573
|
+
});
|
|
536
574
|
if (this._optionsScrollbar.trackY) {
|
|
537
575
|
optionsWrapper.appendChild(this._optionsScrollbar.trackY);
|
|
538
576
|
}
|
|
@@ -555,38 +593,38 @@ export class TyMultiselect extends TyComponent {
|
|
|
555
593
|
*/
|
|
556
594
|
openDropdown() {
|
|
557
595
|
const shadow = this.shadowRoot;
|
|
558
|
-
const dialog = shadow.querySelector(
|
|
596
|
+
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
559
597
|
if (!dialog)
|
|
560
598
|
return;
|
|
561
599
|
// Lock body scroll while dropdown is open
|
|
562
|
-
const lockId = `multiselect-${this.id ||
|
|
600
|
+
const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
|
|
563
601
|
this._scrollLockId = lockId;
|
|
564
602
|
lockScroll(lockId);
|
|
565
603
|
// Show modal
|
|
566
604
|
dialog.showModal();
|
|
567
|
-
dialog.classList.add(
|
|
605
|
+
dialog.classList.add("open");
|
|
568
606
|
// Position dropdown AFTER showing modal
|
|
569
607
|
this.calculatePosition();
|
|
570
608
|
// Update component state
|
|
571
609
|
this._state.open = true;
|
|
572
610
|
// Update visual states
|
|
573
|
-
const chevron = shadow.querySelector(
|
|
611
|
+
const chevron = shadow.querySelector(".dropdown-chevron");
|
|
574
612
|
if (chevron)
|
|
575
|
-
chevron.classList.add(
|
|
576
|
-
const searchChevron = shadow.querySelector(
|
|
613
|
+
chevron.classList.add("open");
|
|
614
|
+
const searchChevron = shadow.querySelector(".dropdown-search-chevron");
|
|
577
615
|
if (searchChevron)
|
|
578
|
-
searchChevron.classList.add(
|
|
616
|
+
searchChevron.classList.add("open");
|
|
579
617
|
// Initialize options state
|
|
580
|
-
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
618
|
+
const tags = this.getTagElements().map((el) => this.getTagData(el));
|
|
581
619
|
this._state.filteredTags = tags;
|
|
582
620
|
this._state.highlightedIndex = -1;
|
|
583
621
|
// Ensure options area is visible (may have been hidden from previous search)
|
|
584
622
|
this.updateOptionsVisibility(true);
|
|
585
623
|
// Setup custom scrollbar on options
|
|
586
624
|
this._setupOptionsScrollbar();
|
|
587
|
-
// Focus search input
|
|
588
|
-
const searchInput = shadow.querySelector(
|
|
589
|
-
if (searchInput) {
|
|
625
|
+
// Focus search input (when the search row is shown)
|
|
626
|
+
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
627
|
+
if (searchInput && this.shouldShowSearch()) {
|
|
590
628
|
setTimeout(() => searchInput.focus(), 100);
|
|
591
629
|
}
|
|
592
630
|
// Lifecycle event (also fires empty search if external-search)
|
|
@@ -597,15 +635,15 @@ export class TyMultiselect extends TyComponent {
|
|
|
597
635
|
*/
|
|
598
636
|
closeDropdown() {
|
|
599
637
|
const shadow = this.shadowRoot;
|
|
600
|
-
const dialog = shadow.querySelector(
|
|
638
|
+
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
601
639
|
if (!dialog)
|
|
602
640
|
return;
|
|
603
641
|
// Destroy custom scrollbar
|
|
604
642
|
this._destroyOptionsScrollbar();
|
|
605
643
|
// Close dialog
|
|
606
|
-
dialog.classList.remove(
|
|
607
|
-
dialog.classList.remove(
|
|
608
|
-
dialog.classList.remove(
|
|
644
|
+
dialog.classList.remove("open");
|
|
645
|
+
dialog.classList.remove("position-above");
|
|
646
|
+
dialog.classList.remove("position-below");
|
|
609
647
|
dialog.close();
|
|
610
648
|
// Unlock body scroll (paired with the lock in openDropdown)
|
|
611
649
|
if (this._scrollLockId) {
|
|
@@ -616,18 +654,18 @@ export class TyMultiselect extends TyComponent {
|
|
|
616
654
|
this._state.open = false;
|
|
617
655
|
this._state.highlightedIndex = -1;
|
|
618
656
|
// Update visual states
|
|
619
|
-
const chevron = shadow.querySelector(
|
|
657
|
+
const chevron = shadow.querySelector(".dropdown-chevron");
|
|
620
658
|
if (chevron)
|
|
621
|
-
chevron.classList.remove(
|
|
622
|
-
const searchChevron = shadow.querySelector(
|
|
659
|
+
chevron.classList.remove("open");
|
|
660
|
+
const searchChevron = shadow.querySelector(".dropdown-search-chevron");
|
|
623
661
|
if (searchChevron)
|
|
624
|
-
searchChevron.classList.remove(
|
|
662
|
+
searchChevron.classList.remove("open");
|
|
625
663
|
// Reset search and restore all tags
|
|
626
|
-
const hadQuery = this._state.search !==
|
|
627
|
-
this._state.search =
|
|
628
|
-
const searchInput = shadow.querySelector(
|
|
664
|
+
const hadQuery = this._state.search !== "";
|
|
665
|
+
this._state.search = "";
|
|
666
|
+
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
629
667
|
if (searchInput) {
|
|
630
|
-
searchInput.value =
|
|
668
|
+
searchInput.value = "";
|
|
631
669
|
}
|
|
632
670
|
if (this._externalSearch) {
|
|
633
671
|
// External mode — notify consumer so it can reset its own filtered state.
|
|
@@ -639,12 +677,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
639
677
|
clearTimeout(this._searchDebounceTimer);
|
|
640
678
|
this._searchDebounceTimer = null;
|
|
641
679
|
}
|
|
642
|
-
this.fireSearchEvent(
|
|
680
|
+
this.fireSearchEvent("");
|
|
643
681
|
}
|
|
644
682
|
}
|
|
645
683
|
else {
|
|
646
684
|
// Internal mode — restore visibility of all tags ourselves
|
|
647
|
-
const allTags = this.getTagElements().map(el => this.getTagData(el));
|
|
685
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
648
686
|
this._state.filteredTags = allTags;
|
|
649
687
|
this.updateTagVisibility(allTags, allTags);
|
|
650
688
|
this.clearHighlights(allTags);
|
|
@@ -658,28 +696,24 @@ export class TyMultiselect extends TyComponent {
|
|
|
658
696
|
*/
|
|
659
697
|
openMobileModal() {
|
|
660
698
|
const shadow = this.shadowRoot;
|
|
661
|
-
const dialog = shadow.querySelector(
|
|
699
|
+
const dialog = shadow.querySelector(".mobile-dialog");
|
|
662
700
|
if (!dialog)
|
|
663
701
|
return;
|
|
664
702
|
// Lock body scroll while mobile modal is open
|
|
665
|
-
const lockId = `multiselect-${this.id ||
|
|
703
|
+
const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
|
|
666
704
|
this._scrollLockId = lockId;
|
|
667
705
|
lockScroll(lockId);
|
|
668
706
|
// Show dialog using native API (handles z-index automatically)
|
|
669
707
|
dialog.showModal();
|
|
670
|
-
dialog.classList.add(
|
|
671
|
-
const stub_slot = shadow.querySelector('#stub-slot');
|
|
672
|
-
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
673
|
-
stub_slot.name = "selected-blocked";
|
|
674
|
-
mobile_slot.name = "selected";
|
|
708
|
+
dialog.classList.add("open");
|
|
675
709
|
// Update component state
|
|
676
710
|
this._state.open = true;
|
|
677
711
|
// Initialize options state
|
|
678
|
-
const tags = this.getTagElements().map(el => this.getTagData(el));
|
|
712
|
+
const tags = this.getTagElements().map((el) => this.getTagData(el));
|
|
679
713
|
this._state.filteredTags = tags;
|
|
680
|
-
// Focus search input
|
|
681
|
-
const searchInput = shadow.querySelector(
|
|
682
|
-
if (searchInput) {
|
|
714
|
+
// Focus search input (when the search row is shown)
|
|
715
|
+
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
716
|
+
if (searchInput && this.shouldShowSearch()) {
|
|
683
717
|
// Small delay to ensure dialog is ready
|
|
684
718
|
setTimeout(() => searchInput.focus(), 100);
|
|
685
719
|
}
|
|
@@ -696,30 +730,26 @@ export class TyMultiselect extends TyComponent {
|
|
|
696
730
|
*/
|
|
697
731
|
closeMobileModal() {
|
|
698
732
|
const shadow = this.shadowRoot;
|
|
699
|
-
const dialog = shadow.querySelector(
|
|
733
|
+
const dialog = shadow.querySelector(".mobile-dialog");
|
|
700
734
|
if (!dialog)
|
|
701
735
|
return;
|
|
702
736
|
// Close immediately — ::backdrop doesn't support transitions
|
|
703
|
-
dialog.classList.remove(
|
|
737
|
+
dialog.classList.remove("open");
|
|
704
738
|
dialog.close();
|
|
705
739
|
// Unlock body scroll (paired with the lock in openMobileModal)
|
|
706
740
|
if (this._scrollLockId) {
|
|
707
741
|
unlockScroll(this._scrollLockId);
|
|
708
742
|
this._scrollLockId = null;
|
|
709
743
|
}
|
|
710
|
-
const stub_slot = shadow.querySelector('#stub-slot');
|
|
711
|
-
const mobile_slot = shadow.querySelector('#mobile-slot');
|
|
712
|
-
stub_slot.name = "selected";
|
|
713
|
-
mobile_slot.name = "selected-blocked";
|
|
714
744
|
// Update state
|
|
715
745
|
this._state.open = false;
|
|
716
746
|
this._state.highlightedIndex = -1;
|
|
717
747
|
// Reset search
|
|
718
|
-
const hadQuery = this._state.search !==
|
|
719
|
-
this._state.search =
|
|
720
|
-
const searchInput = shadow.querySelector(
|
|
748
|
+
const hadQuery = this._state.search !== "";
|
|
749
|
+
this._state.search = "";
|
|
750
|
+
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
721
751
|
if (searchInput) {
|
|
722
|
-
searchInput.value =
|
|
752
|
+
searchInput.value = "";
|
|
723
753
|
}
|
|
724
754
|
if (this._externalSearch) {
|
|
725
755
|
// External mode — notify consumer that the search was cleared on close
|
|
@@ -729,13 +759,13 @@ export class TyMultiselect extends TyComponent {
|
|
|
729
759
|
clearTimeout(this._searchDebounceTimer);
|
|
730
760
|
this._searchDebounceTimer = null;
|
|
731
761
|
}
|
|
732
|
-
this.fireSearchEvent(
|
|
762
|
+
this.fireSearchEvent("");
|
|
733
763
|
}
|
|
734
764
|
}
|
|
735
765
|
else {
|
|
736
766
|
// Internal mode — unhide all tags ourselves
|
|
737
767
|
const allTags = this.getTagElements();
|
|
738
|
-
allTags.forEach(el => el.removeAttribute(
|
|
768
|
+
allTags.forEach((el) => el.removeAttribute("hidden"));
|
|
739
769
|
}
|
|
740
770
|
// Lifecycle event
|
|
741
771
|
this.fireCloseEvent();
|
|
@@ -749,51 +779,38 @@ export class TyMultiselect extends TyComponent {
|
|
|
749
779
|
if (this._disabled || this._readonly) {
|
|
750
780
|
return;
|
|
751
781
|
}
|
|
752
|
-
// Don't open if all tags are already selected
|
|
753
|
-
if (this.allTagsSelected()) {
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
782
|
this.openDropdown();
|
|
757
783
|
}
|
|
758
784
|
handleTagClick(e) {
|
|
759
785
|
const target = e.target;
|
|
760
|
-
// Find the ty-tag
|
|
761
|
-
const tag = target.
|
|
762
|
-
|
|
763
|
-
: target.closest('ty-tag');
|
|
764
|
-
if (!tag || tag.hasAttribute('disabled'))
|
|
786
|
+
// Find the option element (ty-option or ty-tag)
|
|
787
|
+
const tag = target.closest("ty-option, ty-tag");
|
|
788
|
+
if (!tag || tag.hasAttribute("disabled"))
|
|
765
789
|
return;
|
|
766
|
-
if (tag.hasAttribute('selected'))
|
|
767
|
-
return; // Already selected
|
|
768
790
|
e.preventDefault();
|
|
769
791
|
e.stopPropagation();
|
|
770
792
|
const tagValue = this.getTagData(tag).value;
|
|
771
793
|
const currentValues = this.getSelectedValues();
|
|
772
|
-
|
|
773
|
-
//
|
|
774
|
-
this.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
this.closeDropdown();
|
|
794
|
+
// Single select: picking replaces the selection and closes the popup
|
|
795
|
+
// (native <select> semantics — re-picking the same option just closes)
|
|
796
|
+
if (!this._multiple) {
|
|
797
|
+
this.updateComponentValue([tagValue], true, "set", tagValue);
|
|
798
|
+
if (this._state.mode === "mobile") {
|
|
799
|
+
this.closeMobileModal();
|
|
779
800
|
}
|
|
780
801
|
else {
|
|
781
|
-
this.
|
|
802
|
+
this.closeDropdown();
|
|
782
803
|
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
handleTagDismiss(e) {
|
|
786
|
-
e.preventDefault();
|
|
787
|
-
e.stopPropagation();
|
|
788
|
-
const customEvent = e;
|
|
789
|
-
const tag = customEvent.detail?.target;
|
|
790
|
-
if (!tag)
|
|
791
804
|
return;
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
805
|
+
}
|
|
806
|
+
// Multiple: toggle — clicking a selected option deselects it
|
|
807
|
+
if (tag.hasAttribute("selected")) {
|
|
808
|
+
const newValues = currentValues.filter((v) => v !== tagValue);
|
|
809
|
+
this.updateComponentValue(newValues, true, "remove", tagValue);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
|
|
813
|
+
}
|
|
797
814
|
}
|
|
798
815
|
blockSearchClick(e) {
|
|
799
816
|
e.stopPropagation();
|
|
@@ -810,11 +827,10 @@ export class TyMultiselect extends TyComponent {
|
|
|
810
827
|
this.dispatchSearchEvent(query);
|
|
811
828
|
return;
|
|
812
829
|
}
|
|
813
|
-
// Internal search: filter
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
const filtered = this.filterTags(availableTags, query);
|
|
830
|
+
// Internal search: filter ALL options locally — selected ones stay visible
|
|
831
|
+
// (with selected styling) so they can be toggled off while searching
|
|
832
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
833
|
+
const filtered = this.filterTags(allTags, query);
|
|
818
834
|
// Update state
|
|
819
835
|
this._state.filteredTags = filtered;
|
|
820
836
|
this._state.highlightedIndex = -1;
|
|
@@ -831,13 +847,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
831
847
|
if (!this._state.open)
|
|
832
848
|
return;
|
|
833
849
|
const shadow = this.shadowRoot;
|
|
834
|
-
const searchInput = shadow.querySelector(
|
|
850
|
+
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
835
851
|
const target = e.target;
|
|
836
852
|
// Only handle navigation keys when dropdown is open and either:
|
|
837
853
|
// 1. Event comes from search input, OR
|
|
838
854
|
// 2. Event comes from document but search input is not focused
|
|
839
|
-
const shouldHandle = target === searchInput ||
|
|
840
|
-
document.activeElement !== searchInput;
|
|
855
|
+
const shouldHandle = target === searchInput || document.activeElement !== searchInput;
|
|
841
856
|
if (!shouldHandle)
|
|
842
857
|
return;
|
|
843
858
|
// Get current state values
|
|
@@ -845,21 +860,22 @@ export class TyMultiselect extends TyComponent {
|
|
|
845
860
|
const tagsCount = filteredTags.length;
|
|
846
861
|
const currentHighlightedIndex = this._state.highlightedIndex;
|
|
847
862
|
switch (e.key) {
|
|
848
|
-
case
|
|
863
|
+
case "Escape":
|
|
849
864
|
e.preventDefault();
|
|
850
865
|
e.stopPropagation();
|
|
851
866
|
this.closeDropdown();
|
|
852
867
|
break;
|
|
853
|
-
case
|
|
868
|
+
case "Enter":
|
|
854
869
|
e.preventDefault();
|
|
855
870
|
e.stopPropagation();
|
|
856
871
|
// Select highlighted tag if any
|
|
857
|
-
if (currentHighlightedIndex >= 0 &&
|
|
872
|
+
if (currentHighlightedIndex >= 0 &&
|
|
873
|
+
currentHighlightedIndex < tagsCount) {
|
|
858
874
|
const tag = filteredTags[currentHighlightedIndex];
|
|
859
875
|
this.handleTagClick({ target: tag.element });
|
|
860
876
|
}
|
|
861
877
|
break;
|
|
862
|
-
case
|
|
878
|
+
case "ArrowUp":
|
|
863
879
|
e.preventDefault();
|
|
864
880
|
e.stopPropagation();
|
|
865
881
|
let newIndexUp;
|
|
@@ -881,7 +897,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
881
897
|
this._state.highlightedIndex = newIndexUp;
|
|
882
898
|
this.highlightTag(filteredTags, newIndexUp);
|
|
883
899
|
break;
|
|
884
|
-
case
|
|
900
|
+
case "ArrowDown":
|
|
885
901
|
e.preventDefault();
|
|
886
902
|
e.stopPropagation();
|
|
887
903
|
let newIndexDown;
|
|
@@ -912,7 +928,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
912
928
|
* Filter tags based on search query
|
|
913
929
|
*/
|
|
914
930
|
filterTags(tags, query) {
|
|
915
|
-
if (!query || query.trim() ===
|
|
931
|
+
if (!query || query.trim() === "") {
|
|
916
932
|
return tags;
|
|
917
933
|
}
|
|
918
934
|
const searchLower = query.toLowerCase();
|
|
@@ -922,13 +938,13 @@ export class TyMultiselect extends TyComponent {
|
|
|
922
938
|
* Update visibility of tags based on filtered list
|
|
923
939
|
*/
|
|
924
940
|
updateTagVisibility(filteredTags, allTags) {
|
|
925
|
-
const visibleValues = new Set(filteredTags.map(tag => tag.value));
|
|
941
|
+
const visibleValues = new Set(filteredTags.map((tag) => tag.value));
|
|
926
942
|
allTags.forEach(({ value, element }) => {
|
|
927
943
|
if (visibleValues.has(value)) {
|
|
928
|
-
element.removeAttribute(
|
|
944
|
+
element.removeAttribute("hidden");
|
|
929
945
|
}
|
|
930
946
|
else {
|
|
931
|
-
element.setAttribute(
|
|
947
|
+
element.setAttribute("hidden", "");
|
|
932
948
|
}
|
|
933
949
|
});
|
|
934
950
|
}
|
|
@@ -937,9 +953,9 @@ export class TyMultiselect extends TyComponent {
|
|
|
937
953
|
*/
|
|
938
954
|
updateOptionsVisibility(hasOptions) {
|
|
939
955
|
const shadow = this.shadowRoot;
|
|
940
|
-
const options = shadow.querySelector(
|
|
956
|
+
const options = shadow.querySelector(".dropdown-options");
|
|
941
957
|
if (options) {
|
|
942
|
-
options.style.display = hasOptions ?
|
|
958
|
+
options.style.display = hasOptions ? "" : "none";
|
|
943
959
|
}
|
|
944
960
|
}
|
|
945
961
|
/**
|
|
@@ -947,7 +963,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
947
963
|
*/
|
|
948
964
|
clearHighlights(tags) {
|
|
949
965
|
tags.forEach(({ element }) => {
|
|
950
|
-
element.removeAttribute(
|
|
966
|
+
element.removeAttribute("highlighted");
|
|
951
967
|
});
|
|
952
968
|
}
|
|
953
969
|
/**
|
|
@@ -957,12 +973,12 @@ export class TyMultiselect extends TyComponent {
|
|
|
957
973
|
this.clearHighlights(tags);
|
|
958
974
|
if (index >= 0 && index < tags.length) {
|
|
959
975
|
const { element } = tags[index];
|
|
960
|
-
element.setAttribute(
|
|
976
|
+
element.setAttribute("highlighted", "");
|
|
961
977
|
// Scroll into view
|
|
962
978
|
element.scrollIntoView({
|
|
963
|
-
behavior:
|
|
964
|
-
block:
|
|
965
|
-
inline:
|
|
979
|
+
behavior: "smooth",
|
|
980
|
+
block: "nearest",
|
|
981
|
+
inline: "nearest",
|
|
966
982
|
});
|
|
967
983
|
}
|
|
968
984
|
}
|
|
@@ -992,13 +1008,13 @@ export class TyMultiselect extends TyComponent {
|
|
|
992
1008
|
* Fire the actual search event
|
|
993
1009
|
*/
|
|
994
1010
|
fireSearchEvent(query) {
|
|
995
|
-
this.dispatchEvent(new CustomEvent(
|
|
1011
|
+
this.dispatchEvent(new CustomEvent("search", {
|
|
996
1012
|
detail: {
|
|
997
1013
|
query,
|
|
998
|
-
element: this
|
|
1014
|
+
element: this,
|
|
999
1015
|
},
|
|
1000
1016
|
bubbles: true,
|
|
1001
|
-
composed: true
|
|
1017
|
+
composed: true,
|
|
1002
1018
|
}));
|
|
1003
1019
|
}
|
|
1004
1020
|
/**
|
|
@@ -1007,20 +1023,20 @@ export class TyMultiselect extends TyComponent {
|
|
|
1007
1023
|
* query so consumers have a clean hook to reset/refetch the option list.
|
|
1008
1024
|
*/
|
|
1009
1025
|
fireOpenEvent() {
|
|
1010
|
-
this.dispatchEvent(new CustomEvent(
|
|
1026
|
+
this.dispatchEvent(new CustomEvent("open", {
|
|
1011
1027
|
detail: { mode: this._state.mode, element: this },
|
|
1012
1028
|
bubbles: true,
|
|
1013
|
-
composed: true
|
|
1029
|
+
composed: true,
|
|
1014
1030
|
}));
|
|
1015
1031
|
if (this._externalSearch) {
|
|
1016
|
-
this.fireSearchEvent(
|
|
1032
|
+
this.fireSearchEvent("");
|
|
1017
1033
|
}
|
|
1018
1034
|
}
|
|
1019
1035
|
fireCloseEvent() {
|
|
1020
|
-
this.dispatchEvent(new CustomEvent(
|
|
1036
|
+
this.dispatchEvent(new CustomEvent("close", {
|
|
1021
1037
|
detail: { mode: this._state.mode, element: this },
|
|
1022
1038
|
bubbles: true,
|
|
1023
|
-
composed: true
|
|
1039
|
+
composed: true,
|
|
1024
1040
|
}));
|
|
1025
1041
|
}
|
|
1026
1042
|
// ============================================================================
|
|
@@ -1030,10 +1046,10 @@ export class TyMultiselect extends TyComponent {
|
|
|
1030
1046
|
* Dispatch custom change event
|
|
1031
1047
|
*/
|
|
1032
1048
|
dispatchChangeEvent(detail) {
|
|
1033
|
-
this.dispatchEvent(new CustomEvent(
|
|
1049
|
+
this.dispatchEvent(new CustomEvent("change", {
|
|
1034
1050
|
detail,
|
|
1035
1051
|
bubbles: true,
|
|
1036
|
-
cancelable: true
|
|
1052
|
+
cancelable: true,
|
|
1037
1053
|
}));
|
|
1038
1054
|
}
|
|
1039
1055
|
// ============================================================================
|
|
@@ -1045,45 +1061,61 @@ export class TyMultiselect extends TyComponent {
|
|
|
1045
1061
|
*/
|
|
1046
1062
|
render() {
|
|
1047
1063
|
// Sync mode on every render so rotation/resize is picked up
|
|
1048
|
-
this._state.mode = isMobileTouch() ?
|
|
1049
|
-
if (this._state.mode ===
|
|
1064
|
+
this._state.mode = isMobileTouch() ? "mobile" : "desktop";
|
|
1065
|
+
if (this._state.mode === "mobile") {
|
|
1050
1066
|
this.renderMobile();
|
|
1051
1067
|
}
|
|
1052
1068
|
else {
|
|
1053
1069
|
this.renderDesktop();
|
|
1054
1070
|
}
|
|
1071
|
+
this.updateSearchVisibility();
|
|
1055
1072
|
// Loading wrapper is rendered dynamically — re-apply each render
|
|
1056
1073
|
this.applyLoadingState();
|
|
1057
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* When a consumer slots their own trigger (slot="trigger"), strip the
|
|
1077
|
+
* default field chrome so the trigger is styled entirely by the consumer.
|
|
1078
|
+
*/
|
|
1079
|
+
setupTriggerSlot() {
|
|
1080
|
+
const shadow = this.shadowRoot;
|
|
1081
|
+
const stub = shadow.querySelector(".multiselect-stub");
|
|
1082
|
+
const slot = shadow.querySelector('slot[name="trigger"]');
|
|
1083
|
+
if (!stub || !slot)
|
|
1084
|
+
return;
|
|
1085
|
+
const sync = () => stub.classList.toggle("custom-trigger", slot.assignedElements().length > 0);
|
|
1086
|
+
slot.addEventListener("slotchange", sync);
|
|
1087
|
+
sync();
|
|
1088
|
+
}
|
|
1058
1089
|
/**
|
|
1059
1090
|
* Setup event listeners
|
|
1060
1091
|
*/
|
|
1061
1092
|
setupEventListeners() {
|
|
1062
1093
|
const shadow = this.shadowRoot;
|
|
1063
|
-
const stub = shadow.querySelector(
|
|
1064
|
-
const optionsSlot = shadow.querySelector(
|
|
1065
|
-
const searchInput = shadow.querySelector(
|
|
1094
|
+
const stub = shadow.querySelector(".multiselect-stub");
|
|
1095
|
+
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1096
|
+
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
1066
1097
|
if (stub) {
|
|
1067
1098
|
this._stubClickHandler = this.handleStubClick.bind(this);
|
|
1068
|
-
stub.addEventListener(
|
|
1099
|
+
stub.addEventListener("click", this._stubClickHandler);
|
|
1069
1100
|
}
|
|
1101
|
+
this.setupTriggerSlot();
|
|
1070
1102
|
// Add tag click handler to slot
|
|
1071
1103
|
if (optionsSlot) {
|
|
1072
1104
|
this._tagClickHandler = this.handleTagClick.bind(this);
|
|
1073
|
-
optionsSlot.addEventListener(
|
|
1105
|
+
optionsSlot.addEventListener("click", this._tagClickHandler);
|
|
1074
1106
|
}
|
|
1075
1107
|
// Add search input handlers
|
|
1076
1108
|
if (searchInput) {
|
|
1077
1109
|
this._searchInputHandler = this.handleSearchInput.bind(this);
|
|
1078
1110
|
this._blockSearchClick = this.blockSearchClick.bind(this);
|
|
1079
|
-
searchInput.addEventListener(
|
|
1080
|
-
searchInput.addEventListener(
|
|
1111
|
+
searchInput.addEventListener("input", this._searchInputHandler);
|
|
1112
|
+
searchInput.addEventListener("click", this._blockSearchClick);
|
|
1081
1113
|
// searchInput.addEventListener('blur', this._searchBlurHandler)
|
|
1082
1114
|
}
|
|
1083
1115
|
// Setup dialog backdrop click handler
|
|
1084
|
-
const dialog = shadow.querySelector(
|
|
1116
|
+
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
1085
1117
|
if (dialog) {
|
|
1086
|
-
dialog.addEventListener(
|
|
1118
|
+
dialog.addEventListener("click", (e) => {
|
|
1087
1119
|
// Only close if clicking directly on the dialog (backdrop), not its children
|
|
1088
1120
|
if (e.target === dialog) {
|
|
1089
1121
|
this.closeDropdown();
|
|
@@ -1092,10 +1124,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
1092
1124
|
}
|
|
1093
1125
|
// Setup keyboard handler
|
|
1094
1126
|
this._keyboardHandler = this.handleKeyboard.bind(this);
|
|
1095
|
-
document.addEventListener(
|
|
1096
|
-
// Listen for dismiss events from selected tags
|
|
1097
|
-
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
1098
|
-
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
1127
|
+
document.addEventListener("keydown", this._keyboardHandler);
|
|
1099
1128
|
}
|
|
1100
1129
|
/**
|
|
1101
1130
|
* Build CSS class list for stub
|
|
@@ -1103,8 +1132,10 @@ export class TyMultiselect extends TyComponent {
|
|
|
1103
1132
|
buildStubClasses() {
|
|
1104
1133
|
const classes = [this._size];
|
|
1105
1134
|
if (this._disabled)
|
|
1106
|
-
classes.push(
|
|
1107
|
-
|
|
1135
|
+
classes.push("disabled");
|
|
1136
|
+
if (this._compact)
|
|
1137
|
+
classes.push("compact");
|
|
1138
|
+
return classes.join(" ");
|
|
1108
1139
|
}
|
|
1109
1140
|
/**
|
|
1110
1141
|
* Render desktop mode with dialog
|
|
@@ -1112,37 +1143,44 @@ export class TyMultiselect extends TyComponent {
|
|
|
1112
1143
|
renderDesktop() {
|
|
1113
1144
|
const shadow = this.shadowRoot;
|
|
1114
1145
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1115
|
-
if (!shadow.querySelector(
|
|
1146
|
+
if (!shadow.querySelector(".multiselect-container")) {
|
|
1116
1147
|
const stubClasses = this.buildStubClasses();
|
|
1117
|
-
const labelHtml = this._label
|
|
1148
|
+
const labelHtml = this._label
|
|
1149
|
+
? `
|
|
1118
1150
|
<label class="ty-field-label">
|
|
1119
1151
|
${this._label}
|
|
1120
|
-
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` :
|
|
1152
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1121
1153
|
</label>
|
|
1122
|
-
`
|
|
1123
|
-
|
|
1154
|
+
`
|
|
1155
|
+
: "";
|
|
1156
|
+
const searchPlaceholder = "Search...";
|
|
1124
1157
|
shadow.innerHTML = `
|
|
1125
1158
|
<div class="multiselect-container dropdown-mode-desktop">
|
|
1126
1159
|
${labelHtml}
|
|
1127
1160
|
<div class="dropdown-wrapper">
|
|
1128
1161
|
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
1129
|
-
${this._disabled ?
|
|
1130
|
-
<slot name="
|
|
1131
|
-
|
|
1162
|
+
${this._disabled ? "disabled" : ""}>
|
|
1163
|
+
<slot name="trigger">
|
|
1164
|
+
<slot name="start"></slot>
|
|
1132
1165
|
<slot name="selected"></slot>
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1166
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1167
|
+
<span class="select-count" hidden></span>
|
|
1168
|
+
<slot name="end"></slot>
|
|
1169
|
+
<div class="dropdown-chevron">
|
|
1170
|
+
${CHEVRON_DOWN_SVG}
|
|
1171
|
+
</div>
|
|
1172
|
+
</slot>
|
|
1138
1173
|
</div>
|
|
1139
1174
|
<dialog class="dropdown-dialog">
|
|
1140
1175
|
<div class="dropdown-header">
|
|
1141
|
-
<
|
|
1142
|
-
|
|
1176
|
+
<div class="dropdown-search-icon" aria-hidden="true">
|
|
1177
|
+
${SEARCH_ICON_SVG}
|
|
1178
|
+
</div>
|
|
1179
|
+
<input
|
|
1180
|
+
class="dropdown-search-input ${this._size}"
|
|
1143
1181
|
type="text"
|
|
1144
1182
|
placeholder="${searchPlaceholder}"
|
|
1145
|
-
${this._disabled ?
|
|
1183
|
+
${this._disabled ? "disabled" : ""}
|
|
1146
1184
|
/>
|
|
1147
1185
|
<div class="dropdown-search-chevron">
|
|
1148
1186
|
${CHEVRON_DOWN_SVG}
|
|
@@ -1178,31 +1216,38 @@ export class TyMultiselect extends TyComponent {
|
|
|
1178
1216
|
renderMobile() {
|
|
1179
1217
|
const shadow = this.shadowRoot;
|
|
1180
1218
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1181
|
-
if (!shadow.querySelector(
|
|
1219
|
+
if (!shadow.querySelector(".multiselect-container")) {
|
|
1182
1220
|
const stubClasses = this.buildStubClasses();
|
|
1183
|
-
const labelHtml = this._label
|
|
1221
|
+
const labelHtml = this._label
|
|
1222
|
+
? `
|
|
1184
1223
|
<label class="ty-field-label">
|
|
1185
1224
|
${this._label}
|
|
1186
|
-
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` :
|
|
1225
|
+
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1187
1226
|
</label>
|
|
1188
|
-
`
|
|
1227
|
+
`
|
|
1228
|
+
: "";
|
|
1189
1229
|
// Close button SVG (X icon)
|
|
1190
1230
|
const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1191
1231
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
1192
1232
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
1193
1233
|
</svg>`;
|
|
1194
1234
|
// Search placeholder: "Search <label>..." or just "Search..."
|
|
1195
|
-
const searchPlaceholder = this._label
|
|
1235
|
+
const searchPlaceholder = this._label
|
|
1236
|
+
? `Search ${this._label}...`
|
|
1237
|
+
: "Search...";
|
|
1196
1238
|
// Search is always available — only the mode (internal vs external) varies.
|
|
1197
1239
|
const searchHeaderHtml = `
|
|
1198
1240
|
<div class="mobile-search-header">
|
|
1199
|
-
${this._label ? `<span class="mobile-header-label">${this._label}</span>` :
|
|
1241
|
+
${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
|
|
1200
1242
|
<div class="mobile-header-content">
|
|
1243
|
+
<div class="dropdown-search-icon" aria-hidden="true">
|
|
1244
|
+
${SEARCH_ICON_SVG}
|
|
1245
|
+
</div>
|
|
1201
1246
|
<input
|
|
1202
1247
|
class="mobile-search-input ${this._size}"
|
|
1203
1248
|
type="text"
|
|
1204
1249
|
placeholder="${searchPlaceholder}"
|
|
1205
|
-
${this._disabled ?
|
|
1250
|
+
${this._disabled ? "disabled" : ""}
|
|
1206
1251
|
/>
|
|
1207
1252
|
<button class="mobile-close-button" type="button" aria-label="Close">
|
|
1208
1253
|
${closeButtonSvg}
|
|
@@ -1215,35 +1260,27 @@ export class TyMultiselect extends TyComponent {
|
|
|
1215
1260
|
${labelHtml}
|
|
1216
1261
|
<div class="dropdown-wrapper">
|
|
1217
1262
|
<div class="dropdown-stub multiselect-stub ${stubClasses}"
|
|
1218
|
-
${this._disabled ?
|
|
1219
|
-
<slot name="
|
|
1220
|
-
|
|
1221
|
-
<slot
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1263
|
+
${this._disabled ? "disabled" : ""}>
|
|
1264
|
+
<slot name="trigger">
|
|
1265
|
+
<slot name="start"></slot>
|
|
1266
|
+
<slot name="selected"></slot>
|
|
1267
|
+
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1268
|
+
<span class="select-count" hidden></span>
|
|
1269
|
+
<slot name="end"></slot>
|
|
1270
|
+
<div class="dropdown-chevron">
|
|
1271
|
+
${CHEVRON_DOWN_SVG}
|
|
1272
|
+
</div>
|
|
1273
|
+
</slot>
|
|
1227
1274
|
</div>
|
|
1228
1275
|
|
|
1229
1276
|
<dialog class="mobile-dialog">
|
|
1230
1277
|
<div class="mobile-dialog-content">
|
|
1231
|
-
|
|
1278
|
+
|
|
1232
1279
|
<!-- HEADER (matches dropdown.ts) -->
|
|
1233
1280
|
${searchHeaderHtml}
|
|
1234
|
-
|
|
1235
|
-
<!-- BODY: pinned selected strip + filter list -->
|
|
1236
|
-
<div class="mobile-body">
|
|
1237
1281
|
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
<div class="section-header">
|
|
1241
|
-
<span class="section-title">${this._selectedLabel} <span class="section-count">(0)</span></span>
|
|
1242
|
-
</div>
|
|
1243
|
-
<div class="section-content">
|
|
1244
|
-
<slot id="mobile-slot" name="selected"></slot>
|
|
1245
|
-
</div>
|
|
1246
|
-
</div>
|
|
1282
|
+
<!-- BODY: options list with toggleable selected state -->
|
|
1283
|
+
<div class="mobile-body">
|
|
1247
1284
|
|
|
1248
1285
|
<!-- AVAILABLE LIST (always visible, takes remaining space) -->
|
|
1249
1286
|
<div class="mobile-available-section" data-empty="false">
|
|
@@ -1280,31 +1317,30 @@ export class TyMultiselect extends TyComponent {
|
|
|
1280
1317
|
*/
|
|
1281
1318
|
setupMobileEventListeners() {
|
|
1282
1319
|
const shadow = this.shadowRoot;
|
|
1283
|
-
const stub = shadow.querySelector(
|
|
1284
|
-
const optionsSlot = shadow.querySelector(
|
|
1285
|
-
const searchInput = shadow.querySelector(
|
|
1286
|
-
const closeButton = shadow.querySelector(
|
|
1287
|
-
const dialog = shadow.querySelector(
|
|
1320
|
+
const stub = shadow.querySelector(".multiselect-stub");
|
|
1321
|
+
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1322
|
+
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
1323
|
+
const closeButton = shadow.querySelector(".mobile-close-button");
|
|
1324
|
+
const dialog = shadow.querySelector(".mobile-dialog");
|
|
1288
1325
|
if (stub) {
|
|
1289
|
-
stub.addEventListener(
|
|
1326
|
+
stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
|
|
1290
1327
|
}
|
|
1328
|
+
this.setupTriggerSlot();
|
|
1291
1329
|
// Add tag click handler to slot
|
|
1292
1330
|
if (optionsSlot) {
|
|
1293
|
-
optionsSlot.addEventListener(
|
|
1331
|
+
optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
|
|
1294
1332
|
}
|
|
1295
1333
|
// Add search input handlers (if searchable)
|
|
1296
1334
|
if (searchInput) {
|
|
1297
|
-
searchInput.addEventListener(
|
|
1335
|
+
searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
|
|
1298
1336
|
}
|
|
1299
1337
|
// Close button click
|
|
1300
1338
|
if (closeButton) {
|
|
1301
|
-
closeButton.addEventListener(
|
|
1339
|
+
closeButton.addEventListener("click", () => this.closeMobileModal());
|
|
1302
1340
|
}
|
|
1303
|
-
this._tagDismissHandler = this.handleTagDismiss.bind(this);
|
|
1304
|
-
this.addEventListener('dismiss', this._tagDismissHandler);
|
|
1305
1341
|
// Backdrop click to close (native dialog behavior)
|
|
1306
1342
|
if (dialog) {
|
|
1307
|
-
dialog.addEventListener(
|
|
1343
|
+
dialog.addEventListener("click", (e) => {
|
|
1308
1344
|
// Only close if clicking directly on the dialog element (backdrop)
|
|
1309
1345
|
// Not if clicking on its children (dialog-content)
|
|
1310
1346
|
if (e.target === dialog) {
|
|
@@ -1312,7 +1348,7 @@ export class TyMultiselect extends TyComponent {
|
|
|
1312
1348
|
}
|
|
1313
1349
|
});
|
|
1314
1350
|
// Also handle Escape key via cancel event
|
|
1315
|
-
dialog.addEventListener(
|
|
1351
|
+
dialog.addEventListener("cancel", (e) => {
|
|
1316
1352
|
e.preventDefault(); // Prevent default to handle it our way
|
|
1317
1353
|
this.closeMobileModal();
|
|
1318
1354
|
});
|
|
@@ -1327,10 +1363,6 @@ export class TyMultiselect extends TyComponent {
|
|
|
1327
1363
|
if (this._disabled || this._readonly) {
|
|
1328
1364
|
return;
|
|
1329
1365
|
}
|
|
1330
|
-
// Don't open if all tags are already selected
|
|
1331
|
-
if (this.allTagsSelected()) {
|
|
1332
|
-
return;
|
|
1333
|
-
}
|
|
1334
1366
|
this.openMobileModal();
|
|
1335
1367
|
}
|
|
1336
1368
|
/**
|
|
@@ -1342,130 +1374,241 @@ export class TyMultiselect extends TyComponent {
|
|
|
1342
1374
|
this.handleTagClick(e);
|
|
1343
1375
|
}
|
|
1344
1376
|
/**
|
|
1345
|
-
* Update mobile
|
|
1377
|
+
* Update mobile options section state (count, empty state)
|
|
1346
1378
|
*/
|
|
1347
1379
|
updateMobileSelectedState() {
|
|
1348
|
-
if (this._state.mode !==
|
|
1380
|
+
if (this._state.mode !== "mobile")
|
|
1349
1381
|
return;
|
|
1350
1382
|
const shadow = this.shadowRoot;
|
|
1351
|
-
const
|
|
1352
|
-
const availableSection = shadow.querySelector('.mobile-available-section');
|
|
1353
|
-
const sectionCountSpan = shadow.querySelector('.section-count');
|
|
1354
|
-
if (selectedSection) {
|
|
1355
|
-
const selectedCount = this._state.selectedValues.length;
|
|
1356
|
-
const hasSelected = selectedCount > 0;
|
|
1357
|
-
selectedSection.setAttribute('data-empty', String(!hasSelected));
|
|
1358
|
-
// Update header count
|
|
1359
|
-
if (sectionCountSpan) {
|
|
1360
|
-
sectionCountSpan.textContent = `(${selectedCount})`;
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1383
|
+
const availableSection = shadow.querySelector(".mobile-available-section");
|
|
1363
1384
|
if (availableSection) {
|
|
1364
|
-
// Count *visible*
|
|
1365
|
-
const visibleAvailable = this.getTagElements().filter(tag => !tag.hasAttribute(
|
|
1366
|
-
availableSection.setAttribute(
|
|
1385
|
+
// Count *visible* options — options hidden by search filtering count as 0
|
|
1386
|
+
const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
|
|
1387
|
+
availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
|
|
1367
1388
|
// Update available header count
|
|
1368
|
-
const availableTitleSpan = shadow.querySelector(
|
|
1389
|
+
const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
|
|
1369
1390
|
if (availableTitleSpan) {
|
|
1370
1391
|
availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
|
|
1371
1392
|
}
|
|
1372
1393
|
}
|
|
1373
1394
|
}
|
|
1374
1395
|
/**
|
|
1375
|
-
* Update selection display
|
|
1376
|
-
|
|
1396
|
+
* Update selection display: count badge next to the placeholder
|
|
1397
|
+
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Should the popup show a search row? external-search always (the input is
|
|
1400
|
+
* the mechanism); searchable always/never wins otherwise; 'auto' shows it
|
|
1401
|
+
* only when the option list is long enough to need filtering.
|
|
1402
|
+
*/
|
|
1403
|
+
shouldShowSearch() {
|
|
1404
|
+
if (this._externalSearch)
|
|
1405
|
+
return true;
|
|
1406
|
+
if (this._searchable === "always")
|
|
1407
|
+
return true;
|
|
1408
|
+
if (this._searchable === "never")
|
|
1409
|
+
return false;
|
|
1410
|
+
return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Toggle the search row (desktop header / mobile input) per shouldShowSearch.
|
|
1414
|
+
*/
|
|
1415
|
+
updateSearchVisibility() {
|
|
1416
|
+
const shadow = this.shadowRoot;
|
|
1417
|
+
if (!shadow)
|
|
1418
|
+
return;
|
|
1419
|
+
const show = this.shouldShowSearch();
|
|
1420
|
+
const header = shadow.querySelector(".dropdown-header");
|
|
1421
|
+
if (header)
|
|
1422
|
+
header.hidden = !show;
|
|
1423
|
+
// Mobile keeps its header (label + close button); only the input+icon hide
|
|
1424
|
+
const mobileInput = shadow.querySelector(".mobile-search-input");
|
|
1425
|
+
if (mobileInput)
|
|
1426
|
+
mobileInput.hidden = !show;
|
|
1427
|
+
const mobileIcon = shadow.querySelector(".mobile-header-content .dropdown-search-icon");
|
|
1428
|
+
if (mobileIcon)
|
|
1429
|
+
mobileIcon.hidden = !show;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Selection summary appropriate to cardinality × skin:
|
|
1433
|
+
* - single (field or compact): the selected OPTION itself — cloned into
|
|
1434
|
+
* slot="selected" so rich HTML options display intact (dropdown parity);
|
|
1435
|
+
* placeholder when empty
|
|
1436
|
+
* - multiple + field: joined labels (ellipsis via CSS), or placeholder
|
|
1437
|
+
* - multiple + compact: placeholder + count badge
|
|
1377
1438
|
*/
|
|
1378
1439
|
updateSelectionDisplay() {
|
|
1379
1440
|
const shadow = this.shadowRoot;
|
|
1380
|
-
const stub = shadow.querySelector(
|
|
1441
|
+
const stub = shadow.querySelector(".multiselect-stub");
|
|
1381
1442
|
if (!stub)
|
|
1382
1443
|
return;
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1444
|
+
// Selection comes from STATE, not from scanning option elements —
|
|
1445
|
+
// external search replaces the option children, and the selection must
|
|
1446
|
+
// survive even when the current results don't include the selected value.
|
|
1447
|
+
const values = this._state.selectedValues;
|
|
1448
|
+
const count = values.length;
|
|
1449
|
+
stub.classList.toggle("has-selection", count > 0);
|
|
1450
|
+
stub.classList.toggle("compact", this._compact);
|
|
1451
|
+
const badge = stub.querySelector(".select-count");
|
|
1452
|
+
if (badge) {
|
|
1453
|
+
badge.textContent = String(count);
|
|
1454
|
+
badge.hidden = count === 0 || !this._multiple || !this._compact;
|
|
1388
1455
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1456
|
+
// Single select: project the selected option into the stub as a clone
|
|
1457
|
+
// (rich HTML content survives — same mechanism as ty-dropdown).
|
|
1458
|
+
const wantClone = !this._multiple && count > 0 ? values[0] : null;
|
|
1459
|
+
this.syncSelectedClone(wantClone);
|
|
1460
|
+
stub.classList.toggle("has-clone", wantClone !== null);
|
|
1461
|
+
const textEl = stub.querySelector(".dropdown-placeholder");
|
|
1462
|
+
if (textEl) {
|
|
1463
|
+
// The clone replaces the text display entirely in single mode
|
|
1464
|
+
textEl.hidden = wantClone !== null;
|
|
1465
|
+
const showLabels = count > 0 && this._multiple && !this._compact;
|
|
1466
|
+
if (showLabels) {
|
|
1467
|
+
textEl.textContent = this.getSelectedItems(values)
|
|
1468
|
+
.map((i) => i.label)
|
|
1469
|
+
.join(", ");
|
|
1470
|
+
textEl.classList.remove("placeholder-shown");
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
textEl.textContent = this._placeholder;
|
|
1474
|
+
textEl.classList.add("placeholder-shown");
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Keep exactly one display clone (slot="selected", marked `cloned`) in our
|
|
1480
|
+
* light DOM matching the given value — or none. The clone never counts as
|
|
1481
|
+
* an option (getTagElements filters [cloned]) and ty-option's own
|
|
1482
|
+
* :host([cloned]) styling strips its list-row chrome.
|
|
1483
|
+
*/
|
|
1484
|
+
syncSelectedClone(value) {
|
|
1485
|
+
const existing = this.querySelector(':scope > [cloned][slot="selected"]');
|
|
1486
|
+
if (existing?.getAttribute("value") === value) {
|
|
1487
|
+
this._selectedClone = existing;
|
|
1488
|
+
return; // already right
|
|
1489
|
+
}
|
|
1490
|
+
existing?.remove();
|
|
1491
|
+
if (value === null) {
|
|
1492
|
+
this._selectedClone = null;
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
|
|
1496
|
+
if (option) {
|
|
1497
|
+
const clone = option.cloneNode(true);
|
|
1498
|
+
clone.removeAttribute("selected"); // no tick/selected styling in the stub
|
|
1499
|
+
clone.setAttribute("slot", "selected");
|
|
1500
|
+
clone.setAttribute("cloned", "true");
|
|
1501
|
+
this.appendChild(clone);
|
|
1502
|
+
this._selectedClone = clone;
|
|
1503
|
+
}
|
|
1504
|
+
else if (this._selectedClone?.getAttribute("value") === value) {
|
|
1505
|
+
// The option list no longer contains the selection (external search
|
|
1506
|
+
// swapped the children — possibly wiping our clone with them). Restore
|
|
1507
|
+
// the saved display clone so the selection stays visible.
|
|
1508
|
+
const restored = this._selectedClone.cloneNode(true);
|
|
1509
|
+
this.appendChild(restored);
|
|
1510
|
+
this._selectedClone = restored;
|
|
1391
1511
|
}
|
|
1392
1512
|
}
|
|
1393
1513
|
// ============================================================================
|
|
1394
1514
|
// PUBLIC API - Getters/Setters
|
|
1395
1515
|
// ============================================================================
|
|
1516
|
+
/**
|
|
1517
|
+
* Deselect a value programmatically WITH a change event.
|
|
1518
|
+
* Used by out-of-band chip displays (ty-selected-tags) so dismissing a chip
|
|
1519
|
+
* fires change like any in-popup interaction (HTMX hx-trigger="change" etc.).
|
|
1520
|
+
*/
|
|
1521
|
+
deselectValue(value) {
|
|
1522
|
+
const current = this.getSelectedValues();
|
|
1523
|
+
if (!current.includes(value))
|
|
1524
|
+
return;
|
|
1525
|
+
this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
|
|
1526
|
+
}
|
|
1396
1527
|
get value() {
|
|
1397
1528
|
// Always read from DOM - tags with 'selected' attribute are source of truth
|
|
1398
|
-
return this.getSelectedValues().join(
|
|
1529
|
+
return this.getSelectedValues().join(",");
|
|
1399
1530
|
}
|
|
1400
1531
|
set value(val) {
|
|
1401
|
-
this.setProperty(
|
|
1532
|
+
this.setProperty("value", val);
|
|
1402
1533
|
}
|
|
1403
1534
|
get name() {
|
|
1404
|
-
return this.getProperty(
|
|
1535
|
+
return this.getProperty("name");
|
|
1405
1536
|
}
|
|
1406
1537
|
set name(val) {
|
|
1407
|
-
this.setProperty(
|
|
1538
|
+
this.setProperty("name", val);
|
|
1539
|
+
}
|
|
1540
|
+
get multiple() {
|
|
1541
|
+
return this.getProperty("multiple");
|
|
1542
|
+
}
|
|
1543
|
+
set multiple(value) {
|
|
1544
|
+
this.setProperty("multiple", value);
|
|
1545
|
+
}
|
|
1546
|
+
get compact() {
|
|
1547
|
+
return this.getProperty("compact");
|
|
1548
|
+
}
|
|
1549
|
+
set compact(value) {
|
|
1550
|
+
this.setProperty("compact", value);
|
|
1408
1551
|
}
|
|
1409
1552
|
get placeholder() {
|
|
1410
|
-
return this.getProperty(
|
|
1553
|
+
return this.getProperty("placeholder");
|
|
1411
1554
|
}
|
|
1412
1555
|
set placeholder(val) {
|
|
1413
|
-
this.setProperty(
|
|
1556
|
+
this.setProperty("placeholder", val);
|
|
1414
1557
|
}
|
|
1415
1558
|
get label() {
|
|
1416
|
-
return this.getProperty(
|
|
1559
|
+
return this.getProperty("label");
|
|
1417
1560
|
}
|
|
1418
1561
|
set label(val) {
|
|
1419
|
-
this.setProperty(
|
|
1562
|
+
this.setProperty("label", val);
|
|
1420
1563
|
}
|
|
1421
1564
|
get disabled() {
|
|
1422
|
-
return this.getProperty(
|
|
1565
|
+
return this.getProperty("disabled");
|
|
1423
1566
|
}
|
|
1424
1567
|
set disabled(value) {
|
|
1425
|
-
this.setProperty(
|
|
1568
|
+
this.setProperty("disabled", value);
|
|
1426
1569
|
}
|
|
1427
1570
|
get loading() {
|
|
1428
|
-
return this.getProperty(
|
|
1571
|
+
return this.getProperty("loading");
|
|
1429
1572
|
}
|
|
1430
1573
|
set loading(value) {
|
|
1431
|
-
this.setProperty(
|
|
1574
|
+
this.setProperty("loading", value);
|
|
1432
1575
|
}
|
|
1433
1576
|
get readonly() {
|
|
1434
|
-
return this.getProperty(
|
|
1577
|
+
return this.getProperty("readonly");
|
|
1435
1578
|
}
|
|
1436
1579
|
set readonly(value) {
|
|
1437
|
-
this.setProperty(
|
|
1580
|
+
this.setProperty("readonly", value);
|
|
1438
1581
|
}
|
|
1439
1582
|
get required() {
|
|
1440
|
-
return this.getProperty(
|
|
1583
|
+
return this.getProperty("required");
|
|
1441
1584
|
}
|
|
1442
1585
|
set required(value) {
|
|
1443
|
-
this.setProperty(
|
|
1586
|
+
this.setProperty("required", value);
|
|
1587
|
+
}
|
|
1588
|
+
get searchable() {
|
|
1589
|
+
return this.getProperty("searchable");
|
|
1590
|
+
}
|
|
1591
|
+
set searchable(value) {
|
|
1592
|
+
this.setProperty("searchable", value);
|
|
1444
1593
|
}
|
|
1445
1594
|
get externalSearch() {
|
|
1446
|
-
return this.getProperty(
|
|
1595
|
+
return this.getProperty("externalSearch");
|
|
1447
1596
|
}
|
|
1448
1597
|
set externalSearch(value) {
|
|
1449
|
-
this.setProperty(
|
|
1598
|
+
this.setProperty("externalSearch", value);
|
|
1450
1599
|
}
|
|
1451
1600
|
get debounce() {
|
|
1452
|
-
return this.getProperty(
|
|
1601
|
+
return this.getProperty("debounce");
|
|
1453
1602
|
}
|
|
1454
1603
|
set debounce(value) {
|
|
1455
|
-
const numValue = typeof value ===
|
|
1456
|
-
this.setProperty(
|
|
1604
|
+
const numValue = typeof value === "string" ? parseInt(value, 10) : value;
|
|
1605
|
+
this.setProperty("debounce", numValue);
|
|
1457
1606
|
}
|
|
1458
1607
|
get size() {
|
|
1459
|
-
return this.getProperty(
|
|
1608
|
+
return this.getProperty("size");
|
|
1460
1609
|
}
|
|
1461
1610
|
set size(value) {
|
|
1462
|
-
this.setProperty(
|
|
1463
|
-
}
|
|
1464
|
-
get flavor() {
|
|
1465
|
-
return this.getProperty('flavor');
|
|
1466
|
-
}
|
|
1467
|
-
set flavor(value) {
|
|
1468
|
-
this.setProperty('flavor', value);
|
|
1611
|
+
this.setProperty("size", value);
|
|
1469
1612
|
}
|
|
1470
1613
|
get form() {
|
|
1471
1614
|
return this._internals.form;
|
|
@@ -1474,90 +1617,104 @@ export class TyMultiselect extends TyComponent {
|
|
|
1474
1617
|
// ============================================================================
|
|
1475
1618
|
// PROPERTY CONFIGURATION - Declarative property lifecycle
|
|
1476
1619
|
// ============================================================================
|
|
1477
|
-
|
|
1620
|
+
TySelect.properties = {
|
|
1478
1621
|
value: {
|
|
1479
|
-
type:
|
|
1622
|
+
type: "string",
|
|
1480
1623
|
visual: true,
|
|
1481
1624
|
formValue: true,
|
|
1482
1625
|
emitChange: false,
|
|
1483
|
-
default:
|
|
1626
|
+
default: "",
|
|
1484
1627
|
coerce: (v) => {
|
|
1485
1628
|
// Handle array input (from React, Reagent, etc.)
|
|
1486
1629
|
if (Array.isArray(v)) {
|
|
1487
|
-
return v.join(
|
|
1630
|
+
return v.join(",");
|
|
1488
1631
|
}
|
|
1489
1632
|
// Handle null/undefined
|
|
1490
1633
|
if (v === null || v === undefined) {
|
|
1491
|
-
return
|
|
1634
|
+
return "";
|
|
1492
1635
|
}
|
|
1493
1636
|
// String already
|
|
1494
1637
|
return String(v);
|
|
1495
|
-
}
|
|
1638
|
+
},
|
|
1496
1639
|
},
|
|
1497
1640
|
name: {
|
|
1498
|
-
type:
|
|
1499
|
-
default:
|
|
1641
|
+
type: "string",
|
|
1642
|
+
default: "",
|
|
1643
|
+
},
|
|
1644
|
+
// Cardinality — native <select> semantics: absent = single select
|
|
1645
|
+
multiple: {
|
|
1646
|
+
type: "boolean",
|
|
1647
|
+
visual: true,
|
|
1648
|
+
default: false,
|
|
1649
|
+
},
|
|
1650
|
+
// Skin — absent = form field (matches ty-input); present = compact
|
|
1651
|
+
// content-hugging trigger (toolbars, filter bars)
|
|
1652
|
+
compact: {
|
|
1653
|
+
type: "boolean",
|
|
1654
|
+
visual: true,
|
|
1655
|
+
default: false,
|
|
1500
1656
|
},
|
|
1501
1657
|
placeholder: {
|
|
1502
|
-
type:
|
|
1658
|
+
type: "string",
|
|
1503
1659
|
visual: true,
|
|
1504
|
-
default:
|
|
1660
|
+
default: "Select...",
|
|
1505
1661
|
},
|
|
1506
1662
|
label: {
|
|
1507
|
-
type:
|
|
1663
|
+
type: "string",
|
|
1508
1664
|
visual: true,
|
|
1509
|
-
default:
|
|
1665
|
+
default: "",
|
|
1510
1666
|
},
|
|
1511
1667
|
disabled: {
|
|
1512
|
-
type:
|
|
1668
|
+
type: "boolean",
|
|
1513
1669
|
visual: true,
|
|
1514
|
-
default: false
|
|
1670
|
+
default: false,
|
|
1515
1671
|
},
|
|
1516
1672
|
readonly: {
|
|
1517
|
-
type:
|
|
1673
|
+
type: "boolean",
|
|
1518
1674
|
visual: true,
|
|
1519
|
-
default: false
|
|
1675
|
+
default: false,
|
|
1520
1676
|
},
|
|
1521
1677
|
required: {
|
|
1522
|
-
type:
|
|
1678
|
+
type: "boolean",
|
|
1523
1679
|
visual: true,
|
|
1524
|
-
default: false
|
|
1680
|
+
default: false,
|
|
1525
1681
|
},
|
|
1526
1682
|
externalSearch: {
|
|
1527
|
-
type:
|
|
1683
|
+
type: "boolean",
|
|
1528
1684
|
visual: true,
|
|
1529
1685
|
default: false,
|
|
1530
|
-
aliases: {
|
|
1686
|
+
aliases: { "external-search": true },
|
|
1531
1687
|
},
|
|
1532
|
-
|
|
1533
|
-
|
|
1688
|
+
// Search row visibility: 'auto' (default) shows it only for long option
|
|
1689
|
+
// lists; searchable / searchable="true" forces it on, searchable="false"
|
|
1690
|
+
// off. external-search always shows it (the input IS the mechanism).
|
|
1691
|
+
searchable: {
|
|
1692
|
+
type: "string",
|
|
1534
1693
|
visual: true,
|
|
1535
|
-
default:
|
|
1536
|
-
validate: (v) => ['sm', 'md', 'lg'].includes(v),
|
|
1694
|
+
default: "auto",
|
|
1537
1695
|
coerce: (v) => {
|
|
1538
|
-
if (
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
return
|
|
1543
|
-
}
|
|
1696
|
+
if (v === true || v === "" || v === "true")
|
|
1697
|
+
return "always";
|
|
1698
|
+
if (v === false || v === "false")
|
|
1699
|
+
return "never";
|
|
1700
|
+
return "auto";
|
|
1701
|
+
},
|
|
1544
1702
|
},
|
|
1545
|
-
|
|
1546
|
-
type:
|
|
1703
|
+
size: {
|
|
1704
|
+
type: "string",
|
|
1547
1705
|
visual: true,
|
|
1548
|
-
default:
|
|
1549
|
-
validate: (v) => [
|
|
1706
|
+
default: "md",
|
|
1707
|
+
validate: (v) => ["sm", "md", "lg"].includes(v),
|
|
1550
1708
|
coerce: (v) => {
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
return 'neutral';
|
|
1709
|
+
if (!["sm", "md", "lg"].includes(v)) {
|
|
1710
|
+
console.warn(`[ty-select] Invalid size. Using md.`);
|
|
1711
|
+
return "md";
|
|
1555
1712
|
}
|
|
1556
1713
|
return v;
|
|
1557
|
-
}
|
|
1714
|
+
},
|
|
1558
1715
|
},
|
|
1559
1716
|
debounce: {
|
|
1560
|
-
type:
|
|
1717
|
+
type: "number",
|
|
1561
1718
|
default: 0,
|
|
1562
1719
|
validate: (v) => v >= 0 && v <= 5000,
|
|
1563
1720
|
coerce: (v) => {
|
|
@@ -1565,31 +1722,26 @@ TyMultiselect.properties = {
|
|
|
1565
1722
|
if (isNaN(num))
|
|
1566
1723
|
return 0;
|
|
1567
1724
|
return Math.max(0, Math.min(5000, num));
|
|
1568
|
-
}
|
|
1569
|
-
},
|
|
1570
|
-
'selected-label': {
|
|
1571
|
-
type: 'string',
|
|
1572
|
-
visual: true,
|
|
1573
|
-
default: 'Selected'
|
|
1725
|
+
},
|
|
1574
1726
|
},
|
|
1575
|
-
|
|
1576
|
-
type:
|
|
1727
|
+
"available-label": {
|
|
1728
|
+
type: "string",
|
|
1577
1729
|
visual: true,
|
|
1578
|
-
default:
|
|
1730
|
+
default: "Available",
|
|
1579
1731
|
},
|
|
1580
|
-
|
|
1581
|
-
type:
|
|
1732
|
+
"no-options-message": {
|
|
1733
|
+
type: "string",
|
|
1582
1734
|
visual: true,
|
|
1583
|
-
default:
|
|
1735
|
+
default: "No options available",
|
|
1584
1736
|
},
|
|
1585
1737
|
loading: {
|
|
1586
|
-
type:
|
|
1738
|
+
type: "boolean",
|
|
1587
1739
|
visual: true,
|
|
1588
|
-
default: false
|
|
1589
|
-
}
|
|
1740
|
+
default: false,
|
|
1741
|
+
},
|
|
1590
1742
|
};
|
|
1591
1743
|
// Register the custom element
|
|
1592
|
-
if (!customElements.get(
|
|
1593
|
-
customElements.define(
|
|
1744
|
+
if (!customElements.get("ty-select")) {
|
|
1745
|
+
customElements.define("ty-select", TySelect);
|
|
1594
1746
|
}
|
|
1595
|
-
//# sourceMappingURL=
|
|
1747
|
+
//# sourceMappingURL=select.js.map
|