@cdevhub/ngx-tw 0.5.0 → 0.6.1

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 (209) hide show
  1. package/README.md +78 -15
  2. package/fesm2022/cdevhub-ngx-tw-accordion.mjs +16 -5
  3. package/fesm2022/cdevhub-ngx-tw-accordion.mjs.map +1 -1
  4. package/fesm2022/cdevhub-ngx-tw-avatar.mjs +26 -15
  5. package/fesm2022/cdevhub-ngx-tw-avatar.mjs.map +1 -1
  6. package/fesm2022/cdevhub-ngx-tw-badge.mjs +17 -5
  7. package/fesm2022/cdevhub-ngx-tw-badge.mjs.map +1 -1
  8. package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs +27 -6
  9. package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs.map +1 -1
  10. package/fesm2022/cdevhub-ngx-tw-button.mjs +23 -11
  11. package/fesm2022/cdevhub-ngx-tw-button.mjs.map +1 -1
  12. package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs +5 -5
  13. package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs.map +1 -1
  14. package/fesm2022/cdevhub-ngx-tw-calendar-testing.mjs +3 -3
  15. package/fesm2022/cdevhub-ngx-tw-calendar-testing.mjs.map +1 -1
  16. package/fesm2022/cdevhub-ngx-tw-calendar.mjs +427 -122
  17. package/fesm2022/cdevhub-ngx-tw-calendar.mjs.map +1 -1
  18. package/fesm2022/cdevhub-ngx-tw-card.mjs +21 -11
  19. package/fesm2022/cdevhub-ngx-tw-card.mjs.map +1 -1
  20. package/fesm2022/cdevhub-ngx-tw-carousel.mjs +215 -96
  21. package/fesm2022/cdevhub-ngx-tw-carousel.mjs.map +1 -1
  22. package/fesm2022/cdevhub-ngx-tw-checkbox.mjs +108 -64
  23. package/fesm2022/cdevhub-ngx-tw-checkbox.mjs.map +1 -1
  24. package/fesm2022/cdevhub-ngx-tw-code-block.mjs +18 -6
  25. package/fesm2022/cdevhub-ngx-tw-code-block.mjs.map +1 -1
  26. package/fesm2022/cdevhub-ngx-tw-collapsible.mjs +55 -30
  27. package/fesm2022/cdevhub-ngx-tw-collapsible.mjs.map +1 -1
  28. package/fesm2022/cdevhub-ngx-tw-combobox-testing.mjs +218 -0
  29. package/fesm2022/cdevhub-ngx-tw-combobox-testing.mjs.map +1 -0
  30. package/fesm2022/cdevhub-ngx-tw-combobox.mjs +236 -211
  31. package/fesm2022/cdevhub-ngx-tw-combobox.mjs.map +1 -1
  32. package/fesm2022/cdevhub-ngx-tw-command-palette-testing.mjs +200 -0
  33. package/fesm2022/cdevhub-ngx-tw-command-palette-testing.mjs.map +1 -0
  34. package/fesm2022/cdevhub-ngx-tw-command-palette.mjs +34 -7
  35. package/fesm2022/cdevhub-ngx-tw-command-palette.mjs.map +1 -1
  36. package/fesm2022/cdevhub-ngx-tw-core.mjs +135 -15
  37. package/fesm2022/cdevhub-ngx-tw-core.mjs.map +1 -1
  38. package/fesm2022/cdevhub-ngx-tw-date-picker-testing.mjs +203 -0
  39. package/fesm2022/cdevhub-ngx-tw-date-picker-testing.mjs.map +1 -0
  40. package/fesm2022/cdevhub-ngx-tw-date-picker.mjs +160 -102
  41. package/fesm2022/cdevhub-ngx-tw-date-picker.mjs.map +1 -1
  42. package/fesm2022/cdevhub-ngx-tw-date-range-picker-testing.mjs +209 -0
  43. package/fesm2022/cdevhub-ngx-tw-date-range-picker-testing.mjs.map +1 -0
  44. package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs +182 -107
  45. package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs.map +1 -1
  46. package/fesm2022/cdevhub-ngx-tw-dialog-testing.mjs +122 -0
  47. package/fesm2022/cdevhub-ngx-tw-dialog-testing.mjs.map +1 -0
  48. package/fesm2022/cdevhub-ngx-tw-dialog.mjs +11 -2
  49. package/fesm2022/cdevhub-ngx-tw-dialog.mjs.map +1 -1
  50. package/fesm2022/cdevhub-ngx-tw-empty-state.mjs +1 -1
  51. package/fesm2022/cdevhub-ngx-tw-empty-state.mjs.map +1 -1
  52. package/fesm2022/cdevhub-ngx-tw-file-upload-testing.mjs +149 -0
  53. package/fesm2022/cdevhub-ngx-tw-file-upload-testing.mjs.map +1 -0
  54. package/fesm2022/cdevhub-ngx-tw-file-upload.mjs +57 -67
  55. package/fesm2022/cdevhub-ngx-tw-file-upload.mjs.map +1 -1
  56. package/fesm2022/cdevhub-ngx-tw-flip-card.mjs +42 -24
  57. package/fesm2022/cdevhub-ngx-tw-flip-card.mjs.map +1 -1
  58. package/fesm2022/cdevhub-ngx-tw-form-field.mjs +114 -21
  59. package/fesm2022/cdevhub-ngx-tw-form-field.mjs.map +1 -1
  60. package/fesm2022/cdevhub-ngx-tw-icon.mjs +16 -6
  61. package/fesm2022/cdevhub-ngx-tw-icon.mjs.map +1 -1
  62. package/fesm2022/cdevhub-ngx-tw-input.mjs +52 -61
  63. package/fesm2022/cdevhub-ngx-tw-input.mjs.map +1 -1
  64. package/fesm2022/cdevhub-ngx-tw-item.mjs +28 -4
  65. package/fesm2022/cdevhub-ngx-tw-item.mjs.map +1 -1
  66. package/fesm2022/cdevhub-ngx-tw-menu-testing.mjs +168 -0
  67. package/fesm2022/cdevhub-ngx-tw-menu-testing.mjs.map +1 -0
  68. package/fesm2022/cdevhub-ngx-tw-menu.mjs +58 -21
  69. package/fesm2022/cdevhub-ngx-tw-menu.mjs.map +1 -1
  70. package/fesm2022/cdevhub-ngx-tw-number-input.mjs +34 -1
  71. package/fesm2022/cdevhub-ngx-tw-number-input.mjs.map +1 -1
  72. package/fesm2022/cdevhub-ngx-tw-paginator.mjs +95 -45
  73. package/fesm2022/cdevhub-ngx-tw-paginator.mjs.map +1 -1
  74. package/fesm2022/cdevhub-ngx-tw-popover.mjs +81 -32
  75. package/fesm2022/cdevhub-ngx-tw-popover.mjs.map +1 -1
  76. package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs +1 -1
  77. package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs.map +1 -1
  78. package/fesm2022/cdevhub-ngx-tw-radio.mjs +131 -98
  79. package/fesm2022/cdevhub-ngx-tw-radio.mjs.map +1 -1
  80. package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs +207 -42
  81. package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs.map +1 -1
  82. package/fesm2022/cdevhub-ngx-tw-select-testing.mjs +165 -0
  83. package/fesm2022/cdevhub-ngx-tw-select-testing.mjs.map +1 -0
  84. package/fesm2022/cdevhub-ngx-tw-select.mjs +386 -210
  85. package/fesm2022/cdevhub-ngx-tw-select.mjs.map +1 -1
  86. package/fesm2022/{cdevhub-ngx-tw-sheet-sheet-renderer-ByyNluUd.mjs → cdevhub-ngx-tw-sheet-sheet-renderer-CqVJwgmp.mjs} +3 -3
  87. package/fesm2022/cdevhub-ngx-tw-sheet-sheet-renderer-CqVJwgmp.mjs.map +1 -0
  88. package/fesm2022/cdevhub-ngx-tw-sheet-testing.mjs +129 -0
  89. package/fesm2022/cdevhub-ngx-tw-sheet-testing.mjs.map +1 -0
  90. package/fesm2022/cdevhub-ngx-tw-sheet.mjs +23 -7
  91. package/fesm2022/cdevhub-ngx-tw-sheet.mjs.map +1 -1
  92. package/fesm2022/cdevhub-ngx-tw-slider.mjs +89 -54
  93. package/fesm2022/cdevhub-ngx-tw-slider.mjs.map +1 -1
  94. package/fesm2022/cdevhub-ngx-tw-sort.mjs +122 -17
  95. package/fesm2022/cdevhub-ngx-tw-sort.mjs.map +1 -1
  96. package/fesm2022/cdevhub-ngx-tw-split.mjs +72 -19
  97. package/fesm2022/cdevhub-ngx-tw-split.mjs.map +1 -1
  98. package/fesm2022/cdevhub-ngx-tw-stat.mjs +63 -25
  99. package/fesm2022/cdevhub-ngx-tw-stat.mjs.map +1 -1
  100. package/fesm2022/cdevhub-ngx-tw-stepper.mjs +35 -8
  101. package/fesm2022/cdevhub-ngx-tw-stepper.mjs.map +1 -1
  102. package/fesm2022/cdevhub-ngx-tw-switch.mjs +54 -48
  103. package/fesm2022/cdevhub-ngx-tw-switch.mjs.map +1 -1
  104. package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs +12 -15
  105. package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs.map +1 -1
  106. package/fesm2022/cdevhub-ngx-tw-table.mjs +74 -13
  107. package/fesm2022/cdevhub-ngx-tw-table.mjs.map +1 -1
  108. package/fesm2022/cdevhub-ngx-tw-tabs.mjs +84 -30
  109. package/fesm2022/cdevhub-ngx-tw-tabs.mjs.map +1 -1
  110. package/fesm2022/cdevhub-ngx-tw-tags-input-testing.mjs +172 -0
  111. package/fesm2022/cdevhub-ngx-tw-tags-input-testing.mjs.map +1 -0
  112. package/fesm2022/cdevhub-ngx-tw-tags-input.mjs +107 -71
  113. package/fesm2022/cdevhub-ngx-tw-tags-input.mjs.map +1 -1
  114. package/fesm2022/cdevhub-ngx-tw-textarea.mjs +41 -9
  115. package/fesm2022/cdevhub-ngx-tw-textarea.mjs.map +1 -1
  116. package/fesm2022/cdevhub-ngx-tw-theme.mjs +257 -45
  117. package/fesm2022/cdevhub-ngx-tw-theme.mjs.map +1 -1
  118. package/fesm2022/cdevhub-ngx-tw-time-picker-testing.mjs +201 -0
  119. package/fesm2022/cdevhub-ngx-tw-time-picker-testing.mjs.map +1 -0
  120. package/fesm2022/cdevhub-ngx-tw-time-picker.mjs +184 -77
  121. package/fesm2022/cdevhub-ngx-tw-time-picker.mjs.map +1 -1
  122. package/fesm2022/cdevhub-ngx-tw-timeline.mjs +110 -27
  123. package/fesm2022/cdevhub-ngx-tw-timeline.mjs.map +1 -1
  124. package/fesm2022/{cdevhub-ngx-tw-toast-toast-renderer-DSu4YoTy.mjs → cdevhub-ngx-tw-toast-toast-renderer-BlDxemsU.mjs} +107 -19
  125. package/fesm2022/cdevhub-ngx-tw-toast-toast-renderer-BlDxemsU.mjs.map +1 -0
  126. package/fesm2022/cdevhub-ngx-tw-toast.mjs +21 -13
  127. package/fesm2022/cdevhub-ngx-tw-toast.mjs.map +1 -1
  128. package/fesm2022/cdevhub-ngx-tw-tooltip.mjs +102 -15
  129. package/fesm2022/cdevhub-ngx-tw-tooltip.mjs.map +1 -1
  130. package/fesm2022/cdevhub-ngx-tw-transfer-testing.mjs +219 -0
  131. package/fesm2022/cdevhub-ngx-tw-transfer-testing.mjs.map +1 -0
  132. package/fesm2022/cdevhub-ngx-tw-transfer.mjs +88 -69
  133. package/fesm2022/cdevhub-ngx-tw-transfer.mjs.map +1 -1
  134. package/fesm2022/cdevhub-ngx-tw-tree.mjs +68 -19
  135. package/fesm2022/cdevhub-ngx-tw-tree.mjs.map +1 -1
  136. package/index.json +1 -1
  137. package/package.json +63 -2
  138. package/theme/_base.css +13 -0
  139. package/theme/_dark.css +111 -36
  140. package/theme/_high-contrast-dark.css +367 -0
  141. package/theme/_high-contrast.css +13 -2
  142. package/theme/_light.css +332 -0
  143. package/theme/_semantic.css +60 -26
  144. package/theme/_typography.css +17 -0
  145. package/theme/index.css +86 -7
  146. package/types/cdevhub-ngx-tw-accordion.d.ts +12 -4
  147. package/types/cdevhub-ngx-tw-avatar.d.ts +10 -3
  148. package/types/cdevhub-ngx-tw-button.d.ts +1 -0
  149. package/types/cdevhub-ngx-tw-calendar-luxon.d.ts +2 -2
  150. package/types/cdevhub-ngx-tw-calendar-testing.d.ts +1 -1
  151. package/types/cdevhub-ngx-tw-calendar.d.ts +179 -73
  152. package/types/cdevhub-ngx-tw-card.d.ts +11 -3
  153. package/types/cdevhub-ngx-tw-carousel.d.ts +89 -20
  154. package/types/cdevhub-ngx-tw-checkbox.d.ts +40 -12
  155. package/types/cdevhub-ngx-tw-code-block.d.ts +11 -2
  156. package/types/cdevhub-ngx-tw-collapsible.d.ts +32 -5
  157. package/types/cdevhub-ngx-tw-combobox-testing.d.ts +157 -0
  158. package/types/cdevhub-ngx-tw-combobox.d.ts +94 -53
  159. package/types/cdevhub-ngx-tw-command-palette-testing.d.ts +136 -0
  160. package/types/cdevhub-ngx-tw-command-palette.d.ts +33 -5
  161. package/types/cdevhub-ngx-tw-core.d.ts +152 -21
  162. package/types/cdevhub-ngx-tw-date-picker-testing.d.ts +141 -0
  163. package/types/cdevhub-ngx-tw-date-picker.d.ts +37 -29
  164. package/types/cdevhub-ngx-tw-date-range-picker-testing.d.ts +148 -0
  165. package/types/cdevhub-ngx-tw-date-range-picker.d.ts +36 -29
  166. package/types/cdevhub-ngx-tw-dialog-testing.d.ts +98 -0
  167. package/types/cdevhub-ngx-tw-dialog.d.ts +12 -3
  168. package/types/cdevhub-ngx-tw-file-upload-testing.d.ts +120 -0
  169. package/types/cdevhub-ngx-tw-file-upload.d.ts +8 -10
  170. package/types/cdevhub-ngx-tw-flip-card.d.ts +18 -11
  171. package/types/cdevhub-ngx-tw-form-field.d.ts +19 -2
  172. package/types/cdevhub-ngx-tw-icon.d.ts +3 -3
  173. package/types/cdevhub-ngx-tw-input.d.ts +5 -8
  174. package/types/cdevhub-ngx-tw-item.d.ts +2 -0
  175. package/types/cdevhub-ngx-tw-menu-testing.d.ts +131 -0
  176. package/types/cdevhub-ngx-tw-menu.d.ts +2 -2
  177. package/types/cdevhub-ngx-tw-number-input.d.ts +20 -0
  178. package/types/cdevhub-ngx-tw-paginator.d.ts +58 -24
  179. package/types/cdevhub-ngx-tw-popover.d.ts +41 -10
  180. package/types/cdevhub-ngx-tw-progress-bar.d.ts +1 -1
  181. package/types/cdevhub-ngx-tw-radio.d.ts +68 -25
  182. package/types/cdevhub-ngx-tw-segmented-control.d.ts +79 -16
  183. package/types/cdevhub-ngx-tw-select-testing.d.ts +114 -0
  184. package/types/cdevhub-ngx-tw-select.d.ts +104 -33
  185. package/types/cdevhub-ngx-tw-sheet-testing.d.ts +104 -0
  186. package/types/cdevhub-ngx-tw-sheet.d.ts +19 -3
  187. package/types/cdevhub-ngx-tw-slider.d.ts +79 -25
  188. package/types/cdevhub-ngx-tw-sort.d.ts +45 -4
  189. package/types/cdevhub-ngx-tw-split.d.ts +32 -2
  190. package/types/cdevhub-ngx-tw-stat.d.ts +14 -4
  191. package/types/cdevhub-ngx-tw-stepper.d.ts +16 -2
  192. package/types/cdevhub-ngx-tw-switch.d.ts +35 -13
  193. package/types/cdevhub-ngx-tw-tab-nav.d.ts +7 -6
  194. package/types/cdevhub-ngx-tw-table.d.ts +53 -10
  195. package/types/cdevhub-ngx-tw-tabs.d.ts +41 -10
  196. package/types/cdevhub-ngx-tw-tags-input-testing.d.ts +135 -0
  197. package/types/cdevhub-ngx-tw-tags-input.d.ts +26 -11
  198. package/types/cdevhub-ngx-tw-textarea.d.ts +3 -2
  199. package/types/cdevhub-ngx-tw-theme.d.ts +245 -39
  200. package/types/cdevhub-ngx-tw-time-picker-testing.d.ts +129 -0
  201. package/types/cdevhub-ngx-tw-time-picker.d.ts +76 -19
  202. package/types/cdevhub-ngx-tw-timeline.d.ts +36 -12
  203. package/types/cdevhub-ngx-tw-toast.d.ts +20 -6
  204. package/types/cdevhub-ngx-tw-tooltip.d.ts +28 -6
  205. package/types/cdevhub-ngx-tw-transfer-testing.d.ts +152 -0
  206. package/types/cdevhub-ngx-tw-transfer.d.ts +21 -8
  207. package/types/cdevhub-ngx-tw-tree.d.ts +6 -1
  208. package/fesm2022/cdevhub-ngx-tw-sheet-sheet-renderer-ByyNluUd.mjs.map +0 -1
  209. package/fesm2022/cdevhub-ngx-tw-toast-toast-renderer-DSu4YoTy.mjs.map +0 -1
@@ -1,11 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, ElementRef, signal, computed, ChangeDetectionStrategy, Component, TemplateRef, Directive, input, model, output, contentChild, viewChild, ViewContainerRef, Injector, DestroyRef, linkedSignal, effect, untracked, forwardRef } from '@angular/core';
3
- import { NgControl, NgForm, FormGroupDirective } from '@angular/forms';
4
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
- import { merge } from 'rxjs';
6
- import { TW_ERROR_STATE_MATCHER, buildSelectLikePositions, resolveSelectScrollStrategy, consumeOverlayEscape } from '@cdevhub/ngx-tw/core';
3
+ import { NgControl } from '@angular/forms';
4
+ import { PickerOverlayCoordinator, wireErrorState, resolveSelectScrollStrategy, buildSelectLikePositions } from '@cdevhub/ngx-tw/core';
7
5
  import { Overlay } from '@angular/cdk/overlay';
8
- import { ComponentPortal } from '@angular/cdk/portal';
9
6
  import { Platform } from '@angular/cdk/platform';
10
7
  import { FocusMonitor, LiveAnnouncer } from '@angular/cdk/a11y';
11
8
  import { tv } from 'tailwind-variants';
@@ -340,8 +337,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
340
337
  }] });
341
338
 
342
339
  // ── Constants ──
343
- /** Duration of leave animation (ms) — matches scale-out/fade-out in theme/_base.css. */
344
- const ANIMATION_DURATION = 120;
345
340
  /** Debounce window (ms) for LiveAnnouncer result announcements while typing. */
346
341
  const ANNOUNCE_DEBOUNCE = 200;
347
342
  /** Sentinel for an unresolved write-value awaiting late-arriving options. */
@@ -383,20 +378,29 @@ const comboboxVariants = tv({
383
378
  // variants below paint this ring per the `color`/`errorState` axis.
384
379
  trigger: 'flex w-full items-center gap-1.5 rounded-md border border-border bg-surface text-fg transition-colors duration-normal motion-reduce:transition-none hover:border-border-strong focus-within:outline-2 focus-within:outline-offset-2',
385
380
  input: 'flex-1 min-w-0 bg-transparent outline-none placeholder:text-fg-subtle disabled:cursor-not-allowed',
386
- clearButton: 'inline-flex items-center justify-center shrink-0 size-5 rounded-md text-fg-muted hover:text-fg hover:bg-surface-muted transition-colors duration-normal motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500',
381
+ // `size-6` (24px) is the WCAG 2.2 SC 2.5.8 target-size floor and the `xs`
382
+ // step of the square-interactive scale. It does NOT scale with `size`:
383
+ // the clear sits inside the trigger, whose smallest pinned height is
384
+ // 24px (`xs` -> `h-6`), so any larger step would overflow the smallest
385
+ // trigger. The floor is what the success criterion asks for — which is
386
+ // also why `xs` no longer shrinks it to `size-4`.
387
+ clearButton: 'inline-flex items-center justify-center shrink-0 size-6 rounded-md text-fg-muted hover:text-fg hover:bg-surface-muted transition-colors duration-normal motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500',
387
388
  chevron: 'shrink-0 text-fg-muted transition-transform duration-normal motion-reduce:transition-none',
388
389
  spinner: 'shrink-0 text-fg-muted animate-spin',
389
390
  },
390
391
  variants: {
392
+ // Trigger height is PINNED (docs/vertical-rhythm.md §1-3): the box is
393
+ // always one line, so it declares its border-box height and carries no
394
+ // vertical padding. Horizontal padding and the font size are unchanged.
391
395
  size: {
392
396
  // xs density: chevron and spinner use `size-3.5` (14px) — the codified
393
397
  // half-step between size-3 and size-4 that lines up with text-xs metric
394
- // inside a 28px trigger.
395
- xs: { trigger: 'px-2 py-1 text-xs', chevron: 'size-3.5', clearButton: 'size-4', spinner: 'size-3.5' },
396
- sm: { trigger: 'px-3 py-1.5 text-sm', chevron: 'size-4', spinner: 'size-4' },
397
- md: { trigger: 'px-4 py-2 text-sm', chevron: 'size-4', spinner: 'size-4' },
398
- lg: { trigger: 'px-5 py-2.5 text-base', chevron: 'size-5', spinner: 'size-5' },
399
- xl: { trigger: 'px-6 py-3 text-base', chevron: 'size-5', spinner: 'size-5' },
398
+ // inside a 24px trigger.
399
+ xs: { trigger: 'px-2 text-xs h-6', chevron: 'size-3.5', spinner: 'size-3.5' },
400
+ sm: { trigger: 'px-3 text-sm h-8', chevron: 'size-4', spinner: 'size-4' },
401
+ md: { trigger: 'px-4 text-sm h-9', chevron: 'size-4', spinner: 'size-4' },
402
+ lg: { trigger: 'px-5 text-base h-11', chevron: 'size-5', spinner: 'size-5' },
403
+ xl: { trigger: 'px-6 text-base h-12', chevron: 'size-5', spinner: 'size-5' },
400
404
  },
401
405
  color: {
402
406
  primary: { trigger: 'focus-within:outline-primary-500' },
@@ -416,9 +420,14 @@ const comboboxVariants = tv({
416
420
  true: { chevron: 'rotate-180' },
417
421
  false: {},
418
422
  },
423
+ // `naked` = wrapped in a `tw-form-field`, which owns the box: its
424
+ // controlWrapper draws the border and the vertical padding. `h-auto`
425
+ // releases the pinned height (declared after `size`, so twMerge keeps it
426
+ // — the same ordering `px-0` already relies on) so the trigger
427
+ // contributes only its line box and the field's own rhythm governs.
419
428
  naked: {
420
429
  true: {
421
- trigger: 'border-0 bg-transparent rounded-none px-0 py-0 hover:border-0 focus-within:outline-0 focus-within:outline-offset-0',
430
+ trigger: 'border-0 bg-transparent rounded-none px-0 py-0 h-auto hover:border-0 focus-within:outline-0 focus-within:outline-offset-0',
422
431
  },
423
432
  false: {},
424
433
  },
@@ -513,115 +522,109 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
513
522
  */
514
523
  class ComboboxComponent {
515
524
  // ── Inputs ──
516
- /** Array of options to render in the popover. Accepts plain records or `TwComboboxOption<T>`. */
525
+ /** Array of options to render in the popover. Accepts plain records or `TwComboboxOption<T>`. Defaults to an empty array. */
517
526
  options = input([], /* @ts-ignore */
518
527
  ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
519
- /** Accessor returning the visible label for an option. Used by the default filter and the trigger label resolver. */
528
+ /** Accessor returning the visible label for an option. Used by the default filter and the trigger label resolver. Defaults to reading `.label`, falling back to `String(.value)`. */
520
529
  optionLabel = input(defaultOptionLabel, /* @ts-ignore */
521
530
  ...(ngDevMode ? [{ debugName: "optionLabel" }] : /* istanbul ignore next */ []));
522
- /** Accessor returning the value emitted via `valueCommit` when this option is picked. */
531
+ /** Accessor returning the value emitted via `valueCommit` when this option is picked. Defaults to reading `.value`. */
523
532
  optionValue = input(defaultOptionValue, /* @ts-ignore */
524
533
  ...(ngDevMode ? [{ debugName: "optionValue" }] : /* istanbul ignore next */ []));
525
- /** Accessor returning whether an option is non-interactive. */
534
+ /** Accessor returning whether an option is non-interactive. Defaults to reading `.disabled`. */
526
535
  optionDisabled = input(defaultOptionDisabled, /* @ts-ignore */
527
536
  ...(ngDevMode ? [{ debugName: "optionDisabled" }] : /* istanbul ignore next */ []));
528
- /** Accessor returning a group label. Options sharing a group render under a labelled `role="group"` region. */
537
+ /** Accessor returning a group label. Options sharing a group render under a labelled `role="group"` region. Defaults to reading `.group`. */
529
538
  optionGroup = input(defaultOptionGroup, /* @ts-ignore */
530
539
  ...(ngDevMode ? [{ debugName: "optionGroup" }] : /* istanbul ignore next */ []));
531
- /** Accessor returning an optional secondary description rendered under the label in the default option row. */
540
+ /** Accessor returning an optional secondary description rendered under the label in the default option row. Defaults to reading `.description`. */
532
541
  optionDescription = input(defaultOptionDescription, /* @ts-ignore */
533
542
  ...(ngDevMode ? [{ debugName: "optionDescription" }] : /* istanbul ignore next */ []));
534
543
  /** Filter function applied client-side whenever `inputValue` changes. Pass `null` to disable client filtering (async mode). Defaults to case-insensitive `startsWith` on the label. */
535
544
  filterFn = input(defaultStartsWithFilter, /* @ts-ignore */
536
545
  ...(ngDevMode ? [{ debugName: "filterFn" }] : /* istanbul ignore next */ []));
537
- /** When `true`, free-text commits are rejected — the input reverts to the last committed label on blur. */
546
+ /** When `true`, free-text commits are rejected — the input reverts to the last committed label on blur. Defaults to `false`. */
538
547
  strict = input(false, /* @ts-ignore */
539
548
  ...(ngDevMode ? [{ debugName: "strict" }] : /* istanbul ignore next */ []));
540
- /** Placeholder shown when the input is empty. */
549
+ /** Placeholder shown when the input is empty. Defaults to `undefined`. */
541
550
  placeholder = input(undefined, /* @ts-ignore */
542
551
  ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
543
- /** Disables the input and prevents the popover from opening. */
552
+ /** Disables the input and prevents the popover from opening. Defaults to `false`. Alias: `disabled`. */
544
553
  disabledInput = input(false, { ...(ngDevMode ? { debugName: "disabledInput" } : /* istanbul ignore next */ {}), alias: 'disabled' });
545
- /** Sets `aria-required="true"` on the input. */
554
+ /** Sets `aria-required="true"` on the input and the `*` marker on a wrapping `tw-form-field`. Also inferred from `Validators.required` on a bound control, so a reactive/template-driven form does not have to state it twice. Defaults to `false`. */
546
555
  requiredInput = input(false, { ...(ngDevMode ? { debugName: "requiredInput" } : /* istanbul ignore next */ {}), alias: 'required' });
547
- /** Controls trigger padding and font size per the inline padding scale. */
556
+ /** Controls trigger padding and font size per the inline padding scale. Defaults to `'md'`. */
548
557
  size = input('md', /* @ts-ignore */
549
558
  ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
550
- /** Semantic color for the focus ring. */
559
+ /** Semantic color for the focus ring. Defaults to `'primary'`. */
551
560
  color = input('primary', /* @ts-ignore */
552
561
  ...(ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []));
553
- /** Whether the trailing chevron affordance is rendered. */
554
- // TRUE-default: the chevron signals the dropdown affordance; without it the
555
- // combobox reads as a plain input. Opt-out is for inline search inputs.
562
+ /** Whether the trailing chevron affordance is rendered. Defaults to `true` — the chevron signals the dropdown affordance; the special case is an inline search input that opts out. */
556
563
  showChevron = input(true, /* @ts-ignore */
557
564
  ...(ngDevMode ? [{ debugName: "showChevron" }] : /* istanbul ignore next */ []));
558
- /** Whether the inline clear (×) button appears while `inputValue` is non-empty. */
559
- // TRUE-default: the clear affordance is the expected combobox UX once a value
560
- // is set; opt-out is for required-only flows.
565
+ /** Whether the inline clear (×) button appears while `inputValue` is non-empty. Defaults to `true` — clearing a typed value is the expected combobox gesture; the special case is a required-only flow. */
561
566
  clearable = input(true, /* @ts-ignore */
562
567
  ...(ngDevMode ? [{ debugName: "clearable" }] : /* istanbul ignore next */ []));
563
- /** When `true`, shows an in-popover spinner and an inline spinner in the trigger while the popover is open. */
568
+ /** When `true`, shows an in-popover spinner and an inline spinner in the trigger while the popover is open. Defaults to `false`. */
564
569
  loading = input(false, /* @ts-ignore */
565
570
  ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
566
- /** Debounce window (ms) before `queryChange` emits. Local filtering is not debounced. */
571
+ /** Debounce window (ms) before `queryChange` emits. Local filtering is not debounced. Defaults to `150`. */
567
572
  queryDebounce = input(150, /* @ts-ignore */
568
573
  ...(ngDevMode ? [{ debugName: "queryDebounce" }] : /* istanbul ignore next */ []));
569
- /** Minimum query length before the popover opens automatically. `0` opens on focus. */
574
+ /** Minimum query length before the popover opens automatically. `0` opens on focus. Defaults to `0`. */
570
575
  minQueryLength = input(0, /* @ts-ignore */
571
576
  ...(ngDevMode ? [{ debugName: "minQueryLength" }] : /* istanbul ignore next */ []));
572
- /** Whether the popover opens automatically when the input receives focus. */
573
- // TRUE-default: clicking into a combobox opens the dropdown — standard select-
574
- // like UX. Opt-out is for command-palette-style flows triggered only by typing.
577
+ /** Whether the popover opens automatically when the input receives focus. Defaults to `true` — clicking into a combobox opens the dropdown, matching select-like UX; the special case is a flow that should open only once the user types. */
575
578
  openOnFocus = input(true, /* @ts-ignore */
576
579
  ...(ngDevMode ? [{ debugName: "openOnFocus" }] : /* istanbul ignore next */ []));
577
- /** Maximum height (px) of the popover scroll region. */
580
+ /** Maximum height (px) of the popover scroll region. Defaults to `256`. */
578
581
  panelMaxHeight = input(256, /* @ts-ignore */
579
582
  ...(ngDevMode ? [{ debugName: "panelMaxHeight" }] : /* istanbul ignore next */ []));
580
- /** Overlay width strategy. `'trigger'` matches input width; `'auto'` lets content decide; a number is applied as px; a string is passed as a CSS length. */
583
+ /** Overlay width strategy. `'trigger'` matches input width; `'auto'` lets content decide; a number is applied as px; a string is passed as a CSS length. Defaults to `'trigger'`. */
581
584
  panelWidth = input('trigger', /* @ts-ignore */
582
585
  ...(ngDevMode ? [{ debugName: "panelWidth" }] : /* istanbul ignore next */ []));
583
- /** Extra class(es) appended to the overlay panel for consumer customization. */
586
+ /** Extra class(es) appended to the overlay panel for consumer customization. Defaults to an empty string. */
584
587
  panelClass = input('', /* @ts-ignore */
585
588
  ...(ngDevMode ? [{ debugName: "panelClass" }] : /* istanbul ignore next */ []));
586
- /** CDK overlay scroll strategy. */
589
+ /** CDK overlay scroll strategy. Defaults to `'reposition'`. */
587
590
  scrollStrategy = input('reposition', /* @ts-ignore */
588
591
  ...(ngDevMode ? [{ debugName: "scrollStrategy" }] : /* istanbul ignore next */ []));
589
- /** Pixel offset between the input and the popover. */
592
+ /** Pixel offset between the input and the popover. Defaults to `4`. */
590
593
  offset = input(4, /* @ts-ignore */
591
594
  ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
592
- /** Fallback empty-state message when no `*twComboboxEmpty` template is projected. */
595
+ /** Fallback empty-state message when no `*twComboboxEmpty` template is projected. Defaults to `'No results'`. */
593
596
  emptyMessage = input('No results', /* @ts-ignore */
594
597
  ...(ngDevMode ? [{ debugName: "emptyMessage" }] : /* istanbul ignore next */ []));
595
- /** Equality comparator used to reconcile `value` with options during `writeValue`. */
598
+ /** Equality comparator used to reconcile `value` with options during `writeValue`. Defaults to `Object.is`. */
596
599
  compareWith = input(Object.is, /* @ts-ignore */
597
600
  ...(ngDevMode ? [{ debugName: "compareWith" }] : /* istanbul ignore next */ []));
598
- /** Accessible name for the combobox input. */
601
+ /** Accessible name for the combobox input. Defaults to `undefined`. Alias: `aria-label`. */
599
602
  ariaLabel = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
600
- /** ID of an external label element. */
603
+ /** ID of an external label element. Defaults to `undefined`. Alias: `aria-labelledby`. */
601
604
  ariaLabelledby = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabelledby" } : /* istanbul ignore next */ {}), alias: 'aria-labelledby' });
602
- /** ID of an external descriptor element. */
605
+ /** ID of an external descriptor element. Defaults to `undefined`. Alias: `aria-describedby`. */
603
606
  ariaDescribedby = input(undefined, { ...(ngDevMode ? { debugName: "ariaDescribedby" } : /* istanbul ignore next */ {}), alias: 'aria-describedby' });
604
607
  /** Per-instance override of the {@link ErrorStateMatcher}. When omitted, the combobox uses the `TW_ERROR_STATE_MATCHER` token's value. */
605
608
  errorStateMatcher = input(undefined, /* @ts-ignore */
606
609
  ...(ngDevMode ? [{ debugName: "errorStateMatcher" }] : /* istanbul ignore next */ []));
607
610
  // ── Models ──
608
- /** Two-way bound committed value. May be an option's value (`T`), a typed string (free-text mode), or `null`. */
611
+ /** Two-way bound committed value. May be an option's value (`T`), a typed string (free-text mode), or `null`. Defaults to `null`. */
609
612
  value = model(null, /* @ts-ignore */
610
613
  ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
611
- /** Two-way bound visible text in the input. Bound separately from `value` so async consumers can drive the query. */
614
+ /** Two-way bound visible text in the input. Bound separately from `value` so async consumers can drive the query. Writes back on every keystroke, on commit, and on clear. Defaults to `''`. */
612
615
  inputValue = model('', /* @ts-ignore */
613
616
  ...(ngDevMode ? [{ debugName: "inputValue" }] : /* istanbul ignore next */ []));
614
- /** Two-way bound open state of the popover. */
617
+ /** Two-way bound open state of the popover. Defaults to `false`. */
615
618
  open = model(false, /* @ts-ignore */
616
619
  ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
617
620
  // ── Outputs ──
618
- /** Fires after the query text changes, debounced by `queryDebounce`. Async-mode consumers subscribe to this to fetch results. */
621
+ /** Fires after the query text changes, debounced by `queryDebounce`. Async-mode consumers subscribe to this to fetch results. Payload is the current input text, untrimmed. */
619
622
  queryChange = output();
620
- /** Fires when the user picks an option from the list (not on free-text commit). */
623
+ /** Fires when the user picks an option from the list (not on free-text commit). Payload carries the raw option record plus its resolved `value` and `label`. */
621
624
  optionSelected = output();
622
625
  /** Fires whenever `value` changes, with a `source` discriminator distinguishing option / free-text / reset / programmatic origin. */
623
626
  valueCommit = output();
624
- /** Fires when the popover opens or closes. */
627
+ /** Fires when the popover finishes opening or closing. Payload carries the new `open` state and the trigger element the overlay is anchored to. */
625
628
  openedChange = output();
626
629
  // ── Content queries ──
627
630
  /** @internal */
@@ -640,6 +643,7 @@ class ComboboxComponent {
640
643
  ...(ngDevMode ? [{ debugName: "triggerSurfaceRef" }] : /* istanbul ignore next */ []));
641
644
  // ── Injected deps ──
642
645
  overlayService = inject(Overlay);
646
+ coordinator = inject(PickerOverlayCoordinator);
643
647
  elementRef = inject((ElementRef));
644
648
  viewContainerRef = inject(ViewContainerRef);
645
649
  injector = inject(Injector);
@@ -649,9 +653,6 @@ class ComboboxComponent {
649
653
  platform = inject(Platform);
650
654
  formField = inject(TW_FORM_FIELD, { optional: true });
651
655
  ngControl = inject(NgControl, { optional: true, self: true });
652
- parentForm = inject(NgForm, { optional: true });
653
- parentFormGroup = inject(FormGroupDirective, { optional: true });
654
- defaultMatcher = inject(TW_ERROR_STATE_MATCHER);
655
656
  // ── Identity ──
656
657
  uid = nextComboboxId++;
657
658
  /** @internal */
@@ -671,10 +672,12 @@ class ComboboxComponent {
671
672
  ...(ngDevMode ? [{ debugName: "focusedSignal" }] : /* istanbul ignore next */ []));
672
673
  cvaDisabled = signal(false, /* @ts-ignore */
673
674
  ...(ngDevMode ? [{ debugName: "cvaDisabled" }] : /* istanbul ignore next */ []));
674
- _ngControlRev = signal(0, /* @ts-ignore */
675
- ...(ngDevMode ? [{ debugName: "_ngControlRev" }] : /* istanbul ignore next */ []));
676
- _formSubmitRev = signal(0, /* @ts-ignore */
677
- ...(ngDevMode ? [{ debugName: "_formSubmitRev" }] : /* istanbul ignore next */ []));
675
+ /** @internal Shared `errorState` / `required` / `errors` derivation — see `wireErrorState`. */
676
+ errorWiring = wireErrorState({
677
+ ngControl: () => this.ngControl,
678
+ matcher: () => this.errorStateMatcher(),
679
+ required: () => this.requiredInput(),
680
+ });
678
681
  describedByIdsSignal = signal([], /* @ts-ignore */
679
682
  ...(ngDevMode ? [{ debugName: "describedByIdsSignal" }] : /* istanbul ignore next */ []));
680
683
  labelledByIdsSignal = signal([], /* @ts-ignore */
@@ -684,22 +687,34 @@ class ComboboxComponent {
684
687
  ...(ngDevMode ? [{ debugName: "lastCommittedLabel" }] : /* istanbul ignore next */ []));
685
688
  pendingWriteValue = signal(UNRESOLVED, /* @ts-ignore */
686
689
  ...(ngDevMode ? [{ debugName: "pendingWriteValue" }] : /* istanbul ignore next */ []));
687
- closingSignal = signal(false, /* @ts-ignore */
688
- ...(ngDevMode ? [{ debugName: "closingSignal" }] : /* istanbul ignore next */ []));
689
690
  /** Re-runs the input handler once after IME composition ends. */
690
691
  composingPendingValue = null;
691
692
  onChange = () => { };
692
693
  onTouched = () => { };
693
- overlayRef = null;
694
- overlayInstanceSignal = signal(null, /* @ts-ignore */
695
- ...(ngDevMode ? [{ debugName: "overlayInstanceSignal" }] : /* istanbul ignore next */ []));
696
- closeTimer = null;
697
694
  queryDebounceTimer = null;
698
695
  announceTimer = null;
696
+ /**
697
+ * Watches the host for size changes so `panelWidth="trigger"` keeps tracking
698
+ * while the panel is up. Minted per open and disconnected on close, because
699
+ * the `OverlayRef` it feeds is disposed on close (see `openOverlay`).
700
+ */
699
701
  resizeObserver = null;
700
- get overlayInstance() {
701
- return this.overlayInstanceSignal();
702
- }
702
+ // Overlay bookkeeping is deliberately held in plain fields, not signals — same
703
+ // shape as popover and command-palette. A signal here would be track-read by the
704
+ // open/close lifecycle effect that also writes it, which is the cycle shape
705
+ // CLAUDE.md forbids. The only reactive bit is `isAttached`, written by the
706
+ // lifecycle effect and read only by the state-push effect below, so the two
707
+ // never form a loop.
708
+ overlayInstance = null;
709
+ closing = false;
710
+ /**
711
+ * Flips true once the overlay component is attached; the sole trigger for the
712
+ * state-push effect. Load-bearing: the close path leaves `activeIndex` and the
713
+ * rest of the pushed state untouched, so on a reopen no data signal changes and
714
+ * nothing else would wake that effect — the fresh panel would render empty.
715
+ */
716
+ isAttached = signal(false, /* @ts-ignore */
717
+ ...(ngDevMode ? [{ debugName: "isAttached" }] : /* istanbul ignore next */ []));
703
718
  // ── Derived state ──
704
719
  /** @internal */
705
720
  isDisabled = computed(() => this.disabledInput() || this.cvaDisabled(), /* @ts-ignore */
@@ -817,15 +832,7 @@ class ComboboxComponent {
817
832
  }, /* @ts-ignore */
818
833
  ...(ngDevMode ? [{ debugName: "resolvedLabelledBy" }] : /* istanbul ignore next */ []));
819
834
  /** @internal Error-state per the configured `ErrorStateMatcher`. Reads the bound `NgControl.invalid` through the matcher. */
820
- errorState = computed(() => {
821
- this._ngControlRev();
822
- this._formSubmitRev();
823
- const matcher = this.errorStateMatcher() ?? this.defaultMatcher;
824
- const form = this.parentFormGroup ??
825
- this.parentForm;
826
- return matcher.isErrorState(this.ngControl?.control ?? null, form);
827
- }, /* @ts-ignore */
828
- ...(ngDevMode ? [{ debugName: "errorState" }] : /* istanbul ignore next */ []));
835
+ errorState = this.errorWiring.errorState;
829
836
  // ── tv() output ──
830
837
  variantResult = computed(() => comboboxVariants({
831
838
  size: this.size(),
@@ -864,9 +871,18 @@ class ComboboxComponent {
864
871
  empty = this.isEmpty;
865
872
  /** @internal */
866
873
  disabled = this.isDisabled;
867
- /** @internal */
868
- required = computed(() => this.requiredInput(), /* @ts-ignore */
869
- ...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
874
+ /**
875
+ * @internal Resolved required state: the `required` input OR'd with
876
+ * `Validators.required` on a bound `NgControl`. Without the validator arm the
877
+ * form-field `*` marker (`FormFieldComponent.isRequired`) and the input's
878
+ * `aria-required` silently vanish under reactive/template-driven forms, while
879
+ * signal forms shows them — `cvaControlCreate` writes the `required` *input*
880
+ * directly from the field state and never consults validators. The OR keeps
881
+ * both branches true at once.
882
+ */
883
+ required = this.errorWiring.required;
884
+ /** @internal Active validation errors map from the bound `NgControl`. Drives `[twError match="…"]` inside a wrapping `tw-form-field`; without it the form-field's key set is permanently empty and every `match`ed error stays hidden. */
885
+ errors = this.errorWiring.errors;
870
886
  /** @internal */
871
887
  controlType = 'combobox';
872
888
  /** @internal */
@@ -885,30 +901,33 @@ class ComboboxComponent {
885
901
  this.ngControl.valueAccessor = this;
886
902
  }
887
903
  // Mirror parent `open` model into overlay lifecycle.
904
+ // Only `open` and `isDisabled` are read in the tracked phase; the lifecycle
905
+ // calls run inside `untracked()` because they both read and write panel state
906
+ // (openOverlay writes activeIndex and reads visibleOptions).
888
907
  effect(() => {
889
908
  const shouldOpen = this.open();
890
909
  const disabled = this.isDisabled();
891
- if (disabled && this.overlayInstance) {
892
- this.closeOverlay({ silent: false });
893
- return;
894
- }
895
- if (shouldOpen && !this.overlayInstance && !disabled && !this.closingSignal()) {
896
- this.openOverlay();
897
- }
898
- else if (!shouldOpen && this.overlayInstance && !this.closingSignal()) {
899
- this.closeOverlay({ silent: false });
900
- }
910
+ untracked(() => {
911
+ if (disabled && this.overlayInstance) {
912
+ this.closeOverlay({ silent: false });
913
+ return;
914
+ }
915
+ if (shouldOpen && !this.overlayInstance && !disabled && !this.closing) {
916
+ this.openOverlay();
917
+ }
918
+ else if (!shouldOpen && this.overlayInstance && !this.closing) {
919
+ this.closeOverlay({ silent: false });
920
+ }
921
+ });
901
922
  });
902
923
  // Push state into overlay component whenever anything relevant changes.
924
+ // `isAttached` is the trigger — it is written by the lifecycle effect above
925
+ // and never written here, so the two effects cannot feed each other.
903
926
  // While the panel is animating out, freeze content updates so list mutations
904
927
  // (e.g. the input filtering down after commitOption) don't visually flicker
905
928
  // the panel during the leave animation.
906
929
  effect(() => {
907
- const instance = this.overlayInstance;
908
- if (!instance)
909
- return;
910
- if (this.closingSignal())
911
- return;
930
+ const attached = this.isAttached();
912
931
  const renderedRows = this.renderedRows();
913
932
  const activeIndex = this.activeIndex();
914
933
  const panelMaxHeight = this.panelMaxHeight();
@@ -920,7 +939,14 @@ class ComboboxComponent {
920
939
  const emptyTemplate = this.emptyTemplateChild()?.templateRef;
921
940
  const loadingTemplate = this.loadingTemplateChild()?.templateRef;
922
941
  const customPanelClass = this.resolvePanelClass();
942
+ // Tracked so the selection snapshot below refreshes when either changes.
943
+ // The snapshot replaced a live closure — see the `isSelected` push.
944
+ const visibleCount = this.visibleOptions().length;
945
+ void this.value();
923
946
  untracked(() => {
947
+ const instance = this.overlayInstance;
948
+ if (!attached || !instance || this.closing)
949
+ return;
924
950
  instance.renderedRows.set(renderedRows);
925
951
  instance.activeIndex.set(activeIndex);
926
952
  instance.panelMaxHeight.set(panelMaxHeight);
@@ -935,7 +961,19 @@ class ComboboxComponent {
935
961
  instance.listboxId.set(this.listboxId);
936
962
  instance.optionIdFn.set(this.optionId);
937
963
  instance.groupHeaderIdFn.set(this.groupHeaderId);
938
- instance.isSelected.set((index) => this.isVisibleOptionSelected(index));
964
+ // A SNAPSHOT, not a live closure. `renderedRows` is frozen while the
965
+ // panel animates out (the guard above), and each frozen row carries its
966
+ // index into the visible list at freeze time. A closure reading
967
+ // `visibleOptions()` at call time would resolve those stale indices
968
+ // against the current, already-refiltered list — so committing an option
969
+ // marked the WRONG row `aria-selected="true"` for the ~120ms leave
970
+ // window. Snapshotting here binds the flags to the same generation of
971
+ // the list as the rows they describe.
972
+ const selectedFlags = new Array(visibleCount);
973
+ for (let i = 0; i < visibleCount; i++) {
974
+ selectedFlags[i] = this.isVisibleOptionSelected(i);
975
+ }
976
+ instance.isSelected.set((index) => selectedFlags[index] ?? false);
939
977
  });
940
978
  });
941
979
  // Late-arriving options reconciler for the async writeValue race.
@@ -996,20 +1034,21 @@ class ComboboxComponent {
996
1034
  this.onTouched();
997
1035
  // Blur often flips `touched` on the bound `NgControl`; bump the
998
1036
  // revision so `errorState` recomputes.
999
- this._ngControlRev.update((n) => n + 1);
1037
+ this.errorWiring.bump();
1000
1038
  }
1001
1039
  });
1040
+ // The overlay itself is torn down by `PickerOverlayCoordinator`, which
1041
+ // registers its own `DestroyRef.onDestroy` during this component's field
1042
+ // initialisation — i.e. before this hook — and disposes the `OverlayRef`,
1043
+ // cancels the close timer and completes the per-open streams there.
1002
1044
  this.destroyRef.onDestroy(() => {
1003
1045
  monitorSub.unsubscribe();
1004
1046
  this.focusMonitor.stopMonitoring(this.elementRef);
1005
- this.clearCloseTimer();
1006
1047
  this.clearQueryDebounceTimer();
1007
1048
  this.clearAnnounceTimer();
1008
1049
  this.resizeObserver?.disconnect();
1009
1050
  this.resizeObserver = null;
1010
- this.overlayRef?.dispose();
1011
- this.overlayRef = null;
1012
- this.overlayInstanceSignal.set(null);
1051
+ this.overlayInstance = null;
1013
1052
  });
1014
1053
  }
1015
1054
  // ── Public methods ──
@@ -1208,7 +1247,15 @@ class ComboboxComponent {
1208
1247
  event.preventDefault();
1209
1248
  event.stopPropagation();
1210
1249
  }
1211
- /** @internal */
1250
+ /**
1251
+ * @internal
1252
+ *
1253
+ * Reached by mouse and — since the button left `tabindex="-1"` — by Enter or
1254
+ * Space from the keyboard, both of which a native `<button>` delivers here
1255
+ * as a click. Clearing unmounts this button (`showClearButton()` goes
1256
+ * false), so focus is handed back to the input rather than falling to
1257
+ * `<body>` (SC 2.4.3).
1258
+ */
1212
1259
  onClearClick(event) {
1213
1260
  event.preventDefault();
1214
1261
  event.stopPropagation();
@@ -1376,13 +1423,60 @@ class ComboboxComponent {
1376
1423
  }
1377
1424
  }
1378
1425
  // ── Overlay lifecycle ──
1426
+ /**
1427
+ * Builds and attaches the panel through {@link PickerOverlayCoordinator}.
1428
+ *
1429
+ * The coordinator disposes the `OverlayRef` on close, so every value read
1430
+ * here — `offset`, `scrollStrategy`, `panelClass`, and the position
1431
+ * strategy's origin — is re-read on each open rather than frozen at the
1432
+ * first one. That replaces the previous `refreshPositionConfig()` /
1433
+ * `applyScrollStrategy()` pair, which could only refresh the two inputs it
1434
+ * knew about on a reused ref.
1435
+ *
1436
+ * `focusTrap: false` is load-bearing: this is an `aria-activedescendant`
1437
+ * combobox, so DOM focus never leaves the `<input>` and a trap's anchor
1438
+ * elements would inject tab stops around a panel with nothing focusable in it.
1439
+ */
1379
1440
  openOverlay() {
1380
- this.ensureOverlay();
1381
- this.attachOverlayComponent();
1382
- this.subscribeBackdrop();
1383
- this.subscribeOverlayEscape();
1441
+ const result = this.coordinator.open({
1442
+ origin: this.triggerSurfaceRef() ?? this.elementRef,
1443
+ portalComponent: ComboboxOverlayComponent,
1444
+ viewContainerRef: this.viewContainerRef,
1445
+ injector: this.injector,
1446
+ positions: buildSelectLikePositions(this.offset()),
1447
+ scrollStrategy: resolveSelectScrollStrategy(this.scrollStrategy(), this.overlayService),
1448
+ panelClass: 'tw-combobox-panel',
1449
+ focusTrap: false,
1450
+ });
1451
+ if (!result)
1452
+ return;
1453
+ const instance = result.instance;
1454
+ instance.onOptionPick.set((index) => {
1455
+ const visible = this.visibleOptions();
1456
+ if (index < 0 || index >= visible.length)
1457
+ return;
1458
+ const opt = visible[index];
1459
+ if (opt.disabled)
1460
+ return;
1461
+ this.commitOption(opt, 'option');
1462
+ });
1463
+ instance.onOptionHover.set((index) => {
1464
+ const visible = this.visibleOptions();
1465
+ if (index < 0 || index >= visible.length)
1466
+ return;
1467
+ if (visible[index].disabled)
1468
+ return;
1469
+ this.activeIndex.set(index);
1470
+ });
1471
+ this.overlayInstance = instance;
1472
+ this.installResizeObserver();
1473
+ this.updateOverlaySize();
1474
+ this.subscribePerOpen();
1384
1475
  // Reset active to first enabled when opening.
1385
1476
  this.activeIndex.set(this.firstEnabledIndex());
1477
+ // Set last: this is what wakes the state-push effect, and it must not fire
1478
+ // until the instance exists and the per-open state has been reset.
1479
+ this.isAttached.set(true);
1386
1480
  this.liveAnnouncer.announce(`${this.visibleOptions().length} suggestions available`, 'polite');
1387
1481
  this.openedChange.emit({
1388
1482
  open: true,
@@ -1390,47 +1484,29 @@ class ComboboxComponent {
1390
1484
  });
1391
1485
  }
1392
1486
  closeOverlay(_opts) {
1393
- if (this.closingSignal() || !this.overlayInstance)
1487
+ if (this.closing || !this.overlayInstance)
1394
1488
  return;
1395
- this.closingSignal.set(true);
1489
+ this.closing = true;
1396
1490
  this.clearAnnounceTimer();
1397
- this.closeTimer = setTimeout(() => {
1398
- this.closeTimer = null;
1399
- if (this.overlayRef?.hasAttached()) {
1400
- this.overlayRef.detach();
1401
- }
1402
- this.overlayInstanceSignal.set(null);
1491
+ // The coordinator runs the leave animation, then detaches and disposes;
1492
+ // this callback fires once the overlay is fully gone.
1493
+ this.coordinator.close(() => {
1494
+ // Safe to drop the observer: the next open mints a fresh one against the
1495
+ // fresh OverlayRef. This used to be the SRP F-01 bug — the observer was
1496
+ // disconnected here while the reused ref made `ensureOverlay()`
1497
+ // early-return, so it was never re-installed. Under dispose-on-close the
1498
+ // install is unconditional, so that shape cannot come back.
1403
1499
  this.resizeObserver?.disconnect();
1404
1500
  this.resizeObserver = null;
1501
+ this.overlayInstance = null;
1502
+ this.isAttached.set(false);
1405
1503
  untracked(() => this.open.set(false));
1406
- this.closingSignal.set(false);
1504
+ this.closing = false;
1407
1505
  this.openedChange.emit({
1408
1506
  open: false,
1409
1507
  trigger: this.inputRef()?.nativeElement ?? this.elementRef.nativeElement,
1410
1508
  });
1411
- }, ANIMATION_DURATION);
1412
- }
1413
- ensureOverlay() {
1414
- if (this.overlayRef) {
1415
- this.updateOverlaySize();
1416
- return;
1417
- }
1418
- const positionStrategy = this.overlayService
1419
- .position()
1420
- .flexibleConnectedTo(this.triggerSurfaceRef()?.nativeElement ?? this.elementRef.nativeElement)
1421
- .withPositions(buildSelectLikePositions(this.offset()))
1422
- .withFlexibleDimensions(false)
1423
- .withPush(false)
1424
- .withViewportMargin(8);
1425
- this.overlayRef = this.overlayService.create({
1426
- positionStrategy,
1427
- scrollStrategy: resolveSelectScrollStrategy(this.scrollStrategy(), this.overlayService),
1428
- hasBackdrop: true,
1429
- backdropClass: 'cdk-overlay-transparent-backdrop',
1430
- panelClass: 'tw-combobox-panel',
1431
1509
  });
1432
- this.installResizeObserver();
1433
- this.updateOverlaySize();
1434
1510
  }
1435
1511
  installResizeObserver() {
1436
1512
  if (!this.platform.isBrowser || typeof ResizeObserver === 'undefined')
@@ -1441,85 +1517,48 @@ class ComboboxComponent {
1441
1517
  this.resizeObserver.observe(target);
1442
1518
  }
1443
1519
  updateOverlaySize() {
1444
- if (!this.overlayRef)
1520
+ const overlayRef = this.coordinator.ref();
1521
+ if (!overlayRef)
1445
1522
  return;
1446
1523
  const width = this.panelWidth();
1447
1524
  if (width === 'trigger') {
1448
1525
  const anchor = this.triggerSurfaceRef()?.nativeElement ?? this.elementRef.nativeElement;
1449
1526
  const rect = anchor.getBoundingClientRect();
1450
- this.overlayRef.updateSize({ width: rect.width });
1527
+ overlayRef.updateSize({ width: rect.width });
1451
1528
  }
1452
1529
  else if (width === 'auto') {
1453
- this.overlayRef.updateSize({ width: undefined, minWidth: undefined });
1454
- }
1455
- else if (typeof width === 'number') {
1456
- this.overlayRef.updateSize({ width });
1530
+ overlayRef.updateSize({ width: undefined, minWidth: undefined });
1457
1531
  }
1458
1532
  else {
1459
- this.overlayRef.updateSize({ width });
1533
+ overlayRef.updateSize({ width });
1460
1534
  }
1461
1535
  }
1462
- attachOverlayComponent() {
1463
- if (!this.overlayRef)
1464
- return;
1465
- const portal = new ComponentPortal(ComboboxOverlayComponent, this.viewContainerRef, this.injector);
1466
- const ref = this.overlayRef.attach(portal);
1467
- const instance = ref.instance;
1468
- instance.onOptionPick.set((index) => {
1469
- const visible = this.visibleOptions();
1470
- if (index < 0 || index >= visible.length)
1471
- return;
1472
- const opt = visible[index];
1473
- if (opt.disabled)
1474
- return;
1475
- this.commitOption(opt, 'option');
1476
- });
1477
- instance.onOptionHover.set((index) => {
1478
- const visible = this.visibleOptions();
1479
- if (index < 0 || index >= visible.length)
1480
- return;
1481
- if (visible[index].disabled)
1482
- return;
1483
- this.activeIndex.set(index);
1484
- });
1485
- this.overlayInstanceSignal.set(instance);
1486
- }
1487
- subscribeBackdrop() {
1488
- if (!this.overlayRef)
1489
- return;
1490
- const sub = this.overlayRef.backdropClick().subscribe(() => {
1491
- this.closePanel();
1492
- });
1493
- this.destroyRef.onDestroy(() => sub.unsubscribe());
1494
- }
1495
1536
  /**
1496
- * Listens for `Escape` on the overlay's `keydownEvents()`. Mirrors
1537
+ * Registers the backdrop-click and Escape listeners for one open.
1538
+ *
1539
+ * The Escape listener rides the overlay's `keydownEvents()`. Mirrors
1497
1540
  * `SelectComponent` so focus inside the overlay panel (e.g., if the user
1498
1541
  * tabs into projected loading/empty templates) still dismisses cleanly.
1499
1542
  * The combobox's own `<input>` keydown handler already short-circuits
1500
1543
  * Escape when focus is on the input; this layer is the safety net for
1501
1544
  * panel-internal focus.
1545
+ *
1546
+ * Both streams come from the coordinator, which completes them when the
1547
+ * overlay closes — so there is no per-open `Subscription` aggregate to keep
1548
+ * and no way for listeners to accumulate across opens.
1502
1549
  */
1503
- subscribeOverlayEscape() {
1504
- if (!this.overlayRef)
1505
- return;
1506
- const teardown = consumeOverlayEscape(this.overlayRef, (event) => {
1550
+ subscribePerOpen() {
1551
+ this.coordinator.backdropClick$().subscribe(() => this.closePanel());
1552
+ this.coordinator.escape$().subscribe((event) => {
1507
1553
  event.preventDefault();
1508
1554
  this.inputValue.set(this.lastCommittedLabel());
1509
1555
  this.closePanel();
1510
1556
  });
1511
- this.destroyRef.onDestroy(() => teardown());
1512
1557
  }
1513
1558
  resolvePanelClass() {
1514
1559
  const raw = this.panelClass();
1515
1560
  return Array.isArray(raw) ? raw.join(' ') : raw;
1516
1561
  }
1517
- clearCloseTimer() {
1518
- if (this.closeTimer !== null) {
1519
- clearTimeout(this.closeTimer);
1520
- this.closeTimer = null;
1521
- }
1522
- }
1523
1562
  // ── ControlValueAccessor ──
1524
1563
  writeValue(value) {
1525
1564
  if (value === null || value === undefined) {
@@ -1588,21 +1627,7 @@ class ComboboxComponent {
1588
1627
  // NgControl's `control` is set by the parent FormControl* directive before
1589
1628
  // children's `ngOnInit`. Subscribe here so errorState reacts to status/value
1590
1629
  // changes on the bound control.
1591
- const ctrl = this.ngControl?.control;
1592
- if (ctrl) {
1593
- const streams = [ctrl.statusChanges, ctrl.valueChanges].filter((s) => !!s);
1594
- if (streams.length) {
1595
- merge(...streams)
1596
- .pipe(takeUntilDestroyed(this.destroyRef))
1597
- .subscribe(() => this._ngControlRev.update((n) => n + 1));
1598
- }
1599
- }
1600
- const submit = this.parentFormGroup?.ngSubmit ?? this.parentForm?.ngSubmit;
1601
- if (submit) {
1602
- submit
1603
- .pipe(takeUntilDestroyed(this.destroyRef))
1604
- .subscribe(() => this._formSubmitRev.update((n) => n + 1));
1605
- }
1630
+ this.errorWiring.connect();
1606
1631
  }
1607
1632
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: ComboboxComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1608
1633
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: ComboboxComponent, isStandalone: true, selector: "tw-combobox", inputs: { options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, optionLabel: { classPropertyName: "optionLabel", publicName: "optionLabel", isSignal: true, isRequired: false, transformFunction: null }, optionValue: { classPropertyName: "optionValue", publicName: "optionValue", isSignal: true, isRequired: false, transformFunction: null }, optionDisabled: { classPropertyName: "optionDisabled", publicName: "optionDisabled", isSignal: true, isRequired: false, transformFunction: null }, optionGroup: { classPropertyName: "optionGroup", publicName: "optionGroup", isSignal: true, isRequired: false, transformFunction: null }, optionDescription: { classPropertyName: "optionDescription", publicName: "optionDescription", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, strict: { classPropertyName: "strict", publicName: "strict", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabledInput: { classPropertyName: "disabledInput", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requiredInput: { classPropertyName: "requiredInput", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, showChevron: { classPropertyName: "showChevron", publicName: "showChevron", isSignal: true, isRequired: false, transformFunction: null }, clearable: { classPropertyName: "clearable", publicName: "clearable", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, queryDebounce: { classPropertyName: "queryDebounce", publicName: "queryDebounce", isSignal: true, isRequired: false, transformFunction: null }, minQueryLength: { classPropertyName: "minQueryLength", publicName: "minQueryLength", isSignal: true, isRequired: false, transformFunction: null }, openOnFocus: { classPropertyName: "openOnFocus", publicName: "openOnFocus", isSignal: true, isRequired: false, transformFunction: null }, panelMaxHeight: { classPropertyName: "panelMaxHeight", publicName: "panelMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, panelWidth: { classPropertyName: "panelWidth", publicName: "panelWidth", isSignal: true, isRequired: false, transformFunction: null }, panelClass: { classPropertyName: "panelClass", publicName: "panelClass", isSignal: true, isRequired: false, transformFunction: null }, scrollStrategy: { classPropertyName: "scrollStrategy", publicName: "scrollStrategy", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, emptyMessage: { classPropertyName: "emptyMessage", publicName: "emptyMessage", isSignal: true, isRequired: false, transformFunction: null }, compareWith: { classPropertyName: "compareWith", publicName: "compareWith", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedby: { classPropertyName: "ariaDescribedby", publicName: "aria-describedby", isSignal: true, isRequired: false, transformFunction: null }, errorStateMatcher: { classPropertyName: "errorStateMatcher", publicName: "errorStateMatcher", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, inputValue: { classPropertyName: "inputValue", publicName: "inputValue", isSignal: true, isRequired: false, transformFunction: null }, open: { classPropertyName: "open", publicName: "open", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", inputValue: "inputValueChange", open: "openChange", queryChange: "queryChange", optionSelected: "optionSelected", valueCommit: "valueCommit", openedChange: "openedChange" }, host: { properties: { "class": "rootClasses()" } }, providers: [
@@ -1610,6 +1635,7 @@ class ComboboxComponent {
1610
1635
  provide: TW_FORM_FIELD_CONTROL,
1611
1636
  useExisting: forwardRef(() => ComboboxComponent),
1612
1637
  },
1638
+ PickerOverlayCoordinator,
1613
1639
  ], queries: [{ propertyName: "optionTemplateChild", first: true, predicate: ComboboxOptionTemplateDirective, descendants: true, isSignal: true }, { propertyName: "emptyTemplateChild", first: true, predicate: ComboboxEmptyTemplateDirective, descendants: true, isSignal: true }, { propertyName: "loadingTemplateChild", first: true, predicate: ComboboxLoadingTemplateDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "inputRef", first: true, predicate: ["inputEl"], descendants: true, isSignal: true }, { propertyName: "triggerSurfaceRef", first: true, predicate: ["triggerSurface"], descendants: true, isSignal: true }], ngImport: i0, template: `
1614
1640
  <div #triggerSurface [class]="triggerClasses()">
1615
1641
  <ng-content select="[twComboboxPrefix]" />
@@ -1625,7 +1651,7 @@ class ComboboxComponent {
1625
1651
  [attr.aria-expanded]="open() ? 'true' : 'false'"
1626
1652
  [attr.aria-controls]="open() ? listboxId : null"
1627
1653
  [attr.aria-activedescendant]="open() ? (activeOptionId() || null) : null"
1628
- [attr.aria-required]="requiredInput() ? 'true' : null"
1654
+ [attr.aria-required]="required() ? 'true' : null"
1629
1655
  [attr.aria-invalid]="errorState() ? 'true' : null"
1630
1656
  [attr.aria-label]="ariaLabel() || null"
1631
1657
  [attr.aria-labelledby]="resolvedLabelledBy() || null"
@@ -1664,7 +1690,6 @@ class ComboboxComponent {
1664
1690
  @if (showClearButton()) {
1665
1691
  <button
1666
1692
  type="button"
1667
- tabindex="-1"
1668
1693
  [class]="clearButtonClasses()"
1669
1694
  aria-label="Clear"
1670
1695
  (mousedown)="onClearMousedown($event)"
@@ -1711,6 +1736,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
1711
1736
  provide: TW_FORM_FIELD_CONTROL,
1712
1737
  useExisting: forwardRef(() => ComboboxComponent),
1713
1738
  },
1739
+ PickerOverlayCoordinator,
1714
1740
  ],
1715
1741
  template: `
1716
1742
  <div #triggerSurface [class]="triggerClasses()">
@@ -1727,7 +1753,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
1727
1753
  [attr.aria-expanded]="open() ? 'true' : 'false'"
1728
1754
  [attr.aria-controls]="open() ? listboxId : null"
1729
1755
  [attr.aria-activedescendant]="open() ? (activeOptionId() || null) : null"
1730
- [attr.aria-required]="requiredInput() ? 'true' : null"
1756
+ [attr.aria-required]="required() ? 'true' : null"
1731
1757
  [attr.aria-invalid]="errorState() ? 'true' : null"
1732
1758
  [attr.aria-label]="ariaLabel() || null"
1733
1759
  [attr.aria-labelledby]="resolvedLabelledBy() || null"
@@ -1766,7 +1792,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
1766
1792
  @if (showClearButton()) {
1767
1793
  <button
1768
1794
  type="button"
1769
- tabindex="-1"
1770
1795
  [class]="clearButtonClasses()"
1771
1796
  aria-label="Clear"
1772
1797
  (mousedown)="onClearMousedown($event)"