tyrell-components 1.0.0-TC29 → 1.0.0-TC31
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.css +48 -12
- package/dist/tyrell.css +48 -12
- package/dist/tyrell.js +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 +5 -1
- package/lib/components/date-picker.d.ts.map +1 -1
- package/lib/components/date-picker.js +113 -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/popup.d.ts.map +1 -1
- package/lib/components/popup.js +14 -0
- 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 +75 -5
- package/lib/components/select.d.ts.map +1 -1
- package/lib/components/select.js +282 -47
- package/lib/components/select.js.map +1 -1
- package/lib/components/selected-tags.d.ts +1 -1
- package/lib/components/selected-tags.js +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 +18 -2
- package/lib/components/tabs.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/styles/button.d.ts.map +1 -1
- package/lib/styles/button.js +10 -6
- 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 +63 -8
- package/lib/styles/calendar-month.js.map +1 -1
- package/lib/styles/checkbox.d.ts +3 -1
- package/lib/styles/checkbox.d.ts.map +1 -1
- package/lib/styles/checkbox.js +31 -53
- 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 +27 -4
- package/lib/styles/copy.js.map +1 -1
- 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 +51 -98
- package/lib/styles/date-picker.js.map +1 -1
- package/lib/styles/input.d.ts +3 -1
- package/lib/styles/input.d.ts.map +1 -1
- package/lib/styles/input.js +58 -119
- 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/radio.d.ts +3 -1
- package/lib/styles/radio.d.ts.map +1 -1
- package/lib/styles/radio.js +23 -20
- package/lib/styles/radio.js.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 +908 -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 +20 -18
- package/lib/styles/select.js.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 +23 -8
- package/lib/styles/switch.js.map +1 -1
- 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/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 +27 -0
- package/lib/utils/positioning.d.ts.map +1 -1
- package/lib/utils/positioning.js +20 -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 +1 -1
package/lib/components/select.js
CHANGED
|
@@ -39,10 +39,12 @@
|
|
|
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,7 @@ 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
|
-
* Ty
|
|
113
|
+
* Ty Select Component
|
|
102
114
|
*/
|
|
103
115
|
export class TySelect extends TyComponent {
|
|
104
116
|
constructor() {
|
|
@@ -115,6 +127,8 @@ export class TySelect extends TyComponent {
|
|
|
115
127
|
this._readonly = false;
|
|
116
128
|
this._required = false;
|
|
117
129
|
this._externalSearch = false;
|
|
130
|
+
this._allowCreate = false;
|
|
131
|
+
this._createTransform = "none";
|
|
118
132
|
this._searchable = "auto";
|
|
119
133
|
this._loading = false;
|
|
120
134
|
this._scrollLockId = null;
|
|
@@ -147,8 +161,9 @@ export class TySelect extends TyComponent {
|
|
|
147
161
|
// Last single-select display clone — survives external-search option swaps
|
|
148
162
|
// that remove the matching option (and often the clone itself) from the DOM.
|
|
149
163
|
this._selectedClone = null;
|
|
164
|
+
this._customFlavorSheet = null;
|
|
150
165
|
const shadow = this.shadowRoot;
|
|
151
|
-
ensureStyles(shadow, { css:
|
|
166
|
+
ensureStyles(shadow, { css: selectBaseStyles, id: "ty-select-base" });
|
|
152
167
|
ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
|
|
153
168
|
// DON'T render here - wait for onConnect() to initialize values first
|
|
154
169
|
// This matches dropdown.ts pattern and prevents showing empty state
|
|
@@ -179,6 +194,7 @@ export class TySelect extends TyComponent {
|
|
|
179
194
|
// (only acts on tags whose desired-vs-actual selected state differs), so
|
|
180
195
|
// spurious firings caused by our own re-slot work are no-ops.
|
|
181
196
|
this._childObserver = new MutationObserver(() => {
|
|
197
|
+
this.ensureOptionRoles();
|
|
182
198
|
if (this._state.selectedValues.length > 0) {
|
|
183
199
|
this.syncSelectedTags(this._state.selectedValues);
|
|
184
200
|
}
|
|
@@ -190,6 +206,14 @@ export class TySelect extends TyComponent {
|
|
|
190
206
|
this.updateSelectionDisplay();
|
|
191
207
|
});
|
|
192
208
|
this._childObserver.observe(this, { childList: true });
|
|
209
|
+
// MutationObserver only fires on FUTURE mutations — cover the initial
|
|
210
|
+
// set of options too (the common case: none selected yet at connect).
|
|
211
|
+
this.ensureOptionRoles();
|
|
212
|
+
this._syncCustomFlavor();
|
|
213
|
+
}
|
|
214
|
+
/** Custom (non-built-in) flavors — see utils/flavor-sheet.ts. */
|
|
215
|
+
_syncCustomFlavor() {
|
|
216
|
+
this._customFlavorSheet = syncCustomFlavorSheet(this.shadowRoot, this._customFlavorSheet, this.getProperty("flavor"), ({ base }) => selectCustomFlavorCss(base));
|
|
193
217
|
}
|
|
194
218
|
/**
|
|
195
219
|
* Called when component is disconnected from DOM
|
|
@@ -244,6 +268,9 @@ export class TySelect extends TyComponent {
|
|
|
244
268
|
case "name":
|
|
245
269
|
this._name = newValue || "";
|
|
246
270
|
break;
|
|
271
|
+
case "flavor":
|
|
272
|
+
this._syncCustomFlavor();
|
|
273
|
+
break;
|
|
247
274
|
case "multiple":
|
|
248
275
|
this._multiple = newValue;
|
|
249
276
|
// Dropping to single with a multi selection: keep only the first
|
|
@@ -279,6 +306,14 @@ export class TySelect extends TyComponent {
|
|
|
279
306
|
if (this.isConnected && this.shadowRoot)
|
|
280
307
|
this.updateSearchVisibility();
|
|
281
308
|
break;
|
|
309
|
+
case "allowCreate":
|
|
310
|
+
this._allowCreate = newValue;
|
|
311
|
+
if (this.isConnected && this.shadowRoot)
|
|
312
|
+
this.updateSearchVisibility();
|
|
313
|
+
break;
|
|
314
|
+
case "createTransform":
|
|
315
|
+
this._createTransform = newValue;
|
|
316
|
+
break;
|
|
282
317
|
case "size":
|
|
283
318
|
this._size = newValue;
|
|
284
319
|
break;
|
|
@@ -342,7 +377,7 @@ export class TySelect extends TyComponent {
|
|
|
342
377
|
return null;
|
|
343
378
|
}
|
|
344
379
|
/**
|
|
345
|
-
* Parse
|
|
380
|
+
* Parse select value (comma-separated string to array)
|
|
346
381
|
*/
|
|
347
382
|
parseValue(value) {
|
|
348
383
|
// Defensive check: ensure value is actually a string before calling .trim()
|
|
@@ -446,6 +481,24 @@ export class TySelect extends TyComponent {
|
|
|
446
481
|
else if (!shouldBeSelected && isSelected) {
|
|
447
482
|
this.deselectTag(tag);
|
|
448
483
|
}
|
|
484
|
+
// ARIA listbox pattern: every option reports its live selected state,
|
|
485
|
+
// not just ones that changed this pass — cheap (idempotent setAttribute).
|
|
486
|
+
tag.setAttribute("aria-selected", String(shouldBeSelected));
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* role="option" for every current option/tag — separate from
|
|
491
|
+
* syncSelectedTags (which only runs when the `value` property changes)
|
|
492
|
+
* so it also covers the "nothing selected yet, value never touched"
|
|
493
|
+
* startup case. Idempotent; cheap to call from the child MutationObserver
|
|
494
|
+
* on every light-DOM change.
|
|
495
|
+
*/
|
|
496
|
+
ensureOptionRoles() {
|
|
497
|
+
this.getTagElements().forEach((tag) => {
|
|
498
|
+
if (!tag.hasAttribute("role"))
|
|
499
|
+
tag.setAttribute("role", "option");
|
|
500
|
+
if (!tag.hasAttribute("aria-selected"))
|
|
501
|
+
tag.setAttribute("aria-selected", "false");
|
|
449
502
|
});
|
|
450
503
|
}
|
|
451
504
|
/**
|
|
@@ -499,13 +552,11 @@ export class TySelect extends TyComponent {
|
|
|
499
552
|
*/
|
|
500
553
|
calculatePosition() {
|
|
501
554
|
const shadow = this.shadowRoot;
|
|
502
|
-
const stub = shadow.querySelector(".
|
|
555
|
+
const stub = shadow.querySelector(".select-stub");
|
|
503
556
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
504
557
|
if (!stub || !dialog)
|
|
505
558
|
return;
|
|
506
559
|
const stubRect = stub.getBoundingClientRect();
|
|
507
|
-
const viewportHeight = window.innerHeight;
|
|
508
|
-
const viewportWidth = window.innerWidth;
|
|
509
560
|
// Get dialog dimensions (it's already shown with showModal).
|
|
510
561
|
// Cap the estimate: with hundreds of options the raw measurement can be
|
|
511
562
|
// the uncapped content height, which made "fits below?" always false.
|
|
@@ -513,7 +564,6 @@ export class TySelect extends TyComponent {
|
|
|
513
564
|
const MAX_POPUP_ESTIMATE = 440;
|
|
514
565
|
const dialogRect = dialog.getBoundingClientRect();
|
|
515
566
|
const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
|
|
516
|
-
const padding = 8;
|
|
517
567
|
const wrapPadding = 20;
|
|
518
568
|
// Popup width is independent of the trigger — a small button trigger still
|
|
519
569
|
// gets a usable list. Overridable via --ty-select-popup-width.
|
|
@@ -521,20 +571,15 @@ export class TySelect extends TyComponent {
|
|
|
521
571
|
const popupWidth = !isNaN(cssWidth)
|
|
522
572
|
? cssWidth
|
|
523
573
|
: Math.max(stubRect.width, 320);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
// Open below (or above) the trigger — never covering it
|
|
534
|
-
const gap = 4;
|
|
535
|
-
const y = positionBelow
|
|
536
|
-
? stubRect.bottom + gap - wrapPadding
|
|
537
|
-
: viewportHeight - stubRect.top + gap - wrapPadding;
|
|
574
|
+
const pos = computeAnchoredPosition({
|
|
575
|
+
anchorRect: stubRect,
|
|
576
|
+
popupWidth,
|
|
577
|
+
popupHeight: estimatedHeight,
|
|
578
|
+
});
|
|
579
|
+
const positionBelow = pos.below;
|
|
580
|
+
// Shift by the dialog's transparent wrap (room for shadows)
|
|
581
|
+
const x = pos.x - wrapPadding;
|
|
582
|
+
const y = (positionBelow ? pos.topY : pos.bottomY) - wrapPadding;
|
|
538
583
|
const width = popupWidth + wrapPadding + wrapPadding;
|
|
539
584
|
// Set CSS variables for positioning
|
|
540
585
|
this.style.setProperty("--dropdown-x", `${x}px`);
|
|
@@ -596,8 +641,11 @@ export class TySelect extends TyComponent {
|
|
|
596
641
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
597
642
|
if (!dialog)
|
|
598
643
|
return;
|
|
644
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
645
|
+
if (stubEl)
|
|
646
|
+
stubEl.setAttribute("aria-expanded", "true");
|
|
599
647
|
// Lock body scroll while dropdown is open
|
|
600
|
-
const lockId = `
|
|
648
|
+
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
601
649
|
this._scrollLockId = lockId;
|
|
602
650
|
lockScroll(lockId);
|
|
603
651
|
// Show modal
|
|
@@ -638,6 +686,9 @@ export class TySelect extends TyComponent {
|
|
|
638
686
|
const dialog = shadow.querySelector(".dropdown-dialog");
|
|
639
687
|
if (!dialog)
|
|
640
688
|
return;
|
|
689
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
690
|
+
if (stubEl)
|
|
691
|
+
stubEl.setAttribute("aria-expanded", "false");
|
|
641
692
|
// Destroy custom scrollbar
|
|
642
693
|
this._destroyOptionsScrollbar();
|
|
643
694
|
// Close dialog
|
|
@@ -699,8 +750,11 @@ export class TySelect extends TyComponent {
|
|
|
699
750
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
700
751
|
if (!dialog)
|
|
701
752
|
return;
|
|
753
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
754
|
+
if (stubEl)
|
|
755
|
+
stubEl.setAttribute("aria-expanded", "true");
|
|
702
756
|
// Lock body scroll while mobile modal is open
|
|
703
|
-
const lockId = `
|
|
757
|
+
const lockId = `select-${this.id || "anon"}-${getElementHash(this)}`;
|
|
704
758
|
this._scrollLockId = lockId;
|
|
705
759
|
lockScroll(lockId);
|
|
706
760
|
// Show dialog using native API (handles z-index automatically)
|
|
@@ -733,6 +787,9 @@ export class TySelect extends TyComponent {
|
|
|
733
787
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
734
788
|
if (!dialog)
|
|
735
789
|
return;
|
|
790
|
+
const stubEl = shadow.querySelector(".select-stub");
|
|
791
|
+
if (stubEl)
|
|
792
|
+
stubEl.setAttribute("aria-expanded", "false");
|
|
736
793
|
// Close immediately — ::backdrop doesn't support transitions
|
|
737
794
|
dialog.classList.remove("open");
|
|
738
795
|
dialog.close();
|
|
@@ -781,6 +838,22 @@ export class TySelect extends TyComponent {
|
|
|
781
838
|
}
|
|
782
839
|
this.openDropdown();
|
|
783
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* Keyboard-open the (closed) stub. The stub is a plain div with
|
|
843
|
+
* tabindex="0", not a native <button> — Enter/Space don't turn into
|
|
844
|
+
* click events on their own the way they do for real form controls, and
|
|
845
|
+
* without this a keyboard-only user could Tab to the field but never
|
|
846
|
+
* open it. document-level handleKeyboard() only runs once already open
|
|
847
|
+
* (it early-returns on !this._state.open), so this is the other half.
|
|
848
|
+
*/
|
|
849
|
+
handleStubKeydown(e) {
|
|
850
|
+
if (this._disabled || this._readonly || this._state.open)
|
|
851
|
+
return;
|
|
852
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
853
|
+
e.preventDefault();
|
|
854
|
+
this.openDropdown();
|
|
855
|
+
}
|
|
856
|
+
}
|
|
784
857
|
handleTagClick(e) {
|
|
785
858
|
const target = e.target;
|
|
786
859
|
// Find the option element (ty-option or ty-tag)
|
|
@@ -789,6 +862,16 @@ export class TySelect extends TyComponent {
|
|
|
789
862
|
return;
|
|
790
863
|
e.preventDefault();
|
|
791
864
|
e.stopPropagation();
|
|
865
|
+
this.selectByTag(tag);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Select/toggle a tag by element — the part of handleTagClick that has no
|
|
869
|
+
* dependency on a real DOM event, so keyboard/programmatic callers (Enter
|
|
870
|
+
* on a highlighted option, allow-create's exact-match dedup) can call it
|
|
871
|
+
* directly instead of faking an Event object just to satisfy
|
|
872
|
+
* preventDefault()/stopPropagation().
|
|
873
|
+
*/
|
|
874
|
+
selectByTag(tag) {
|
|
792
875
|
const tagValue = this.getTagData(tag).value;
|
|
793
876
|
const currentValues = this.getSelectedValues();
|
|
794
877
|
// Single select: picking replaces the selection and closes the popup
|
|
@@ -812,6 +895,88 @@ export class TySelect extends TyComponent {
|
|
|
812
895
|
this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
|
|
813
896
|
}
|
|
814
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* allow-create: Enter with unmatched search text mints a new option.
|
|
900
|
+
* Returns true if it handled the key (created, or selected an existing
|
|
901
|
+
* exact match instead of duplicating) — false if there's nothing to do
|
|
902
|
+
* (blank query, or allow-create is off), so the caller's normal Enter
|
|
903
|
+
* handling (select highlighted, or no-op) proceeds.
|
|
904
|
+
*/
|
|
905
|
+
tryCreateFromSearch() {
|
|
906
|
+
if (!this._allowCreate)
|
|
907
|
+
return false;
|
|
908
|
+
const query = (this._state.search || "").trim();
|
|
909
|
+
if (!query)
|
|
910
|
+
return false;
|
|
911
|
+
const queryLower = query.toLowerCase();
|
|
912
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
913
|
+
// Dedup: typed text exactly matches an existing option (by value or
|
|
914
|
+
// display text) — select it instead of minting a duplicate.
|
|
915
|
+
const existing = allTags.find((t) => t.value.toLowerCase() === queryLower ||
|
|
916
|
+
t.text.toLowerCase() === queryLower);
|
|
917
|
+
if (existing) {
|
|
918
|
+
this.selectByTag(existing.element);
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
const rawLabel = query;
|
|
922
|
+
const defaultValue = this._createTransform === "slug" ? slugify(rawLabel) : rawLabel;
|
|
923
|
+
if (!defaultValue)
|
|
924
|
+
return true; // slugified to nothing — handled no-op
|
|
925
|
+
const detail = { value: defaultValue, label: rawLabel };
|
|
926
|
+
const event = new CustomEvent("create", {
|
|
927
|
+
detail,
|
|
928
|
+
bubbles: true,
|
|
929
|
+
composed: true,
|
|
930
|
+
cancelable: true,
|
|
931
|
+
});
|
|
932
|
+
this.dispatchEvent(event);
|
|
933
|
+
if (event.defaultPrevented)
|
|
934
|
+
return true; // consumer takes over entirely
|
|
935
|
+
const finalValue = detail.value;
|
|
936
|
+
const option = document.createElement("ty-option");
|
|
937
|
+
option.setAttribute("value", finalValue);
|
|
938
|
+
option.textContent = rawLabel;
|
|
939
|
+
this.appendChild(option);
|
|
940
|
+
if (!this._multiple) {
|
|
941
|
+
this.updateComponentValue([finalValue], true, "create", finalValue);
|
|
942
|
+
if (this._state.mode === "mobile") {
|
|
943
|
+
this.closeMobileModal();
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
this.closeDropdown();
|
|
947
|
+
}
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
const currentValues = this.getSelectedValues();
|
|
951
|
+
this.updateComponentValue([...currentValues, finalValue], true, "create", finalValue);
|
|
952
|
+
this.clearSearchAfterCreate();
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Multiple + allow-create: clear the search box and re-show the full
|
|
957
|
+
* (now-longer) option list so the next tag can be typed immediately —
|
|
958
|
+
* the tags-input pattern.
|
|
959
|
+
*/
|
|
960
|
+
clearSearchAfterCreate() {
|
|
961
|
+
this._state.search = "";
|
|
962
|
+
this._state.highlightedIndex = -1;
|
|
963
|
+
const shadow = this.shadowRoot;
|
|
964
|
+
if (!shadow)
|
|
965
|
+
return;
|
|
966
|
+
const desktopInput = shadow.querySelector(".dropdown-search-input");
|
|
967
|
+
const mobileInput = shadow.querySelector(".mobile-search-input");
|
|
968
|
+
if (desktopInput)
|
|
969
|
+
desktopInput.value = "";
|
|
970
|
+
if (mobileInput)
|
|
971
|
+
mobileInput.value = "";
|
|
972
|
+
const allTags = this.getTagElements().map((el) => this.getTagData(el));
|
|
973
|
+
this._state.filteredTags = allTags;
|
|
974
|
+
this.updateTagVisibility(allTags, allTags);
|
|
975
|
+
this.updateOptionsVisibility(true);
|
|
976
|
+
this.clearHighlights(allTags);
|
|
977
|
+
this.updateMobileSelectedState();
|
|
978
|
+
(desktopInput ?? mobileInput)?.focus();
|
|
979
|
+
}
|
|
815
980
|
blockSearchClick(e) {
|
|
816
981
|
e.stopPropagation();
|
|
817
982
|
e.preventDefault();
|
|
@@ -868,11 +1033,14 @@ export class TySelect extends TyComponent {
|
|
|
868
1033
|
case "Enter":
|
|
869
1034
|
e.preventDefault();
|
|
870
1035
|
e.stopPropagation();
|
|
871
|
-
//
|
|
1036
|
+
// A highlighted (arrowed-to) option always wins — the user picked
|
|
1037
|
+
// it explicitly, so allow-create must not shadow it with a new one.
|
|
872
1038
|
if (currentHighlightedIndex >= 0 &&
|
|
873
1039
|
currentHighlightedIndex < tagsCount) {
|
|
874
|
-
|
|
875
|
-
|
|
1040
|
+
this.selectByTag(filteredTags[currentHighlightedIndex].element);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
this.tryCreateFromSearch();
|
|
876
1044
|
}
|
|
877
1045
|
break;
|
|
878
1046
|
case "ArrowUp":
|
|
@@ -1078,7 +1246,7 @@ export class TySelect extends TyComponent {
|
|
|
1078
1246
|
*/
|
|
1079
1247
|
setupTriggerSlot() {
|
|
1080
1248
|
const shadow = this.shadowRoot;
|
|
1081
|
-
const stub = shadow.querySelector(".
|
|
1249
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1082
1250
|
const slot = shadow.querySelector('slot[name="trigger"]');
|
|
1083
1251
|
if (!stub || !slot)
|
|
1084
1252
|
return;
|
|
@@ -1091,12 +1259,13 @@ export class TySelect extends TyComponent {
|
|
|
1091
1259
|
*/
|
|
1092
1260
|
setupEventListeners() {
|
|
1093
1261
|
const shadow = this.shadowRoot;
|
|
1094
|
-
const stub = shadow.querySelector(".
|
|
1262
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1095
1263
|
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1096
1264
|
const searchInput = shadow.querySelector(".dropdown-search-input");
|
|
1097
1265
|
if (stub) {
|
|
1098
1266
|
this._stubClickHandler = this.handleStubClick.bind(this);
|
|
1099
1267
|
stub.addEventListener("click", this._stubClickHandler);
|
|
1268
|
+
stub.addEventListener("keydown", this.handleStubKeydown.bind(this));
|
|
1100
1269
|
}
|
|
1101
1270
|
this.setupTriggerSlot();
|
|
1102
1271
|
// Add tag click handler to slot
|
|
@@ -1143,11 +1312,13 @@ export class TySelect extends TyComponent {
|
|
|
1143
1312
|
renderDesktop() {
|
|
1144
1313
|
const shadow = this.shadowRoot;
|
|
1145
1314
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1146
|
-
if (!shadow.querySelector(".
|
|
1315
|
+
if (!shadow.querySelector(".select-container")) {
|
|
1147
1316
|
const stubClasses = this.buildStubClasses();
|
|
1317
|
+
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
1318
|
+
const labelId = `select-label-${getElementHash(this)}`;
|
|
1148
1319
|
const labelHtml = this._label
|
|
1149
1320
|
? `
|
|
1150
|
-
<label class="ty-field-label">
|
|
1321
|
+
<label class="ty-field-label" id="${labelId}">
|
|
1151
1322
|
${this._label}
|
|
1152
1323
|
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1153
1324
|
</label>
|
|
@@ -1155,11 +1326,17 @@ export class TySelect extends TyComponent {
|
|
|
1155
1326
|
: "";
|
|
1156
1327
|
const searchPlaceholder = "Search...";
|
|
1157
1328
|
shadow.innerHTML = `
|
|
1158
|
-
<div class="
|
|
1329
|
+
<div class="select-container dropdown-mode-desktop">
|
|
1159
1330
|
${labelHtml}
|
|
1160
1331
|
<div class="dropdown-wrapper">
|
|
1161
|
-
<div class="dropdown-stub
|
|
1162
|
-
|
|
1332
|
+
<div class="dropdown-stub select-stub ${stubClasses}"
|
|
1333
|
+
role="combobox"
|
|
1334
|
+
aria-haspopup="listbox"
|
|
1335
|
+
aria-expanded="false"
|
|
1336
|
+
aria-controls="${listboxId}"
|
|
1337
|
+
${this._label ? `aria-labelledby="${labelId}"` : ""}
|
|
1338
|
+
tabindex="${this._disabled ? "-1" : "0"}"
|
|
1339
|
+
${this._disabled ? 'disabled aria-disabled="true"' : ""}>
|
|
1163
1340
|
<slot name="trigger">
|
|
1164
1341
|
<slot name="start"></slot>
|
|
1165
1342
|
<slot name="selected"></slot>
|
|
@@ -1187,7 +1364,7 @@ export class TySelect extends TyComponent {
|
|
|
1187
1364
|
</div>
|
|
1188
1365
|
</div>
|
|
1189
1366
|
<div class="dropdown-options-wrapper">
|
|
1190
|
-
<div class="dropdown-options">
|
|
1367
|
+
<div class="dropdown-options" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
|
|
1191
1368
|
<slot id="options-slot"></slot>
|
|
1192
1369
|
</div>
|
|
1193
1370
|
<div class="dropdown-loading" aria-hidden="true">
|
|
@@ -1216,11 +1393,13 @@ export class TySelect extends TyComponent {
|
|
|
1216
1393
|
renderMobile() {
|
|
1217
1394
|
const shadow = this.shadowRoot;
|
|
1218
1395
|
// Only set innerHTML and setup listeners if container doesn't exist
|
|
1219
|
-
if (!shadow.querySelector(".
|
|
1396
|
+
if (!shadow.querySelector(".select-container")) {
|
|
1220
1397
|
const stubClasses = this.buildStubClasses();
|
|
1398
|
+
const listboxId = `select-listbox-${getElementHash(this)}`;
|
|
1399
|
+
const labelId = `select-label-${getElementHash(this)}`;
|
|
1221
1400
|
const labelHtml = this._label
|
|
1222
1401
|
? `
|
|
1223
|
-
<label class="ty-field-label">
|
|
1402
|
+
<label class="ty-field-label" id="${labelId}">
|
|
1224
1403
|
${this._label}
|
|
1225
1404
|
${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
|
|
1226
1405
|
</label>
|
|
@@ -1256,11 +1435,17 @@ export class TySelect extends TyComponent {
|
|
|
1256
1435
|
</div>
|
|
1257
1436
|
`;
|
|
1258
1437
|
shadow.innerHTML = `
|
|
1259
|
-
<div class="
|
|
1438
|
+
<div class="select-container dropdown-mode-mobile">
|
|
1260
1439
|
${labelHtml}
|
|
1261
1440
|
<div class="dropdown-wrapper">
|
|
1262
|
-
<div class="dropdown-stub
|
|
1263
|
-
|
|
1441
|
+
<div class="dropdown-stub select-stub ${stubClasses}"
|
|
1442
|
+
role="combobox"
|
|
1443
|
+
aria-haspopup="listbox"
|
|
1444
|
+
aria-expanded="false"
|
|
1445
|
+
aria-controls="${listboxId}"
|
|
1446
|
+
${this._label ? `aria-labelledby="${labelId}"` : ""}
|
|
1447
|
+
tabindex="${this._disabled ? "-1" : "0"}"
|
|
1448
|
+
${this._disabled ? 'disabled aria-disabled="true"' : ""}>
|
|
1264
1449
|
<slot name="trigger">
|
|
1265
1450
|
<slot name="start"></slot>
|
|
1266
1451
|
<slot name="selected"></slot>
|
|
@@ -1287,7 +1472,7 @@ export class TySelect extends TyComponent {
|
|
|
1287
1472
|
<div class="section-header">
|
|
1288
1473
|
<span class="section-title">${this._availableLabel}</span>
|
|
1289
1474
|
</div>
|
|
1290
|
-
<div class="section-content dropdown-options-wrapper">
|
|
1475
|
+
<div class="section-content dropdown-options-wrapper" id="${listboxId}" role="listbox" ${this._multiple ? 'aria-multiselectable="true"' : ""}>
|
|
1291
1476
|
<slot id="options-slot"></slot>
|
|
1292
1477
|
<div class="empty-state">${this._noOptionsMessage}</div>
|
|
1293
1478
|
<div class="dropdown-loading" aria-hidden="true">
|
|
@@ -1317,13 +1502,14 @@ export class TySelect extends TyComponent {
|
|
|
1317
1502
|
*/
|
|
1318
1503
|
setupMobileEventListeners() {
|
|
1319
1504
|
const shadow = this.shadowRoot;
|
|
1320
|
-
const stub = shadow.querySelector(".
|
|
1505
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1321
1506
|
const optionsSlot = shadow.querySelector("#options-slot");
|
|
1322
1507
|
const searchInput = shadow.querySelector(".mobile-search-input");
|
|
1323
1508
|
const closeButton = shadow.querySelector(".mobile-close-button");
|
|
1324
1509
|
const dialog = shadow.querySelector(".mobile-dialog");
|
|
1325
1510
|
if (stub) {
|
|
1326
1511
|
stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
|
|
1512
|
+
stub.addEventListener("keydown", (e) => this.handleMobileStubKeydown(e));
|
|
1327
1513
|
}
|
|
1328
1514
|
this.setupTriggerSlot();
|
|
1329
1515
|
// Add tag click handler to slot
|
|
@@ -1365,6 +1551,15 @@ export class TySelect extends TyComponent {
|
|
|
1365
1551
|
}
|
|
1366
1552
|
this.openMobileModal();
|
|
1367
1553
|
}
|
|
1554
|
+
/** Keyboard-open the (closed) mobile stub — see handleStubKeydown for why this is needed. */
|
|
1555
|
+
handleMobileStubKeydown(e) {
|
|
1556
|
+
if (this._disabled || this._readonly || this._state.open)
|
|
1557
|
+
return;
|
|
1558
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
1559
|
+
e.preventDefault();
|
|
1560
|
+
this.openMobileModal();
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1368
1563
|
/**
|
|
1369
1564
|
* Handle mobile tag click - select and potentially close
|
|
1370
1565
|
*/
|
|
@@ -1403,6 +1598,9 @@ export class TySelect extends TyComponent {
|
|
|
1403
1598
|
shouldShowSearch() {
|
|
1404
1599
|
if (this._externalSearch)
|
|
1405
1600
|
return true;
|
|
1601
|
+
// allow-create's only input mechanism IS the search box.
|
|
1602
|
+
if (this._allowCreate)
|
|
1603
|
+
return true;
|
|
1406
1604
|
if (this._searchable === "always")
|
|
1407
1605
|
return true;
|
|
1408
1606
|
if (this._searchable === "never")
|
|
@@ -1438,7 +1636,7 @@ export class TySelect extends TyComponent {
|
|
|
1438
1636
|
*/
|
|
1439
1637
|
updateSelectionDisplay() {
|
|
1440
1638
|
const shadow = this.shadowRoot;
|
|
1441
|
-
const stub = shadow.querySelector(".
|
|
1639
|
+
const stub = shadow.querySelector(".select-stub");
|
|
1442
1640
|
if (!stub)
|
|
1443
1641
|
return;
|
|
1444
1642
|
// Selection comes from STATE, not from scanning option elements —
|
|
@@ -1610,6 +1808,12 @@ export class TySelect extends TyComponent {
|
|
|
1610
1808
|
set size(value) {
|
|
1611
1809
|
this.setProperty("size", value);
|
|
1612
1810
|
}
|
|
1811
|
+
get flavor() {
|
|
1812
|
+
return this.getProperty("flavor");
|
|
1813
|
+
}
|
|
1814
|
+
set flavor(value) {
|
|
1815
|
+
this.setProperty("flavor", value);
|
|
1816
|
+
}
|
|
1613
1817
|
get form() {
|
|
1614
1818
|
return this._internals.form;
|
|
1615
1819
|
}
|
|
@@ -1685,6 +1889,24 @@ TySelect.properties = {
|
|
|
1685
1889
|
default: false,
|
|
1686
1890
|
aliases: { "external-search": true },
|
|
1687
1891
|
},
|
|
1892
|
+
// Enter on unmatched search text mints a new <ty-option> — forces the
|
|
1893
|
+
// search row on (same as external-search: the input IS the mechanism).
|
|
1894
|
+
allowCreate: {
|
|
1895
|
+
type: "boolean",
|
|
1896
|
+
visual: true,
|
|
1897
|
+
default: false,
|
|
1898
|
+
aliases: { "allow-create": true },
|
|
1899
|
+
},
|
|
1900
|
+
// Opt-in normalizer for the value minted by allow-create (label/display
|
|
1901
|
+
// text is always kept verbatim). 'none' (default): value === typed text.
|
|
1902
|
+
// 'slug': lowercase, spaces → '_', strips anything else non-alphanumeric.
|
|
1903
|
+
createTransform: {
|
|
1904
|
+
type: "string",
|
|
1905
|
+
visual: false,
|
|
1906
|
+
default: "none",
|
|
1907
|
+
aliases: { "create-transform": true },
|
|
1908
|
+
coerce: (v) => (v === "slug" ? "slug" : "none"),
|
|
1909
|
+
},
|
|
1688
1910
|
// Search row visibility: 'auto' (default) shows it only for long option
|
|
1689
1911
|
// lists; searchable / searchable="true" forces it on, searchable="false"
|
|
1690
1912
|
// off. external-search always shows it (the input IS the mechanism).
|
|
@@ -1704,8 +1926,13 @@ TySelect.properties = {
|
|
|
1704
1926
|
type: "string",
|
|
1705
1927
|
visual: true,
|
|
1706
1928
|
default: "md",
|
|
1929
|
+
// Fields come in exactly three sizes; legacy xs/xl map to sm/lg.
|
|
1707
1930
|
validate: (v) => ["sm", "md", "lg"].includes(v),
|
|
1708
1931
|
coerce: (v) => {
|
|
1932
|
+
if (v === "xs")
|
|
1933
|
+
return "sm";
|
|
1934
|
+
if (v === "xl")
|
|
1935
|
+
return "lg";
|
|
1709
1936
|
if (!["sm", "md", "lg"].includes(v)) {
|
|
1710
1937
|
console.warn(`[ty-select] Invalid size. Using md.`);
|
|
1711
1938
|
return "md";
|
|
@@ -1739,6 +1966,14 @@ TySelect.properties = {
|
|
|
1739
1966
|
visual: true,
|
|
1740
1967
|
default: false,
|
|
1741
1968
|
},
|
|
1969
|
+
// Any string is accepted: built-ins get static CSS, other identifiers
|
|
1970
|
+
// become custom flavors (see _syncCustomFlavor), garbage degrades to
|
|
1971
|
+
// the default look.
|
|
1972
|
+
flavor: {
|
|
1973
|
+
type: "string",
|
|
1974
|
+
visual: true,
|
|
1975
|
+
default: "neutral",
|
|
1976
|
+
},
|
|
1742
1977
|
};
|
|
1743
1978
|
// Register the custom element
|
|
1744
1979
|
if (!customElements.get("ty-select")) {
|