tyrell-components 1.0.0-RC12 → 1.0.0-RC13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/css/tyrell-colors-static.css +316 -0
- package/css/tyrell-theme.css +1454 -0
- package/css/tyrell.css +108 -423
- package/dist/tyrell-colors-static.css +316 -0
- package/dist/tyrell-theme.css +1454 -0
- package/dist/tyrell.css +108 -423
- package/dist/tyrell.js +1 -1
- package/lib/components/button.d.ts +7 -0
- package/lib/components/button.d.ts.map +1 -1
- package/lib/components/button.js +12 -1
- package/lib/components/button.js.map +1 -1
- package/lib/components/calendar-month.d.ts +17 -0
- package/lib/components/calendar-month.d.ts.map +1 -1
- package/lib/components/calendar-month.js +121 -7
- package/lib/components/calendar-month.js.map +1 -1
- package/lib/components/calendar.d.ts +3 -1
- package/lib/components/calendar.d.ts.map +1 -1
- package/lib/components/calendar.js +19 -9
- package/lib/components/calendar.js.map +1 -1
- package/lib/components/checkbox.d.ts +4 -3
- package/lib/components/checkbox.d.ts.map +1 -1
- package/lib/components/checkbox.js +23 -35
- package/lib/components/checkbox.js.map +1 -1
- package/lib/components/copy.d.ts +4 -3
- package/lib/components/copy.d.ts.map +1 -1
- package/lib/components/copy.js +22 -21
- package/lib/components/copy.js.map +1 -1
- package/lib/components/date-picker.d.ts +12 -1
- package/lib/components/date-picker.d.ts.map +1 -1
- package/lib/components/date-picker.js +122 -32
- package/lib/components/date-picker.js.map +1 -1
- package/lib/components/input.d.ts +3 -2
- package/lib/components/input.d.ts.map +1 -1
- package/lib/components/input.js +24 -21
- package/lib/components/input.js.map +1 -1
- package/lib/components/modal.d.ts.map +1 -1
- package/lib/components/modal.js +22 -3
- package/lib/components/modal.js.map +1 -1
- package/lib/components/option.d.ts +0 -11
- package/lib/components/option.d.ts.map +1 -1
- package/lib/components/option.js +4 -103
- package/lib/components/option.js.map +1 -1
- package/lib/components/popup.d.ts.map +1 -1
- package/lib/components/popup.js +36 -2
- package/lib/components/popup.js.map +1 -1
- package/lib/components/radio.d.ts +5 -2
- package/lib/components/radio.d.ts.map +1 -1
- package/lib/components/radio.js +16 -5
- package/lib/components/radio.js.map +1 -1
- package/lib/components/select.d.ts +119 -9
- package/lib/components/select.d.ts.map +1 -1
- package/lib/components/select.js +426 -58
- package/lib/components/select.js.map +1 -1
- package/lib/components/selected-tags.d.ts +19 -6
- package/lib/components/selected-tags.d.ts.map +1 -1
- package/lib/components/selected-tags.js +43 -7
- package/lib/components/selected-tags.js.map +1 -1
- package/lib/components/switch.d.ts +4 -3
- package/lib/components/switch.d.ts.map +1 -1
- package/lib/components/switch.js +17 -29
- package/lib/components/switch.js.map +1 -1
- package/lib/components/tabs.d.ts.map +1 -1
- package/lib/components/tabs.js +180 -25
- package/lib/components/tabs.js.map +1 -1
- package/lib/components/tag.js +1 -1
- package/lib/components/tag.js.map +1 -1
- package/lib/components/tooltip.d.ts +4 -2
- package/lib/components/tooltip.d.ts.map +1 -1
- package/lib/components/tooltip.js +109 -70
- package/lib/components/tooltip.js.map +1 -1
- package/lib/components/wizard.d.ts.map +1 -1
- package/lib/components/wizard.js +22 -3
- package/lib/components/wizard.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/lib/styles/button.d.ts +36 -6
- package/lib/styles/button.d.ts.map +1 -1
- package/lib/styles/button.js +239 -36
- package/lib/styles/button.js.map +1 -1
- package/lib/styles/calendar-month.d.ts +3 -1
- package/lib/styles/calendar-month.d.ts.map +1 -1
- package/lib/styles/calendar-month.js +64 -9
- 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 +1 -1
- package/lib/styles/checkbox.d.ts +3 -1
- package/lib/styles/checkbox.d.ts.map +1 -1
- package/lib/styles/checkbox.js +33 -55
- package/lib/styles/checkbox.js.map +1 -1
- package/lib/styles/copy.d.ts +3 -1
- package/lib/styles/copy.d.ts.map +1 -1
- package/lib/styles/copy.js +29 -6
- package/lib/styles/copy.js.map +1 -1
- package/lib/styles/custom-scrollbar.d.ts +1 -1
- package/lib/styles/custom-scrollbar.d.ts.map +1 -1
- package/lib/styles/custom-scrollbar.js +2 -2
- package/lib/styles/date-picker.d.ts +3 -1
- package/lib/styles/date-picker.d.ts.map +1 -1
- package/lib/styles/date-picker.js +53 -99
- package/lib/styles/date-picker.js.map +1 -1
- package/lib/styles/file-upload.d.ts +1 -1
- package/lib/styles/file-upload.d.ts.map +1 -1
- package/lib/styles/file-upload.js +3 -3
- package/lib/styles/input.d.ts +3 -1
- package/lib/styles/input.d.ts.map +1 -1
- package/lib/styles/input.js +67 -123
- package/lib/styles/input.js.map +1 -1
- package/lib/styles/multiselect.d.ts +3 -1
- package/lib/styles/multiselect.d.ts.map +1 -1
- package/lib/styles/multiselect.js +26 -2
- package/lib/styles/multiselect.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 +0 -51
- package/lib/styles/option.js.map +1 -1
- package/lib/styles/radio.d.ts +3 -1
- package/lib/styles/radio.d.ts.map +1 -1
- package/lib/styles/radio.js +24 -21
- package/lib/styles/radio.js.map +1 -1
- package/lib/styles/scroll-container.d.ts +1 -1
- package/lib/styles/scroll-container.d.ts.map +1 -1
- package/lib/styles/select-base.d.ts +9 -0
- package/lib/styles/select-base.d.ts.map +1 -0
- package/lib/styles/select-base.js +973 -0
- package/lib/styles/select-base.js.map +1 -0
- package/lib/styles/select.d.ts +3 -3
- package/lib/styles/select.d.ts.map +1 -1
- package/lib/styles/select.js +98 -30
- package/lib/styles/select.js.map +1 -1
- package/lib/styles/step.d.ts +1 -1
- package/lib/styles/step.d.ts.map +1 -1
- package/lib/styles/switch.d.ts +3 -1
- package/lib/styles/switch.d.ts.map +1 -1
- package/lib/styles/switch.js +25 -10
- package/lib/styles/switch.js.map +1 -1
- package/lib/styles/tab.d.ts +1 -1
- package/lib/styles/tab.d.ts.map +1 -1
- package/lib/styles/tabs.d.ts +1 -1
- package/lib/styles/tabs.d.ts.map +1 -1
- package/lib/styles/tabs.js +71 -1
- package/lib/styles/tabs.js.map +1 -1
- package/lib/styles/tag.d.ts.map +1 -1
- package/lib/styles/tag.js +4 -1
- package/lib/styles/tag.js.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 +5 -5
- package/lib/styles/tooltip.d.ts +4 -2
- package/lib/styles/tooltip.d.ts.map +1 -1
- package/lib/styles/tooltip.js +4 -119
- package/lib/styles/tooltip.js.map +1 -1
- package/lib/styles/wizard.d.ts +1 -1
- package/lib/styles/wizard.d.ts.map +1 -1
- package/lib/styles/wizard.js +18 -6
- package/lib/styles/wizard.js.map +1 -1
- package/lib/types/common.d.ts +1 -1
- package/lib/types/common.d.ts.map +1 -1
- package/lib/types/common.js +3 -1
- package/lib/types/common.js.map +1 -1
- package/lib/utils/icons.d.ts +10 -0
- package/lib/utils/icons.d.ts.map +1 -0
- package/lib/utils/icons.js +10 -0
- package/lib/utils/icons.js.map +1 -0
- package/lib/utils/loader-registry.d.ts +2 -2
- package/lib/utils/loader-registry.js +2 -2
- package/lib/utils/positioning.d.ts +31 -0
- package/lib/utils/positioning.d.ts.map +1 -1
- package/lib/utils/positioning.js +22 -3
- package/lib/utils/positioning.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +7 -4
- package/css/tyrell-brand.css +0 -1015
- package/dist/tyrell-brand.css +0 -1015
package/lib/components/select.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* shows the selected label(s) inline
|
|
14
14
|
* - `compact`: content-hugging trigger for toolbars/filter bars; single shows
|
|
15
15
|
* the selected label, multiple shows placeholder + count badge (pair with
|
|
16
|
-
* <ty-selected-
|
|
16
|
+
* <ty-selected-options>)
|
|
17
17
|
* - slot="trigger": consumer skin — replaces field/compact chrome entirely,
|
|
18
18
|
* behavior/ARIA/form participation unchanged
|
|
19
19
|
*
|
|
@@ -35,14 +35,16 @@
|
|
|
35
35
|
*
|
|
36
36
|
* <!-- Multi select, compact skin + out-of-band chips -->
|
|
37
37
|
* <ty-select multiple compact label="Robots" name="robots" id="robots">...</ty-select>
|
|
38
|
-
* <ty-selected-
|
|
38
|
+
* <ty-selected-options for="robots"></ty-selected-options>
|
|
39
39
|
* ```
|
|
40
40
|
*/
|
|
41
41
|
import { ensureStyles } from "../utils/styles.js";
|
|
42
|
-
import {
|
|
42
|
+
import { syncCustomFlavorSheet } from "../utils/flavor-sheet.js";
|
|
43
|
+
import { selectBaseStyles, selectCustomFlavorCss } from "../styles/select-base.js";
|
|
43
44
|
import { selectStyles } from "../styles/select.js";
|
|
44
45
|
import { getLoaderSvg } from "../utils/loader-registry.js";
|
|
45
46
|
import { lockScroll, unlockScroll } from "../utils/scroll-lock.js";
|
|
47
|
+
import { computeAnchoredPosition } from "../utils/positioning.js";
|
|
46
48
|
import { isMobileTouch } from "../utils/mobile.js";
|
|
47
49
|
import { TyComponent } from "../base/ty-component.js";
|
|
48
50
|
import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scrollbar.js";
|
|
@@ -76,10 +78,7 @@ function getElementHash(element) {
|
|
|
76
78
|
// ============================================================================
|
|
77
79
|
// SVG Icons
|
|
78
80
|
// ============================================================================
|
|
79
|
-
|
|
80
|
-
* Required indicator SVG icon (from Lucide)
|
|
81
|
-
*/
|
|
82
|
-
const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-asterisk"><path d="M12 6v12"/><path d="M17.196 9 6.804 15"/><path d="m6.804 9 10.392 6"/></svg>`;
|
|
81
|
+
import { REQUIRED_ICON_SVG } from "../utils/icons.js";
|
|
83
82
|
/**
|
|
84
83
|
* Chevron down icon SVG
|
|
85
84
|
*/
|
|
@@ -91,6 +90,19 @@ const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
|
|
|
91
90
|
* Short lists scan faster than they search — native <select> has no search.
|
|
92
91
|
*/
|
|
93
92
|
const SEARCH_AUTO_THRESHOLD = 7;
|
|
93
|
+
/**
|
|
94
|
+
* create-transform="slug": lowercase, spaces → '_', strip anything else
|
|
95
|
+
* non-alphanumeric, collapse/trim stray underscores.
|
|
96
|
+
*/
|
|
97
|
+
function slugify(text) {
|
|
98
|
+
return text
|
|
99
|
+
.trim()
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/\s+/g, "_")
|
|
102
|
+
.replace(/[^a-z0-9_]/g, "")
|
|
103
|
+
.replace(/_+/g, "_")
|
|
104
|
+
.replace(/^_|_$/g, "");
|
|
105
|
+
}
|
|
94
106
|
/**
|
|
95
107
|
* Search (magnifier) icon SVG — popup search input adornment (from Lucide)
|
|
96
108
|
*/
|
|
@@ -98,7 +110,15 @@ const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentCol
|
|
|
98
110
|
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
99
111
|
</svg>`;
|
|
100
112
|
/**
|
|
101
|
-
*
|
|
113
|
+
* Clear (X) icon SVG — .select-clear button, shown in the trigger's fallback
|
|
114
|
+
* chrome (default/compact skins) when clearable and something is selected.
|
|
115
|
+
*/
|
|
116
|
+
const CLEAR_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
117
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
118
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
119
|
+
</svg>`;
|
|
120
|
+
/**
|
|
121
|
+
* Ty Select Component
|
|
102
122
|
*/
|
|
103
123
|
export class TySelect extends TyComponent {
|
|
104
124
|
constructor() {
|
|
@@ -114,7 +134,10 @@ export class TySelect extends TyComponent {
|
|
|
114
134
|
this._disabled = false;
|
|
115
135
|
this._readonly = false;
|
|
116
136
|
this._required = false;
|
|
137
|
+
this._clearable = true;
|
|
117
138
|
this._externalSearch = false;
|
|
139
|
+
this._allowCreate = false;
|
|
140
|
+
this._createTransform = "none";
|
|
118
141
|
this._searchable = "auto";
|
|
119
142
|
this._loading = false;
|
|
120
143
|
this._scrollLockId = null;
|
|
@@ -147,8 +170,9 @@ export class TySelect extends TyComponent {
|
|
|
147
170
|
// Last single-select display clone — survives external-search option swaps
|
|
148
171
|
// that remove the matching option (and often the clone itself) from the DOM.
|
|
149
172
|
this._selectedClone = null;
|
|
173
|
+
this._customFlavorSheet = null;
|
|
150
174
|
const shadow = this.shadowRoot;
|
|
151
|
-
ensureStyles(shadow, { css:
|
|
175
|
+
ensureStyles(shadow, { css: selectBaseStyles, id: "ty-select-base" });
|
|
152
176
|
ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
|
|
153
177
|
// DON'T render here - wait for onConnect() to initialize values first
|
|
154
178
|
// This matches dropdown.ts pattern and prevents showing empty state
|
|
@@ -179,6 +203,7 @@ export class TySelect extends TyComponent {
|
|
|
179
203
|
// (only acts on tags whose desired-vs-actual selected state differs), so
|
|
180
204
|
// spurious firings caused by our own re-slot work are no-ops.
|
|
181
205
|
this._childObserver = new MutationObserver(() => {
|
|
206
|
+
this.ensureOptionRoles();
|
|
182
207
|
if (this._state.selectedValues.length > 0) {
|
|
183
208
|
this.syncSelectedTags(this._state.selectedValues);
|
|
184
209
|
}
|
|
@@ -188,8 +213,25 @@ export class TySelect extends TyComponent {
|
|
|
188
213
|
// including our display clone. Rebuild the selection display (idempotent:
|
|
189
214
|
// a clone that already matches is left alone, so no observer loop).
|
|
190
215
|
this.updateSelectionDisplay();
|
|
216
|
+
// Mobile's "Available (N)" count and empty-state come from
|
|
217
|
+
// updateMobileSelectedState(), which internal search filtering already
|
|
218
|
+
// triggers — but external-search bypasses internal filtering entirely
|
|
219
|
+
// (handleSearchInput returns early for it), so this was the only place
|
|
220
|
+
// left that could catch a child-list change and it didn't. Without
|
|
221
|
+
// this, .mobile-available-section[data-empty="true"] slot { display:
|
|
222
|
+
// none } stays stuck hidden after a consumer replaces the options —
|
|
223
|
+
// the new options are correctly in the DOM, just invisible.
|
|
224
|
+
this.updateMobileSelectedState();
|
|
191
225
|
});
|
|
192
226
|
this._childObserver.observe(this, { childList: true });
|
|
227
|
+
// MutationObserver only fires on FUTURE mutations — cover the initial
|
|
228
|
+
// set of options too (the common case: none selected yet at connect).
|
|
229
|
+
this.ensureOptionRoles();
|
|
230
|
+
this._syncCustomFlavor();
|
|
231
|
+
}
|
|
232
|
+
/** Custom (non-built-in) flavors — see utils/flavor-sheet.ts. */
|
|
233
|
+
_syncCustomFlavor() {
|
|
234
|
+
this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.getProperty("flavor"), ({ base }) => selectCustomFlavorCss(base));
|
|
193
235
|
}
|
|
194
236
|
/**
|
|
195
237
|
* Called when component is disconnected from DOM
|
|
@@ -244,6 +286,9 @@ export class TySelect extends TyComponent {
|
|
|
244
286
|
case "name":
|
|
245
287
|
this._name = newValue || "";
|
|
246
288
|
break;
|
|
289
|
+
case "flavor":
|
|
290
|
+
this._syncCustomFlavor();
|
|
291
|
+
break;
|
|
247
292
|
case "multiple":
|
|
248
293
|
this._multiple = newValue;
|
|
249
294
|
// Dropping to single with a multi selection: keep only the first
|
|
@@ -271,6 +316,11 @@ export class TySelect extends TyComponent {
|
|
|
271
316
|
case "required":
|
|
272
317
|
this._required = newValue;
|
|
273
318
|
break;
|
|
319
|
+
case "clearable":
|
|
320
|
+
this._clearable = newValue;
|
|
321
|
+
if (this.isConnected && this.shadowRoot)
|
|
322
|
+
this.updateSelectionDisplay();
|
|
323
|
+
break;
|
|
274
324
|
case "externalSearch":
|
|
275
325
|
this._externalSearch = newValue;
|
|
276
326
|
break;
|
|
@@ -279,6 +329,14 @@ export class TySelect extends TyComponent {
|
|
|
279
329
|
if (this.isConnected && this.shadowRoot)
|
|
280
330
|
this.updateSearchVisibility();
|
|
281
331
|
break;
|
|
332
|
+
case "allowCreate":
|
|
333
|
+
this._allowCreate = newValue;
|
|
334
|
+
if (this.isConnected && this.shadowRoot)
|
|
335
|
+
this.updateSearchVisibility();
|
|
336
|
+
break;
|
|
337
|
+
case "createTransform":
|
|
338
|
+
this._createTransform = newValue;
|
|
339
|
+
break;
|
|
282
340
|
case "size":
|
|
283
341
|
this._size = newValue;
|
|
284
342
|
break;
|
|
@@ -342,7 +400,7 @@ export class TySelect extends TyComponent {
|
|
|
342
400
|
return null;
|
|
343
401
|
}
|
|
344
402
|
/**
|
|
345
|
-
* Parse
|
|
403
|
+
* Parse select value (comma-separated string to array)
|
|
346
404
|
*/
|
|
347
405
|
parseValue(value) {
|
|
348
406
|
// Defensive check: ensure value is actually a string before calling .trim()
|
|
@@ -410,7 +468,7 @@ export class TySelect extends TyComponent {
|
|
|
410
468
|
}
|
|
411
469
|
/**
|
|
412
470
|
* Select an option - attribute only. The option stays in the list with
|
|
413
|
-
* selected styling; chips are rendered out-of-band (ty-selected-
|
|
471
|
+
* selected styling; chips are rendered out-of-band (ty-selected-options).
|
|
414
472
|
*/
|
|
415
473
|
selectTag(tag) {
|
|
416
474
|
tag.setAttribute("selected", "");
|
|
@@ -446,6 +504,24 @@ export class TySelect extends TyComponent {
|
|
|
446
504
|
else if (!shouldBeSelected && isSelected) {
|
|
447
505
|
this.deselectTag(tag);
|
|
448
506
|
}
|
|
507
|
+
// ARIA listbox pattern: every option reports its live selected state,
|
|
508
|
+
// not just ones that changed this pass — cheap (idempotent setAttribute).
|
|
509
|
+
tag.setAttribute("aria-selected", String(shouldBeSelected));
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* role="option" for every current option/tag — separate from
|
|
514
|
+
* syncSelectedTags (which only runs when the `value` property changes)
|
|
515
|
+
* so it also covers the "nothing selected yet, value never touched"
|
|
516
|
+
* startup case. Idempotent; cheap to call from the child MutationObserver
|
|
517
|
+
* on every light-DOM change.
|
|
518
|
+
*/
|
|
519
|
+
ensureOptionRoles() {
|
|
520
|
+
this.getTagElements().forEach((tag) => {
|
|
521
|
+
if (!tag.hasAttribute("role"))
|
|
522
|
+
tag.setAttribute("role", "option");
|
|
523
|
+
if (!tag.hasAttribute("aria-selected"))
|
|
524
|
+
tag.setAttribute("aria-selected", "false");
|
|
449
525
|
});
|
|
450
526
|
}
|
|
451
527
|
/**
|
|
@@ -463,6 +539,10 @@ export class TySelect extends TyComponent {
|
|
|
463
539
|
// 3. updateFormValue() → automatic (formValue: true in config)
|
|
464
540
|
// 4. render() → automatic if visual properties changed
|
|
465
541
|
this.setProperty("value", valueStr);
|
|
542
|
+
// Explicit call, not just relying on render()'s cascade — `value` may
|
|
543
|
+
// not always be flagged as a "visual" property requiring a full
|
|
544
|
+
// re-render, and validity must update the moment selection changes.
|
|
545
|
+
this.updateValidity();
|
|
466
546
|
// Dispatch change event (with rich items for out-of-band chip rendering)
|
|
467
547
|
if (dispatchChange) {
|
|
468
548
|
this.dispatchChangeEvent({
|
|
@@ -499,13 +579,11 @@ export class TySelect extends TyComponent {
|
|
|
499
579
|
*/
|
|
500
580
|
calculatePosition() {
|
|
501
581
|
const shadow = this.shadowRoot;
|
|
502
|
-
const stub = shadow.querySelector(".
|
|
582
|
+
const stub = shadow.querySelector(".select-stub");
|
|
503
583
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
504
584
|
if (!stub || !dialog)
|
|
505
585
|
return;
|
|
506
586
|
const stubRect = stub.getBoundingClientRect();
|
|
507
|
-
const viewportHeight = window.innerHeight;
|
|
508
|
-
const viewportWidth = window.innerWidth;
|
|
509
587
|
// Get dialog dimensions (it's already shown with showModal).
|
|
510
588
|
// Cap the estimate: with hundreds of options the raw measurement can be
|
|
511
589
|
// the uncapped content height, which made "fits below?" always false.
|
|
@@ -513,7 +591,6 @@ export class TySelect extends TyComponent {
|
|
|
513
591
|
const MAX_POPUP_ESTIMATE = 440;
|
|
514
592
|
const dialogRect = dialog.getBoundingClientRect();
|
|
515
593
|
const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
|
|
516
|
-
const padding = 8;
|
|
517
594
|
const wrapPadding = 20;
|
|
518
595
|
// Popup width is independent of the trigger — a small button trigger still
|
|
519
596
|
// gets a usable list. Overridable via --ty-select-popup-width.
|
|
@@ -521,20 +598,16 @@ export class TySelect extends TyComponent {
|
|
|
521
598
|
const popupWidth = !isNaN(cssWidth)
|
|
522
599
|
? cssWidth
|
|
523
600
|
: Math.max(stubRect.width, 320);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const positionBelow =
|
|
531
|
-
//
|
|
532
|
-
const x =
|
|
533
|
-
|
|
534
|
-
const gap = 4;
|
|
535
|
-
const y = positionBelow
|
|
536
|
-
? stubRect.bottom + gap - wrapPadding
|
|
537
|
-
: viewportHeight - stubRect.top + gap - wrapPadding;
|
|
601
|
+
const pos = computeAnchoredPosition({
|
|
602
|
+
anchorRect: stubRect,
|
|
603
|
+
popupWidth,
|
|
604
|
+
popupHeight: estimatedHeight,
|
|
605
|
+
align: this.getProperty("align"),
|
|
606
|
+
});
|
|
607
|
+
const positionBelow = pos.below;
|
|
608
|
+
// Shift by the dialog's transparent wrap (room for shadows)
|
|
609
|
+
const x = pos.x - wrapPadding;
|
|
610
|
+
const y = (positionBelow ? pos.topY : pos.bottomY) - wrapPadding;
|
|
538
611
|
const width = popupWidth + wrapPadding + wrapPadding;
|
|
539
612
|
// Set CSS variables for positioning
|
|
540
613
|
this.style.setProperty("--dropdown-x", `${x}px`);
|
|
@@ -596,8 +669,11 @@ export class TySelect extends TyComponent {
|
|
|
596
669
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
597
670
|
if (!dialog)
|
|
598
671
|
return;
|
|
672
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
673
|
+
if (stubEl)
|
|
674
|
+
stubEl.setAttribute("aria-expanded", "true");
|
|
599
675
|
// Lock body scroll while dropdown is open
|
|
600
|
-
const lockId = `
|
|
676
|
+
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
601
677
|
this._scrollLockId = lockId;
|
|
602
678
|
lockScroll(lockId);
|
|
603
679
|
// Show modal
|
|
@@ -638,6 +714,9 @@ export class TySelect extends TyComponent {
|
|
|
638
714
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
639
715
|
if (!dialog)
|
|
640
716
|
return;
|
|
717
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
718
|
+
if (stubEl)
|
|
719
|
+
stubEl.setAttribute("aria-expanded", "false");
|
|
641
720
|
// Destroy custom scrollbar
|
|
642
721
|
this._destroyOptionsScrollbar();
|
|
643
722
|
// Close dialog
|
|
@@ -699,8 +778,11 @@ export class TySelect extends TyComponent {
|
|
|
699
778
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
700
779
|
if (!dialog)
|
|
701
780
|
return;
|
|
781
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
782
|
+
if (stubEl)
|
|
783
|
+
stubEl.setAttribute("aria-expanded", "true");
|
|
702
784
|
// Lock body scroll while mobile modal is open
|
|
703
|
-
const lockId = `
|
|
785
|
+
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
704
786
|
this._scrollLockId = lockId;
|
|
705
787
|
lockScroll(lockId);
|
|
706
788
|
// Show dialog using native API (handles z-index automatically)
|
|
@@ -708,15 +790,28 @@ export class TySelect extends TyComponent {
|
|
|
708
790
|
dialog.classList.add("open");
|
|
709
791
|
// Update component state
|
|
710
792
|
this._state.open = true;
|
|
711
|
-
// Initialize options state
|
|
793
|
+
// Initialize options state. Also ensure nothing stays hidden from a
|
|
794
|
+
// previous search — mirrors openDropdown()'s updateOptionsVisibility(true)
|
|
795
|
+
// ("may have been hidden from previous search"), which openMobileModal()
|
|
796
|
+
// never had a mobile-appropriate equivalent for: desktop hides the whole
|
|
797
|
+
// options container (a single display:none toggle, harmless to reset
|
|
798
|
+
// unconditionally), mobile hides individual options via their `hidden`
|
|
799
|
+
// attribute (updateTagVisibility), so the reset has to happen here.
|
|
712
800
|
const tags = this.getTagElements().map((el) => this.getTagData(el));
|
|
713
801
|
this._state.filteredTags = tags;
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
802
|
+
this.updateTagVisibility(tags, tags);
|
|
803
|
+
// Focus the search input: the `autofocus` attribute on
|
|
804
|
+
// .mobile-search-input (see render template) handles this — showModal()
|
|
805
|
+
// itself runs the spec'd "dialog focusing steps" on every call, which
|
|
806
|
+
// look for an autofocus descendant and focus it natively, skipping it
|
|
807
|
+
// automatically if it's hidden (search disabled) in favor of the next
|
|
808
|
+
// focusable element. A manual searchInput.focus() call here, AFTER
|
|
809
|
+
// showModal() already ran its own focusing steps in the same task, was
|
|
810
|
+
// racing that native behavior — document.activeElement ended up back on
|
|
811
|
+
// the host element, not the input, which is why typing did nothing:
|
|
812
|
+
// keystrokes weren't reaching the search box at all despite it visually
|
|
813
|
+
// looking focused (cursor showing from the browser's own caret paint,
|
|
814
|
+
// not actual DOM focus).
|
|
720
815
|
// Update state after slots are ready
|
|
721
816
|
requestAnimationFrame(() => {
|
|
722
817
|
this.updateMobileSelectedState();
|
|
@@ -733,6 +828,9 @@ export class TySelect extends TyComponent {
|
|
|
733
828
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
734
829
|
if (!dialog)
|
|
735
830
|
return;
|
|
831
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
832
|
+
if (stubEl)
|
|
833
|
+
stubEl.setAttribute("aria-expanded", "false");
|
|
736
834
|
// Close immediately — ::backdrop doesn't support transitions
|
|
737
835
|
dialog.classList.remove("open");
|
|
738
836
|
dialog.close();
|
|
@@ -781,6 +879,34 @@ export class TySelect extends TyComponent {
|
|
|
781
879
|
}
|
|
782
880
|
this.openDropdown();
|
|
783
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* .select-clear click — must stop propagation before it bubbles to the
|
|
884
|
+
* stub's own click handler (which would otherwise also open the dropdown).
|
|
885
|
+
*/
|
|
886
|
+
handleClearClick(e) {
|
|
887
|
+
e.preventDefault();
|
|
888
|
+
e.stopPropagation();
|
|
889
|
+
if (this._disabled || this._readonly) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
this.clear();
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Keyboard-open the (closed) stub. The stub is a plain div with
|
|
896
|
+
* tabindex="0", not a native <button> — Enter/Space don't turn into
|
|
897
|
+
* click events on their own the way they do for real form controls, and
|
|
898
|
+
* without this a keyboard-only user could Tab to the field but never
|
|
899
|
+
* open it. document-level handleKeyboard() only runs once already open
|
|
900
|
+
* (it early-returns on !this._state.open), so this is the other half.
|
|
901
|
+
*/
|
|
902
|
+
handleStubKeydown(e) {
|
|
903
|
+
if (this._disabled || this._readonly || this._state.open)
|
|
904
|
+
return;
|
|
905
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
906
|
+
e.preventDefault();
|
|
907
|
+
this.openDropdown();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
784
910
|
handleTagClick(e) {
|
|
785
911
|
const target = e.target;
|
|
786
912
|
// Find the option element (ty-option or ty-tag)
|
|
@@ -789,6 +915,16 @@ export class TySelect extends TyComponent {
|
|
|
789
915
|
return;
|
|
790
916
|
e.preventDefault();
|
|
791
917
|
e.stopPropagation();
|
|
918
|
+
this.selectByTag(tag);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Select/toggle a tag by element — the part of handleTagClick that has no
|
|
922
|
+
* dependency on a real DOM event, so keyboard/programmatic callers (Enter
|
|
923
|
+
* on a highlighted option, allow-create's exact-match dedup) can call it
|
|
924
|
+
* directly instead of faking an Event object just to satisfy
|
|
925
|
+
* preventDefault()/stopPropagation().
|
|
926
|
+
*/
|
|
927
|
+
selectByTag(tag) {
|
|
792
928
|
const tagValue = this.getTagData(tag).value;
|
|
793
929
|
const currentValues = this.getSelectedValues();
|
|
794
930
|
// Single select: picking replaces the selection and closes the popup
|
|
@@ -812,6 +948,88 @@ export class TySelect extends TyComponent {
|
|
|
812
948
|
this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
|
|
813
949
|
}
|
|
814
950
|
}
|
|
951
|
+
/**
|
|
952
|
+
* allow-create: Enter with unmatched search text mints a new option.
|
|
953
|
+
* Returns true if it handled the key (created, or selected an existing
|
|
954
|
+
* exact match instead of duplicating) — false if there's nothing to do
|
|
955
|
+
* (blank query, or allow-create is off), so the caller's normal Enter
|
|
956
|
+
* handling (select highlighted, or no-op) proceeds.
|
|
957
|
+
*/
|
|
958
|
+
tryCreateFromSearch() {
|
|
959
|
+
if (!this._allowCreate)
|
|
960
|
+
return false;
|
|
961
|
+
const query = (this._state.search || "").trim();
|
|
962
|
+
if (!query)
|
|
963
|
+
return false;
|
|
964
|
+
const queryLower = query.toLowerCase();
|
|
965
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
966
|
+
// Dedup: typed text exactly matches an existing option (by value or
|
|
967
|
+
// display text) — select it instead of minting a duplicate.
|
|
968
|
+
const existing = allTags.find((t) => t.value.toLowerCase() === queryLower ||
|
|
969
|
+
t.text.toLowerCase() === queryLower);
|
|
970
|
+
if (existing) {
|
|
971
|
+
this.selectByTag(existing.element);
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
const rawLabel = query;
|
|
975
|
+
const defaultValue = this._createTransform === "slug" ? slugify(rawLabel) : rawLabel;
|
|
976
|
+
if (!defaultValue)
|
|
977
|
+
return true; // slugified to nothing — handled no-op
|
|
978
|
+
const detail = { value: defaultValue, label: rawLabel };
|
|
979
|
+
const event = new CustomEvent("create", {
|
|
980
|
+
detail,
|
|
981
|
+
bubbles: true,
|
|
982
|
+
composed: true,
|
|
983
|
+
cancelable: true,
|
|
984
|
+
});
|
|
985
|
+
this.dispatchEvent(event);
|
|
986
|
+
if (event.defaultPrevented)
|
|
987
|
+
return true; // consumer takes over entirely
|
|
988
|
+
const finalValue = detail.value;
|
|
989
|
+
const option = document.createElement("ty-option");
|
|
990
|
+
option.setAttribute("value", finalValue);
|
|
991
|
+
option.textContent = rawLabel;
|
|
992
|
+
this.appendChild(option);
|
|
993
|
+
if (!this._multiple) {
|
|
994
|
+
this.updateComponentValue([finalValue], true, "create", finalValue);
|
|
995
|
+
if (this._state.mode === "mobile") {
|
|
996
|
+
this.closeMobileModal();
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
this.closeDropdown();
|
|
1000
|
+
}
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
const currentValues = this.getSelectedValues();
|
|
1004
|
+
this.updateComponentValue([...currentValues, finalValue], true, "create", finalValue);
|
|
1005
|
+
this.clearSearchAfterCreate();
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Multiple + allow-create: clear the search box and re-show the full
|
|
1010
|
+
* (now-longer) option list so the next tag can be typed immediately —
|
|
1011
|
+
* the tags-input pattern.
|
|
1012
|
+
*/
|
|
1013
|
+
clearSearchAfterCreate() {
|
|
1014
|
+
this._state.search = "";
|
|
1015
|
+
this._state.highlightedIndex = -1;
|
|
1016
|
+
const shadow = this.shadowRoot;
|
|
1017
|
+
if (!shadow)
|
|
1018
|
+
return;
|
|
1019
|
+
const desktopInput = shadow.querySelector(".dropdown-search-input");
|
|
1020
|
+
const mobileInput = shadow.querySelector(".mobile-search-input");
|
|
1021
|
+
if (desktopInput)
|
|
1022
|
+
desktopInput.value = "";
|
|
1023
|
+
if (mobileInput)
|
|
1024
|
+
mobileInput.value = "";
|
|
1025
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
1026
|
+
this._state.filteredTags = allTags;
|
|
1027
|
+
this.updateTagVisibility(allTags, allTags);
|
|
1028
|
+
this.updateOptionsVisibility(true);
|
|
1029
|
+
this.clearHighlights(allTags);
|
|
1030
|
+
this.updateMobileSelectedState();
|
|
1031
|
+
(desktopInput ?? mobileInput)?.focus();
|
|
1032
|
+
}
|
|
815
1033
|
blockSearchClick(e) {
|
|
816
1034
|
e.stopPropagation();
|
|
817
1035
|
e.preventDefault();
|
|
@@ -868,11 +1086,14 @@ export class TySelect extends TyComponent {
|
|
|
868
1086
|
case "Enter":
|
|
869
1087
|
e.preventDefault();
|
|
870
1088
|
e.stopPropagation();
|
|
871
|
-
//
|
|
1089
|
+
// A highlighted (arrowed-to) option always wins — the user picked
|
|
1090
|
+
// it explicitly, so allow-create must not shadow it with a new one.
|
|
872
1091
|
if (currentHighlightedIndex >= 0 &&
|
|
873
1092
|
currentHighlightedIndex < tagsCount) {
|
|
874
|
-
|
|
875
|
-
|
|
1093
|
+
this.selectByTag(filteredTags[currentHighlightedIndex].element);
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
this.tryCreateFromSearch();
|
|
876
1097
|
}
|
|
877
1098
|
break;
|
|
878
1099
|
case "ArrowUp":
|
|
@@ -1071,6 +1292,28 @@ export class TySelect extends TyComponent {
|
|
|
1071
1292
|
this.updateSearchVisibility();
|
|
1072
1293
|
// Loading wrapper is rendered dynamically — re-apply each render
|
|
1073
1294
|
this.applyLoadingState();
|
|
1295
|
+
// Reflect constraint validation (required) to the owning <form>.
|
|
1296
|
+
this.updateValidity();
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Reflect constraint validation to the owning <form> via ElementInternals.
|
|
1300
|
+
* Without this `required` is only cosmetic — form.checkValidity() (and a
|
|
1301
|
+
* real submit) never actually blocks on an empty required select. Mirrors
|
|
1302
|
+
* ty-input's updateValidity(): empty + required => valueMissing; disabled
|
|
1303
|
+
* fields are barred from constraint validation.
|
|
1304
|
+
*/
|
|
1305
|
+
updateValidity() {
|
|
1306
|
+
if (this.disabled) {
|
|
1307
|
+
this._internals.setValidity({});
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (this.required && this.getSelectedValues().length === 0) {
|
|
1311
|
+
const stub = this.shadowRoot?.querySelector(".select-stub");
|
|
1312
|
+
this._internals.setValidity({ valueMissing: true }, "Please select a value", stub ?? undefined);
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
this._internals.setValidity({});
|
|
1316
|
+
}
|
|
1074
1317
|
}
|
|
1075
1318
|
/**
|
|
1076
1319
|
* When a consumer slots their own trigger (slot="trigger"), strip the
|
|
@@ -1078,7 +1321,7 @@ export class TySelect extends TyComponent {
|
|
|
1078
1321
|
*/
|
|
1079
1322
|
setupTriggerSlot() {
|
|
1080
1323
|
const shadow = this.shadowRoot;
|
|
1081
|
-
const stub = shadow.querySelector(".
|
|
1324
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1082
1325
|
const slot = shadow.querySelector('slot[name="trigger"]');
|
|
1083
1326
|
if (!stub || !slot)
|
|
1084
1327
|
return;
|
|
@@ -1091,13 +1334,16 @@ export class TySelect extends TyComponent {
|
|
|
1091
1334
|
*/
|
|
1092
1335
|
setupEventListeners() {
|
|
1093
1336
|
const shadow = this.shadowRoot;
|
|
1094
|
-
const stub = shadow.querySelector(".
|
|
1337
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1095
1338
|
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1096
1339
|
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
1097
1340
|
if (stub) {
|
|
1098
1341
|
this._stubClickHandler = this.handleStubClick.bind(this);
|
|
1099
1342
|
stub.addEventListener("click", this._stubClickHandler);
|
|
1343
|
+
stub.addEventListener("keydown", this.handleStubKeydown.bind(this));
|
|
1100
1344
|
}
|
|
1345
|
+
const clearBtn = shadow.querySelector(".select-clear");
|
|
1346
|
+
clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
|
|
1101
1347
|
this.setupTriggerSlot();
|
|
1102
1348
|
// Add tag click handler to slot
|
|
1103
1349
|
if (optionsSlot) {
|
|
@@ -1143,11 +1389,13 @@ export class TySelect extends TyComponent {
|
|
|
1143
1389
|
renderDesktop() {
|
|
1144
1390
|
const shadow = this.shadowRoot;
|
|
1145
1391
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1146
|
-
if (!shadow.querySelector(".
|
|
1392
|
+
if (!shadow.querySelector(".select-container")) {
|
|
1147
1393
|
const stubClasses = this.buildStubClasses();
|
|
1394
|
+
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
1395
|
+
const labelId = `select-label-${getElementHash(this)}`;
|
|
1148
1396
|
const labelHtml = this._label
|
|
1149
1397
|
? `
|
|
1150
|
-
<label class="ty-field-label">
|
|
1398
|
+
<label class="ty-field-label" id="${labelId}">
|
|
1151
1399
|
${this._label}
|
|
1152
1400
|
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1153
1401
|
</label>
|
|
@@ -1155,17 +1403,26 @@ export class TySelect extends TyComponent {
|
|
|
1155
1403
|
: "";
|
|
1156
1404
|
const searchPlaceholder = "Search...";
|
|
1157
1405
|
shadow.innerHTML = `
|
|
1158
|
-
<div class="
|
|
1406
|
+
<div class="select-container dropdown-mode-desktop">
|
|
1159
1407
|
${labelHtml}
|
|
1160
1408
|
<div class="dropdown-wrapper">
|
|
1161
|
-
<div class="dropdown-stub
|
|
1162
|
-
|
|
1409
|
+
<div class="dropdown-stub select-stub ${stubClasses}"
|
|
1410
|
+
role="combobox"
|
|
1411
|
+
aria-haspopup="listbox"
|
|
1412
|
+
aria-expanded="false"
|
|
1413
|
+
aria-controls="${listboxId}"
|
|
1414
|
+
${this._label ? `aria-labelledby="${labelId}"` : ""}
|
|
1415
|
+
tabindex="${this._disabled ? "-1" : "0"}"
|
|
1416
|
+
${this._disabled ? 'disabled aria-disabled="true"' : ""}>
|
|
1163
1417
|
<slot name="trigger">
|
|
1164
1418
|
<slot name="start"></slot>
|
|
1165
1419
|
<slot name="selected"></slot>
|
|
1166
1420
|
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1167
1421
|
<span class="select-count" hidden></span>
|
|
1168
1422
|
<slot name="end"></slot>
|
|
1423
|
+
<button class="select-clear" type="button" aria-label="Clear selection" hidden>
|
|
1424
|
+
${CLEAR_ICON_SVG}
|
|
1425
|
+
</button>
|
|
1169
1426
|
<div class="dropdown-chevron">
|
|
1170
1427
|
${CHEVRON_DOWN_SVG}
|
|
1171
1428
|
</div>
|
|
@@ -1187,7 +1444,7 @@ export class TySelect extends TyComponent {
|
|
|
1187
1444
|
</div>
|
|
1188
1445
|
</div>
|
|
1189
1446
|
<div class="dropdown-options-wrapper">
|
|
1190
|
-
<div class="dropdown-options">
|
|
1447
|
+
<div class="dropdown-options" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
|
|
1191
1448
|
<slot id="options-slot"></slot>
|
|
1192
1449
|
</div>
|
|
1193
1450
|
<div class="dropdown-loading" aria-hidden="true">
|
|
@@ -1216,11 +1473,13 @@ export class TySelect extends TyComponent {
|
|
|
1216
1473
|
renderMobile() {
|
|
1217
1474
|
const shadow = this.shadowRoot;
|
|
1218
1475
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1219
|
-
if (!shadow.querySelector(".
|
|
1476
|
+
if (!shadow.querySelector(".select-container")) {
|
|
1220
1477
|
const stubClasses = this.buildStubClasses();
|
|
1478
|
+
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
1479
|
+
const labelId = `select-label-${getElementHash(this)}`;
|
|
1221
1480
|
const labelHtml = this._label
|
|
1222
1481
|
? `
|
|
1223
|
-
<label class="ty-field-label">
|
|
1482
|
+
<label class="ty-field-label" id="${labelId}">
|
|
1224
1483
|
${this._label}
|
|
1225
1484
|
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1226
1485
|
</label>
|
|
@@ -1247,6 +1506,7 @@ export class TySelect extends TyComponent {
|
|
|
1247
1506
|
class="mobile-search-input ${this._size}"
|
|
1248
1507
|
type="text"
|
|
1249
1508
|
placeholder="${searchPlaceholder}"
|
|
1509
|
+
autofocus
|
|
1250
1510
|
${this._disabled ? "disabled" : ""}
|
|
1251
1511
|
/>
|
|
1252
1512
|
<button class="mobile-close-button" type="button" aria-label="Close">
|
|
@@ -1256,17 +1516,26 @@ export class TySelect extends TyComponent {
|
|
|
1256
1516
|
</div>
|
|
1257
1517
|
`;
|
|
1258
1518
|
shadow.innerHTML = `
|
|
1259
|
-
<div class="
|
|
1519
|
+
<div class="select-container dropdown-mode-mobile">
|
|
1260
1520
|
${labelHtml}
|
|
1261
1521
|
<div class="dropdown-wrapper">
|
|
1262
|
-
<div class="dropdown-stub
|
|
1263
|
-
|
|
1522
|
+
<div class="dropdown-stub select-stub ${stubClasses}"
|
|
1523
|
+
role="combobox"
|
|
1524
|
+
aria-haspopup="listbox"
|
|
1525
|
+
aria-expanded="false"
|
|
1526
|
+
aria-controls="${listboxId}"
|
|
1527
|
+
${this._label ? `aria-labelledby="${labelId}"` : ""}
|
|
1528
|
+
tabindex="${this._disabled ? "-1" : "0"}"
|
|
1529
|
+
${this._disabled ? 'disabled aria-disabled="true"' : ""}>
|
|
1264
1530
|
<slot name="trigger">
|
|
1265
1531
|
<slot name="start"></slot>
|
|
1266
1532
|
<slot name="selected"></slot>
|
|
1267
1533
|
<span class="dropdown-placeholder">${this._placeholder}</span>
|
|
1268
1534
|
<span class="select-count" hidden></span>
|
|
1269
1535
|
<slot name="end"></slot>
|
|
1536
|
+
<button class="select-clear" type="button" aria-label="Clear selection" hidden>
|
|
1537
|
+
${CLEAR_ICON_SVG}
|
|
1538
|
+
</button>
|
|
1270
1539
|
<div class="dropdown-chevron">
|
|
1271
1540
|
${CHEVRON_DOWN_SVG}
|
|
1272
1541
|
</div>
|
|
@@ -1287,7 +1556,7 @@ export class TySelect extends TyComponent {
|
|
|
1287
1556
|
<div class="section-header">
|
|
1288
1557
|
<span class="section-title">${this._availableLabel}</span>
|
|
1289
1558
|
</div>
|
|
1290
|
-
<div class="section-content dropdown-options-wrapper">
|
|
1559
|
+
<div class="section-content dropdown-options-wrapper" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
|
|
1291
1560
|
<slot id="options-slot"></slot>
|
|
1292
1561
|
<div class="empty-state">${this._noOptionsMessage}</div>
|
|
1293
1562
|
<div class="dropdown-loading" aria-hidden="true">
|
|
@@ -1317,14 +1586,17 @@ export class TySelect extends TyComponent {
|
|
|
1317
1586
|
*/
|
|
1318
1587
|
setupMobileEventListeners() {
|
|
1319
1588
|
const shadow = this.shadowRoot;
|
|
1320
|
-
const stub = shadow.querySelector(".
|
|
1589
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1321
1590
|
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1322
1591
|
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
1323
1592
|
const closeButton = shadow.querySelector(".mobile-close-button");
|
|
1324
1593
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
1325
1594
|
if (stub) {
|
|
1326
1595
|
stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
|
|
1596
|
+
stub.addEventListener("keydown", (e) => this.handleMobileStubKeydown(e));
|
|
1327
1597
|
}
|
|
1598
|
+
const clearBtn = shadow.querySelector(".select-clear");
|
|
1599
|
+
clearBtn?.addEventListener("click", this.handleClearClick.bind(this));
|
|
1328
1600
|
this.setupTriggerSlot();
|
|
1329
1601
|
// Add tag click handler to slot
|
|
1330
1602
|
if (optionsSlot) {
|
|
@@ -1365,6 +1637,15 @@ export class TySelect extends TyComponent {
|
|
|
1365
1637
|
}
|
|
1366
1638
|
this.openMobileModal();
|
|
1367
1639
|
}
|
|
1640
|
+
/** Keyboard-open the (closed) mobile stub — see handleStubKeydown for why this is needed. */
|
|
1641
|
+
handleMobileStubKeydown(e) {
|
|
1642
|
+
if (this._disabled || this._readonly || this._state.open)
|
|
1643
|
+
return;
|
|
1644
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
1645
|
+
e.preventDefault();
|
|
1646
|
+
this.openMobileModal();
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1368
1649
|
/**
|
|
1369
1650
|
* Handle mobile tag click - select and potentially close
|
|
1370
1651
|
*/
|
|
@@ -1403,6 +1684,9 @@ export class TySelect extends TyComponent {
|
|
|
1403
1684
|
shouldShowSearch() {
|
|
1404
1685
|
if (this._externalSearch)
|
|
1405
1686
|
return true;
|
|
1687
|
+
// allow-create's only input mechanism IS the search box.
|
|
1688
|
+
if (this._allowCreate)
|
|
1689
|
+
return true;
|
|
1406
1690
|
if (this._searchable === "always")
|
|
1407
1691
|
return true;
|
|
1408
1692
|
if (this._searchable === "never")
|
|
@@ -1438,7 +1722,7 @@ export class TySelect extends TyComponent {
|
|
|
1438
1722
|
*/
|
|
1439
1723
|
updateSelectionDisplay() {
|
|
1440
1724
|
const shadow = this.shadowRoot;
|
|
1441
|
-
const stub = shadow.querySelector(".
|
|
1725
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1442
1726
|
if (!stub)
|
|
1443
1727
|
return;
|
|
1444
1728
|
// Selection comes from STATE, not from scanning option elements —
|
|
@@ -1453,6 +1737,10 @@ export class TySelect extends TyComponent {
|
|
|
1453
1737
|
badge.textContent = String(count);
|
|
1454
1738
|
badge.hidden = count === 0 || !this._multiple || !this._compact;
|
|
1455
1739
|
}
|
|
1740
|
+
const clearBtn = stub.querySelector(".select-clear");
|
|
1741
|
+
if (clearBtn) {
|
|
1742
|
+
clearBtn.hidden = !(count > 0 && this._clearable && !this._disabled && !this._readonly);
|
|
1743
|
+
}
|
|
1456
1744
|
// Single select: project the selected option into the stub as a clone
|
|
1457
1745
|
// (rich HTML content survives — same mechanism as ty-dropdown).
|
|
1458
1746
|
const wantClone = !this._multiple && count > 0 ? values[0] : null;
|
|
@@ -1515,7 +1803,7 @@ export class TySelect extends TyComponent {
|
|
|
1515
1803
|
// ============================================================================
|
|
1516
1804
|
/**
|
|
1517
1805
|
* Deselect a value programmatically WITH a change event.
|
|
1518
|
-
* Used by out-of-band chip displays (ty-selected-
|
|
1806
|
+
* Used by out-of-band chip displays (ty-selected-options) so dismissing a chip
|
|
1519
1807
|
* fires change like any in-popup interaction (HTMX hx-trigger="change" etc.).
|
|
1520
1808
|
*/
|
|
1521
1809
|
deselectValue(value) {
|
|
@@ -1524,6 +1812,15 @@ export class TySelect extends TyComponent {
|
|
|
1524
1812
|
return;
|
|
1525
1813
|
this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
|
|
1526
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Clear the entire selection programmatically WITH a change event
|
|
1817
|
+
* (action: 'clear'). No-op if nothing is selected. Exists so slot="trigger"
|
|
1818
|
+
* consumers (custom chrome, built-in clear UI hidden by design) can wire
|
|
1819
|
+
* their own clear icon's click handler to it.
|
|
1820
|
+
*/
|
|
1821
|
+
clear() {
|
|
1822
|
+
this.updateComponentValue([], true, "clear", null);
|
|
1823
|
+
}
|
|
1527
1824
|
get value() {
|
|
1528
1825
|
// Always read from DOM - tags with 'selected' attribute are source of truth
|
|
1529
1826
|
return this.getSelectedValues().join(",");
|
|
@@ -1579,6 +1876,12 @@ export class TySelect extends TyComponent {
|
|
|
1579
1876
|
set readonly(value) {
|
|
1580
1877
|
this.setProperty("readonly", value);
|
|
1581
1878
|
}
|
|
1879
|
+
get clearable() {
|
|
1880
|
+
return this.getProperty("clearable");
|
|
1881
|
+
}
|
|
1882
|
+
set clearable(value) {
|
|
1883
|
+
this.setProperty("clearable", value);
|
|
1884
|
+
}
|
|
1582
1885
|
get required() {
|
|
1583
1886
|
return this.getProperty("required");
|
|
1584
1887
|
}
|
|
@@ -1610,6 +1913,18 @@ export class TySelect extends TyComponent {
|
|
|
1610
1913
|
set size(value) {
|
|
1611
1914
|
this.setProperty("size", value);
|
|
1612
1915
|
}
|
|
1916
|
+
get align() {
|
|
1917
|
+
return this.getProperty("align");
|
|
1918
|
+
}
|
|
1919
|
+
set align(value) {
|
|
1920
|
+
this.setProperty("align", value);
|
|
1921
|
+
}
|
|
1922
|
+
get flavor() {
|
|
1923
|
+
return this.getProperty("flavor");
|
|
1924
|
+
}
|
|
1925
|
+
set flavor(value) {
|
|
1926
|
+
this.setProperty("flavor", value);
|
|
1927
|
+
}
|
|
1613
1928
|
get form() {
|
|
1614
1929
|
return this._internals.form;
|
|
1615
1930
|
}
|
|
@@ -1679,12 +1994,40 @@ TySelect.properties = {
|
|
|
1679
1994
|
visual: true,
|
|
1680
1995
|
default: false,
|
|
1681
1996
|
},
|
|
1997
|
+
// Built-in × clear button in the default/compact trigger, shown once
|
|
1998
|
+
// something is selected. Automatically absent under slot="trigger" —
|
|
1999
|
+
// it lives in the slot's fallback content, same mechanism that already
|
|
2000
|
+
// hides the chevron there. Default true matches ty-date-picker.
|
|
2001
|
+
clearable: {
|
|
2002
|
+
type: "boolean",
|
|
2003
|
+
visual: true,
|
|
2004
|
+
default: true,
|
|
2005
|
+
aliases: { "not-clearable": false },
|
|
2006
|
+
},
|
|
1682
2007
|
externalSearch: {
|
|
1683
2008
|
type: "boolean",
|
|
1684
2009
|
visual: true,
|
|
1685
2010
|
default: false,
|
|
1686
2011
|
aliases: { "external-search": true },
|
|
1687
2012
|
},
|
|
2013
|
+
// Enter on unmatched search text mints a new <ty-option> — forces the
|
|
2014
|
+
// search row on (same as external-search: the input IS the mechanism).
|
|
2015
|
+
allowCreate: {
|
|
2016
|
+
type: "boolean",
|
|
2017
|
+
visual: true,
|
|
2018
|
+
default: false,
|
|
2019
|
+
aliases: { "allow-create": true },
|
|
2020
|
+
},
|
|
2021
|
+
// Opt-in normalizer for the value minted by allow-create (label/display
|
|
2022
|
+
// text is always kept verbatim). 'none' (default): value === typed text.
|
|
2023
|
+
// 'slug': lowercase, spaces → '_', strips anything else non-alphanumeric.
|
|
2024
|
+
createTransform: {
|
|
2025
|
+
type: "string",
|
|
2026
|
+
visual: false,
|
|
2027
|
+
default: "none",
|
|
2028
|
+
aliases: { "create-transform": true },
|
|
2029
|
+
coerce: (v) => (v === "slug" ? "slug" : "none"),
|
|
2030
|
+
},
|
|
1688
2031
|
// Search row visibility: 'auto' (default) shows it only for long option
|
|
1689
2032
|
// lists; searchable / searchable="true" forces it on, searchable="false"
|
|
1690
2033
|
// off. external-search always shows it (the input IS the mechanism).
|
|
@@ -1700,12 +2043,29 @@ TySelect.properties = {
|
|
|
1700
2043
|
return "auto";
|
|
1701
2044
|
},
|
|
1702
2045
|
},
|
|
2046
|
+
// Horizontal anchor for the popup: "start" (default) matches the
|
|
2047
|
+
// trigger's left edge, "end" matches its right edge — either way
|
|
2048
|
+
// clamped into the viewport. Useful with slot="trigger" custom
|
|
2049
|
+
// triggers whose own position makes left-anchoring look wrong (e.g.
|
|
2050
|
+
// an icon button at the right edge of a toolbar).
|
|
2051
|
+
align: {
|
|
2052
|
+
type: "string",
|
|
2053
|
+
visual: true,
|
|
2054
|
+
default: "start",
|
|
2055
|
+
validate: (v) => ["start", "end"].includes(v),
|
|
2056
|
+
coerce: (v) => (v === "end" ? "end" : "start"),
|
|
2057
|
+
},
|
|
1703
2058
|
size: {
|
|
1704
2059
|
type: "string",
|
|
1705
2060
|
visual: true,
|
|
1706
2061
|
default: "md",
|
|
2062
|
+
// Fields come in exactly three sizes; legacy xs/xl map to sm/lg.
|
|
1707
2063
|
validate: (v) => ["sm", "md", "lg"].includes(v),
|
|
1708
2064
|
coerce: (v) => {
|
|
2065
|
+
if (v === "xs")
|
|
2066
|
+
return "sm";
|
|
2067
|
+
if (v === "xl")
|
|
2068
|
+
return "lg";
|
|
1709
2069
|
if (!["sm", "md", "lg"].includes(v)) {
|
|
1710
2070
|
console.warn(`[ty-select] Invalid size. Using md.`);
|
|
1711
2071
|
return "md";
|
|
@@ -1739,6 +2099,14 @@ TySelect.properties = {
|
|
|
1739
2099
|
visual: true,
|
|
1740
2100
|
default: false,
|
|
1741
2101
|
},
|
|
2102
|
+
// Any string is accepted: built-ins get static CSS, other identifiers
|
|
2103
|
+
// become custom flavors (see _syncCustomFlavor), garbage degrades to
|
|
2104
|
+
// the default look.
|
|
2105
|
+
flavor: {
|
|
2106
|
+
type: "string",
|
|
2107
|
+
visual: true,
|
|
2108
|
+
default: "neutral",
|
|
2109
|
+
},
|
|
1742
2110
|
};
|
|
1743
2111
|
// Register the custom element
|
|
1744
2112
|
if (!customElements.get("ty-select")) {
|