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.
Files changed (178) hide show
  1. package/README.md +1 -0
  2. package/css/tyrell-colors-static.css +316 -0
  3. package/css/tyrell-theme.css +1454 -0
  4. package/css/tyrell.css +108 -423
  5. package/dist/tyrell-colors-static.css +316 -0
  6. package/dist/tyrell-theme.css +1454 -0
  7. package/dist/tyrell.css +108 -423
  8. package/dist/tyrell.js +1 -1
  9. package/lib/components/button.d.ts +7 -0
  10. package/lib/components/button.d.ts.map +1 -1
  11. package/lib/components/button.js +12 -1
  12. package/lib/components/button.js.map +1 -1
  13. package/lib/components/calendar-month.d.ts +17 -0
  14. package/lib/components/calendar-month.d.ts.map +1 -1
  15. package/lib/components/calendar-month.js +121 -7
  16. package/lib/components/calendar-month.js.map +1 -1
  17. package/lib/components/calendar.d.ts +3 -1
  18. package/lib/components/calendar.d.ts.map +1 -1
  19. package/lib/components/calendar.js +19 -9
  20. package/lib/components/calendar.js.map +1 -1
  21. package/lib/components/checkbox.d.ts +4 -3
  22. package/lib/components/checkbox.d.ts.map +1 -1
  23. package/lib/components/checkbox.js +23 -35
  24. package/lib/components/checkbox.js.map +1 -1
  25. package/lib/components/copy.d.ts +4 -3
  26. package/lib/components/copy.d.ts.map +1 -1
  27. package/lib/components/copy.js +22 -21
  28. package/lib/components/copy.js.map +1 -1
  29. package/lib/components/date-picker.d.ts +12 -1
  30. package/lib/components/date-picker.d.ts.map +1 -1
  31. package/lib/components/date-picker.js +122 -32
  32. package/lib/components/date-picker.js.map +1 -1
  33. package/lib/components/input.d.ts +3 -2
  34. package/lib/components/input.d.ts.map +1 -1
  35. package/lib/components/input.js +24 -21
  36. package/lib/components/input.js.map +1 -1
  37. package/lib/components/modal.d.ts.map +1 -1
  38. package/lib/components/modal.js +22 -3
  39. package/lib/components/modal.js.map +1 -1
  40. package/lib/components/option.d.ts +0 -11
  41. package/lib/components/option.d.ts.map +1 -1
  42. package/lib/components/option.js +4 -103
  43. package/lib/components/option.js.map +1 -1
  44. package/lib/components/popup.d.ts.map +1 -1
  45. package/lib/components/popup.js +36 -2
  46. package/lib/components/popup.js.map +1 -1
  47. package/lib/components/radio.d.ts +5 -2
  48. package/lib/components/radio.d.ts.map +1 -1
  49. package/lib/components/radio.js +16 -5
  50. package/lib/components/radio.js.map +1 -1
  51. package/lib/components/select.d.ts +119 -9
  52. package/lib/components/select.d.ts.map +1 -1
  53. package/lib/components/select.js +426 -58
  54. package/lib/components/select.js.map +1 -1
  55. package/lib/components/selected-tags.d.ts +19 -6
  56. package/lib/components/selected-tags.d.ts.map +1 -1
  57. package/lib/components/selected-tags.js +43 -7
  58. package/lib/components/selected-tags.js.map +1 -1
  59. package/lib/components/switch.d.ts +4 -3
  60. package/lib/components/switch.d.ts.map +1 -1
  61. package/lib/components/switch.js +17 -29
  62. package/lib/components/switch.js.map +1 -1
  63. package/lib/components/tabs.d.ts.map +1 -1
  64. package/lib/components/tabs.js +180 -25
  65. package/lib/components/tabs.js.map +1 -1
  66. package/lib/components/tag.js +1 -1
  67. package/lib/components/tag.js.map +1 -1
  68. package/lib/components/tooltip.d.ts +4 -2
  69. package/lib/components/tooltip.d.ts.map +1 -1
  70. package/lib/components/tooltip.js +109 -70
  71. package/lib/components/tooltip.js.map +1 -1
  72. package/lib/components/wizard.d.ts.map +1 -1
  73. package/lib/components/wizard.js +22 -3
  74. package/lib/components/wizard.js.map +1 -1
  75. package/lib/index.d.ts +2 -1
  76. package/lib/index.d.ts.map +1 -1
  77. package/lib/index.js +2 -1
  78. package/lib/index.js.map +1 -1
  79. package/lib/styles/button.d.ts +36 -6
  80. package/lib/styles/button.d.ts.map +1 -1
  81. package/lib/styles/button.js +239 -36
  82. package/lib/styles/button.js.map +1 -1
  83. package/lib/styles/calendar-month.d.ts +3 -1
  84. package/lib/styles/calendar-month.d.ts.map +1 -1
  85. package/lib/styles/calendar-month.js +64 -9
  86. package/lib/styles/calendar-month.js.map +1 -1
  87. package/lib/styles/calendar-navigation.d.ts +1 -1
  88. package/lib/styles/calendar-navigation.d.ts.map +1 -1
  89. package/lib/styles/calendar-navigation.js +1 -1
  90. package/lib/styles/checkbox.d.ts +3 -1
  91. package/lib/styles/checkbox.d.ts.map +1 -1
  92. package/lib/styles/checkbox.js +33 -55
  93. package/lib/styles/checkbox.js.map +1 -1
  94. package/lib/styles/copy.d.ts +3 -1
  95. package/lib/styles/copy.d.ts.map +1 -1
  96. package/lib/styles/copy.js +29 -6
  97. package/lib/styles/copy.js.map +1 -1
  98. package/lib/styles/custom-scrollbar.d.ts +1 -1
  99. package/lib/styles/custom-scrollbar.d.ts.map +1 -1
  100. package/lib/styles/custom-scrollbar.js +2 -2
  101. package/lib/styles/date-picker.d.ts +3 -1
  102. package/lib/styles/date-picker.d.ts.map +1 -1
  103. package/lib/styles/date-picker.js +53 -99
  104. package/lib/styles/date-picker.js.map +1 -1
  105. package/lib/styles/file-upload.d.ts +1 -1
  106. package/lib/styles/file-upload.d.ts.map +1 -1
  107. package/lib/styles/file-upload.js +3 -3
  108. package/lib/styles/input.d.ts +3 -1
  109. package/lib/styles/input.d.ts.map +1 -1
  110. package/lib/styles/input.js +67 -123
  111. package/lib/styles/input.js.map +1 -1
  112. package/lib/styles/multiselect.d.ts +3 -1
  113. package/lib/styles/multiselect.d.ts.map +1 -1
  114. package/lib/styles/multiselect.js +26 -2
  115. package/lib/styles/multiselect.js.map +1 -1
  116. package/lib/styles/option.d.ts +1 -1
  117. package/lib/styles/option.d.ts.map +1 -1
  118. package/lib/styles/option.js +0 -51
  119. package/lib/styles/option.js.map +1 -1
  120. package/lib/styles/radio.d.ts +3 -1
  121. package/lib/styles/radio.d.ts.map +1 -1
  122. package/lib/styles/radio.js +24 -21
  123. package/lib/styles/radio.js.map +1 -1
  124. package/lib/styles/scroll-container.d.ts +1 -1
  125. package/lib/styles/scroll-container.d.ts.map +1 -1
  126. package/lib/styles/select-base.d.ts +9 -0
  127. package/lib/styles/select-base.d.ts.map +1 -0
  128. package/lib/styles/select-base.js +973 -0
  129. package/lib/styles/select-base.js.map +1 -0
  130. package/lib/styles/select.d.ts +3 -3
  131. package/lib/styles/select.d.ts.map +1 -1
  132. package/lib/styles/select.js +98 -30
  133. package/lib/styles/select.js.map +1 -1
  134. package/lib/styles/step.d.ts +1 -1
  135. package/lib/styles/step.d.ts.map +1 -1
  136. package/lib/styles/switch.d.ts +3 -1
  137. package/lib/styles/switch.d.ts.map +1 -1
  138. package/lib/styles/switch.js +25 -10
  139. package/lib/styles/switch.js.map +1 -1
  140. package/lib/styles/tab.d.ts +1 -1
  141. package/lib/styles/tab.d.ts.map +1 -1
  142. package/lib/styles/tabs.d.ts +1 -1
  143. package/lib/styles/tabs.d.ts.map +1 -1
  144. package/lib/styles/tabs.js +71 -1
  145. package/lib/styles/tabs.js.map +1 -1
  146. package/lib/styles/tag.d.ts.map +1 -1
  147. package/lib/styles/tag.js +4 -1
  148. package/lib/styles/tag.js.map +1 -1
  149. package/lib/styles/textarea.d.ts +1 -1
  150. package/lib/styles/textarea.d.ts.map +1 -1
  151. package/lib/styles/textarea.js +5 -5
  152. package/lib/styles/tooltip.d.ts +4 -2
  153. package/lib/styles/tooltip.d.ts.map +1 -1
  154. package/lib/styles/tooltip.js +4 -119
  155. package/lib/styles/tooltip.js.map +1 -1
  156. package/lib/styles/wizard.d.ts +1 -1
  157. package/lib/styles/wizard.d.ts.map +1 -1
  158. package/lib/styles/wizard.js +18 -6
  159. package/lib/styles/wizard.js.map +1 -1
  160. package/lib/types/common.d.ts +1 -1
  161. package/lib/types/common.d.ts.map +1 -1
  162. package/lib/types/common.js +3 -1
  163. package/lib/types/common.js.map +1 -1
  164. package/lib/utils/icons.d.ts +10 -0
  165. package/lib/utils/icons.d.ts.map +1 -0
  166. package/lib/utils/icons.js +10 -0
  167. package/lib/utils/icons.js.map +1 -0
  168. package/lib/utils/loader-registry.d.ts +2 -2
  169. package/lib/utils/loader-registry.js +2 -2
  170. package/lib/utils/positioning.d.ts +31 -0
  171. package/lib/utils/positioning.d.ts.map +1 -1
  172. package/lib/utils/positioning.js +22 -3
  173. package/lib/utils/positioning.js.map +1 -1
  174. package/lib/version.d.ts +1 -1
  175. package/lib/version.js +1 -1
  176. package/package.json +7 -4
  177. package/css/tyrell-brand.css +0 -1015
  178. package/dist/tyrell-brand.css +0 -1015
@@ -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-tags>)
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-tags for="robots"></ty-selected-tags>
38
+ * <ty-selected-options for="robots"></ty-selected-options>
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,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
- * Ty Multiselect Component
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: multiselectStyles, id: "ty-select-base" });
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 multiselect value (comma-separated string to array)
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-tags).
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(".multiselect-stub");
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
- // 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;
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 = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
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 = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
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
- // Focus search input (when the search row is shown)
715
- const searchInput = shadow.querySelector(".mobile-search-input");
716
- if (searchInput && this.shouldShowSearch()) {
717
- // Small delay to ensure dialog is ready
718
- setTimeout(() => searchInput.focus(), 100);
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
- // Select highlighted tag if any
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
- const tag = filteredTags[currentHighlightedIndex];
875
- this.handleTagClick({ target: tag.element });
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(".multiselect-stub");
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(".multiselect-stub");
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(".multiselect-container")) {
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="multiselect-container dropdown-mode-desktop">
1406
+ <div class="select-container dropdown-mode-desktop">
1159
1407
  ${labelHtml}
1160
1408
  <div class="dropdown-wrapper">
1161
- <div class="dropdown-stub multiselect-stub ${stubClasses}"
1162
- ${this._disabled ? "disabled" : ""}>
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(".multiselect-container")) {
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="multiselect-container dropdown-mode-mobile">
1519
+ <div class="select-container dropdown-mode-mobile">
1260
1520
  ${labelHtml}
1261
1521
  <div class="dropdown-wrapper">
1262
- <div class="dropdown-stub multiselect-stub ${stubClasses}"
1263
- ${this._disabled ? "disabled" : ""}>
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(".multiselect-stub");
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(".multiselect-stub");
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-tags) so dismissing a chip
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")) {