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.
Files changed (117) hide show
  1. package/css/tyrell.css +48 -12
  2. package/dist/tyrell.css +48 -12
  3. package/dist/tyrell.js +1 -1
  4. package/lib/components/calendar-month.d.ts +17 -0
  5. package/lib/components/calendar-month.d.ts.map +1 -1
  6. package/lib/components/calendar-month.js +121 -7
  7. package/lib/components/calendar-month.js.map +1 -1
  8. package/lib/components/calendar.d.ts +3 -1
  9. package/lib/components/calendar.d.ts.map +1 -1
  10. package/lib/components/calendar.js +19 -9
  11. package/lib/components/calendar.js.map +1 -1
  12. package/lib/components/checkbox.d.ts +4 -3
  13. package/lib/components/checkbox.d.ts.map +1 -1
  14. package/lib/components/checkbox.js +23 -35
  15. package/lib/components/checkbox.js.map +1 -1
  16. package/lib/components/copy.d.ts +4 -3
  17. package/lib/components/copy.d.ts.map +1 -1
  18. package/lib/components/copy.js +22 -21
  19. package/lib/components/copy.js.map +1 -1
  20. package/lib/components/date-picker.d.ts +5 -1
  21. package/lib/components/date-picker.d.ts.map +1 -1
  22. package/lib/components/date-picker.js +113 -32
  23. package/lib/components/date-picker.js.map +1 -1
  24. package/lib/components/input.d.ts +3 -2
  25. package/lib/components/input.d.ts.map +1 -1
  26. package/lib/components/input.js +24 -21
  27. package/lib/components/input.js.map +1 -1
  28. package/lib/components/modal.d.ts.map +1 -1
  29. package/lib/components/modal.js +22 -3
  30. package/lib/components/modal.js.map +1 -1
  31. package/lib/components/popup.d.ts.map +1 -1
  32. package/lib/components/popup.js +14 -0
  33. package/lib/components/popup.js.map +1 -1
  34. package/lib/components/radio.d.ts +5 -2
  35. package/lib/components/radio.d.ts.map +1 -1
  36. package/lib/components/radio.js +16 -5
  37. package/lib/components/radio.js.map +1 -1
  38. package/lib/components/select.d.ts +75 -5
  39. package/lib/components/select.d.ts.map +1 -1
  40. package/lib/components/select.js +282 -47
  41. package/lib/components/select.js.map +1 -1
  42. package/lib/components/selected-tags.d.ts +1 -1
  43. package/lib/components/selected-tags.js +1 -1
  44. package/lib/components/switch.d.ts +4 -3
  45. package/lib/components/switch.d.ts.map +1 -1
  46. package/lib/components/switch.js +17 -29
  47. package/lib/components/switch.js.map +1 -1
  48. package/lib/components/tabs.d.ts.map +1 -1
  49. package/lib/components/tabs.js +18 -2
  50. package/lib/components/tabs.js.map +1 -1
  51. package/lib/components/tooltip.d.ts +4 -2
  52. package/lib/components/tooltip.d.ts.map +1 -1
  53. package/lib/components/tooltip.js +109 -70
  54. package/lib/components/tooltip.js.map +1 -1
  55. package/lib/components/wizard.d.ts.map +1 -1
  56. package/lib/components/wizard.js +22 -3
  57. package/lib/components/wizard.js.map +1 -1
  58. package/lib/styles/button.d.ts.map +1 -1
  59. package/lib/styles/button.js +10 -6
  60. package/lib/styles/button.js.map +1 -1
  61. package/lib/styles/calendar-month.d.ts +3 -1
  62. package/lib/styles/calendar-month.d.ts.map +1 -1
  63. package/lib/styles/calendar-month.js +63 -8
  64. package/lib/styles/calendar-month.js.map +1 -1
  65. package/lib/styles/checkbox.d.ts +3 -1
  66. package/lib/styles/checkbox.d.ts.map +1 -1
  67. package/lib/styles/checkbox.js +31 -53
  68. package/lib/styles/checkbox.js.map +1 -1
  69. package/lib/styles/copy.d.ts +3 -1
  70. package/lib/styles/copy.d.ts.map +1 -1
  71. package/lib/styles/copy.js +27 -4
  72. package/lib/styles/copy.js.map +1 -1
  73. package/lib/styles/date-picker.d.ts +3 -1
  74. package/lib/styles/date-picker.d.ts.map +1 -1
  75. package/lib/styles/date-picker.js +51 -98
  76. package/lib/styles/date-picker.js.map +1 -1
  77. package/lib/styles/input.d.ts +3 -1
  78. package/lib/styles/input.d.ts.map +1 -1
  79. package/lib/styles/input.js +58 -119
  80. package/lib/styles/input.js.map +1 -1
  81. package/lib/styles/multiselect.d.ts +3 -1
  82. package/lib/styles/multiselect.d.ts.map +1 -1
  83. package/lib/styles/multiselect.js +26 -2
  84. package/lib/styles/multiselect.js.map +1 -1
  85. package/lib/styles/radio.d.ts +3 -1
  86. package/lib/styles/radio.d.ts.map +1 -1
  87. package/lib/styles/radio.js +23 -20
  88. package/lib/styles/radio.js.map +1 -1
  89. package/lib/styles/select-base.d.ts +9 -0
  90. package/lib/styles/select-base.d.ts.map +1 -0
  91. package/lib/styles/select-base.js +908 -0
  92. package/lib/styles/select-base.js.map +1 -0
  93. package/lib/styles/select.d.ts +3 -3
  94. package/lib/styles/select.d.ts.map +1 -1
  95. package/lib/styles/select.js +20 -18
  96. package/lib/styles/select.js.map +1 -1
  97. package/lib/styles/switch.d.ts +3 -1
  98. package/lib/styles/switch.d.ts.map +1 -1
  99. package/lib/styles/switch.js +23 -8
  100. package/lib/styles/switch.js.map +1 -1
  101. package/lib/styles/tooltip.d.ts +4 -2
  102. package/lib/styles/tooltip.d.ts.map +1 -1
  103. package/lib/styles/tooltip.js +4 -119
  104. package/lib/styles/tooltip.js.map +1 -1
  105. package/lib/utils/icons.d.ts +10 -0
  106. package/lib/utils/icons.d.ts.map +1 -0
  107. package/lib/utils/icons.js +10 -0
  108. package/lib/utils/icons.js.map +1 -0
  109. package/lib/utils/loader-registry.d.ts +2 -2
  110. package/lib/utils/loader-registry.js +2 -2
  111. package/lib/utils/positioning.d.ts +27 -0
  112. package/lib/utils/positioning.d.ts.map +1 -1
  113. package/lib/utils/positioning.js +20 -3
  114. package/lib/utils/positioning.js.map +1 -1
  115. package/lib/version.d.ts +1 -1
  116. package/lib/version.js +1 -1
  117. package/package.json +1 -1
@@ -39,10 +39,12 @@
39
39
  * ```
40
40
  */
41
41
  import { ensureStyles } from "../utils/styles.js";
42
- import { multiselectStyles } from "../styles/multiselect.js";
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 Multiselect Component
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: multiselectStyles, id: "ty-select-base" });
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 multiselect value (comma-separated string to array)
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(".multiselect-stub");
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
- // Available space calculations
525
- const spaceBelow = viewportHeight - stubRect.bottom;
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;
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 = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
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 = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
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
- // Select highlighted tag if any
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
- const tag = filteredTags[currentHighlightedIndex];
875
- this.handleTagClick({ target: tag.element });
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(".multiselect-stub");
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(".multiselect-stub");
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(".multiselect-container")) {
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="multiselect-container dropdown-mode-desktop">
1329
+ <div class="select-container dropdown-mode-desktop">
1159
1330
  ${labelHtml}
1160
1331
  <div class="dropdown-wrapper">
1161
- <div class="dropdown-stub multiselect-stub ${stubClasses}"
1162
- ${this._disabled ? "disabled" : ""}>
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(".multiselect-container")) {
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="multiselect-container dropdown-mode-mobile">
1438
+ <div class="select-container dropdown-mode-mobile">
1260
1439
  ${labelHtml}
1261
1440
  <div class="dropdown-wrapper">
1262
- <div class="dropdown-stub multiselect-stub ${stubClasses}"
1263
- ${this._disabled ? "disabled" : ""}>
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(".multiselect-stub");
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(".multiselect-stub");
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")) {