@tolle_/tolle-ui 18.2.29 → 18.3.0

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 (294) hide show
  1. package/esm2022/lib/accordion-item.component.mjs +5 -11
  2. package/esm2022/lib/accordion.component.mjs +4 -9
  3. package/esm2022/lib/alert-dialog-dynamic.component.mjs +12 -12
  4. package/esm2022/lib/alert-dialog.component.mjs +82 -64
  5. package/esm2022/lib/alert-dialog.service.mjs +38 -6
  6. package/esm2022/lib/alert.component.mjs +5 -11
  7. package/esm2022/lib/aspect-ratio.component.mjs +119 -0
  8. package/esm2022/lib/attachment.component.mjs +334 -0
  9. package/esm2022/lib/avatar-fallback.component.mjs +3 -3
  10. package/esm2022/lib/avatar.component.mjs +4 -9
  11. package/esm2022/lib/badge.component.mjs +4 -9
  12. package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
  13. package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
  14. package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
  15. package/esm2022/lib/breadcrumb.component.mjs +3 -3
  16. package/esm2022/lib/bubble.component.mjs +301 -0
  17. package/esm2022/lib/button.component.mjs +5 -11
  18. package/esm2022/lib/calendar.component.mjs +5 -11
  19. package/esm2022/lib/card.component.mjs +14 -43
  20. package/esm2022/lib/carousel/carousel.component.mjs +5 -12
  21. package/esm2022/lib/chain-of-thought.component.mjs +380 -0
  22. package/esm2022/lib/chart-pie.component.mjs +386 -0
  23. package/esm2022/lib/chart.component.mjs +1146 -0
  24. package/esm2022/lib/chart.service.mjs +555 -0
  25. package/esm2022/lib/checkbox.component.mjs +5 -11
  26. package/esm2022/lib/checkpoint.component.mjs +134 -0
  27. package/esm2022/lib/collapsible.component.mjs +4 -11
  28. package/esm2022/lib/combobox.component.mjs +372 -0
  29. package/esm2022/lib/command-dialog.component.mjs +164 -0
  30. package/esm2022/lib/command.component.mjs +514 -0
  31. package/esm2022/lib/command.service.mjs +130 -0
  32. package/esm2022/lib/confirmation.component.mjs +219 -0
  33. package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
  34. package/esm2022/lib/context-menu.component.mjs +460 -0
  35. package/esm2022/lib/context-menu.service.mjs +125 -0
  36. package/esm2022/lib/context.component.mjs +377 -0
  37. package/esm2022/lib/conversation.component.mjs +442 -0
  38. package/esm2022/lib/country-selector.component.mjs +5 -11
  39. package/esm2022/lib/data-table.component.mjs +59 -12
  40. package/esm2022/lib/date-picker.component.mjs +251 -198
  41. package/esm2022/lib/date-range-picker.component.mjs +224 -116
  42. package/esm2022/lib/date-time-picker.component.mjs +506 -0
  43. package/esm2022/lib/direction.component.mjs +79 -0
  44. package/esm2022/lib/dropdown-item.component.mjs +3 -3
  45. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  46. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  47. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  48. package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
  49. package/esm2022/lib/empty-state.component.mjs +4 -9
  50. package/esm2022/lib/field.component.mjs +394 -0
  51. package/esm2022/lib/hover-card.component.mjs +6 -16
  52. package/esm2022/lib/inline-citation.component.mjs +263 -0
  53. package/esm2022/lib/input-group.component.mjs +418 -0
  54. package/esm2022/lib/input.component.mjs +5 -11
  55. package/esm2022/lib/item.component.mjs +243 -0
  56. package/esm2022/lib/kbd.component.mjs +82 -0
  57. package/esm2022/lib/label.component.mjs +4 -9
  58. package/esm2022/lib/marker.component.mjs +135 -0
  59. package/esm2022/lib/masked-input.component.mjs +5 -11
  60. package/esm2022/lib/menubar.component.mjs +683 -0
  61. package/esm2022/lib/message-scroller.component.mjs +353 -0
  62. package/esm2022/lib/message-scroller.service.mjs +157 -0
  63. package/esm2022/lib/message.component.mjs +228 -0
  64. package/esm2022/lib/modal-ref.mjs +36 -7
  65. package/esm2022/lib/modal.component.mjs +42 -28
  66. package/esm2022/lib/modal.mjs +2 -29
  67. package/esm2022/lib/modal.service.mjs +26 -4
  68. package/esm2022/lib/model-selector.component.mjs +310 -0
  69. package/esm2022/lib/multi-select.component.mjs +155 -72
  70. package/esm2022/lib/native-select.component.mjs +201 -0
  71. package/esm2022/lib/navigation-menu.component.mjs +642 -0
  72. package/esm2022/lib/otp-group.component.mjs +3 -3
  73. package/esm2022/lib/otp-slot.component.mjs +4 -9
  74. package/esm2022/lib/otp.component.mjs +5 -11
  75. package/esm2022/lib/pagination.component.mjs +7 -10
  76. package/esm2022/lib/plan.component.mjs +335 -0
  77. package/esm2022/lib/popover-content.component.mjs +4 -9
  78. package/esm2022/lib/popover.component.mjs +15 -13
  79. package/esm2022/lib/progress.component.mjs +4 -9
  80. package/esm2022/lib/prompt-input.component.mjs +526 -0
  81. package/esm2022/lib/queue.component.mjs +280 -0
  82. package/esm2022/lib/radio-group.component.mjs +6 -13
  83. package/esm2022/lib/radio-item.component.mjs +4 -9
  84. package/esm2022/lib/range-calendar.component.mjs +5 -11
  85. package/esm2022/lib/reasoning.component.mjs +335 -0
  86. package/esm2022/lib/resizable-panel.component.mjs +5 -5
  87. package/esm2022/lib/resizable.component.mjs +219 -0
  88. package/esm2022/lib/segment.component.mjs +19 -9
  89. package/esm2022/lib/select-group.component.mjs +4 -8
  90. package/esm2022/lib/select-item.component.mjs +4 -9
  91. package/esm2022/lib/select-separator.component.mjs +4 -8
  92. package/esm2022/lib/select.component.mjs +133 -49
  93. package/esm2022/lib/separator.component.mjs +4 -9
  94. package/esm2022/lib/sheet.component.mjs +38 -56
  95. package/esm2022/lib/shimmer.component.mjs +84 -0
  96. package/esm2022/lib/sidebar.component.mjs +11 -1
  97. package/esm2022/lib/skeleton.component.mjs +4 -9
  98. package/esm2022/lib/sources.component.mjs +452 -0
  99. package/esm2022/lib/spinner.component.mjs +68 -0
  100. package/esm2022/lib/suggestion.component.mjs +207 -0
  101. package/esm2022/lib/switch.component.mjs +5 -11
  102. package/esm2022/lib/table.component.mjs +213 -0
  103. package/esm2022/lib/tabs.component.mjs +4 -11
  104. package/esm2022/lib/tag-input.component.mjs +5 -11
  105. package/esm2022/lib/task.component.mjs +359 -0
  106. package/esm2022/lib/textarea.component.mjs +5 -11
  107. package/esm2022/lib/theme.service.mjs +17 -6
  108. package/esm2022/lib/time-picker.component.mjs +923 -0
  109. package/esm2022/lib/toast.service.mjs +3 -2
  110. package/esm2022/lib/toaster.component.mjs +76 -39
  111. package/esm2022/lib/toggle-group.component.mjs +4 -9
  112. package/esm2022/lib/toggle.component.mjs +5 -11
  113. package/esm2022/lib/tool.component.mjs +462 -0
  114. package/esm2022/lib/typography.component.mjs +83 -0
  115. package/esm2022/lib/utils/color.mjs +140 -19
  116. package/esm2022/public-api.mjs +61 -1
  117. package/fesm2022/tolle-ui.mjs +16642 -1105
  118. package/fesm2022/tolle-ui.mjs.map +1 -1
  119. package/lib/alert-dialog-dynamic.component.d.ts +5 -0
  120. package/lib/alert-dialog.component.d.ts +23 -2
  121. package/lib/aspect-ratio.component.d.ts +20 -0
  122. package/lib/attachment.component.d.ts +101 -0
  123. package/lib/bubble.component.d.ts +112 -0
  124. package/lib/chain-of-thought.component.d.ts +166 -0
  125. package/lib/chart-pie.component.d.ts +108 -0
  126. package/lib/chart.component.d.ts +369 -0
  127. package/lib/chart.service.d.ts +222 -0
  128. package/lib/checkpoint.component.d.ts +51 -0
  129. package/lib/combobox.component.d.ts +90 -0
  130. package/lib/command-dialog.component.d.ts +53 -0
  131. package/lib/command.component.d.ts +189 -0
  132. package/lib/command.service.d.ts +67 -0
  133. package/lib/confirmation.component.d.ts +70 -0
  134. package/lib/context-menu-trigger.directive.d.ts +12 -0
  135. package/lib/context-menu.component.d.ts +41 -0
  136. package/lib/context-menu.service.d.ts +38 -0
  137. package/lib/context.component.d.ts +134 -0
  138. package/lib/conversation.component.d.ts +165 -0
  139. package/lib/data-table.component.d.ts +26 -1
  140. package/lib/date-picker.component.d.ts +72 -11
  141. package/lib/date-range-picker.component.d.ts +63 -6
  142. package/lib/date-time-picker.component.d.ts +119 -0
  143. package/lib/direction.component.d.ts +45 -0
  144. package/lib/field.component.d.ts +201 -0
  145. package/lib/inline-citation.component.d.ts +103 -0
  146. package/lib/input-group.component.d.ts +150 -0
  147. package/lib/item.component.d.ts +154 -0
  148. package/lib/kbd.component.d.ts +50 -0
  149. package/lib/marker.component.d.ts +58 -0
  150. package/lib/menubar.component.d.ts +247 -0
  151. package/lib/message-scroller.component.d.ts +159 -0
  152. package/lib/message-scroller.service.d.ts +73 -0
  153. package/lib/message.component.d.ts +128 -0
  154. package/lib/modal-ref.d.ts +6 -1
  155. package/lib/modal.component.d.ts +7 -4
  156. package/lib/modal.d.ts +9 -3
  157. package/lib/model-selector.component.d.ts +103 -0
  158. package/lib/multi-select.component.d.ts +66 -14
  159. package/lib/native-select.component.d.ts +57 -0
  160. package/lib/navigation-menu.component.d.ts +230 -0
  161. package/lib/plan.component.d.ts +120 -0
  162. package/lib/popover.component.d.ts +1 -0
  163. package/lib/prompt-input.component.d.ts +194 -0
  164. package/lib/queue.component.d.ts +106 -0
  165. package/lib/reasoning.component.d.ts +127 -0
  166. package/lib/resizable.component.d.ts +43 -0
  167. package/lib/segment.component.d.ts +13 -3
  168. package/lib/select.component.d.ts +37 -5
  169. package/lib/sheet.component.d.ts +3 -0
  170. package/lib/shimmer.component.d.ts +38 -0
  171. package/lib/sidebar.component.d.ts +2 -0
  172. package/lib/sources.component.d.ts +156 -0
  173. package/lib/spinner.component.d.ts +29 -0
  174. package/lib/suggestion.component.d.ts +96 -0
  175. package/lib/table.component.d.ts +106 -0
  176. package/lib/task.component.d.ts +151 -0
  177. package/lib/theme.service.d.ts +9 -0
  178. package/lib/time-picker.component.d.ts +243 -0
  179. package/lib/toast.service.d.ts +3 -1
  180. package/lib/toaster.component.d.ts +26 -9
  181. package/lib/tool.component.d.ts +158 -0
  182. package/lib/typography.component.d.ts +32 -0
  183. package/lib/utils/color.d.ts +19 -5
  184. package/package.json +3 -2
  185. package/preset.js +7 -1
  186. package/public-api.d.ts +50 -0
  187. package/registry/docs-content.json +8480 -1542
  188. package/registry/llms-full.txt +2679 -319
  189. package/registry/llms.txt +43 -4
  190. package/registry/manifest.json +9090 -1742
  191. package/registry/r/accordion-item.json +1 -1
  192. package/registry/r/accordion.json +1 -1
  193. package/registry/r/alert-dialog-dynamic.json +1 -1
  194. package/registry/r/alert-dialog.json +1 -1
  195. package/registry/r/alert.json +1 -1
  196. package/registry/r/attachment.json +23 -0
  197. package/registry/r/avatar-fallback.json +1 -1
  198. package/registry/r/avatar.json +1 -1
  199. package/registry/r/badge.json +1 -1
  200. package/registry/r/breadcrumb-item.json +1 -1
  201. package/registry/r/breadcrumb-link.json +1 -1
  202. package/registry/r/breadcrumb-separator.json +1 -1
  203. package/registry/r/breadcrumb.json +1 -1
  204. package/registry/r/bubble.json +23 -0
  205. package/registry/r/button.json +1 -1
  206. package/registry/r/calendar.json +1 -1
  207. package/registry/r/card.json +1 -1
  208. package/registry/r/carousel.json +1 -1
  209. package/registry/r/chain-of-thought.json +23 -0
  210. package/registry/r/chart-pie.json +30 -0
  211. package/registry/r/chart.json +28 -0
  212. package/registry/r/checkbox.json +1 -1
  213. package/registry/r/checkpoint.json +23 -0
  214. package/registry/r/collapsible.json +1 -1
  215. package/registry/r/combobox.json +31 -0
  216. package/registry/r/command-dialog.json +21 -0
  217. package/registry/r/command.json +26 -0
  218. package/registry/r/confirmation.json +23 -0
  219. package/registry/r/context-menu-trigger.json +1 -1
  220. package/registry/r/context-menu.json +1 -1
  221. package/registry/r/context.json +26 -0
  222. package/registry/r/conversation.json +23 -0
  223. package/registry/r/country-selector.json +1 -1
  224. package/registry/r/data-table.json +1 -1
  225. package/registry/r/date-picker.json +4 -4
  226. package/registry/r/date-range-picker.json +3 -3
  227. package/registry/r/date-time-picker.json +28 -0
  228. package/registry/r/direction.json +16 -0
  229. package/registry/r/dropdown-item.json +1 -1
  230. package/registry/r/dropdown-label.json +1 -1
  231. package/registry/r/dropdown-menu.json +1 -1
  232. package/registry/r/dropdown-separator.json +1 -1
  233. package/registry/r/dropdown-trigger.json +1 -1
  234. package/registry/r/empty-state.json +1 -1
  235. package/registry/r/field.json +23 -0
  236. package/registry/r/hover-card.json +1 -1
  237. package/registry/r/inline-citation.json +26 -0
  238. package/registry/r/input-group.json +23 -0
  239. package/registry/r/input.json +1 -1
  240. package/registry/r/item.json +23 -0
  241. package/registry/r/kbd.json +23 -0
  242. package/registry/r/label.json +1 -1
  243. package/registry/r/marker.json +23 -0
  244. package/registry/r/masked-input.json +1 -1
  245. package/registry/r/menubar.json +24 -0
  246. package/registry/r/message-scroller.json +26 -0
  247. package/registry/r/message.json +23 -0
  248. package/registry/r/modal.json +3 -3
  249. package/registry/r/model-selector.json +32 -0
  250. package/registry/r/multi-select.json +2 -2
  251. package/registry/r/native-select.json +23 -0
  252. package/registry/r/navigation-menu.json +24 -0
  253. package/registry/r/otp-group.json +1 -1
  254. package/registry/r/otp-slot.json +1 -1
  255. package/registry/r/otp.json +1 -1
  256. package/registry/r/pagination.json +3 -2
  257. package/registry/r/plan.json +23 -0
  258. package/registry/r/popover-content.json +1 -1
  259. package/registry/r/popover.json +1 -1
  260. package/registry/r/progress.json +1 -1
  261. package/registry/r/prompt-input.json +23 -0
  262. package/registry/r/queue.json +23 -0
  263. package/registry/r/radio-group.json +1 -1
  264. package/registry/r/radio-item.json +1 -1
  265. package/registry/r/range-calendar.json +1 -1
  266. package/registry/r/reasoning.json +23 -0
  267. package/registry/r/resizable-panel.json +1 -1
  268. package/registry/r/segment.json +1 -1
  269. package/registry/r/select-group.json +1 -1
  270. package/registry/r/select-item.json +1 -1
  271. package/registry/r/select-separator.json +1 -1
  272. package/registry/r/select.json +4 -3
  273. package/registry/r/separator.json +1 -1
  274. package/registry/r/sheet.json +1 -1
  275. package/registry/r/shimmer.json +23 -0
  276. package/registry/r/sidebar.json +1 -1
  277. package/registry/r/skeleton.json +1 -1
  278. package/registry/r/sources.json +23 -0
  279. package/registry/r/spinner.json +23 -0
  280. package/registry/r/suggestion.json +23 -0
  281. package/registry/r/switch.json +1 -1
  282. package/registry/r/table.json +21 -0
  283. package/registry/r/tabs.json +1 -1
  284. package/registry/r/tag-input.json +1 -1
  285. package/registry/r/task.json +23 -0
  286. package/registry/r/textarea.json +1 -1
  287. package/registry/r/time-picker.json +24 -0
  288. package/registry/r/toaster.json +2 -2
  289. package/registry/r/toggle-group.json +1 -1
  290. package/registry/r/toggle.json +1 -1
  291. package/registry/r/tool.json +23 -0
  292. package/registry/r/typography.json +23 -0
  293. package/registry/registry.json +915 -22
  294. package/theme.css +121 -8
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "marker",
3
+ "type": "registry:ui",
4
+ "title": "Marker",
5
+ "description": "Marker component with variant options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "marker.component.ts",
14
+ "target": "ui/marker.component.ts",
15
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst markerVariants = cva('flex w-full items-center gap-2 text-xs', {\n variants: {\n variant: {\n // System notes and tool activity — centred, quiet.\n default: 'justify-center py-1 text-muted-foreground',\n // A labelled rule between two stretches of transcript.\n separator: 'py-3 text-muted-foreground',\n // Live status such as \"Thinking…\", aligned with the transcript's leading edge.\n status: 'justify-start py-1 text-muted-foreground',\n // A failed send or a tool error.\n error: 'justify-center py-1 font-medium text-destructive',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type MarkerProps = VariantProps<typeof markerVariants>;\n\n/**\n * An inline row in a chat transcript that is not a message: a system note, a\n * streaming status, tool activity, or a labelled date separator.\n * @new\n */\n@Component({\n selector: 'tolle-marker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"variant === 'separator' ? 'separator' : isLive ? 'status' : null\"\n [attr.aria-live]=\"isLive ? 'polite' : null\">\n <span *ngIf=\"variant === 'separator'\" aria-hidden=\"true\" class=\"h-px flex-1 bg-border\"></span>\n\n <i *ngIf=\"icon\" [class]=\"iconClass\" aria-hidden=\"true\"></i>\n\n <span [class]=\"labelClass\">\n <span *ngIf=\"label\">{{ label }}</span>\n <ng-content></ng-content>\n </span>\n\n <span *ngIf=\"variant === 'separator'\" aria-hidden=\"true\" class=\"h-px flex-1 bg-border\"></span>\n </div>\n `,\n})\nexport class MarkerComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Kind of transcript marker this row represents. @default 'default' */\n @Input() variant: MarkerProps['variant'] = 'default';\n /** Text for the marker — the date on a separator, or the status wording. */\n @Input() label = '';\n /** Remixicon class for a leading icon, e.g. 'ri-tools-line'. */\n @Input() icon = '';\n /** Applies the shimmer text sweep, for live status such as \"Thinking…\". @default false */\n @Input() shimmer = false;\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(markerVariants({ variant: this.variant }), this.class);\n }\n\n /** Whether this marker announces a change to assistive technology. */\n get isLive(): boolean {\n return this.variant === 'status' || this.variant === 'error';\n }\n\n get iconClass(): string {\n return cn('shrink-0', this.icon, this.shimmer && 'animate-pulse');\n }\n\n get labelClass(): string {\n return cn(\n 'inline-flex items-center gap-1 whitespace-nowrap',\n this.variant === 'separator' && 'shrink-0 px-1 font-medium',\n this.shimmer && 'shimmer'\n );\n }\n}\n\n/**\n * Groups related markers — a run of tool calls, say — into one indented block so\n * they read as a single step in the transcript.\n */\n@Component({\n selector: 'tolle-marker-group',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex w-full flex-col gap-0.5', class)\" role=\"group\"><ng-content></ng-content></div>`,\n})\nexport class MarkerGroupComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "masked-input.component.ts",
12
12
  "target": "ui/masked-input.component.ts",
13
- "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n AfterContentChecked,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-masked-input',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => MaskedInputComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n <label *ngIf=\"label\" [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div [class]=\"computedContainerClass\" (click)=\"focusInput()\">\n <!-- Prefix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputEl\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"displayValue\"\n (input)=\"onInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\" />\n\n <!-- Suffix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n <p *ngIf=\"error && errorMessage\" [id]=\"id + '-error'\" class=\"px-1 text-xs text-destructive\">\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class MaskedInputComponent implements ControlValueAccessor, AfterContentChecked {\n @Input() id: string = `masked-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() mask: string = '';\n @Input() placeholder = '';\n @Input() type = 'text';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() class = '';\n @Input() containerClass: string = '';\n @Input() error: boolean = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() returnRaw = false;\n @Input() hideHintOnFocus: boolean = true;\n @Input() externalFocused: boolean | undefined;\n\n @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>;\n\n hasPrefix = false;\n hasSuffix = false;\n displayValue = '';\n isFocused: boolean = false;\n\n private tokens: { [key: string]: RegExp } = {\n '0': /\\d/,\n '9': /\\d/,\n a: /[a-z]/i,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n };\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(\n private el: ElementRef,\n private cdr: ChangeDetectorRef\n ) {}\n\n ngAfterContentChecked() {\n const prefix = this.el.nativeElement.querySelector('[prefix]');\n const suffix = this.el.nativeElement.querySelector('[suffix]');\n\n if (this.hasPrefix !== !!prefix || this.hasSuffix !== !!suffix) {\n this.hasPrefix = !!prefix;\n this.hasSuffix = !!suffix;\n this.cdr.detectChanges();\n }\n }\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get computedContainerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n // Base styles\n 'group relative flex items-center w-full border transition-all duration-200',\n 'bg-background border-input shadow-sm',\n\n // Sizing\n this.size === 'xs' && 'h-8 px-2 gap-1.5 text-xs',\n this.size === 'sm' && 'h-9 px-3 gap-2 text-sm',\n this.size === 'default' && 'h-10 px-3 gap-2 text-sm',\n this.size === 'lg' && 'h-11 px-4 gap-3 text-base',\n\n // Rounded corners\n 'rounded-md',\n\n // Focus state\n !(this.readonly || this.disabled) && [\n 'focus-within:ring-4',\n 'focus-within:ring-ring/30',\n 'focus-within:ring-offset-0',\n 'focus-within:shadow-none',\n this.error ? 'focus-within:border-destructive/80' : 'focus-within:border-primary/80',\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && 'focus-within:ring-destructive/30',\n ],\n\n // Disabled state\n this.disabled && ['cursor-not-allowed opacity-50', 'border-opacity-50'],\n\n // Readonly state\n this.readonly && ['cursor-default', 'border-dashed'],\n\n this.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n 'flex-1 bg-transparent border-none p-0',\n 'placeholder:text-muted-foreground',\n\n // Remove all default focus styles\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n\n // Text sizing\n this.size === 'xs' && 'text-xs',\n this.size === 'sm' && 'text-sm',\n this.size === 'default' && 'text-sm',\n this.size === 'lg' && 'text-base',\n\n // Cursor states\n this.disabled && 'cursor-not-allowed',\n this.readonly && 'cursor-default',\n\n // Text color\n 'text-foreground',\n\n // Selection color\n 'selection:bg-primary/20 selection:text-foreground',\n\n this.class\n );\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputEl) {\n this.inputEl.nativeElement.focus();\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n // --- Masking Logic ---\n onInput(event: Event) {\n if (this.readonly || this.disabled) return;\n const input = event.target as HTMLInputElement;\n const raw = this.unmask(input.value);\n const masked = this.applyMask(raw);\n this.displayValue = masked;\n input.value = masked;\n this.returnRaw ? this.onChange(raw) : this.onChange(masked);\n }\n\n private applyMask(rawValue: string): string {\n let rawIndex = 0;\n let formatted = '';\n for (let i = 0; i < this.mask.length; i++) {\n if (rawIndex >= rawValue.length) break;\n const maskChar = this.mask[i];\n const rawChar = rawValue[rawIndex];\n if (this.tokens[maskChar]) {\n if (this.tokens[maskChar].test(rawChar)) {\n formatted += rawChar;\n rawIndex++;\n } else {\n rawIndex++;\n i--;\n }\n } else {\n formatted += maskChar;\n if (rawChar === maskChar) rawIndex++;\n }\n }\n return formatted;\n }\n\n private unmask(val: string): string {\n return val.replace(/[^a-zA-Z0-9]/g, '');\n }\n\n writeValue(value: any): void {\n this.displayValue = value ? this.applyMask(this.unmask(value.toString())) : '';\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
13
+ "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n AfterContentChecked,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-masked-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => MaskedInputComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n <label *ngIf=\"label\" [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div [class]=\"computedContainerClass\" (click)=\"focusInput()\">\n <!-- Prefix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputEl\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"displayValue\"\n (input)=\"onInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\" />\n\n <!-- Suffix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n <p *ngIf=\"error && errorMessage\" [id]=\"id + '-error'\" class=\"px-1 text-xs text-destructive\">\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class MaskedInputComponent implements ControlValueAccessor, AfterContentChecked {\n @Input() id: string = `masked-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() mask: string = '';\n @Input() placeholder = '';\n @Input() type = 'text';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() class = '';\n @Input() containerClass: string = '';\n @Input() error: boolean = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() returnRaw = false;\n @Input() hideHintOnFocus: boolean = true;\n @Input() externalFocused: boolean | undefined;\n\n @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>;\n\n hasPrefix = false;\n hasSuffix = false;\n displayValue = '';\n isFocused: boolean = false;\n\n private tokens: { [key: string]: RegExp } = {\n '0': /\\d/,\n '9': /\\d/,\n a: /[a-z]/i,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n };\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(\n private el: ElementRef,\n private cdr: ChangeDetectorRef\n ) {}\n\n ngAfterContentChecked() {\n const prefix = this.el.nativeElement.querySelector('[prefix]');\n const suffix = this.el.nativeElement.querySelector('[suffix]');\n\n if (this.hasPrefix !== !!prefix || this.hasSuffix !== !!suffix) {\n this.hasPrefix = !!prefix;\n this.hasSuffix = !!suffix;\n this.cdr.detectChanges();\n }\n }\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get computedContainerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n // Base styles\n 'group relative flex items-center w-full border transition-all duration-200',\n 'bg-background border-input shadow-sm',\n\n // Sizing\n this.size === 'xs' && 'h-8 px-2 gap-1.5 text-xs',\n this.size === 'sm' && 'h-9 px-3 gap-2 text-sm',\n this.size === 'default' && 'h-10 px-3 gap-2 text-sm',\n this.size === 'lg' && 'h-11 px-4 gap-3 text-base',\n\n // Rounded corners\n 'rounded-md',\n\n // Focus state\n !(this.readonly || this.disabled) && [\n 'focus-within:ring-4',\n 'focus-within:ring-ring/30',\n 'focus-within:ring-offset-0',\n 'focus-within:shadow-none',\n this.error ? 'focus-within:border-destructive/80' : 'focus-within:border-primary/80',\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && 'focus-within:ring-destructive/30',\n ],\n\n // Disabled state\n this.disabled && ['cursor-not-allowed opacity-50', 'border-opacity-50'],\n\n // Readonly state\n this.readonly && ['cursor-default', 'border-dashed'],\n\n this.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n 'flex-1 bg-transparent border-none p-0',\n 'placeholder:text-muted-foreground',\n\n // Remove all default focus styles\n 'focus:outline-none focus:ring-0 focus:shadow-none',\n\n // Text sizing\n this.size === 'xs' && 'text-xs',\n this.size === 'sm' && 'text-sm',\n this.size === 'default' && 'text-sm',\n this.size === 'lg' && 'text-base',\n\n // Cursor states\n this.disabled && 'cursor-not-allowed',\n this.readonly && 'cursor-default',\n\n // Text color\n 'text-foreground',\n\n // Selection color\n 'selection:bg-primary/20 selection:text-foreground',\n\n this.class\n );\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputEl) {\n this.inputEl.nativeElement.focus();\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n // --- Masking Logic ---\n onInput(event: Event) {\n if (this.readonly || this.disabled) return;\n const input = event.target as HTMLInputElement;\n const raw = this.unmask(input.value);\n const masked = this.applyMask(raw);\n this.displayValue = masked;\n input.value = masked;\n this.returnRaw ? this.onChange(raw) : this.onChange(masked);\n }\n\n private applyMask(rawValue: string): string {\n let rawIndex = 0;\n let formatted = '';\n for (let i = 0; i < this.mask.length; i++) {\n if (rawIndex >= rawValue.length) break;\n const maskChar = this.mask[i];\n const rawChar = rawValue[rawIndex];\n if (this.tokens[maskChar]) {\n if (this.tokens[maskChar].test(rawChar)) {\n formatted += rawChar;\n rawIndex++;\n } else {\n rawIndex++;\n i--;\n }\n } else {\n formatted += maskChar;\n if (rawChar === maskChar) rawIndex++;\n }\n }\n return formatted;\n }\n\n private unmask(val: string): string {\n return val.replace(/[^a-zA-Z0-9]/g, '');\n }\n\n writeValue(value: any): void {\n this.displayValue = value ? this.applyMask(this.unmask(value.toString())) : '';\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "menubar",
3
+ "type": "registry:ui",
4
+ "title": "Menubar",
5
+ "description": "Menubar component with size options.",
6
+ "category": "navigation",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom",
10
+ "class-variance-authority"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "menubar.component.ts",
15
+ "target": "ui/menubar.component.ts",
16
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, inject } from '@angular/core';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Contract a `tolle-menubar-menu` fulfils so `MenubarService` can drive focus and\n * keyboard navigation without importing the component (which would be circular).\n */\nexport interface MenubarMenuRef {\n /** Stable id, unique per menu instance. */\n readonly id: string;\n /** The menu's trigger button, once rendered. */\n triggerElement: HTMLElement | null;\n /** The menu's open panel, or `null` while closed. */\n contentElement: HTMLElement | null;\n}\n\nlet menubarMenuCounter = 0;\n\n/**\n * Coordinates the menus of a single `tolle-menubar`. Provided by\n * `MenubarComponent`, injected by every descendant menu/trigger/content.\n *\n * The defining behaviour of a menu bar lives here: once *any* menu is open,\n * hovering a different trigger switches to it immediately (`hoverEnter`), while\n * hovering with everything closed does nothing.\n */\n@Injectable()\nexport class MenubarService {\n private openIdSource = new BehaviorSubject<string | null>(null);\n /** Emits the id of the currently open menu, or `null` when all are closed. */\n readonly openId$ = this.openIdSource.asObservable();\n\n private sizeSource = new BehaviorSubject<'sm' | 'default' | 'lg'>('default');\n /**\n * Emits the bar's size so OnPush triggers re-render when it changes. Reading\n * the parent component's property directly would not mark them dirty.\n */\n readonly size$ = this.sizeSource.asObservable();\n\n /** The bar's current size scale. */\n get size(): 'sm' | 'default' | 'lg' {\n return this.sizeSource.value;\n }\n\n setSize(size: 'sm' | 'default' | 'lg'): void {\n if (this.sizeSource.value !== size) this.sizeSource.next(size);\n }\n\n /** Registered menus in DOM (registration) order. */\n private menus: MenubarMenuRef[] = [];\n\n /** Id of the open menu, or `null`. */\n get openId(): string | null {\n return this.openIdSource.value;\n }\n\n /** True while one of the bar's menus is open. */\n get isAnyOpen(): boolean {\n return this.openIdSource.value !== null;\n }\n\n register(menu: MenubarMenuRef): void {\n if (this.menus.indexOf(menu) === -1) this.menus.push(menu);\n }\n\n unregister(menu: MenubarMenuRef): void {\n const i = this.menus.indexOf(menu);\n if (i > -1) this.menus.splice(i, 1);\n if (this.openId === menu.id) this.close();\n }\n\n isOpen(id: string): boolean {\n return this.openId === id;\n }\n\n open(id: string): void {\n if (this.openId !== id) this.openIdSource.next(id);\n }\n\n close(): void {\n if (this.openId !== null) this.openIdSource.next(null);\n }\n\n toggle(id: string): void {\n if (this.openId === id) this.close();\n else this.open(id);\n }\n\n /**\n * Pointer entered a trigger. Only switches menus when the bar is already\n * \"active\" (something open) — this is what makes a menu bar feel native.\n */\n hoverEnter(id: string): void {\n if (this.isAnyOpen) this.open(id);\n }\n\n /** Moves focus to the previous/next menu, keeping the open state. */\n moveMenu(delta: number): void {\n if (!this.menus.length) return;\n const current = this.menus.findIndex(m => m.id === this.openId);\n const from = current > -1 ? current : this.indexOfFocusedTrigger();\n const next =\n from < 0\n ? delta > 0\n ? 0\n : this.menus.length - 1\n : (from + delta + this.menus.length) % this.menus.length;\n const target = this.menus[next];\n if (this.isAnyOpen) this.open(target.id);\n target.triggerElement?.focus();\n }\n\n /** Moves focus between the enabled items of the currently open menu. */\n moveItem(delta: number): void {\n const items = this.openItems();\n if (!items.length) return;\n const current = items.indexOf(document.activeElement as HTMLElement);\n const next =\n current < 0\n ? delta > 0\n ? 0\n : items.length - 1\n : (current + delta + items.length) % items.length;\n items[next].focus();\n }\n\n /** Focuses the trigger of the given menu (used when Escape closes a menu). */\n focusTrigger(id: string): void {\n this.menus.find(m => m.id === id)?.triggerElement?.focus();\n }\n\n private openItems(): HTMLElement[] {\n const content = this.menus.find(m => m.id === this.openId)?.contentElement;\n if (!content) return [];\n return Array.from(\n content.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([data-disabled])')\n );\n }\n\n private indexOfFocusedTrigger(): number {\n const active = document.activeElement;\n return this.menus.findIndex(m => !!m.triggerElement && m.triggerElement === active);\n }\n}\n\nconst menubarVariants = cva(\n 'flex items-center gap-1 rounded-md border border-border bg-background shadow-sm',\n {\n variants: {\n size: {\n sm: 'h-8 p-0.5',\n default: 'h-10 p-1',\n lg: 'h-12 p-1.5',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type MenubarProps = VariantProps<typeof menubarVariants>;\n\nconst menubarTriggerVariants = cva(\n 'flex select-none items-center rounded-sm font-medium outline-none transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground ' +\n 'data-[state=open]:bg-accent data-[state=open]:text-accent-foreground ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-3 text-sm',\n lg: 'h-9 px-4 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type MenubarTriggerProps = VariantProps<typeof menubarTriggerVariants>;\n\nconst menubarItemVariants = cva(\n 'relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors ' +\n 'focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground ' +\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n {\n variants: {\n variant: {\n default: 'text-popover-foreground',\n destructive: 'text-destructive focus:bg-destructive/10 hover:bg-destructive/10',\n },\n inset: {\n true: 'pl-8',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n inset: false,\n },\n }\n);\n\nexport type MenubarItemProps = VariantProps<typeof menubarItemVariants>;\n\n/**\n * Desktop-application style menu bar.\n *\n * ```html\n * <tolle-menubar>\n * <tolle-menubar-menu>\n * <tolle-menubar-trigger>File</tolle-menubar-trigger>\n * <tolle-menubar-content>\n * <tolle-menubar-label>Document</tolle-menubar-label>\n * <tolle-menubar-item (select)=\"onNew()\">New</tolle-menubar-item>\n * <tolle-menubar-separator />\n * <tolle-menubar-item variant=\"destructive\">Delete</tolle-menubar-item>\n * </tolle-menubar-content>\n * </tolle-menubar-menu>\n * </tolle-menubar>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-menubar',\n standalone: true,\n imports: [],\n providers: [MenubarService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: {\n role: 'menubar',\n '[class]': 'computedClass',\n '(keydown)': 'onKeydown($event)',\n },\n template: `<ng-content></ng-content>`,\n})\nexport class MenubarComponent implements OnInit, OnChanges, OnDestroy {\n /** Height/padding scale, inherited by every trigger in the bar. @default 'default' */\n @Input() size: MenubarProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the id of the menu that opened, or `null` when the bar closes. */\n @Output() openChange = new EventEmitter<string | null>();\n\n protected readonly service = inject(MenubarService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly sub = new Subscription();\n private listening = false;\n\n get computedClass(): string {\n return cn(menubarVariants({ size: this.size }), this.class);\n }\n\n ngOnChanges(): void {\n // Push size into the service so OnPush triggers get told about it.\n this.service.setSize(this.size ?? 'default');\n }\n\n ngOnInit(): void {\n this.service.setSize(this.size ?? 'default');\n this.sub.add(\n this.service.openId$.subscribe(id => {\n this.openChange.emit(id);\n if (id !== null) this.startListening();\n else this.stopListening();\n })\n );\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n switch (event.key) {\n case 'ArrowRight':\n event.preventDefault();\n this.service.moveMenu(1);\n break;\n case 'ArrowLeft':\n event.preventDefault();\n this.service.moveMenu(-1);\n break;\n case 'ArrowDown':\n if (!this.service.isAnyOpen) break;\n event.preventDefault();\n this.service.moveItem(1);\n break;\n case 'ArrowUp':\n if (!this.service.isAnyOpen) break;\n event.preventDefault();\n this.service.moveItem(-1);\n break;\n case 'Escape': {\n const openId = this.service.openId;\n if (openId === null) break;\n event.preventDefault();\n this.service.close();\n this.service.focusTrigger(openId);\n break;\n }\n default:\n break;\n }\n }\n\n // Capture phase so nested containers that stopPropagation() on their root\n // (modal, sheet) cannot prevent the outside click from reaching us.\n private outsideClick = (event: Event) => {\n if (!this.host.nativeElement.contains(event.target as Node)) this.service.close();\n };\n\n private startListening(): void {\n if (this.listening) return;\n this.listening = true;\n document.addEventListener('pointerdown', this.outsideClick, true);\n }\n\n private stopListening(): void {\n if (!this.listening) return;\n this.listening = false;\n document.removeEventListener('pointerdown', this.outsideClick, true);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopListening();\n }\n}\n\n/** A single menu in the bar — wraps one trigger and its content panel. */\n@Component({\n selector: 'tolle-menubar-menu',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n // Presentational so the menubar \"owns\" the trigger's menuitem role directly\n // even though this wrapper element sits in between.\n host: { role: 'none', '[class]': \"'relative inline-flex'\" },\n template: `<ng-content></ng-content>`,\n})\nexport class MenubarMenuComponent implements MenubarMenuRef, OnInit, OnDestroy {\n /** Explicit id for the menu; one is generated when omitted. */\n @Input() id = `tolle-menubar-menu-${menubarMenuCounter++}`;\n\n triggerElement: HTMLElement | null = null;\n contentElement: HTMLElement | null = null;\n\n readonly service = inject(MenubarService);\n\n get triggerId(): string {\n return `${this.id}-trigger`;\n }\n\n get isOpen(): boolean {\n return this.service.isOpen(this.id);\n }\n\n ngOnInit(): void {\n this.service.register(this);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this);\n }\n}\n\n/** Button that opens its menu on click, and on hover once the bar is active. */\n@Component({\n selector: 'tolle-menubar-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { role: 'none' },\n template: `\n <button\n #trigger\n type=\"button\"\n role=\"menuitem\"\n [id]=\"menu.triggerId\"\n [disabled]=\"disabled\"\n aria-haspopup=\"menu\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? menu.id + '-content' : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onMouseEnter()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class MenubarTriggerComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Prevents the menu from opening. @default false */\n @Input() disabled = false;\n /** Overrides the size inherited from `tolle-menubar`. */\n @Input() size?: MenubarTriggerProps['size'];\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly menu = inject(MenubarMenuComponent);\n private readonly service = inject(MenubarService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n constructor() {\n this.sub.add(this.service.openId$.subscribe(() => this.cdr.markForCheck()));\n // The bar's size is not an input of this component, so nothing else would\n // mark an OnPush trigger dirty when it changes.\n this.sub.add(this.service.size$.subscribe(() => this.cdr.markForCheck()));\n }\n\n @ViewChild('trigger') set triggerRef(ref: ElementRef<HTMLButtonElement> | undefined) {\n this.menu.triggerElement = ref?.nativeElement ?? null;\n }\n\n protected get isOpen(): boolean {\n return this.menu.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(menubarTriggerVariants({ size: this.size ?? this.service.size }), this.class);\n }\n\n protected onClick(): void {\n if (this.disabled) return;\n this.service.toggle(this.menu.id);\n }\n\n protected onMouseEnter(): void {\n if (this.disabled) return;\n this.service.hoverEnter(this.menu.id);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n}\n\n/** The dropdown panel of a menu. Rendered only while its menu is open. */\n@Component({\n selector: 'tolle-menubar-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div\n #panel\n role=\"menu\"\n aria-orientation=\"vertical\"\n [id]=\"menu.id + '-content'\"\n [attr.aria-labelledby]=\"menu.triggerId\"\n data-state=\"open\"\n [class]=\"computedClass\"\n style=\"position: fixed; top: 0; left: 0;\"\n >\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class MenubarContentComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Preferred side of the trigger to render on; flips when space is tight. @default 'bottom-start' */\n @Input() placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' = 'bottom-start';\n /** Gap in pixels between the trigger and the panel. @default 4 */\n @Input() sideOffset = 4;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly menu = inject(MenubarMenuComponent);\n private readonly service = inject(MenubarService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n private cleanup?: () => void;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('panel') set panelRef(ref: ElementRef<HTMLElement> | undefined) {\n const el = ref?.nativeElement ?? null;\n this.menu.contentElement = el;\n this.stopPositioning();\n if (el) this.startPositioning(el);\n }\n\n protected get isOpen(): boolean {\n return this.menu.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(\n 'z-[1000] min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1',\n 'text-popover-foreground shadow-md animate-in fade-in zoom-in-95',\n this.class\n );\n }\n\n private startPositioning(panel: HTMLElement): void {\n const trigger = this.menu.triggerElement;\n if (!trigger) return;\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: this.placement,\n middleware: [offset(this.sideOffset), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(panel.style, { left: `${x}px`, top: `${y}px` });\n });\n });\n }\n\n private stopPositioning(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopPositioning();\n }\n}\n\n/** A selectable row inside a menu. Closes the bar when activated. */\n@Component({\n selector: 'tolle-menubar-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n role=\"menuitem\"\n [attr.tabindex]=\"disabled ? null : -1\"\n [attr.data-disabled]=\"disabled ? '' : null\"\n [attr.aria-disabled]=\"disabled || null\"\n [class]=\"computedClass\"\n (click)=\"activate($event)\"\n (keydown.enter)=\"activate($event)\"\n (keydown.space)=\"activate($event)\"\n >\n <ng-content select=\"[prefix]\"></ng-content>\n <span class=\"flex-1 truncate\"><ng-content></ng-content></span>\n <ng-content select=\"[shortcut]\"></ng-content>\n </div>\n `,\n})\nexport class MenubarItemComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Visual style of the item. @default 'default' */\n @Input() variant: MenubarItemProps['variant'] = 'default';\n /** Adds left padding so the label lines up with items that have icons. @default false */\n @Input() inset = false;\n /** Blocks activation and dims the item. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the item via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the item is clicked or activated with Enter/Space. */\n @Output() select = new EventEmitter<Event>();\n\n private readonly service = inject(MenubarService);\n\n protected get computedClass(): string {\n return cn(\n menubarItemVariants({ variant: this.variant, inset: this.inset }),\n this.class\n );\n }\n\n protected activate(event: Event): void {\n if (this.disabled) return;\n event.preventDefault();\n this.select.emit(event);\n this.service.close();\n }\n}\n\n/** A non-interactive heading inside a menu. */\n@Component({\n selector: 'tolle-menubar-label',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset ? 'pl-8' : '', class)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MenubarLabelComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Adds left padding so the label lines up with items that have icons. @default false */\n @Input() inset = false;\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n\n/** A horizontal rule between groups of menu items. */\n@Component({\n selector: 'tolle-menubar-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"separator\" aria-orientation=\"horizontal\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>`,\n})\nexport class MenubarSeparatorComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the separator via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n"
17
+ },
18
+ {
19
+ "path": "utils/cn.ts",
20
+ "target": "ui/utils/cn.ts",
21
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "message-scroller",
3
+ "type": "registry:ui",
4
+ "title": "Message Scroller",
5
+ "description": "Message Scroller component.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "message-scroller.component.ts",
12
+ "target": "ui/message-scroller.component.ts",
13
+ "content": "import { Component, Directive, Input, Output, EventEmitter, ElementRef, NgZone, ChangeDetectionStrategy, ChangeDetectorRef, AfterViewInit, OnInit, OnDestroy, inject, OnChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cn } from './utils/cn';\nimport { MessageScrollerService, ScrollStartPosition } from './message-scroller.service';\n\n/**\n * A scroll container built for conversations, where content streams in while\n * the reader is still reading.\n *\n * A plain overflow-scroll div gets this wrong in both directions: it either\n * pins to the bottom and yanks the page mid-sentence, or it never follows and\n * the reader misses the reply. This handles the cases that are easy to get\n * wrong — following the live edge only while the reader is already there,\n * opening a saved thread at the newest turn rather than the absolute end, and\n * holding position when older history loads in above.\n *\n * ```html\n * <tolle-message-scroller startPosition=\"last-anchor\">\n * <tolle-message-scroller-viewport>\n * <tolle-message-scroller-content>\n * <div tolleMessageScrollerItem [scrollAnchor]=\"turn.isNewTurn\" *ngFor=\"let turn of turns\">\n * …\n * </div>\n * </tolle-message-scroller-content>\n * </tolle-message-scroller-viewport>\n * <tolle-message-scroller-button />\n * </tolle-message-scroller>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-message-scroller',\n // The host sits between a consumer-sized parent and the templated flex\n // frame below; display:block would drop the parent's height and the\n // frame's `flex-1 min-h-0` would have nothing to size against.\n styles: [':host { display: flex; flex-direction: column; height: 100%; min-height: 0; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [MessageScrollerService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"cn('relative flex min-h-0 flex-1 flex-col overflow-hidden', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /**\n * Where to land when the transcript first renders. `last-anchor` opens at the\n * newest turn so a long answer starts from its first line.\n * @default 'last-anchor'\n */\n @Input() startPosition: ScrollStartPosition = 'last-anchor';\n /**\n * Pixels of the previous turn kept visible above an anchored turn, so the\n * reader can see what the reply is responding to. @default 48\n */\n @Input() scrollPreviousItemPeek = 48;\n /**\n * Distance from the bottom, in px, still treated as the live edge.\n * @default 32\n */\n @Input() threshold = 32;\n /** Extra Tailwind classes merged onto the frame via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the reader arrives at, or leaves, the live edge. */\n @Output() atBottomChange = new EventEmitter<boolean>();\n\n private readonly service = inject(MessageScrollerService);\n private readonly sub = new Subscription();\n protected cn = cn;\n\n ngOnInit(): void {\n this.service.threshold = this.threshold;\n this.sub.add(this.service.atBottom$.subscribe((v) => this.atBottomChange.emit(v)));\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** Jumps to the newest content and resumes following. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n this.service.scrollToBottom(behavior);\n }\n}\n\n/**\n * The scrolling element. Watches its own size and its content's size so it can\n * follow streamed text and hold position when history is prepended.\n */\n@Component({\n selector: 'tolle-message-scroller-viewport',\n // Same reasoning as tolle-message-scroller: this host is the flex item the\n // scrollable inner div sizes against, so it must join the flex chain itself.\n styles: [':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div #viewport [class]=\"cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerViewportComponent implements OnChanges, AfterViewInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the viewport via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageScrollerService);\n private readonly scroller = inject(MessageScrollerComponent);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly zone = inject(NgZone);\n\n private viewportEl: HTMLElement | null = null;\n private resizeObserver?: ResizeObserver;\n private mutationObserver?: MutationObserver;\n private onScroll = () => this.service.measure();\n /** scrollHeight captured before each DOM mutation, to detect prepends. */\n private lastScrollHeight = 0;\n private lastScrollTop = 0;\n protected cn = cn;\n\n ngAfterViewInit(): void {\n const el = this.host.nativeElement.querySelector<HTMLElement>('div');\n if (!el) return;\n this.viewportEl = el;\n this.service.registerViewport(el);\n\n // Scroll fires at frame rate; keeping the listener outside Angular means we\n // only re-enter change detection when a tracked boolean actually flips.\n this.zone.runOutsideAngular(() => {\n el.addEventListener('scroll', this.onScroll, { passive: true });\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => this.service.handleContentGrowth());\n this.resizeObserver.observe(el);\n const content = el.firstElementChild;\n if (content) this.resizeObserver.observe(content);\n }\n\n if (typeof MutationObserver !== 'undefined') {\n this.mutationObserver = new MutationObserver(() => this.afterMutation());\n this.mutationObserver.observe(el, { childList: true, subtree: true, characterData: true });\n }\n });\n\n this.lastScrollHeight = el.scrollHeight;\n\n // Wait a frame so projected content has laid out before positioning.\n setTimeout(() => {\n this.service.applyStartPosition(\n this.scroller.startPosition,\n this.scroller.scrollPreviousItemPeek\n );\n this.lastScrollHeight = el.scrollHeight;\n });\n }\n\n ngOnDestroy(): void {\n this.viewportEl?.removeEventListener('scroll', this.onScroll);\n this.resizeObserver?.disconnect();\n this.mutationObserver?.disconnect();\n }\n\n /**\n * Distinguishes content appended below (stream) from content inserted above\n * (history). Growth while the reader has NOT moved but the scrollable area\n * above them has, means a prepend — restore their position rather than let\n * the view lurch.\n */\n private afterMutation(): void {\n const el = this.viewportEl;\n if (!el) return;\n\n const grew = el.scrollHeight - this.lastScrollHeight;\n const readerMoved = el.scrollTop !== this.lastScrollTop;\n\n if (grew > 0 && !readerMoved && !this.service.isFollowing && el.scrollTop > 0) {\n this.service.preserveOnPrepend(this.lastScrollHeight);\n } else if (grew > 0) {\n this.service.handleContentGrowth();\n }\n\n this.lastScrollHeight = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.service.measure();\n }\n}\n\n/** The transcript itself. Announced politely so streamed replies reach screen readers. */\n@Component({\n selector: 'tolle-message-scroller-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions text\"\n [class]=\"cn('flex flex-col gap-4 p-4', class)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class MessageScrollerContentComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the transcript via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Marks one row of the transcript. Set `scrollAnchor` on the row that begins a\n * conversation turn so `startPosition=\"last-anchor\"` can open there.\n */\n@Directive({\n selector: '[tolleMessageScrollerItem]',\n standalone: true,\n host: { '[attr.data-scroll-anchor]': `scrollAnchor ? '' : null` },\n})\nexport class MessageScrollerItemDirective implements OnInit, OnDestroy {\n /** Marks this row as the start of a turn. @default false */\n @Input() scrollAnchor = false;\n\n private readonly service = inject(MessageScrollerService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n\n ngOnInit(): void {\n if (this.scrollAnchor) this.service.registerAnchor(this.host.nativeElement);\n }\n\n ngOnDestroy(): void {\n this.service.unregisterAnchor(this.host.nativeElement);\n }\n\n /** Brings this row into view — use for jump-to-message. */\n scrollIntoView(behavior: ScrollBehavior = 'smooth'): void {\n this.service.scrollToItem(this.host.nativeElement, behavior);\n }\n}\n\n/** Floating \"jump to latest\" control. Hidden while the reader is already at the edge. */\n@Component({\n selector: 'tolle-message-scroller-button',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n *ngIf=\"!atBottom\"\n type=\"button\"\n [attr.aria-label]=\"ariaLabel\"\n [class]=\"cn(\n 'absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full',\n 'border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground shadow-md',\n 'transition-colors hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n class\n )\"\n (click)=\"jump()\"\n >\n <i class=\"ri-arrow-down-line\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class MessageScrollerButtonComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Accessible name for the jump control. @default 'Jump to latest messages' */\n @Input() ariaLabel = 'Jump to latest messages';\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageScrollerService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n atBottom = true;\n protected cn = cn;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.atBottom$.subscribe((v) => {\n this.atBottom = v;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n jump(): void {\n this.service.scrollToBottom();\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "message-scroller.service.ts",
17
+ "target": "ui/message-scroller.service.ts",
18
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** Where a freshly-mounted scroller should start. */\nexport type ScrollStartPosition = 'top' | 'bottom' | 'last-anchor';\n\n/**\n * Scroll state for one `tolle-message-scroller`, shared with its viewport,\n * items and buttons.\n *\n * The governing rule is: **never move the reader against their intent.** Every\n * automatic scroll here is gated on the reader already being at the live edge.\n * The moment they scroll up, auto-scroll switches off and stays off until they\n * come back down or explicitly jump.\n *\n * Positions are read from and written to the DOM directly rather than mirrored\n * into component state — scroll events fire far too often to run change\n * detection on, and the values would be stale by the time they landed.\n */\n@Injectable()\nexport class MessageScrollerService {\n /** The scrollable element, set by the viewport once it renders. */\n private viewport: HTMLElement | null = null;\n\n /** Rows registered as the start of a conversation turn, in DOM order. */\n private anchors: HTMLElement[] = [];\n\n private readonly atBottomSubject = new BehaviorSubject<boolean>(true);\n /** True while the reader is at (or within `threshold` of) the live edge. */\n readonly atBottom$ = this.atBottomSubject.asObservable();\n\n private readonly followingSubject = new BehaviorSubject<boolean>(true);\n /**\n * True while new content should pull the view along. Set false the instant\n * the reader scrolls away from the bottom.\n */\n readonly following$ = this.followingSubject.asObservable();\n\n /** Distance from the bottom, in px, still considered \"at the live edge\". */\n threshold = 32;\n\n get atBottom(): boolean {\n return this.atBottomSubject.value;\n }\n\n get isFollowing(): boolean {\n return this.followingSubject.value;\n }\n\n registerViewport(el: HTMLElement): void {\n this.viewport = el;\n }\n\n registerAnchor(el: HTMLElement): void {\n if (!this.anchors.includes(el)) this.anchors.push(el);\n }\n\n unregisterAnchor(el: HTMLElement): void {\n const i = this.anchors.indexOf(el);\n if (i > -1) this.anchors.splice(i, 1);\n }\n\n /**\n * Recomputes whether we're at the live edge. Called from the viewport's\n * scroll handler, which runs outside Angular — this only pushes through the\n * subjects when the boolean actually flips, so we re-enter change detection\n * once per state change instead of once per scroll event.\n */\n measure(): void {\n const el = this.viewport;\n if (!el) return;\n\n const distance = el.scrollHeight - el.scrollTop - el.clientHeight;\n const atBottom = distance <= this.threshold;\n\n if (atBottom !== this.atBottomSubject.value) this.atBottomSubject.next(atBottom);\n\n // Leaving the bottom stops following; returning resumes it. This is the\n // whole \"don't fight the reader\" contract in two lines.\n if (atBottom !== this.followingSubject.value) this.followingSubject.next(atBottom);\n }\n\n /** Jumps to the live edge and resumes following. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n const el = this.viewport;\n if (!el) return;\n el.scrollTo({ top: el.scrollHeight, behavior });\n if (!this.followingSubject.value) this.followingSubject.next(true);\n if (!this.atBottomSubject.value) this.atBottomSubject.next(true);\n }\n\n /** Scrolls a specific row into view, e.g. jump-to-message from a search result. */\n scrollToItem(el: HTMLElement, behavior: ScrollBehavior = 'smooth'): void {\n el.scrollIntoView({ behavior, block: 'start' });\n }\n\n /**\n * Positions the transcript on open. `last-anchor` puts the newest turn at the\n * top of the viewport instead of dumping the reader at the absolute end, so a\n * long streamed answer starts from its beginning — with `peek` px of the\n * previous turn left visible for context.\n */\n applyStartPosition(position: ScrollStartPosition, peek: number): void {\n const el = this.viewport;\n if (!el) return;\n\n if (position === 'top') {\n el.scrollTop = 0;\n this.measure();\n return;\n }\n\n if (position === 'last-anchor') {\n const anchor = this.lastAnchorInDomOrder();\n if (anchor) {\n el.scrollTop = Math.max(0, anchor.offsetTop - el.offsetTop - peek);\n this.measure();\n return;\n }\n // No turns marked — fall through to the bottom.\n }\n\n el.scrollTop = el.scrollHeight;\n this.measure();\n }\n\n /**\n * Content grew. Follows only if the reader was already at the edge; uses\n * 'auto' so streamed tokens don't queue a smooth animation per chunk.\n */\n handleContentGrowth(): void {\n if (!this.followingSubject.value) {\n // Not following, but the scrollbar just changed size — re-measure so the\n // jump button's visibility stays honest.\n this.measure();\n return;\n }\n const el = this.viewport;\n if (!el) return;\n el.scrollTop = el.scrollHeight;\n }\n\n /**\n * History was prepended above the current view. Restores the reader's exact\n * position by re-adding the height the new content introduced — otherwise the\n * transcript visibly lurches downward as older messages load in.\n */\n preserveOnPrepend(previousScrollHeight: number): void {\n const el = this.viewport;\n if (!el) return;\n const delta = el.scrollHeight - previousScrollHeight;\n if (delta > 0) el.scrollTop += delta;\n }\n\n /** Current scrollHeight, captured before a prepend so it can be compared after. */\n snapshotScrollHeight(): number {\n return this.viewport?.scrollHeight ?? 0;\n }\n\n /** The last registered anchor in document order (registration order can vary). */\n private lastAnchorInDomOrder(): HTMLElement | undefined {\n if (!this.anchors.length) return undefined;\n return [...this.anchors].sort((a, b) =>\n a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1\n )[this.anchors.length - 1];\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "utils/cn.ts",
22
+ "target": "ui/utils/cn.ts",
23
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "message",
3
+ "type": "registry:ui",
4
+ "title": "Message",
5
+ "description": "Message component with align and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "message.component.ts",
14
+ "target": "ui/message.component.ts",
15
+ "content": "import { Component, Directive, Injectable, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, OnDestroy, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst messageVariants = cva('group/message flex w-full gap-2', {\n variants: {\n align: {\n // Incoming: avatar first, content grows to the right.\n start: 'flex-row justify-start',\n // Outgoing: the row is mirrored so the avatar lands on the trailing edge.\n end: 'flex-row-reverse justify-start',\n },\n size: {\n sm: 'gap-1.5 text-sm',\n default: 'gap-2',\n },\n },\n defaultVariants: { align: 'start', size: 'default' },\n});\n\nexport type MessageProps = VariantProps<typeof messageVariants>;\n\n/** Which edge of the transcript a message hangs off. */\nexport type MessageAlign = 'start' | 'end';\n\n/**\n * Shares the owning message's alignment with its OnPush children.\n *\n * Avatar / content / header / footer all need to know which edge the row hangs\n * off, but reading `MessageComponent.align` from a child getter would never mark\n * that child dirty. Emitting through an observable and calling `markForCheck()`\n * on each emission is the only thing that re-renders them.\n */\n@Injectable()\nexport class MessageAlignService {\n private alignSource = new BehaviorSubject<MessageAlign>('start');\n\n /** Emits the owning message's alignment whenever it changes. */\n readonly align$ = this.alignSource.asObservable();\n\n /** The owning message's current alignment. */\n get align(): MessageAlign {\n return this.alignSource.value;\n }\n\n setAlign(align: MessageAlign): void {\n if (this.alignSource.value !== align) this.alignSource.next(align);\n }\n}\n\n/**\n * Base class for the message sub-components: resolves the row's alignment from\n * the parent `tolle-message` and keeps the OnPush view in sync with it.\n */\n@Directive()\nabstract class MessageAlignAware implements OnChanges, OnDestroy {\n /** Extra Tailwind classes merged onto the element via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageAlignService, { optional: true });\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n /**\n * A bound `class` input is written through Angular's styling path, which\n * does not mark an OnPush view dirty — without this every subclass would\n * keep rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n constructor() {\n // Alignment is not an input of this component, so nothing else would mark it\n // dirty when the parent message flips sides.\n if (this.service) {\n this.sub.add(this.service.align$.subscribe(() => this.cdr.markForCheck()));\n }\n }\n\n /** Alignment inherited from the enclosing message, defaulting to 'start'. */\n protected get align(): MessageAlign {\n return this.service?.align ?? 'start';\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n protected cn = cn;\n}\n\n/**\n * The row layout for a single chat message: avatar on one edge, the content\n * column (header, bubble, footer) filling the rest. Compose it with\n * `tolle-bubble` for the visible surface.\n * @new\n */\n@Component({\n selector: 'tolle-message',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [MessageAlignService],\n template: `<div [class]=\"computedClass\" role=\"listitem\"><ng-content></ng-content></div>`,\n})\nexport class MessageComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Which edge the message hangs off — 'start' for incoming, 'end' for outgoing. @default 'start' */\n @Input() align: MessageProps['align'] = 'start';\n /** Gap density of the row. @default 'default' */\n @Input() size: MessageProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(MessageAlignService);\n\n ngOnChanges(): void {\n // Push alignment into the service so OnPush children get told about it.\n this.service.setAlign(this.align ?? 'start');\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n get computedClass(): string {\n return cn(messageVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst messageGroupVariants = cva('flex w-full flex-col', {\n variants: {\n align: {\n start: 'items-start',\n end: 'items-end',\n },\n spacing: {\n tight: 'gap-0.5',\n default: 'gap-1',\n loose: 'gap-3',\n },\n },\n defaultVariants: { align: 'start', spacing: 'default' },\n});\n\nexport type MessageGroupProps = VariantProps<typeof messageGroupVariants>;\n\n/**\n * Stacks consecutive messages from the same sender with tighter spacing, so a\n * burst of replies reads as one turn rather than several.\n */\n@Component({\n selector: 'tolle-message-group',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\" role=\"group\"><ng-content></ng-content></div>`,\n})\nexport class MessageGroupComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Edge the grouped messages hang off. @default 'start' */\n @Input() align: MessageGroupProps['align'] = 'start';\n /** Vertical gap between the grouped messages. @default 'default' */\n @Input() spacing: MessageGroupProps['spacing'] = 'default';\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(messageGroupVariants({ align: this.align, spacing: this.spacing }), this.class);\n }\n}\n\n/**\n * Avatar slot for a message. Anchored to the bottom of the row so it sits beside\n * the last line of a multi-line bubble rather than floating at the top.\n */\n@Component({\n selector: 'tolle-message-avatar',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageAvatarComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex size-8 shrink-0 select-none items-center justify-center self-end overflow-hidden rounded-full bg-muted text-xs font-medium text-muted-foreground',\n this.class\n );\n }\n}\n\n/** Content column of a message — wraps the header, the bubble, and the footer. */\n@Component({\n selector: 'tolle-message-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageContentComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex min-w-0 flex-1 flex-col gap-1',\n this.align === 'end' ? 'items-end text-right' : 'items-start text-left',\n this.class\n );\n }\n}\n\n/** Sender name and metadata rendered above the message surface. */\n@Component({\n selector: 'tolle-message-header',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageHeaderComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex items-center gap-2 px-1 text-xs font-medium text-muted-foreground',\n this.align === 'end' ? 'flex-row-reverse' : 'flex-row',\n this.class\n );\n }\n}\n\n/** Delivery status, actions, or a timestamp rendered below the message surface. */\n@Component({\n selector: 'tolle-message-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class MessageFooterComponent extends MessageAlignAware {\n get computedClass(): string {\n return cn(\n 'flex items-center gap-2 px-1 text-xs text-muted-foreground',\n this.align === 'end' ? 'flex-row-reverse' : 'flex-row',\n this.class\n );\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "modal-ref.ts",
12
12
  "target": "ui/modal-ref.ts",
13
- "content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\nexport class ModalRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal instantly.\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose(); // Instant disposal (No animation timer)\n this.stack.unregister(this);\n }\n}\n"
13
+ "content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { BehaviorSubject, Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\n/**\n * Safety net if the exit animation's `animationend` never fires — reduced\n * motion, no matching keyframe, or a panel that never rendered — so a modal\n * can never get stuck open forever.\n */\nconst CLOSE_FALLBACK_MS = 300;\n\nexport class ModalRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n private readonly _closingSubject = new BehaviorSubject<boolean>(false);\n /** True once `close()` has been called — drives the panel's exit animation. */\n readonly closing$ = this._closingSubject.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal. Signals the panel and backdrop to play their exit\n * animation, then tears down the overlay once it finishes (or after\n * `CLOSE_FALLBACK_MS`, whichever comes first).\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n if (this._closingSubject.value) return;\n this._closingSubject.next(true);\n this.overlay.backdropElement?.setAttribute('data-state', 'closed');\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose();\n this.stack.unregister(this);\n };\n\n const panel = this.overlay.overlayElement.querySelector<HTMLElement>('[data-state]');\n if (panel) {\n panel.addEventListener('animationend', finish, { once: true });\n setTimeout(finish, CLOSE_FALLBACK_MS);\n } else {\n finish();\n }\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "modal-stack.service.ts",
@@ -20,12 +20,12 @@
20
20
  {
21
21
  "path": "modal.component.ts",
22
22
  "target": "ui/modal.component.ts",
23
- "content": "import { Component, OnInit, TemplateRef, Type } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div [class]=\"modalClasses\" class=\"pointer-events-auto\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n\n <!-- Header -->\n <div *ngIf=\"hasHeader\" class=\"flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4\">\n <h2 *ngIf=\"ref.modal.title\" [id]=\"titleId\" class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n <button\n *ngIf=\"ref.modal.showCloseButton\"\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"-mr-2 -mt-2 ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <i class=\"ri-close-line text-xl\"></i>\n </button>\n </div>\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <p *ngSwitchCase=\"'string'\" [id]=\"descId\" [class]=\"hasHeader ? 'px-6 pb-6 text-sm leading-relaxed text-muted-foreground' : 'p-6 text-sm leading-relaxed text-muted-foreground'\">{{ content }}</p>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.context\"></ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'component'\">\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n </ng-container>\n </ng-container>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: contents; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `]\n})\nexport class ModalComponent implements OnInit {\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n }\n\n /** Whether the auto-rendered header (title and/or close button) is shown. */\n get hasHeader(): boolean {\n return !!(this.ref.modal.showCloseButton || this.ref.modal.title);\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'bg-background text-foreground border border-border shadow-lg relative flex flex-col w-full mx-auto',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'rounded-lg max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\n );\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() { return this.content as TemplateRef<any>; }\n get asComponent() { return this.content as Type<any>; }\n protected cn = cn;\n}\n"
23
+ "content": "import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, TemplateRef, Type } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { Subscription } from 'rxjs';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div [class]=\"modalClasses\" class=\"pointer-events-auto\"\n [attr.data-state]=\"closing ? 'closed' : 'open'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n\n <button\n *ngIf=\"ref.modal.showCloseButton\"\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"absolute right-4 top-4 grid h-6 w-6 place-items-center rounded-sm text-muted-foreground opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\">\n <i class=\"ri-close-line text-base\" aria-hidden=\"true\"></i>\n </button>\n\n <!-- Header -->\n <div *ngIf=\"ref.modal.title\" class=\"flex shrink-0 flex-col gap-1.5 pr-8 text-center sm:text-left\">\n <h2 [id]=\"titleId\" class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n </div>\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <p *ngSwitchCase=\"'string'\" [id]=\"descId\" class=\"text-sm leading-relaxed text-muted-foreground\">{{ content }}</p>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.context\"></ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'component'\">\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n </ng-container>\n </ng-container>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: contents; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `]\n})\nexport class ModalComponent implements OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscription = new Subscription();\n\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\n /** True while the exit animation plays, just before the overlay is torn down. */\n closing = false;\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n\n this.subscription.add(\n this.ref.closing$.subscribe((closing) => {\n this.closing = closing;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy() {\n this.subscription.unsubscribe();\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'relative flex w-full mx-auto flex-col gap-4 rounded-lg border border-border bg-background p-6 text-foreground shadow-lg duration-200',\n\n // Enter/exit animation, driven by `data-state` (see `closing` above).\n 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',\n 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\n );\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() { return this.content as TemplateRef<any>; }\n get asComponent() { return this.content as Type<any>; }\n protected cn = cn;\n}\n"
24
24
  },
25
25
  {
26
26
  "path": "modal.ts",
27
27
  "target": "ui/modal.ts",
28
- "content": "import { TemplateRef, Type } from \"@angular/core\";\n\nexport class Modal<T = any> {\n /** The content to display (String, Component, or Template) */\n content!: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 544px (Forms)\n * - lg: 90% / 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen' = 'default';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean = true;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n showCloseButton?: boolean = true;\n}\n"
28
+ "content": "import { TemplateRef, Type } from \"@angular/core\";\n\n/**\n * Configuration for `ModalService.open()`. Always passed as a plain object\n * literal, so unset fields fall back to `MODAL_DEFAULTS` in `modal.service.ts`\n * rather than a class field initializer — those never run for a literal.\n */\nexport interface Modal<T = any> {\n /** The content to display (String, Component, or Template) */\n content: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 512px (Forms)\n * - lg: 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n /** Whether the built-in corner close button renders. Defaults to true. */\n showCloseButton?: boolean;\n}\n"
29
29
  },
30
30
  {
31
31
  "path": "utils/cn.ts",
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "model-selector",
3
+ "type": "registry:ui",
4
+ "title": "Model Selector",
5
+ "description": "Model Selector component with size and variant options.",
6
+ "category": "ai",
7
+ "registryDependencies": [
8
+ "command",
9
+ "popover"
10
+ ],
11
+ "dependencies": [
12
+ "@floating-ui/dom",
13
+ "class-variance-authority"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "command.service.ts",
18
+ "target": "ui/command.service.ts",
19
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject, Subject } from 'rxjs';\n\n/** One registered `tolle-command-item`, as seen by the service. */\nexport interface CommandItemState {\n id: string;\n /** Text the query is matched against — the item's label plus any `keywords`. */\n searchText: string;\n value: any;\n disabled: boolean;\n /** Registration order, used to keep the visible list in DOM order. */\n order: number;\n}\n\n/**\n * Coordinates a `tolle-command` and its descendants: owns the query, decides\n * which items survive filtering, and tracks the active (highlighted) item for\n * `aria-activedescendant`.\n *\n * Provided on `CommandComponent`, so each command menu gets its own instance.\n */\n@Injectable()\nexport class CommandService {\n private readonly items = new Map<string, CommandItemState>();\n private nextOrder = 0;\n\n private readonly querySubject = new BehaviorSubject<string>('');\n /** Current search query. */\n readonly query$ = this.querySubject.asObservable();\n\n private readonly visibleSubject = new BehaviorSubject<string[]>([]);\n /** Ids of the items surviving the current query, in DOM order. */\n readonly visible$ = this.visibleSubject.asObservable();\n\n private readonly activeIdSubject = new BehaviorSubject<string | null>(null);\n /** Id of the highlighted item, or null when nothing matches. */\n readonly activeId$ = this.activeIdSubject.asObservable();\n\n private readonly selectedSubject = new Subject<any>();\n /** Emits the value of the item the user chose. */\n readonly selected$ = this.selectedSubject.asObservable();\n\n /**\n * When set, filtering is left entirely to the consumer — every registered\n * item stays visible regardless of the query. Use for server-side search.\n */\n shouldFilter = true;\n\n get query(): string {\n return this.querySubject.value;\n }\n\n get visibleIds(): string[] {\n return this.visibleSubject.value;\n }\n\n get activeId(): string | null {\n return this.activeIdSubject.value;\n }\n\n /**\n * True when there is nothing to show.\n *\n * With built-in filtering that means items exist but the query excluded them\n * all. With `shouldFilter = false` the consumer renders the result set itself,\n * so \"no items registered\" IS the empty result — requiring items to exist\n * would make `tolle-command-empty` permanently invisible in that mode.\n */\n get isEmpty(): boolean {\n if (!this.shouldFilter) return this.items.size === 0;\n return this.items.size > 0 && this.visibleSubject.value.length === 0;\n }\n\n register(item: Omit<CommandItemState, 'order'>): void {\n this.items.set(item.id, { ...item, order: this.nextOrder++ });\n this.recompute();\n }\n\n /** Keeps the stored search text/value in sync when an item's inputs change. */\n update(id: string, patch: Partial<Omit<CommandItemState, 'id' | 'order'>>): void {\n const existing = this.items.get(id);\n if (!existing) return;\n this.items.set(id, { ...existing, ...patch });\n this.recompute();\n }\n\n unregister(id: string): void {\n this.items.delete(id);\n this.recompute();\n }\n\n setQuery(query: string): void {\n this.querySubject.next(query);\n this.recompute();\n }\n\n isVisible(id: string): boolean {\n return this.visibleSubject.value.includes(id);\n }\n\n /** Moves the highlight by `delta` positions, wrapping at both ends. */\n move(delta: number): void {\n const ids = this.visibleSubject.value;\n if (!ids.length) return;\n const current = ids.indexOf(this.activeIdSubject.value ?? '');\n // From \"nothing active\", ArrowUp should land on the last item, not the second.\n const start = current < 0 ? (delta > 0 ? -1 : 0) : current;\n const next = (start + delta + ids.length) % ids.length;\n this.activeIdSubject.next(ids[next]);\n }\n\n setActive(id: string | null): void {\n if (id !== null && !this.isVisible(id)) return;\n this.activeIdSubject.next(id);\n }\n\n /** Selects the highlighted item, if there is one. */\n selectActive(): void {\n const id = this.activeIdSubject.value;\n if (!id) return;\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n selectById(id: string): void {\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n /** Recomputes the visible set and keeps the highlight on something valid. */\n private recompute(): void {\n const q = this.querySubject.value.trim().toLowerCase();\n\n const visible = [...this.items.values()]\n .filter((item) => !this.shouldFilter || !q || item.searchText.toLowerCase().includes(q))\n .sort((a, b) => a.order - b.order)\n .map((item) => item.id);\n\n this.visibleSubject.next(visible);\n\n // If the highlight fell out of the visible set (or there was none), move it\n // to the first selectable item so Enter always has a sensible target.\n const active = this.activeIdSubject.value;\n if (!active || !visible.includes(active)) {\n const firstEnabled = visible.find((id) => !this.items.get(id)?.disabled) ?? null;\n this.activeIdSubject.next(firstEnabled);\n }\n }\n}\n"
20
+ },
21
+ {
22
+ "path": "model-selector.component.ts",
23
+ "target": "ui/model-selector.component.ts",
24
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, ViewChild, forwardRef, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { PopoverComponent } from './popover.component';\nimport {\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandGroupComponent,\n CommandItemComponent,\n} from './command.component';\n\n/** One model the user can pick. Deliberately provider-agnostic. */\nexport type ModelOption = {\n /** Stable identifier written back through the form control. */\n id: string;\n /** Human-readable model name. */\n name: string;\n /** Vendor or family; when present the list is grouped by it. */\n provider?: string;\n /** One-line summary shown under the name. */\n description?: string;\n /** Short tag rendered as a pill, e.g. \"new\" or \"preview\". */\n badge?: string;\n /** Prevents the option from being chosen. */\n disabled?: boolean;\n};\n\n/** A provider heading and the models filed under it. */\nexport type ModelSelectorGroup = { provider: string; models: ModelOption[] };\n\nconst modelSelectorTriggerVariants = cva(\n 'inline-flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-7 px-2 text-xs',\n sm: 'h-8 px-2.5 text-xs',\n default: 'h-9 px-3 text-sm',\n lg: 'h-10 px-3.5 text-sm',\n },\n variant: {\n default: '',\n ghost: 'border-transparent bg-transparent shadow-none hover:bg-accent hover:text-accent-foreground',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n }\n);\n\nexport type ModelSelectorProps = VariantProps<typeof modelSelectorTriggerVariants>;\n\n/**\n * A compact picker for choosing which model a conversation should use.\n *\n * Implements `ControlValueAccessor`, so it binds with `ngModel` or a reactive\n * form control and round-trips the chosen model's `id`. Searching, keyboard\n * navigation and positioning are reused from `tolle-command` and\n * `tolle-popover` rather than reimplemented here.\n *\n * ```html\n * <tolle-model-selector [models]=\"models\" [(ngModel)]=\"modelId\"></tolle-model-selector>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-model-selector',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n PopoverComponent,\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandGroupComponent,\n CommandItemComponent,\n ],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ModelSelectorComponent),\n multi: true,\n },\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-popover [placement]=\"placement\" (onClose)=\"onPopoverClose()\">\n <button\n trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-label]=\"ariaLabel || 'Select a model'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n >\n <i *ngIf=\"icon\" [class]=\"icon + ' shrink-0 text-muted-foreground'\" aria-hidden=\"true\"></i>\n <span [class]=\"selected ? 'truncate text-foreground' : 'truncate text-muted-foreground'\">{{ triggerLabel }}</span>\n <i class=\"ri-expand-up-down-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div class=\"w-72 overflow-hidden rounded-md border border-border bg-popover shadow-md\">\n <tolle-command [ariaLabel]=\"ariaLabel || 'Select a model'\" (selected)=\"onSelect($event)\">\n <tolle-command-input *ngIf=\"searchable\" [placeholder]=\"searchPlaceholder\"></tolle-command-input>\n <tolle-command-list>\n <tolle-command-empty>{{ emptyMessage }}</tolle-command-empty>\n <tolle-command-group *ngFor=\"let group of groups\" [heading]=\"group.provider\">\n <tolle-command-item\n *ngFor=\"let model of group.models\"\n [value]=\"model.id\"\n [label]=\"model.name\"\n [keywords]=\"[model.provider || '', model.description || '']\"\n [disabled]=\"model.disabled || false\"\n class=\"items-start\"\n >\n <i\n class=\"ri-check-line mt-0.5 shrink-0 text-primary\"\n [class.opacity-0]=\"model.id !== value\"\n aria-hidden=\"true\"\n ></i>\n <span class=\"flex min-w-0 flex-1 flex-col\">\n <span class=\"flex items-center gap-1.5\">\n <span class=\"truncate font-medium\">{{ model.name }}</span>\n <span\n *ngIf=\"model.badge\"\n class=\"shrink-0 rounded-full border border-border bg-muted px-1.5 py-px text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >{{ model.badge }}</span\n >\n </span>\n <span *ngIf=\"model.description\" class=\"truncate text-xs text-muted-foreground\">{{ model.description }}</span>\n </span>\n </tolle-command-item>\n </tolle-command-group>\n </tolle-command-list>\n </tolle-command>\n </div>\n </tolle-popover>\n `,\n})\nexport class ModelSelectorComponent implements ControlValueAccessor, OnChanges {\n /** Models to choose from; grouped by `provider` when that field is set. @default [] */\n @Input() models: ModelOption[] = [];\n /** Text shown on the trigger when nothing is selected. @default 'Select a model' */\n @Input() placeholder = 'Select a model';\n /** Placeholder inside the search box. @default 'Search models…' */\n @Input() searchPlaceholder = 'Search models…';\n /** Message shown when the query matches no model. @default 'No models found.' */\n @Input() emptyMessage = 'No models found.';\n /** Shows the search box above the list. @default true */\n @Input() searchable = true;\n /** Prefixes the selected model's name with its provider on the trigger. @default false */\n @Input() showProviderOnTrigger = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: ModelSelectorProps['size'] = 'default';\n /** Visual style of the trigger. @default 'default' */\n @Input() variant: ModelSelectorProps['variant'] = 'default';\n /** Optional remixicon class shown at the start of the trigger. @default 'ri-sparkling-line' */\n @Input() icon = 'ri-sparkling-line';\n /** Where the list opens relative to the trigger. @default 'bottom-start' */\n @Input() placement: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' = 'bottom-start';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Accessible name for the trigger and list. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen model's `id` whenever the selection changes. */\n @Output() valueChange = new EventEmitter<string | null>();\n /** Emitted with the whole `ModelOption` whenever the selection changes. */\n @Output() modelChange = new EventEmitter<ModelOption | null>();\n\n @ViewChild(PopoverComponent) popover?: PopoverComponent;\n\n /** Id of the currently selected model, or `null`. */\n value: string | null = null;\n /** Models bucketed by provider, recomputed whenever `models` changes. */\n groups: ModelSelectorGroup[] = [];\n\n private readonly cdr = inject(ChangeDetectorRef);\n private onChange: (value: string | null) => void = () => {};\n private onTouched: () => void = () => {};\n\n ngOnChanges(): void {\n this.groups = this.groupModels(this.models);\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n /** The selected `ModelOption`, or `null` when the value matches nothing. */\n get selected(): ModelOption | null {\n return this.models.find((model) => model.id === this.value) ?? null;\n }\n\n get triggerLabel(): string {\n const selected = this.selected;\n if (!selected) return this.placeholder;\n return this.showProviderOnTrigger && selected.provider\n ? selected.provider + ' / ' + selected.name\n : selected.name;\n }\n\n get computedTriggerClass() {\n return cn(\n modelSelectorTriggerVariants({ size: this.size, variant: this.variant }),\n this.class\n );\n }\n\n onSelect(id: string): void {\n this.value = id;\n this.onChange(id);\n this.valueChange.emit(id);\n this.modelChange.emit(this.selected);\n this.popover?.close();\n this.cdr.markForCheck();\n }\n\n onPopoverClose(): void {\n this.onTouched();\n }\n\n /**\n * Buckets models by provider, preserving input order both between and within\n * groups. Models with no provider land in a single unlabelled group so a flat\n * list still renders.\n */\n private groupModels(models: ModelOption[]): ModelSelectorGroup[] {\n const groups: ModelSelectorGroup[] = [];\n const byProvider = new Map<string, ModelSelectorGroup>();\n\n for (const model of models) {\n const provider = model.provider ?? '';\n let group = byProvider.get(provider);\n if (!group) {\n group = { provider, models: [] };\n byProvider.set(provider, group);\n groups.push(group);\n }\n group.models.push(model);\n }\n\n return groups;\n }\n\n writeValue(value: string | null): void {\n this.value = value ?? null;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string | null) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
25
+ },
26
+ {
27
+ "path": "utils/cn.ts",
28
+ "target": "ui/utils/cn.ts",
29
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
30
+ }
31
+ ]
32
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "multi-select",
3
3
  "type": "registry:ui",
4
4
  "title": "Multi Select",
5
- "description": "Multi Select component.",
5
+ "description": "Multi Select component with size and error and disabled options.",
6
6
  "category": "forms",
7
7
  "registryDependencies": [
8
8
  "badge",
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "path": "multi-select.component.ts",
19
19
  "target": "ui/multi-select.component.ts",
20
- "content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, ViewChild, OnDestroy } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-multi-select',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent, InputComponent],\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true }\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n\n <div class=\"flex flex-wrap gap-1 items-center max-w-[95%]\">\n <ng-container *ngIf=\"value.length; else placeholderTpl\">\n <tolle-badge *ngFor=\"let item of displayItems\" size=\"xs\" variant=\"secondary\" [removable]=\"true\" (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n <span *ngIf=\"exceedsDisplayLimit\" class=\"text-xs text-muted-foreground px-1\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-xs text-muted-foreground px-1\">\n (Max reached)\n </span>\n </ng-container>\n <ng-template #placeholderTpl><span class=\"text-muted-foreground\">{{ placeholder }}</span></ng-template>\n </div>\n <i [class]=\"cn('ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200', isOpen ? 'rotate-180' : '')\"></i>\n </button>\n\n <div #popover *ngIf=\"isOpen\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed bg-popover text-popover-foreground z-[999] rounded-md border border-border shadow-md overflow-hidden\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n\n <div class=\"p-2 border-b border-border space-y-2 bg-popover\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n <span *ngIf=\"maxSelections\">/ {{ maxSelections }} max</span>\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-destructive text-xs font-medium\">\n Maximum reached\n </span>\n </div>\n\n <tolle-input *ngIf=\"searchable\" #searchInput size=\"xs\" placeholder=\"Search...\" [(ngModel)]=\"searchQuery\" (ngModelChange)=\"onSearchChange($event)\" (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n\n <div class=\"flex items-center justify-between px-1\">\n <button type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'text-muted-foreground cursor-not-allowed'\n : 'text-primary hover:underline'\n )\">\n Select All\n </button>\n <button type=\"button\" (click)=\"clearAll()\" class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n\n <div class=\"p-1 max-h-60 overflow-y-auto\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{searchQuery}}\"\n </div>\n <div *ngIf=\"maxSelections && value.length >= maxSelections\"\n class=\"p-2 text-center border-t border-border bg-muted/20\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{maxSelections}})\n </span>\n </div>\n </div>\n </div>\n </div>\n `\n})\nexport class MultiSelectComponent implements ControlValueAccessor, AfterContentInit, OnDestroy {\n @Input() placeholder = 'Select options...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() searchable = false;\n @Input() disabled = false;\n @Input() class = '';\n @Input() maxSelections?: number;\n @Input() maxDisplayItems = 3;\n @Input() error = false; // Added to support error styling\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: { label: string, value: any }[] = [];\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(private selectService: SelectService) {\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n });\n }\n\n // NEW: Matches InputComponent styles exactly\n get computedTriggerClass() {\n return cn(\n // Base styles\n 'flex min-h-10 w-full items-center justify-between rounded-md border transition-all duration-200 h-auto',\n 'bg-background text-sm',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Padding based on size (aligned with InputComponent logic)\n this.size === 'xs' && 'px-2 py-1',\n this.size === 'sm' && 'px-3 py-1.5',\n this.size === 'default' && 'px-3 py-2',\n this.size === 'lg' && 'px-4 py-2',\n\n // Focus state - ZARDUI STYLE (Soft ring, no offset)\n !this.disabled && [\n 'focus-visible:outline-none',\n 'focus-visible:ring-4',\n 'focus-visible:ring-ring/30',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'focus-visible:border-primary/80' // Darkens border on focus\n ],\n\n // Hover state\n !this.disabled && 'hover:border-accent',\n\n // Error state\n this.error && [\n 'border-destructive',\n !this.disabled && [\n 'focus-visible:border-destructive/80',\n 'focus-visible:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.items.changes.subscribe(() => this.syncItems());\n }\n\n get displayItems(): { label: string, value: any }[] {\n return this.selectedItems.slice(0, this.maxDisplayItems);\n }\n\n get exceedsDisplayLimit(): boolean {\n return this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n this.initActiveAfterOpen();\n });\n }\n\n close() {\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: `${rects.reference.width}px`,\n maxHeight: `${availableHeight}px`\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.onChange([]);\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n document.removeEventListener('mousedown', this._outsideClickHandler);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => { };\n onTouched: any = () => { };\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }\n\n protected cn = cn;\n}\n"
20
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ElementRef,\n ViewChild,\n OnChanges,\n OnDestroy,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { Subscription } from 'rxjs';\nimport { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\nconst multiSelectTriggerVariants = cva(\n 'flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-input bg-background text-sm shadow-sm transition-all duration-200',\n {\n variants: {\n size: {\n xs: 'px-2 py-1',\n sm: 'px-3 py-1.5',\n default: 'px-3 py-2',\n lg: 'px-4 py-2',\n },\n error: {\n true: 'border-destructive',\n false: '',\n },\n disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // …and an invalid one rings destructive instead. Declared after the rule\n // above so `cn()` resolves the conflict in its favour.\n {\n disabled: false,\n error: true,\n class: 'focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n },\n ],\n defaultVariants: { size: 'default', error: false, disabled: false },\n }\n);\n\nexport type MultiSelectProps = VariantProps<typeof multiSelectTriggerVariants>;\n\n/** One chip on the trigger, standing for a selected option. */\nexport interface MultiSelectChip {\n label: string;\n value: any;\n}\n\n/**\n * A multi-select listbox: a trigger that shows the current selection as\n * removable chips and opens a floating panel of `tolle-select-item` options.\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms.\n */\n@Component({\n selector: 'tolle-multi-select',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true }\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n\n <div class=\"flex flex-wrap gap-1 items-center max-w-[95%]\">\n <ng-container *ngIf=\"value.length; else placeholderTpl\">\n <tolle-badge *ngFor=\"let item of displayItems; trackBy: trackChip\" size=\"xs\" variant=\"secondary\" [removable]=\"true\" (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n <span *ngIf=\"exceedsDisplayLimit\" class=\"text-xs text-muted-foreground px-1\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-xs text-muted-foreground px-1\">\n (Max reached)\n </span>\n </ng-container>\n <ng-template #placeholderTpl><span class=\"text-muted-foreground\">{{ placeholder }}</span></ng-template>\n </div>\n <i [class]=\"cn('ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200', isOpen ? 'rotate-180' : '')\"></i>\n </button>\n\n <div #popover *ngIf=\"isOpen\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed bg-popover text-popover-foreground z-[999] rounded-md border border-border shadow-md overflow-hidden\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n\n <div class=\"p-2 border-b border-border space-y-2 bg-popover\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n <span *ngIf=\"maxSelections\">/ {{ maxSelections }} max</span>\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-destructive text-xs font-medium\">\n Maximum reached\n </span>\n </div>\n\n <tolle-input *ngIf=\"searchable\" #searchInput size=\"xs\" placeholder=\"Search...\" [(ngModel)]=\"searchQuery\" (ngModelChange)=\"onSearchChange($event)\" (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n\n <div class=\"flex items-center justify-between px-1\">\n <button type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'text-muted-foreground cursor-not-allowed'\n : 'text-primary hover:underline'\n )\">\n Select All\n </button>\n <button type=\"button\" (click)=\"clearAll()\" class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n\n <div class=\"p-1 max-h-60 overflow-y-auto\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{searchQuery}}\"\n </div>\n <div *ngIf=\"maxSelections && value.length >= maxSelections\"\n class=\"p-2 text-center border-t border-border bg-muted/20\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{maxSelections}})\n </span>\n </div>\n </div>\n </div>\n </div>\n `\n})\nexport class MultiSelectComponent\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy {\n /** Text shown on the trigger when nothing is selected. @default 'Select options...' */\n @Input() placeholder = 'Select options...';\n /** Padding and text size of the trigger. @default 'default' */\n @Input() size: MultiSelectProps['size'] = 'default';\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Largest number of values that can be selected at once; leave unset for no cap. */\n @Input() maxSelections?: number;\n /** How many chips to render before collapsing the rest into a \"+N more\" hint. @default 3 */\n @Input() maxDisplayItems = 3;\n /** Applies the destructive border and focus ring. @default false */\n @Input() error = false;\n\n /** Emitted with the full array of selected values whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any[]>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: MultiSelectChip[] = [];\n\n /**\n * The chips actually rendered, built into a field rather than read from a\n * getter. A getter hands `*ngFor` a freshly-allocated array on every\n * change-detection pass; paired with the default identity differ that tore\n * down and rebuilt every badge — including the one under the pointer, whose\n * destruction re-fires its own remove handler. See `trackChip` below, which\n * closes the same hole for the rebuilt chip objects `syncItems()` produces.\n */\n displayItems: MultiSelectChip[] = [];\n exceedsDisplayLimit = false;\n\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n private sub = new Subscription();\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n multiSelectTriggerVariants({\n size: this.size,\n error: this.error,\n disabled: this.disabled,\n }),\n this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.sub.add(this.items.changes.subscribe(() => {\n this.syncItems();\n this.cdr.markForCheck();\n }));\n }\n\n /**\n * `maxDisplayItems` decides how much of the selection is rendered, so the\n * chip list has to be rebuilt when it changes.\n *\n * The `markForCheck()` is not redundant: under OnPush a `[class]` binding is\n * applied through Angular's *styling* path, which writes the input but —\n * unlike a plain property binding — never marks the component dirty, so the\n * trigger would keep rendering the class it was born with.\n */\n ngOnChanges() {\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n /** `*ngFor` identity for the chips — keyed on the option value, not the\n * wrapper object, which `syncItems()` re-allocates on every sync. */\n trackChip = (_index: number, chip: MultiSelectChip): any => chip.value;\n\n private refreshDisplay(): void {\n this.displayItems = this.selectedItems.slice(0, this.maxDisplayItems);\n this.exceedsDisplayLimit = this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n const wasOpen = this.isOpen;\n this.isOpen = true;\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: rects.reference.width + 'px',\n maxHeight: availableHeight + 'px'\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.emitValue();\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.emitValue();\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.emitValue();\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private emitValue(): void {\n const snapshot = [...this.value];\n this.onChange(snapshot);\n this.valueChange.emit(snapshot);\n this.cdr.markForCheck();\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n this.cdr.markForCheck();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n this.cdr.markForCheck();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => { };\n onTouched: any = () => { };\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\n this.refreshDisplay();\n this.cdr.markForCheck();\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
21
21
  },
22
22
  {
23
23
  "path": "select.service.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "native-select",
3
+ "type": "registry:ui",
4
+ "title": "Native Select",
5
+ "description": "Native Select component with size and invalid options.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "native-select.component.ts",
14
+ "target": "ui/native-select.component.ts",
15
+ "content": "import { Component, Input, Output, EventEmitter, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst nativeSelectVariants = cva(\n 'flex w-full appearance-none items-center rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 pl-2 pr-7 text-xs',\n sm: 'h-9 pl-3 pr-8 text-sm',\n default: 'h-10 pl-3 pr-8 text-sm',\n lg: 'h-11 pl-4 pr-9 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\nexport type NativeSelectProps = VariantProps<typeof nativeSelectVariants>;\n\nexport type NativeSelectOption = {\n label: string;\n value: any;\n disabled?: boolean;\n};\n\n/**\n * A styled native `<select>`. Unlike `tolle-select` it uses the platform\n * dropdown, so it needs no portal or floating-ui and works well on mobile and\n * inside forms that must degrade without JavaScript.\n *\n * Options come from `options`, or from projected `<option>` elements when you\n * need `<optgroup>` or custom markup.\n * @new\n */\n@Component({\n selector: 'tolle-native-select',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => NativeSelectComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"relative w-full\">\n <select\n [id]=\"id\"\n [disabled]=\"disabled\"\n [attr.aria-invalid]=\"invalid || null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (change)=\"onSelectChange($event)\"\n (blur)=\"onTouched()\"\n >\n <option *ngIf=\"placeholder\" value=\"\" [selected]=\"value === null || value === undefined || value === ''\" disabled>\n {{ placeholder }}\n </option>\n <!--\n The DOM value is the option's INDEX, not its value. A select element\n coerces every option value to a string, which collapses all object\n options to \"[object Object]\" and makes them indistinguishable on the\n way back out. Indexing keeps object- and number-valued options\n round-tripping exactly.\n -->\n <option\n *ngFor=\"let option of options; let i = index\"\n [value]=\"i\"\n [disabled]=\"option.disabled || false\"\n [selected]=\"option.value === value\"\n >\n {{ option.label }}\n </option>\n <ng-content></ng-content>\n </select>\n\n <i\n class=\"ri-arrow-down-s-line pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground\"\n aria-hidden=\"true\"\n ></i>\n </div>\n `,\n})\nexport class NativeSelectComponent implements ControlValueAccessor {\n /** Id applied to the underlying `<select>`; pair it with a `tolle-label`. */\n @Input() id = `native-select-${Math.random().toString(36).slice(2, 11)}`;\n /** Options to render. Omit and project `<option>` elements for custom markup. @default [] */\n @Input() options: NativeSelectOption[] = [];\n /** Non-selectable prompt shown when no value is set. @default '' */\n @Input() placeholder = '';\n /** Height and text size of the control. @default 'default' */\n @Input() size: NativeSelectProps['size'] = 'default';\n /** Applies the destructive border and sets `aria-invalid`. @default false */\n @Input() invalid = false;\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the `<select>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the newly selected value whenever the user picks an option. */\n @Output() valueChange = new EventEmitter<any>();\n\n value: any = null;\n onChange: (value: any) => void = () => {};\n onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(nativeSelectVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n onSelectChange(event: Event): void {\n const raw = (event.target as HTMLSelectElement).value;\n\n // Options rendered from `options` carry their index as the DOM value, so we\n // can hand back the ORIGINAL value — object, number or string — untouched.\n // Anything else came from a projected <option>, where the string is all\n // there is.\n const index = Number(raw);\n const fromOptions = raw !== '' && Number.isInteger(index) && index >= 0 && index < this.options.length;\n const next = fromOptions ? this.options[index].value : raw;\n\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: any) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }