@tolle_/tolle-ui 18.2.28 → 18.2.30

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 (279) 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 +5 -10
  4. package/esm2022/lib/alert-dialog.component.mjs +44 -59
  5. package/esm2022/lib/alert.component.mjs +5 -11
  6. package/esm2022/lib/aspect-ratio.component.mjs +119 -0
  7. package/esm2022/lib/attachment.component.mjs +334 -0
  8. package/esm2022/lib/avatar-fallback.component.mjs +3 -3
  9. package/esm2022/lib/avatar.component.mjs +4 -9
  10. package/esm2022/lib/badge.component.mjs +4 -9
  11. package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
  12. package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
  13. package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
  14. package/esm2022/lib/breadcrumb.component.mjs +3 -3
  15. package/esm2022/lib/bubble.component.mjs +301 -0
  16. package/esm2022/lib/button.component.mjs +5 -11
  17. package/esm2022/lib/calendar.component.mjs +5 -11
  18. package/esm2022/lib/card.component.mjs +14 -43
  19. package/esm2022/lib/carousel/carousel.component.mjs +5 -12
  20. package/esm2022/lib/chain-of-thought.component.mjs +380 -0
  21. package/esm2022/lib/chart-pie.component.mjs +386 -0
  22. package/esm2022/lib/chart.component.mjs +1146 -0
  23. package/esm2022/lib/chart.service.mjs +555 -0
  24. package/esm2022/lib/checkbox.component.mjs +5 -11
  25. package/esm2022/lib/checkpoint.component.mjs +134 -0
  26. package/esm2022/lib/collapsible.component.mjs +4 -11
  27. package/esm2022/lib/combobox.component.mjs +372 -0
  28. package/esm2022/lib/command-dialog.component.mjs +164 -0
  29. package/esm2022/lib/command.component.mjs +514 -0
  30. package/esm2022/lib/command.service.mjs +130 -0
  31. package/esm2022/lib/confirmation.component.mjs +219 -0
  32. package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
  33. package/esm2022/lib/context-menu.component.mjs +460 -0
  34. package/esm2022/lib/context-menu.service.mjs +121 -0
  35. package/esm2022/lib/context.component.mjs +377 -0
  36. package/esm2022/lib/conversation.component.mjs +442 -0
  37. package/esm2022/lib/country-selector.component.mjs +5 -11
  38. package/esm2022/lib/data-table.component.mjs +63 -12
  39. package/esm2022/lib/date-picker.component.mjs +251 -198
  40. package/esm2022/lib/date-range-picker.component.mjs +224 -116
  41. package/esm2022/lib/date-time-picker.component.mjs +506 -0
  42. package/esm2022/lib/direction.component.mjs +79 -0
  43. package/esm2022/lib/dropdown-item.component.mjs +3 -3
  44. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  45. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  46. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  47. package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
  48. package/esm2022/lib/empty-state.component.mjs +4 -9
  49. package/esm2022/lib/field.component.mjs +394 -0
  50. package/esm2022/lib/hover-card.component.mjs +6 -16
  51. package/esm2022/lib/inline-citation.component.mjs +263 -0
  52. package/esm2022/lib/input-group.component.mjs +418 -0
  53. package/esm2022/lib/input.component.mjs +5 -11
  54. package/esm2022/lib/item.component.mjs +243 -0
  55. package/esm2022/lib/kbd.component.mjs +82 -0
  56. package/esm2022/lib/label.component.mjs +4 -9
  57. package/esm2022/lib/marker.component.mjs +135 -0
  58. package/esm2022/lib/masked-input.component.mjs +5 -11
  59. package/esm2022/lib/menubar.component.mjs +683 -0
  60. package/esm2022/lib/message-scroller.component.mjs +353 -0
  61. package/esm2022/lib/message-scroller.service.mjs +157 -0
  62. package/esm2022/lib/message.component.mjs +228 -0
  63. package/esm2022/lib/model-selector.component.mjs +310 -0
  64. package/esm2022/lib/multi-select.component.mjs +155 -72
  65. package/esm2022/lib/native-select.component.mjs +201 -0
  66. package/esm2022/lib/navigation-menu.component.mjs +642 -0
  67. package/esm2022/lib/otp-group.component.mjs +3 -3
  68. package/esm2022/lib/otp-slot.component.mjs +4 -9
  69. package/esm2022/lib/otp.component.mjs +5 -11
  70. package/esm2022/lib/pagination.component.mjs +7 -10
  71. package/esm2022/lib/plan.component.mjs +335 -0
  72. package/esm2022/lib/popover-content.component.mjs +4 -9
  73. package/esm2022/lib/popover.component.mjs +15 -13
  74. package/esm2022/lib/progress.component.mjs +4 -9
  75. package/esm2022/lib/prompt-input.component.mjs +526 -0
  76. package/esm2022/lib/queue.component.mjs +280 -0
  77. package/esm2022/lib/radio-group.component.mjs +6 -13
  78. package/esm2022/lib/radio-item.component.mjs +4 -9
  79. package/esm2022/lib/range-calendar.component.mjs +5 -11
  80. package/esm2022/lib/reasoning.component.mjs +335 -0
  81. package/esm2022/lib/resizable.component.mjs +219 -0
  82. package/esm2022/lib/segment.component.mjs +17 -7
  83. package/esm2022/lib/select-group.component.mjs +4 -8
  84. package/esm2022/lib/select-item.component.mjs +4 -9
  85. package/esm2022/lib/select-separator.component.mjs +4 -8
  86. package/esm2022/lib/select.component.mjs +133 -49
  87. package/esm2022/lib/separator.component.mjs +4 -9
  88. package/esm2022/lib/sheet.component.mjs +38 -56
  89. package/esm2022/lib/shimmer.component.mjs +84 -0
  90. package/esm2022/lib/sidebar.component.mjs +11 -1
  91. package/esm2022/lib/skeleton.component.mjs +4 -9
  92. package/esm2022/lib/sources.component.mjs +452 -0
  93. package/esm2022/lib/spinner.component.mjs +68 -0
  94. package/esm2022/lib/suggestion.component.mjs +207 -0
  95. package/esm2022/lib/switch.component.mjs +5 -11
  96. package/esm2022/lib/table.component.mjs +213 -0
  97. package/esm2022/lib/tabs.component.mjs +4 -11
  98. package/esm2022/lib/tag-input.component.mjs +5 -11
  99. package/esm2022/lib/task.component.mjs +359 -0
  100. package/esm2022/lib/textarea.component.mjs +5 -11
  101. package/esm2022/lib/theme.service.mjs +17 -6
  102. package/esm2022/lib/time-picker.component.mjs +923 -0
  103. package/esm2022/lib/toast.service.mjs +3 -2
  104. package/esm2022/lib/toaster.component.mjs +76 -39
  105. package/esm2022/lib/toggle-group.component.mjs +4 -9
  106. package/esm2022/lib/toggle.component.mjs +5 -11
  107. package/esm2022/lib/tool.component.mjs +462 -0
  108. package/esm2022/lib/typography.component.mjs +83 -0
  109. package/esm2022/lib/utils/color.mjs +140 -19
  110. package/esm2022/public-api.mjs +61 -1
  111. package/fesm2022/tolle-ui.mjs +16457 -1024
  112. package/fesm2022/tolle-ui.mjs.map +1 -1
  113. package/lib/alert-dialog.component.d.ts +10 -2
  114. package/lib/aspect-ratio.component.d.ts +20 -0
  115. package/lib/attachment.component.d.ts +101 -0
  116. package/lib/bubble.component.d.ts +112 -0
  117. package/lib/chain-of-thought.component.d.ts +166 -0
  118. package/lib/chart-pie.component.d.ts +108 -0
  119. package/lib/chart.component.d.ts +369 -0
  120. package/lib/chart.service.d.ts +222 -0
  121. package/lib/checkpoint.component.d.ts +51 -0
  122. package/lib/combobox.component.d.ts +90 -0
  123. package/lib/command-dialog.component.d.ts +53 -0
  124. package/lib/command.component.d.ts +189 -0
  125. package/lib/command.service.d.ts +67 -0
  126. package/lib/confirmation.component.d.ts +70 -0
  127. package/lib/context-menu-trigger.directive.d.ts +12 -0
  128. package/lib/context-menu.component.d.ts +41 -0
  129. package/lib/context-menu.service.d.ts +38 -0
  130. package/lib/context.component.d.ts +134 -0
  131. package/lib/conversation.component.d.ts +165 -0
  132. package/lib/data-table.component.d.ts +27 -1
  133. package/lib/date-picker.component.d.ts +72 -11
  134. package/lib/date-range-picker.component.d.ts +63 -6
  135. package/lib/date-time-picker.component.d.ts +119 -0
  136. package/lib/direction.component.d.ts +45 -0
  137. package/lib/field.component.d.ts +201 -0
  138. package/lib/inline-citation.component.d.ts +103 -0
  139. package/lib/input-group.component.d.ts +150 -0
  140. package/lib/item.component.d.ts +154 -0
  141. package/lib/kbd.component.d.ts +50 -0
  142. package/lib/marker.component.d.ts +58 -0
  143. package/lib/menubar.component.d.ts +247 -0
  144. package/lib/message-scroller.component.d.ts +159 -0
  145. package/lib/message-scroller.service.d.ts +73 -0
  146. package/lib/message.component.d.ts +128 -0
  147. package/lib/model-selector.component.d.ts +103 -0
  148. package/lib/multi-select.component.d.ts +66 -14
  149. package/lib/native-select.component.d.ts +57 -0
  150. package/lib/navigation-menu.component.d.ts +230 -0
  151. package/lib/plan.component.d.ts +120 -0
  152. package/lib/popover.component.d.ts +1 -0
  153. package/lib/prompt-input.component.d.ts +194 -0
  154. package/lib/queue.component.d.ts +106 -0
  155. package/lib/reasoning.component.d.ts +127 -0
  156. package/lib/resizable.component.d.ts +43 -0
  157. package/lib/segment.component.d.ts +13 -3
  158. package/lib/select.component.d.ts +37 -5
  159. package/lib/sheet.component.d.ts +3 -0
  160. package/lib/shimmer.component.d.ts +38 -0
  161. package/lib/sidebar.component.d.ts +2 -0
  162. package/lib/sources.component.d.ts +156 -0
  163. package/lib/spinner.component.d.ts +29 -0
  164. package/lib/suggestion.component.d.ts +96 -0
  165. package/lib/table.component.d.ts +106 -0
  166. package/lib/task.component.d.ts +151 -0
  167. package/lib/theme.service.d.ts +9 -0
  168. package/lib/time-picker.component.d.ts +243 -0
  169. package/lib/toast.service.d.ts +3 -1
  170. package/lib/toaster.component.d.ts +26 -9
  171. package/lib/tool.component.d.ts +158 -0
  172. package/lib/typography.component.d.ts +32 -0
  173. package/lib/utils/color.d.ts +19 -5
  174. package/package.json +1 -1
  175. package/public-api.d.ts +50 -0
  176. package/registry/docs-content.json +8497 -1553
  177. package/registry/llms-full.txt +2679 -318
  178. package/registry/llms.txt +43 -4
  179. package/registry/manifest.json +9093 -1739
  180. package/registry/r/accordion-item.json +1 -1
  181. package/registry/r/accordion.json +1 -1
  182. package/registry/r/alert-dialog-dynamic.json +1 -1
  183. package/registry/r/alert-dialog.json +1 -1
  184. package/registry/r/alert.json +1 -1
  185. package/registry/r/attachment.json +23 -0
  186. package/registry/r/avatar-fallback.json +1 -1
  187. package/registry/r/avatar.json +1 -1
  188. package/registry/r/badge.json +1 -1
  189. package/registry/r/breadcrumb-item.json +1 -1
  190. package/registry/r/breadcrumb-link.json +1 -1
  191. package/registry/r/breadcrumb-separator.json +1 -1
  192. package/registry/r/breadcrumb.json +1 -1
  193. package/registry/r/bubble.json +23 -0
  194. package/registry/r/button.json +1 -1
  195. package/registry/r/calendar.json +1 -1
  196. package/registry/r/card.json +1 -1
  197. package/registry/r/carousel.json +1 -1
  198. package/registry/r/chain-of-thought.json +23 -0
  199. package/registry/r/chart-pie.json +30 -0
  200. package/registry/r/chart.json +28 -0
  201. package/registry/r/checkbox.json +1 -1
  202. package/registry/r/checkpoint.json +23 -0
  203. package/registry/r/collapsible.json +1 -1
  204. package/registry/r/combobox.json +31 -0
  205. package/registry/r/command-dialog.json +21 -0
  206. package/registry/r/command.json +26 -0
  207. package/registry/r/confirmation.json +23 -0
  208. package/registry/r/context.json +26 -0
  209. package/registry/r/conversation.json +23 -0
  210. package/registry/r/country-selector.json +1 -1
  211. package/registry/r/data-table.json +1 -1
  212. package/registry/r/date-picker.json +4 -4
  213. package/registry/r/date-range-picker.json +3 -3
  214. package/registry/r/date-time-picker.json +28 -0
  215. package/registry/r/direction.json +16 -0
  216. package/registry/r/dropdown-item.json +1 -1
  217. package/registry/r/dropdown-label.json +1 -1
  218. package/registry/r/dropdown-menu.json +1 -1
  219. package/registry/r/dropdown-separator.json +1 -1
  220. package/registry/r/dropdown-trigger.json +1 -1
  221. package/registry/r/empty-state.json +1 -1
  222. package/registry/r/field.json +23 -0
  223. package/registry/r/hover-card.json +1 -1
  224. package/registry/r/inline-citation.json +26 -0
  225. package/registry/r/input-group.json +23 -0
  226. package/registry/r/input.json +1 -1
  227. package/registry/r/item.json +23 -0
  228. package/registry/r/kbd.json +23 -0
  229. package/registry/r/label.json +1 -1
  230. package/registry/r/marker.json +23 -0
  231. package/registry/r/masked-input.json +1 -1
  232. package/registry/r/menubar.json +24 -0
  233. package/registry/r/message-scroller.json +26 -0
  234. package/registry/r/message.json +23 -0
  235. package/registry/r/model-selector.json +32 -0
  236. package/registry/r/multi-select.json +2 -2
  237. package/registry/r/native-select.json +23 -0
  238. package/registry/r/navigation-menu.json +24 -0
  239. package/registry/r/otp-group.json +1 -1
  240. package/registry/r/otp-slot.json +1 -1
  241. package/registry/r/otp.json +1 -1
  242. package/registry/r/pagination.json +3 -2
  243. package/registry/r/plan.json +23 -0
  244. package/registry/r/popover-content.json +1 -1
  245. package/registry/r/popover.json +1 -1
  246. package/registry/r/progress.json +1 -1
  247. package/registry/r/prompt-input.json +23 -0
  248. package/registry/r/queue.json +23 -0
  249. package/registry/r/radio-group.json +1 -1
  250. package/registry/r/radio-item.json +1 -1
  251. package/registry/r/range-calendar.json +1 -1
  252. package/registry/r/reasoning.json +23 -0
  253. package/registry/r/segment.json +1 -1
  254. package/registry/r/select-group.json +1 -1
  255. package/registry/r/select-item.json +1 -1
  256. package/registry/r/select-separator.json +1 -1
  257. package/registry/r/select.json +4 -3
  258. package/registry/r/separator.json +1 -1
  259. package/registry/r/sheet.json +1 -1
  260. package/registry/r/shimmer.json +23 -0
  261. package/registry/r/sidebar.json +1 -1
  262. package/registry/r/skeleton.json +1 -1
  263. package/registry/r/sources.json +23 -0
  264. package/registry/r/spinner.json +23 -0
  265. package/registry/r/suggestion.json +23 -0
  266. package/registry/r/switch.json +1 -1
  267. package/registry/r/table.json +21 -0
  268. package/registry/r/tabs.json +1 -1
  269. package/registry/r/tag-input.json +1 -1
  270. package/registry/r/task.json +23 -0
  271. package/registry/r/textarea.json +1 -1
  272. package/registry/r/time-picker.json +24 -0
  273. package/registry/r/toaster.json +2 -2
  274. package/registry/r/toggle-group.json +1 -1
  275. package/registry/r/toggle.json +1 -1
  276. package/registry/r/tool.json +23 -0
  277. package/registry/r/typography.json +23 -0
  278. package/registry/registry.json +915 -22
  279. package/theme.css +109 -8
@@ -10,13 +10,14 @@
10
10
  "select-item"
11
11
  ],
12
12
  "dependencies": [
13
- "@floating-ui/dom"
13
+ "@floating-ui/dom",
14
+ "class-variance-authority"
14
15
  ],
15
16
  "files": [
16
17
  {
17
18
  "path": "pagination.component.ts",
18
19
  "target": "ui/pagination.component.ts",
19
- "content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n inject,\n Input,\n OnChanges,\n OnInit,\n Output,\n SimpleChanges\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {FormsModule} from '@angular/forms';\nimport {SelectComponent} from './select.component';\nimport {SelectItemComponent} from './select-item.component';\n\n@Component({\n selector: 'tolle-pagination',\n standalone: true,\n imports: [CommonModule, FormsModule, SelectComponent, SelectItemComponent],\n template: `\n <nav role=\"navigation\" aria-label=\"pagination\">\n <div [class]=\"cn('flex items-center justify-between px-2 py-1', class)\">\n\n <div *ngIf=\"showCurrentPageInfo\" class=\"text-sm text-muted-foreground\">\n <ng-container *ngIf=\"currentPageInfoTemplate; else defaultReport\">\n {{ pageReport }}\n </ng-container>\n <ng-template #defaultReport>\n Showing {{ first }} to {{ last }} of {{ totalRecords }} entries\n </ng-template>\n </div>\n\n <div class=\"flex items-center space-x-6 lg:space-x-8\">\n\n <div *ngIf=\"showPageOptions\" class=\"flex items-center space-x-2\">\n <p class=\"text-sm font-medium\">Rows per page</p>\n <tolle-select\n class=\"w-[70px]\"\n size=\"sm\"\n [ngModel]=\"currentPageSize\"\n (ngModelChange)=\"sizeChange($event)\"\n >\n <tolle-select-item *ngFor=\"let opt of pageSizeOptions\" [value]=\"opt\">\n {{ opt }}\n </tolle-select-item>\n </tolle-select>\n </div>\n\n <div class=\"flex items-center space-x-2\">\n <div *ngIf=\"!showPageLinks\" class=\"flex w-[100px] items-center justify-center text-sm font-medium\">\n Page {{ currentPage }} of {{ totalPages }}\n </div>\n\n <div *ngIf=\"showPageLinks\" class=\"flex items-center space-x-1\">\n <button\n (click)=\"previousPage()\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Go to previous page\"\n [class]=\"navBtnClass\"\n >\n <i aria-hidden=\"true\" class=\"ri-arrow-left-s-line\"></i>\n </button>\n\n <button\n *ngFor=\"let page of displayPageIndex\"\n (click)=\"selectPage(page)\"\n [attr.aria-current]=\"currentPage === page ? 'page' : null\"\n [class]=\"cn(\n 'h-8 w-8 text-sm rounded-md flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer',\n currentPage === page\n ? 'bg-primary text-primary-foreground font-medium'\n : 'hover:bg-accent hover:text-accent-foreground'\n )\"\n >\n {{ page }}\n </button>\n\n <button\n (click)=\"nextPage()\"\n [disabled]=\"currentPage === totalPages || totalPages === 0\"\n aria-label=\"Go to next page\"\n [class]=\"navBtnClass\"\n >\n <i aria-hidden=\"true\" class=\"ri-arrow-right-s-line\"></i>\n </button>\n </div>\n </div>\n </div>\n </div>\n </nav>\n `,\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class PaginationComponent implements OnInit, OnChanges {\n @Input() class = '';\n @Input() showPageLinks = true;\n @Input() showPageOptions = true;\n @Input() showCurrentPageInfo = true;\n @Input() currentPageInfoTemplate?: string;\n\n @Input() totalRecords = 0;\n @Input() currentPageSize = 10;\n @Input() currentPage = 1;\n @Input() pageSizeOptions: number[] = [10, 20, 30, 50];\n\n @Output() onPageNumberChange = new EventEmitter<number>();\n @Output() onPageSizeChange = new EventEmitter<number>();\n\n totalPages = 0;\n first = 0;\n last = 0;\n displayPageIndex: number[] = [];\n pageReport = '';\n\n private initialized = false;\n private cd = inject(ChangeDetectorRef);\n protected cn = cn;\n\n navBtnClass = 'h-8 w-8 p-0 flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer';\n\n ngOnInit(): void {\n if (this.pageSizeOptions.length === 0) {\n this.pageSizeOptions = [10, 20, 30, 50];\n }\n this.initializePagination();\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n // Only re-init if meaningful data changes\n if (changes['totalRecords'] || changes['currentPage'] || changes['currentPageSize']) {\n this.initializePagination();\n }\n }\n\n private initializePagination(): void {\n this.calcPagination();\n if (!this.initialized) {\n this.initialized = true;\n }\n this.cd.detectChanges();\n }\n\n nextPage() {\n if (this.totalPages > this.currentPage) {\n this.currentPage++;\n this.emitChange();\n }\n }\n\n previousPage() {\n if (this.currentPage > 1) {\n this.currentPage--;\n this.emitChange();\n }\n }\n\n selectPage(page: number) {\n if (this.currentPage === page) return;\n this.currentPage = page;\n this.emitChange();\n }\n\n sizeChange(size: number) {\n this.currentPageSize = size;\n this.currentPage = 1; // Reset to page 1 on size change\n this.emitChange();\n }\n\n private emitChange(): void {\n this.calcPagination();\n this.onPageNumberChange.emit(this.currentPage);\n this.onPageSizeChange.emit(this.currentPageSize);\n this.cd.detectChanges();\n }\n\n private calcPagination(): void {\n this.totalPages = Math.ceil(this.totalRecords / this.currentPageSize) || 0;\n\n // Bounds check\n if (this.currentPage > this.totalPages && this.totalPages > 0) {\n this.currentPage = this.totalPages;\n }\n\n this.first = this.totalRecords === 0 ? 0 : (this.currentPage - 1) * this.currentPageSize + 1;\n this.last = Math.min(this.totalRecords, this.currentPage * this.currentPageSize);\n\n // Calculate Sliding Window for Page Numbers (Max 5 visible)\n const pages: number[] = Array.from({ length: this.totalPages }, (_, i) => i + 1);\n\n if (this.totalPages <= 5) {\n this.displayPageIndex = pages;\n } else {\n let start = Math.max(0, this.currentPage - 3);\n let end = start + 5;\n\n if (end > this.totalPages) {\n end = this.totalPages;\n start = end - 5;\n }\n this.displayPageIndex = pages.slice(start, end);\n }\n\n // Template Parsing\n if (this.currentPageInfoTemplate) {\n this.pageReport = this.currentPageInfoTemplate.replace(\n /{first}|{last}|{totalRecords}|{currentPage}|{currentPageSize}|{totalPages}/g,\n (match) => {\n switch (match) {\n case '{first}': return `${this.first}`;\n case '{last}': return `${this.last}`;\n case '{totalRecords}': return `${this.totalRecords}`;\n case '{totalPages}': return `${this.totalPages}`;\n case '{currentPage}': return `${this.currentPage}`;\n case '{currentPageSize}': return `${this.currentPageSize}`;\n default: return match;\n }\n }\n );\n }\n }\n}\n"
20
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {FormsModule} from '@angular/forms';\nimport {SelectComponent} from './select.component';\nimport {SelectItemComponent} from './select-item.component';\n\n@Component({\n selector: 'tolle-pagination',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, SelectComponent, SelectItemComponent],\n template: `\n <nav role=\"navigation\" aria-label=\"pagination\">\n <div [class]=\"cn('flex items-center justify-between px-2 py-1', class)\">\n\n <div *ngIf=\"showCurrentPageInfo\" class=\"text-sm text-muted-foreground\">\n <ng-container *ngIf=\"currentPageInfoTemplate; else defaultReport\">\n {{ pageReport }}\n </ng-container>\n <ng-template #defaultReport>\n Showing {{ first }} to {{ last }} of {{ totalRecords }} entries\n </ng-template>\n </div>\n\n <div class=\"flex items-center space-x-6 lg:space-x-8\">\n\n <div *ngIf=\"showPageOptions\" class=\"flex items-center space-x-2\">\n <p class=\"text-sm font-medium\">Rows per page</p>\n <tolle-select\n class=\"w-[70px]\"\n size=\"sm\"\n [ngModel]=\"currentPageSize\"\n (ngModelChange)=\"sizeChange($event)\"\n >\n <tolle-select-item *ngFor=\"let opt of pageSizeOptions\" [value]=\"opt\">\n {{ opt }}\n </tolle-select-item>\n </tolle-select>\n </div>\n\n <div class=\"flex items-center space-x-2\">\n <div *ngIf=\"!showPageLinks\" class=\"flex w-[100px] items-center justify-center text-sm font-medium\">\n Page {{ currentPage }} of {{ totalPages }}\n </div>\n\n <div *ngIf=\"showPageLinks\" class=\"flex items-center space-x-1\">\n <button\n (click)=\"previousPage()\"\n [disabled]=\"currentPage === 1\"\n aria-label=\"Go to previous page\"\n [class]=\"navBtnClass\"\n >\n <i aria-hidden=\"true\" class=\"ri-arrow-left-s-line\"></i>\n </button>\n\n <button\n *ngFor=\"let page of displayPageIndex\"\n (click)=\"selectPage(page)\"\n [attr.aria-current]=\"currentPage === page ? 'page' : null\"\n [class]=\"cn(\n 'h-8 w-8 text-sm rounded-md flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer',\n currentPage === page\n ? 'bg-primary text-primary-foreground font-medium'\n : 'hover:bg-accent hover:text-accent-foreground'\n )\"\n >\n {{ page }}\n </button>\n\n <button\n (click)=\"nextPage()\"\n [disabled]=\"currentPage === totalPages || totalPages === 0\"\n aria-label=\"Go to next page\"\n [class]=\"navBtnClass\"\n >\n <i aria-hidden=\"true\" class=\"ri-arrow-right-s-line\"></i>\n </button>\n </div>\n </div>\n </div>\n </div>\n </nav>\n `,\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class PaginationComponent implements OnInit, OnChanges {\n @Input() class = '';\n @Input() showPageLinks = true;\n @Input() showPageOptions = true;\n @Input() showCurrentPageInfo = true;\n @Input() currentPageInfoTemplate?: string;\n\n @Input() totalRecords = 0;\n @Input() currentPageSize = 10;\n @Input() currentPage = 1;\n @Input() pageSizeOptions: number[] = [10, 20, 30, 50];\n\n @Output() onPageNumberChange = new EventEmitter<number>();\n @Output() onPageSizeChange = new EventEmitter<number>();\n\n totalPages = 0;\n first = 0;\n last = 0;\n displayPageIndex: number[] = [];\n pageReport = '';\n\n private initialized = false;\n private cd = inject(ChangeDetectorRef);\n protected cn = cn;\n\n navBtnClass = 'h-8 w-8 p-0 flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer';\n\n ngOnInit(): void {\n if (this.pageSizeOptions.length === 0) {\n this.pageSizeOptions = [10, 20, 30, 50];\n }\n this.initializePagination();\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n // Only re-init if meaningful data changes\n if (changes['totalRecords'] || changes['currentPage'] || changes['currentPageSize']) {\n this.initializePagination();\n }\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.cd.markForCheck();\n }\n\n private initializePagination(): void {\n this.calcPagination();\n if (!this.initialized) {\n this.initialized = true;\n }\n this.cd.detectChanges();\n }\n\n nextPage() {\n if (this.totalPages > this.currentPage) {\n this.currentPage++;\n this.emitChange();\n }\n }\n\n previousPage() {\n if (this.currentPage > 1) {\n this.currentPage--;\n this.emitChange();\n }\n }\n\n selectPage(page: number) {\n if (this.currentPage === page) return;\n this.currentPage = page;\n this.emitChange();\n }\n\n sizeChange(size: number) {\n this.currentPageSize = size;\n this.currentPage = 1; // Reset to page 1 on size change\n this.emitChange();\n }\n\n private emitChange(): void {\n this.calcPagination();\n this.onPageNumberChange.emit(this.currentPage);\n this.onPageSizeChange.emit(this.currentPageSize);\n this.cd.detectChanges();\n }\n\n private calcPagination(): void {\n this.totalPages = Math.ceil(this.totalRecords / this.currentPageSize) || 0;\n\n // Bounds check\n if (this.currentPage > this.totalPages && this.totalPages > 0) {\n this.currentPage = this.totalPages;\n }\n\n this.first = this.totalRecords === 0 ? 0 : (this.currentPage - 1) * this.currentPageSize + 1;\n this.last = Math.min(this.totalRecords, this.currentPage * this.currentPageSize);\n\n // Calculate Sliding Window for Page Numbers (Max 5 visible)\n const pages: number[] = Array.from({ length: this.totalPages }, (_, i) => i + 1);\n\n if (this.totalPages <= 5) {\n this.displayPageIndex = pages;\n } else {\n let start = Math.max(0, this.currentPage - 3);\n let end = start + 5;\n\n if (end > this.totalPages) {\n end = this.totalPages;\n start = end - 5;\n }\n this.displayPageIndex = pages.slice(start, end);\n }\n\n // Template Parsing\n if (this.currentPageInfoTemplate) {\n this.pageReport = this.currentPageInfoTemplate.replace(\n /{first}|{last}|{totalRecords}|{currentPage}|{currentPageSize}|{totalPages}/g,\n (match) => {\n switch (match) {\n case '{first}': return `${this.first}`;\n case '{last}': return `${this.last}`;\n case '{totalRecords}': return `${this.totalRecords}`;\n case '{totalPages}': return `${this.totalPages}`;\n case '{currentPage}': return `${this.currentPage}`;\n case '{currentPageSize}': return `${this.currentPageSize}`;\n default: return match;\n }\n }\n );\n }\n }\n}\n"
20
21
  },
21
22
  {
22
23
  "path": "select.service.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "plan",
3
+ "type": "registry:ui",
4
+ "title": "Plan",
5
+ "description": "Plan component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "plan.component.ts",
14
+ "target": "ui/plan.component.ts",
15
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, 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\n/** Progress of a single step in an agent plan. */\nexport type PlanStepStatus = 'pending' | 'active' | 'done' | 'skipped';\n\nlet planStepId = 0;\n\n/** How far through the plan the agent is. */\nexport interface PlanProgress {\n /** 1-based position of the step being worked on, or of the last finished one. */\n current: number;\n /** Number of registered steps. */\n total: number;\n}\n\nconst planVariants = cva('w-full rounded-lg border border-border bg-card p-3 text-card-foreground', {\n variants: {\n size: {\n sm: 'p-2 text-xs',\n default: 'p-3 text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\nexport type PlanProps = VariantProps<typeof planVariants>;\n\nconst planStepVariants = cva(\n // The connector is hidden on the final step so the line stops at the last badge.\n 'relative flex gap-3 pb-3 last:pb-0 [&:last-child_.tolle-plan-rail]:hidden',\n {\n variants: {\n status: {\n pending: 'opacity-100',\n active: 'opacity-100',\n done: 'opacity-100',\n skipped: 'opacity-60',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nconst planBadgeVariants = cva(\n 'relative z-[1] flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium tabular-nums',\n {\n variants: {\n status: {\n pending: 'border-border bg-background text-muted-foreground',\n active: 'border-primary bg-primary text-primary-foreground',\n done: 'border-success/50 bg-success/10 text-success',\n skipped: 'border-dashed border-border bg-background text-muted-foreground',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nexport type PlanStepProps = VariantProps<typeof planStepVariants>;\n\n/**\n * Coordinates a `tolle-plan` and its steps: assigns each step its number and\n * derives the \"Step N of M\" summary.\n *\n * The header is `OnPush` and the step count is not one of its inputs, so the\n * summary is published as an observable and every subscriber calls\n * `markForCheck()`.\n */\n@Injectable()\nexport class PlanService {\n private readonly statuses = new Map<string, PlanStepStatus>();\n private readonly order: string[] = [];\n\n private readonly progressSource = new BehaviorSubject<PlanProgress>({ current: 0, total: 0 });\n /** Emits the plan's current position and step count. */\n readonly progress$ = this.progressSource.asObservable();\n\n private readonly collapsedSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the plan is showing only its summary row. */\n readonly collapsed$ = this.collapsedSource.asObservable();\n\n get progress(): PlanProgress {\n return this.progressSource.value;\n }\n\n get collapsed(): boolean {\n return this.collapsedSource.value;\n }\n\n setCollapsed(collapsed: boolean): void {\n if (this.collapsedSource.value !== collapsed) this.collapsedSource.next(collapsed);\n }\n\n toggleCollapsed(): void {\n this.collapsedSource.next(!this.collapsedSource.value);\n }\n\n /** Registers a step and returns its 1-based number. */\n register(id: string, status: PlanStepStatus): number {\n if (!this.statuses.has(id)) this.order.push(id);\n this.statuses.set(id, status);\n this.recompute();\n return this.order.indexOf(id) + 1;\n }\n\n setStatus(id: string, status: PlanStepStatus): void {\n if (!this.statuses.has(id)) return;\n if (this.statuses.get(id) === status) return;\n this.statuses.set(id, status);\n this.recompute();\n }\n\n unregister(id: string): void {\n const index = this.order.indexOf(id);\n if (index > -1) this.order.splice(index, 1);\n this.statuses.delete(id);\n this.recompute();\n }\n\n /**\n * \"Current\" is the first step still in flight — the active one if there is\n * one, otherwise however many have already been resolved.\n */\n private recompute(): void {\n const total = this.order.length;\n const activeIndex = this.order.findIndex((id) => this.statuses.get(id) === 'active');\n const resolved = this.order.filter((id) => {\n const status = this.statuses.get(id);\n return status === 'done' || status === 'skipped';\n }).length;\n\n const current = activeIndex > -1 ? activeIndex + 1 : Math.min(resolved, total);\n this.progressSource.next({ current, total });\n }\n}\n\n/**\n * An ordered plan the agent intends to work through. Each `tolle-plan-step`\n * numbers itself and joins the connector line; collapsing the plan swaps the\n * steps for a single \"Step N of M\" summary.\n *\n * ```html\n * <tolle-plan title=\"Migration plan\">\n * <tolle-plan-step label=\"Read the schema\" status=\"done\"></tolle-plan-step>\n * <tolle-plan-step label=\"Write the migration\" status=\"active\"></tolle-plan-step>\n * <tolle-plan-step label=\"Run it\" status=\"pending\"></tolle-plan-step>\n * </tolle-plan>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-plan',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [PlanService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-collapsed]=\"isCollapsed ? '' : null\">\n <button\n type=\"button\"\n [disabled]=\"!collapsible\"\n [attr.aria-expanded]=\"collapsible ? !isCollapsed : null\"\n [class]=\"headerClass\"\n (click)=\"toggle()\"\n >\n <i\n *ngIf=\"collapsible\"\n [class]=\"isCollapsed ? 'ri-arrow-right-s-line shrink-0 text-muted-foreground' : 'ri-arrow-down-s-line shrink-0 text-muted-foreground'\"\n aria-hidden=\"true\"\n ></i>\n <span class=\"truncate font-medium text-foreground\">{{ title }}</span>\n <span class=\"ml-auto shrink-0 text-xs tabular-nums text-muted-foreground\">{{ summary }}</span>\n </button>\n\n <div [class]=\"isCollapsed ? 'hidden' : 'mt-3 flex flex-col'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class PlanComponent implements OnInit, OnChanges, OnDestroy {\n /** Heading shown above the steps. @default 'Plan' */\n @Input() title = 'Plan';\n /** Shows only the header and its \"Step N of M\" summary. @default false */\n @Input() collapsed = false;\n /** Lets the user collapse and expand the plan by clicking the header. @default true */\n @Input() collapsible = true;\n /** Padding and text scale of the plan. @default 'default' */\n @Input() size: PlanProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the plan via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new collapsed state when the header is toggled. */\n @Output() collapsedChange = new EventEmitter<boolean>();\n\n private readonly service = inject(PlanService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isCollapsed = false;\n progress: PlanProgress = { current: 0, total: 0 };\n\n ngOnInit(): void {\n this.service.setCollapsed(this.collapsed);\n // Seeded before subscribing so the replayed initial value is not mistaken\n // for a user toggle and echoed back out as a `collapsedChange`.\n this.isCollapsed = this.collapsed;\n this.sub.add(\n this.service.collapsed$.subscribe((collapsed) => {\n const changed = this.isCollapsed !== collapsed;\n this.isCollapsed = collapsed;\n this.cdr.markForCheck();\n if (changed) this.collapsedChange.emit(collapsed);\n })\n );\n this.sub.add(\n this.service.progress$.subscribe((progress) => {\n this.progress = progress;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.service.setCollapsed(this.collapsed);\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 ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** The compact \"Step N of M\" label, empty until at least one step registers. */\n get summary(): string {\n if (!this.progress.total) return '';\n return 'Step ' + Math.max(1, this.progress.current) + ' of ' + this.progress.total;\n }\n\n get computedClass() {\n return cn(planVariants({ size: this.size }), this.class);\n }\n\n get headerClass() {\n return cn(\n 'flex w-full items-center gap-1.5 rounded-md text-left text-sm outline-none transition-colors',\n this.collapsible ? 'cursor-pointer' : 'cursor-default',\n 'focus-visible:ring-2 focus-visible:ring-ring'\n );\n }\n\n toggle(): void {\n if (!this.collapsible) return;\n this.service.toggleCollapsed();\n }\n}\n\n/** One numbered step of a `tolle-plan`. */\n@Component({\n selector: 'tolle-plan-step',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': 'computedClass', '[attr.data-status]': 'status' },\n template: `\n <span class=\"tolle-plan-rail absolute bottom-0 left-3 top-6 w-px -translate-x-1/2 bg-border\" aria-hidden=\"true\"></span>\n\n <span [class]=\"badgeClass\">\n <i *ngIf=\"status === 'done'\" class=\"ri-check-line\" aria-hidden=\"true\"></i>\n <i *ngIf=\"status === 'skipped'\" class=\"ri-subtract-line\" aria-hidden=\"true\"></i>\n <ng-container *ngIf=\"status !== 'done' && status !== 'skipped'\">{{ index }}</ng-container>\n </span>\n\n <div class=\"min-w-0 flex-1 pt-0.5\">\n <p [class]=\"labelClass\">{{ label }}</p>\n <p *ngIf=\"description\" class=\"mt-0.5 text-xs text-muted-foreground\">{{ description }}</p>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PlanStepComponent implements OnInit, OnChanges, OnDestroy {\n /** What this step does. @default '' */\n @Input() label = '';\n /** Supporting detail shown under the label. @default '' */\n @Input() description = '';\n /** Progress of this step; drives the badge colour and glyph. @default 'pending' */\n @Input() status: PlanStepStatus = 'pending';\n /** Extra Tailwind classes merged onto the step via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(PlanService);\n private readonly id = 'tolle-plan-step-' + planStepId++;\n\n /** 1-based position of this step, assigned on registration. */\n index = 0;\n\n ngOnInit(): void {\n this.index = this.service.register(this.id, this.status);\n }\n\n ngOnChanges(): void {\n this.service.setStatus(this.id, this.status);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n }\n\n get computedClass() {\n return cn(planStepVariants({ status: this.status }), this.class);\n }\n\n get badgeClass() {\n return cn(planBadgeVariants({ status: this.status }));\n }\n\n get labelClass() {\n return cn(\n 'text-sm',\n this.status === 'active' && 'font-medium text-foreground',\n this.status === 'done' && 'text-foreground',\n this.status === 'pending' && 'text-muted-foreground',\n this.status === 'skipped' && 'text-muted-foreground line-through'\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": "popover-content.component.ts",
12
12
  "target": "ui/popover-content.component.ts",
13
- "content": "import {\n Component,\n Input,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-popover-content',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"cn(\n 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',\n 'animate-in fade-in zoom-in-95 duration-200',\n class\n )\"\n role=\"dialog\"\n >\n <ng-content></ng-content>\n </div>\n `\n})\nexport class PopoverContentComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
13
+ "content": "import {\n Component,\n Input,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-popover-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"cn(\n 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',\n 'animate-in fade-in zoom-in-95 duration-200',\n class\n )\"\n role=\"dialog\"\n >\n <ng-content></ng-content>\n </div>\n `\n})\nexport class PopoverContentComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "popover.component.ts",
14
14
  "target": "ui/popover.component.ts",
15
- "content": "\nimport { CommonModule } from '@angular/common';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';\n\n@Component({\n selector: 'tolle-popover',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div class=\"inline-block\" #trigger (click)=\"toggle()\">\n <ng-content select=\"[trigger]\"></ng-content>\n </div>\n\n <div\n *ngIf=\"isOpen\"\n #popover\n class=\"fixed top-0 left-0 w-max z-[9999]\"\n style=\"visibility: hidden\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PopoverComponent implements OnDestroy {\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom';\n @Output() onOpen = new EventEmitter<void>();\n @Output() onClose = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl!: ElementRef;\n @ViewChild('popover') popoverEl!: ElementRef;\n\n isOpen = false;\n private cleanup?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.triggerEl.nativeElement.contains(event.target) && !this.popoverEl?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.onOpen.emit();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n this.onClose.emit();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.triggerEl || !this.popoverEl) return;\n\n this.cleanup = autoUpdate(\n this.triggerEl.nativeElement,\n this.popoverEl.nativeElement,\n () => {\n computePosition(this.triggerEl.nativeElement, this.popoverEl.nativeElement, {\n placement: this.placement,\n strategy: 'fixed',\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(this.popoverEl.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n}\n"
15
+ "content": "\nimport { CommonModule } from '@angular/common';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';\n\n@Component({\n selector: 'tolle-popover',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div class=\"inline-block\" #trigger (click)=\"toggle()\">\n <ng-content select=\"[trigger]\"></ng-content>\n </div>\n\n <div\n *ngIf=\"isOpen\"\n #popover\n class=\"fixed top-0 left-0 w-max z-[9999]\"\n style=\"visibility: hidden\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PopoverComponent implements OnDestroy {\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom';\n @Output() onOpen = new EventEmitter<void>();\n @Output() onClose = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl!: ElementRef;\n @ViewChild('popover') popoverEl!: ElementRef;\n\n isOpen = false;\n private cleanup?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.triggerEl?.nativeElement.contains(event.target) && !this.popoverEl?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n private _escapeHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape') this.close();\n };\n\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.onOpen.emit();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n document.addEventListener('keydown', this._escapeHandler, true);\n });\n }\n\n close() {\n this.isOpen = false;\n this.onClose.emit();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n document.removeEventListener('keydown', this._escapeHandler, true);\n }\n\n private updatePosition() {\n if (!this.triggerEl || !this.popoverEl) return;\n\n this.cleanup = autoUpdate(\n this.triggerEl.nativeElement,\n this.popoverEl.nativeElement,\n () => {\n computePosition(this.triggerEl.nativeElement, this.popoverEl.nativeElement, {\n placement: this.placement,\n strategy: 'fixed',\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(this.popoverEl.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n document.removeEventListener('keydown', this._escapeHandler, true);\n }\n}\n"
16
16
  }
17
17
  ]
18
18
  }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "progress.component.ts",
12
12
  "target": "ui/progress.component.ts",
13
- "content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-progress',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\"\n >\n <div\n class=\"h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out\"\n [style.transform]=\"'translateX(-' + (100 - clampedValue) + '%)'\"\n ></div>\n </div>\n `\n})\nexport class ProgressComponent {\n @Input() value: number | null = 0;\n @Input() class: string = '';\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get computedClass() {\n return cn(\n \"relative h-2 w-full overflow-hidden rounded-full bg-primary/20\",\n this.class\n );\n }\n}\n"
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-progress',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\"\n >\n <div\n class=\"h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out\"\n [style.transform]=\"'translateX(-' + (100 - clampedValue) + '%)'\"\n ></div>\n </div>\n `\n})\nexport class ProgressComponent {\n @Input() value: number | null = 0;\n @Input() class: string = '';\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get computedClass() {\n return cn(\n \"relative h-2 w-full overflow-hidden rounded-full bg-primary/20\",\n this.class\n );\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "prompt-input",
3
+ "type": "registry:ui",
4
+ "title": "Prompt Input",
5
+ "description": "Prompt Input component with status and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "prompt-input.component.ts",
14
+ "target": "ui/prompt-input.component.ts",
15
+ "content": "import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, forwardRef, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { BehaviorSubject, Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst promptInputVariants = cva(\n 'flex w-full flex-col gap-2 rounded-lg border bg-background p-2 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-offset-background',\n {\n variants: {\n status: {\n ready: 'border-input focus-within:ring-ring',\n streaming: 'border-info focus-within:ring-info',\n error: 'border-destructive focus-within:ring-destructive',\n },\n size: {\n sm: 'p-1.5 text-xs',\n default: 'p-2 text-sm',\n lg: 'p-3 text-base',\n },\n },\n defaultVariants: {\n status: 'ready',\n size: 'default',\n },\n }\n);\n\nexport type PromptInputProps = VariantProps<typeof promptInputVariants>;\n\n/** Lifecycle of the response the composer is driving. */\nexport type PromptInputStatus = NonNullable<PromptInputProps['status']>;\n\n/**\n * Coordinates a `tolle-prompt-input` with its projected toolbar controls.\n *\n * The submit button is an OnPush child that must flip to a stop button the\n * moment the status changes, so the composer publishes its state here instead\n * of the button reading the parent's fields.\n *\n * Provided on `PromptInputComponent`, so each composer gets its own instance.\n */\n@Injectable()\nexport class PromptInputService {\n private readonly statusSource = new BehaviorSubject<PromptInputStatus>('ready');\n /** Emits the composer's current status. */\n readonly status$ = this.statusSource.asObservable();\n\n private readonly canSubmitSource = new BehaviorSubject<boolean>(false);\n /** Emits whether there is submittable text and the composer is enabled. */\n readonly canSubmit$ = this.canSubmitSource.asObservable();\n\n private readonly submitSource = new Subject<void>();\n /** Emits when a toolbar control asks the composer to submit. */\n readonly submit$ = this.submitSource.asObservable();\n\n private readonly stopSource = new Subject<void>();\n /** Emits when a toolbar control asks the composer to stop streaming. */\n readonly stop$ = this.stopSource.asObservable();\n\n /** The composer's current status. */\n get status(): PromptInputStatus {\n return this.statusSource.value;\n }\n\n /** True when the composer has submittable text. */\n get canSubmit(): boolean {\n return this.canSubmitSource.value;\n }\n\n setStatus(status: PromptInputStatus): void {\n if (this.statusSource.value !== status) this.statusSource.next(status);\n }\n\n setCanSubmit(value: boolean): void {\n if (this.canSubmitSource.value !== value) this.canSubmitSource.next(value);\n }\n\n requestSubmit(): void {\n this.submitSource.next();\n }\n\n requestStop(): void {\n this.stopSource.next();\n }\n}\n\n/**\n * A message composer: an auto-growing textarea plus a toolbar, wired as a\n * `ControlValueAccessor` so it drops into template- or reactive-driven forms.\n *\n * Enter submits and Shift+Enter inserts a newline (swap with `submitOnEnter`).\n * While `status` is 'streaming' the projected `tolle-prompt-input-submit`\n * becomes a stop button that emits `stopped`.\n * @new\n */\n@Component({\n selector: 'tolle-prompt-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n PromptInputService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => PromptInputComponent),\n multi: true,\n },\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <textarea\n #textarea\n data-prompt-input-textarea\n rows=\"1\"\n [value]=\"value\"\n [attr.placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || placeholder || 'Message'\"\n [attr.aria-invalid]=\"status === 'error' ? 'true' : null\"\n [class]=\"textareaComputedClass\"\n (input)=\"onInput($event)\"\n (keydown)=\"onKeyDown($event)\"\n (blur)=\"onBlur()\"\n ></textarea>\n\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputComponent\n implements ControlValueAccessor, OnInit, OnChanges, AfterViewInit, OnDestroy\n{\n /** Placeholder shown while the composer is empty. @default 'Send a message…' */\n @Input() placeholder = 'Send a message…';\n /** Blocks typing and submission. @default false */\n @Input() disabled = false;\n /** Rows the textarea may grow to before it starts scrolling. @default 8 */\n @Input() maxRows = 8;\n /** Lifecycle of the response being composed. @default 'ready' */\n @Input() status: PromptInputStatus = 'ready';\n /** Size scale of the composer. @default 'default' */\n @Input() size: PromptInputProps['size'] = 'default';\n /** Submit on Enter, keeping Shift+Enter for newlines. @default true */\n @Input() submitOnEnter = true;\n /** Clears the composer after a successful submit. @default true */\n @Input() clearOnSubmit = true;\n /** Accessible name for the textarea. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the composer via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the textarea via `cn()` (last-wins). */\n @Input() textareaClass = '';\n\n /** Emitted with the trimmed message text when the user submits. */\n @Output() submitted = new EventEmitter<string>();\n /** Emitted when the user cancels an in-flight streaming response. */\n @Output() stopped = new EventEmitter<void>();\n /** Emitted with the message text whenever it changes. */\n @Output() valueChange = new EventEmitter<string>();\n\n @ViewChild('textarea') private textarea?: ElementRef<HTMLTextAreaElement>;\n\n private readonly service = inject(PromptInputService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n value = '';\n\n private onChange: (value: string) => void = () => {};\n private onTouched: () => void = () => {};\n\n ngOnInit(): void {\n this.syncService();\n this.subscriptions.add(this.service.submit$.subscribe(() => this.submit()));\n this.subscriptions.add(this.service.stop$.subscribe(() => this.stop()));\n }\n\n ngOnChanges(): void {\n this.syncService();\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 ngAfterViewInit(): void {\n this.resize();\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n promptInputVariants({ status: this.status, size: this.size }),\n this.disabled && 'cursor-not-allowed opacity-60',\n this.class\n );\n }\n\n get textareaComputedClass() {\n return cn(\n 'w-full resize-none bg-transparent px-2 py-1.5 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed',\n this.textareaClass\n );\n }\n\n onInput(event: Event): void {\n const next = (event.target as HTMLTextAreaElement).value;\n this.setValue(next);\n this.resize();\n }\n\n onKeyDown(event: KeyboardEvent): void {\n if (event.key !== 'Enter' || !this.submitOnEnter) return;\n // Shift+Enter is the newline escape hatch; modifier chords and IME\n // composition must never steal the keystroke.\n if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;\n if ((event as KeyboardEvent & { isComposing?: boolean }).isComposing) return;\n\n event.preventDefault();\n this.submit();\n }\n\n onBlur(): void {\n this.onTouched();\n }\n\n /** Submits the current text, unless the composer is empty, disabled or busy. */\n submit(): void {\n if (this.disabled || this.status === 'streaming') return;\n const text = this.value.trim();\n if (!text) return;\n\n this.submitted.emit(text);\n\n if (this.clearOnSubmit) {\n this.setValue('');\n if (this.textarea) this.textarea.nativeElement.value = '';\n this.resize();\n }\n }\n\n /** Cancels an in-flight streaming response. */\n stop(): void {\n this.stopped.emit();\n }\n\n /** Moves focus into the textarea. */\n focus(): void {\n this.textarea?.nativeElement.focus();\n }\n\n writeValue(value: string | null): void {\n this.value = value ?? '';\n if (this.textarea) this.textarea.nativeElement.value = this.value;\n this.service.setCanSubmit(this.canSubmit);\n this.cdr.markForCheck();\n this.resize();\n }\n\n registerOnChange(fn: (value: string) => 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.syncService();\n this.cdr.markForCheck();\n }\n\n private get canSubmit(): boolean {\n return !this.disabled && this.value.trim().length > 0;\n }\n\n private setValue(next: string): void {\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n this.service.setCanSubmit(this.canSubmit);\n }\n\n private syncService(): void {\n this.service.setStatus(this.status);\n this.service.setCanSubmit(this.canSubmit);\n }\n\n /**\n * Grows the textarea with its content up to `maxRows`, then lets it scroll.\n * Measured from the computed line height so it tracks the inherited font.\n */\n private resize(): void {\n const el = this.textarea?.nativeElement;\n if (!el) return;\n\n el.style.height = 'auto';\n\n const styles = getComputedStyle(el);\n const parsedLineHeight = parseFloat(styles.lineHeight);\n const lineHeight = Number.isFinite(parsedLineHeight)\n ? parsedLineHeight\n : parseFloat(styles.fontSize) * 1.5 || 20;\n const vertical =\n parseFloat(styles.paddingTop || '0') +\n parseFloat(styles.paddingBottom || '0') +\n parseFloat(styles.borderTopWidth || '0') +\n parseFloat(styles.borderBottomWidth || '0');\n const max = lineHeight * Math.max(1, this.maxRows) + vertical;\n\n const next = Math.min(el.scrollHeight, max);\n el.style.height = next + 'px';\n el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';\n }\n}\n\n/** Bottom bar of a `tolle-prompt-input`: tools on the left, submit on the right. */\n@Component({\n selector: 'tolle-prompt-input-toolbar',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputToolbarComponent 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 toolbar via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex items-center justify-between gap-2 px-1', this.class);\n }\n}\n\n/** Left-aligned cluster of tool buttons inside a `tolle-prompt-input-toolbar`. */\n@Component({\n selector: 'tolle-prompt-input-tools',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputToolsComponent 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 tool row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-1 items-center gap-1 text-muted-foreground', this.class);\n }\n}\n\nconst promptInputSubmitVariants = cva(\n 'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'size-8 text-sm',\n lg: 'h-10 px-4 text-sm',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\nexport type PromptInputSubmitProps = VariantProps<typeof promptInputSubmitVariants>;\n\n/**\n * Send button for a `tolle-prompt-input`. Turns into a stop button while the\n * composer's status is 'streaming', so one control covers both directions.\n */\n@Component({\n selector: 'tolle-prompt-input-submit',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-prompt-input-submit\n [attr.data-state]=\"status\"\n [attr.aria-label]=\"currentLabel\"\n [disabled]=\"isDisabled\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n >\n <i [class]=\"currentIcon\" aria-hidden=\"true\"></i>\n <span *ngIf=\"showLabel\">{{ currentLabel }}</span>\n </button>\n `,\n})\nexport class PromptInputSubmitComponent 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 /** Visual style of the button. @default 'default' */\n @Input() variant: PromptInputSubmitProps['variant'] = 'default';\n /** Size of the button. @default 'default' */\n @Input() size: PromptInputSubmitProps['size'] = 'default';\n /** Accessible name while the composer is idle. @default 'Send message' */\n @Input() submitLabel = 'Send message';\n /** Accessible name while the composer is streaming. @default 'Stop generating' */\n @Input() stopLabel = 'Stop generating';\n /** Renders the label next to the icon. @default false */\n @Input() showLabel = false;\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(PromptInputService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n status: PromptInputStatus = 'ready';\n canSubmit = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.status$.subscribe((status) => {\n this.status = status;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.canSubmit$.subscribe((canSubmit) => {\n this.canSubmit = canSubmit;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** True while the button is showing its stop affordance. */\n get isStreaming(): boolean {\n return this.status === 'streaming';\n }\n\n get currentIcon(): string {\n return this.isStreaming ? 'ri-stop-circle-line' : 'ri-send-plane-2-fill';\n }\n\n get currentLabel(): string {\n return this.isStreaming ? this.stopLabel : this.submitLabel;\n }\n\n /** Stopping stays available with an empty composer; sending does not. */\n get isDisabled(): boolean {\n return !this.isStreaming && !this.canSubmit;\n }\n\n get computedClass() {\n return cn(\n promptInputSubmitVariants({ variant: this.variant, size: this.size }),\n this.isStreaming && 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n this.class\n );\n }\n\n onClick(): void {\n if (this.isStreaming) {\n this.service.requestStop();\n } else {\n this.service.requestSubmit();\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
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "queue",
3
+ "type": "registry:ui",
4
+ "title": "Queue",
5
+ "description": "Queue component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "queue.component.ts",
14
+ "target": "ui/queue.component.ts",
15
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, 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\n/** Where a queued entry is in its lifecycle. */\nexport type QueueItemStatus = 'queued' | 'running' | 'done';\n\nlet queueItemId = 0;\n\n/** Tallies of the entries currently in a queue. */\nexport interface QueueCounts {\n total: number;\n queued: number;\n running: number;\n done: number;\n}\n\nconst queueVariants = cva('w-full rounded-lg border border-border bg-card text-card-foreground', {\n variants: {\n size: {\n sm: 'p-2 text-xs',\n default: 'p-3 text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\nexport type QueueProps = VariantProps<typeof queueVariants>;\n\nconst queueItemVariants = cva(\n 'group/queue-item flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm transition-colors',\n {\n variants: {\n status: {\n queued: 'border-border bg-background text-muted-foreground',\n running: 'border-info/40 bg-info/5 text-foreground',\n done: 'border-transparent bg-transparent text-muted-foreground',\n },\n },\n defaultVariants: { status: 'queued' },\n }\n);\n\nconst queueItemIconVariants = cva('shrink-0 text-base leading-none', {\n variants: {\n status: {\n queued: 'text-muted-foreground',\n running: 'text-info',\n done: 'text-success',\n },\n },\n defaultVariants: { status: 'queued' },\n});\n\nexport type QueueItemProps = VariantProps<typeof queueItemVariants>;\n\n/** Remixicon class shown for each queue item status. */\nconst QUEUE_ICONS: Record<QueueItemStatus, string> = {\n queued: 'ri-time-line',\n running: 'ri-loader-4-line animate-spin',\n done: 'ri-check-line',\n};\n\n/**\n * Counts the items of a single `tolle-queue`.\n *\n * The header is `OnPush` and the item count is not one of its inputs, so it is\n * published as an observable and the subscriber calls `markForCheck()`.\n */\n@Injectable()\nexport class QueueService {\n private readonly statuses = new Map<string, QueueItemStatus>();\n\n private readonly countsSource = new BehaviorSubject<QueueCounts>({\n total: 0,\n queued: 0,\n running: 0,\n done: 0,\n });\n /** Emits the queue's tallies whenever an item is added, updated or removed. */\n readonly counts$ = this.countsSource.asObservable();\n\n get counts(): QueueCounts {\n return this.countsSource.value;\n }\n\n register(id: string, status: QueueItemStatus): void {\n this.statuses.set(id, status);\n this.recompute();\n }\n\n setStatus(id: string, status: QueueItemStatus): void {\n if (!this.statuses.has(id)) return;\n if (this.statuses.get(id) === status) return;\n this.statuses.set(id, status);\n this.recompute();\n }\n\n unregister(id: string): void {\n if (this.statuses.delete(id)) this.recompute();\n }\n\n private recompute(): void {\n const values = [...this.statuses.values()];\n this.countsSource.next({\n total: values.length,\n queued: values.filter((status) => status === 'queued').length,\n running: values.filter((status) => status === 'running').length,\n done: values.filter((status) => status === 'done').length,\n });\n }\n}\n\n/**\n * Messages or tasks waiting their turn. The header counts the entries for you,\n * so the host only has to render `tolle-queue-item` children.\n *\n * ```html\n * <tolle-queue title=\"Queued\">\n * <tolle-queue-item label=\"Summarise the thread\" status=\"running\"></tolle-queue-item>\n * <tolle-queue-item label=\"Draft a reply\" (remove)=\"drop(1)\"></tolle-queue-item>\n * </tolle-queue>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-queue',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [QueueService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"flex items-center gap-2\">\n <i class=\"ri-stack-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"truncate font-medium text-foreground\">{{ title }}</span>\n <span [class]=\"countClass\" [attr.aria-label]=\"counts.total + ' items queued'\">{{ counts.total }}</span>\n </div>\n\n <p *ngIf=\"counts.total === 0\" class=\"mt-2 text-sm text-muted-foreground\">{{ emptyMessage }}</p>\n\n <div role=\"list\" [class]=\"listClass\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class QueueComponent 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 /** Heading shown above the queued items. @default 'Queue' */\n @Input() title = 'Queue';\n /** Text shown while the queue holds nothing. @default 'Nothing queued.' */\n @Input() emptyMessage = 'Nothing queued.';\n /** Padding and text scale of the queue. @default 'default' */\n @Input() size: QueueProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the queue via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the queue's tallies whenever its items change. */\n @Output() countsChange = new EventEmitter<QueueCounts>();\n\n private readonly service = inject(QueueService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n counts: QueueCounts = this.service.counts;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.counts$.subscribe((counts) => {\n this.counts = counts;\n this.cdr.markForCheck();\n this.countsChange.emit(counts);\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn(queueVariants({ size: this.size }), this.class);\n }\n\n get countClass() {\n return cn(\n 'ml-auto shrink-0 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground'\n );\n }\n\n get listClass() {\n return cn(this.counts.total === 0 ? 'flex flex-col' : 'mt-2 flex flex-col gap-1');\n }\n}\n\n/** A single entry in a `tolle-queue`. */\n@Component({\n selector: 'tolle-queue-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div role=\"listitem\" [class]=\"computedClass\" [attr.data-status]=\"status\">\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n\n <span [class]=\"labelClass\">{{ label }}</span>\n <ng-content></ng-content>\n\n <button\n *ngIf=\"removable\"\n type=\"button\"\n [attr.aria-label]=\"removeLabel\"\n class=\"ml-auto shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n (click)=\"remove.emit()\"\n >\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n </button>\n </div>\n `,\n})\nexport class QueueItemComponent implements OnInit, OnChanges, OnDestroy {\n /** Text describing the queued work. @default '' */\n @Input() label = '';\n /** Lifecycle state of this entry. @default 'queued' */\n @Input() status: QueueItemStatus = 'queued';\n /** Shows the trailing dismiss button that emits `remove`. @default true */\n @Input() removable = true;\n /** Accessible label for the dismiss button. @default 'Remove from queue' */\n @Input() removeLabel = 'Remove from queue';\n /** Extra Tailwind classes merged onto the entry via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user dismisses this entry. */\n @Output() remove = new EventEmitter<void>();\n\n private readonly service = inject(QueueService);\n private readonly id = 'tolle-queue-item-' + queueItemId++;\n\n ngOnInit(): void {\n this.service.register(this.id, this.status);\n }\n\n ngOnChanges(): void {\n this.service.setStatus(this.id, this.status);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n }\n\n get computedClass() {\n return cn(queueItemVariants({ status: this.status }), this.class);\n }\n\n get iconClass() {\n return cn(queueItemIconVariants({ status: this.status }), QUEUE_ICONS[this.status]);\n }\n\n get labelClass() {\n return cn('min-w-0 flex-1 truncate', this.status === 'done' && 'line-through');\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": "radio-group.component.ts",
12
12
  "target": "ui/radio-group.component.ts",
13
- "content": "import {Component, Input, forwardRef, AfterContentInit, OnChanges} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport {RadioService} from './radio-service';\n\n@Component({\n selector: 'tolle-radio-group',\n standalone: true,\n imports: [CommonModule],\n providers: [\n RadioService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RadioGroupComponent),\n multi: true\n }\n ],\n host: {\n '(keydown)': 'onKeydown($event)'\n },\n template: `\n <div [class]=\"cn('grid gap-2', class)\" role=\"radiogroup\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class RadioGroupComponent implements ControlValueAccessor, OnChanges {\n @Input() class = '';\n @Input() disabled = false;\n @Input() name = `radio-group-${Math.random().toString(36).substring(2, 9)}`;\n\n value: any;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n /** Suppresses onChange while applying a programmatic writeValue(). */\n private suppress = false;\n\n constructor(private radioService: RadioService) {\n this.radioService.selectedValue$.subscribe(val => {\n this.value = val;\n if (!this.suppress) {\n this.onChange(val);\n this.onTouched();\n }\n });\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.radioService.setDisabled(isDisabled);\n }\n\n ngOnChanges() {\n this.radioService.setDisabled(this.disabled);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.suppress = true;\n this.radioService.select(value);\n this.suppress = false;\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Down/Right select+focus the next\n * enabled item, Arrow Up/Left the previous. Wraps around and skips disabled\n * items; moving selection also moves focus.\n */\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowDown' || key === 'ArrowRight';\n const backward = key === 'ArrowUp' || key === 'ArrowLeft';\n if (!forward && !backward) return;\n\n const items = this.radioService.getEnabledItems();\n if (!items.length) return;\n\n event.preventDefault();\n\n const target = event.target as HTMLElement | null;\n let current = target\n ? items.findIndex(i => i.hostElement === target || i.hostElement.contains(target))\n : -1;\n if (current < 0) {\n // Fall back to the currently selected item.\n current = items.findIndex(i => i.value === this.value);\n }\n\n let next: number;\n if (current < 0) {\n next = forward ? 0 : items.length - 1;\n } else {\n next = forward\n ? (current + 1) % items.length\n : (current - 1 + items.length) % items.length;\n }\n\n const targetItem = items[next];\n this.radioService.select(targetItem.value);\n targetItem.focusItem();\n }\n\n protected readonly cn = cn;\n}\n"
13
+ "content": "import {Component, Input, forwardRef, AfterContentInit, OnChanges} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport {RadioService} from './radio-service';\n\n@Component({\n selector: 'tolle-radio-group',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n RadioService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RadioGroupComponent),\n multi: true\n }\n ],\n host: {\n '(keydown)': 'onKeydown($event)'\n },\n template: `\n <div [class]=\"cn('grid gap-2', class)\" role=\"radiogroup\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class RadioGroupComponent implements ControlValueAccessor, OnChanges {\n @Input() class = '';\n @Input() disabled = false;\n @Input() name = `radio-group-${Math.random().toString(36).substring(2, 9)}`;\n\n value: any;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n /** Suppresses onChange while applying a programmatic writeValue(). */\n private suppress = false;\n\n constructor(private radioService: RadioService) {\n this.radioService.selectedValue$.subscribe(val => {\n this.value = val;\n if (!this.suppress) {\n this.onChange(val);\n this.onTouched();\n }\n });\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.radioService.setDisabled(isDisabled);\n }\n\n ngOnChanges() {\n this.radioService.setDisabled(this.disabled);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.suppress = true;\n this.radioService.select(value);\n this.suppress = false;\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Down/Right select+focus the next\n * enabled item, Arrow Up/Left the previous. Wraps around and skips disabled\n * items; moving selection also moves focus.\n */\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowDown' || key === 'ArrowRight';\n const backward = key === 'ArrowUp' || key === 'ArrowLeft';\n if (!forward && !backward) return;\n\n const items = this.radioService.getEnabledItems();\n if (!items.length) return;\n\n event.preventDefault();\n\n const target = event.target as HTMLElement | null;\n let current = target\n ? items.findIndex(i => i.hostElement === target || i.hostElement.contains(target))\n : -1;\n if (current < 0) {\n // Fall back to the currently selected item.\n current = items.findIndex(i => i.value === this.value);\n }\n\n let next: number;\n if (current < 0) {\n next = forward ? 0 : items.length - 1;\n } else {\n next = forward\n ? (current + 1) % items.length\n : (current - 1 + items.length) % items.length;\n }\n\n const targetItem = items[next];\n this.radioService.select(targetItem.value);\n targetItem.focusItem();\n }\n\n protected readonly cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "radio-service.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "radio-item.component.ts",
12
12
  "target": "ui/radio-item.component.ts",
13
- "content": "import { Component, Input, OnInit, OnDestroy, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { RadioService, RovingRadioItem } from './radio-service';\nimport { Subscription } from 'rxjs';\n\n@Component({\n selector: 'tolle-radio-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n (click)=\"!isEffectiveDisabled && select()\"\n [class]=\"cn(\n 'flex items-center space-x-2 group py-1',\n isEffectiveDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'\n )\"\n >\n <div\n role=\"radio\"\n [attr.aria-checked]=\"isSelected\"\n [attr.aria-disabled]=\"isEffectiveDisabled || null\"\n [attr.data-state]=\"isSelected ? 'checked' : 'unchecked'\"\n [attr.tabindex]=\"isEffectiveDisabled ? -1 : (isTabbable ? 0 : -1)\"\n (keydown.enter)=\"isEffectiveDisabled ? null : select()\"\n (keydown.space)=\"isEffectiveDisabled ? null : select(); $event.preventDefault()\"\n [class]=\"cn(\n 'aspect-square h-4 w-4 rounded-full border border-primary text-primary transition-all flex items-center justify-center',\n 'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n !isEffectiveDisabled && 'group-hover:border-primary/70',\n isSelected ? 'bg-background' : 'bg-transparent',\n class\n )\">\n <div\n *ngIf=\"isSelected\"\n class=\"h-2.5 w-2.5 rounded-full bg-primary animate-in zoom-in-50 duration-200\"\n ></div>\n </div>\n\n <label class=\"text-sm font-medium leading-none select-none\" [class.cursor-pointer]=\"!isEffectiveDisabled\">\n <ng-content></ng-content>\n </label>\n </div>\n `\n})\nexport class RadioItemComponent implements OnInit, OnDestroy, RovingRadioItem {\n @Input() value: any;\n @Input() disabled = false;\n @Input() class = '';\n\n isSelected = false;\n groupDisabled = false;\n /** Whether this item is the single roving tab stop for the group. */\n isTabbable = false;\n\n private sub = new Subscription();\n\n get isEffectiveDisabled(): boolean {\n return this.disabled || this.groupDisabled;\n }\n\n // Inject ChangeDetectorRef to ensure UI updates when service emits\n constructor(\n private radioService: RadioService,\n private cdr: ChangeDetectorRef,\n private el: ElementRef<HTMLElement>\n ) {}\n\n // --- RovingRadioItem contract (used by the group/service for keyboard nav) ---\n get hostElement(): HTMLElement {\n return this.el.nativeElement;\n }\n\n isItemDisabled(): boolean {\n return this.isEffectiveDisabled;\n }\n\n focusItem(): void {\n const radioEl = this.el.nativeElement.querySelector('[role=\"radio\"]') as HTMLElement | null;\n radioEl?.focus();\n }\n\n ngOnInit() {\n // Listen for selection changes\n this.sub.add(\n this.radioService.selectedValue$.subscribe(val => {\n this.isSelected = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for group-level disabled state\n this.sub.add(\n this.radioService.disabled$.subscribe(dis => {\n this.groupDisabled = dis;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for roving tabindex changes (which single item is tab-reachable)\n this.sub.add(\n this.radioService.tabbableValue$.subscribe(val => {\n this.isTabbable = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n this.radioService.register(this);\n }\n\n select() {\n this.radioService.select(this.value);\n }\n\n ngOnDestroy() {\n this.radioService.unregister(this);\n this.sub.unsubscribe(); // Clean up subscriptions\n }\n\n protected cn = cn;\n}\n"
13
+ "content": "import { Component, Input, OnInit, OnDestroy, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { RadioService, RovingRadioItem } from './radio-service';\nimport { Subscription } from 'rxjs';\n\n@Component({\n selector: 'tolle-radio-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n (click)=\"!isEffectiveDisabled && select()\"\n [class]=\"cn(\n 'flex items-center space-x-2 group py-1',\n isEffectiveDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'\n )\"\n >\n <div\n role=\"radio\"\n [attr.aria-checked]=\"isSelected\"\n [attr.aria-disabled]=\"isEffectiveDisabled || null\"\n [attr.data-state]=\"isSelected ? 'checked' : 'unchecked'\"\n [attr.tabindex]=\"isEffectiveDisabled ? -1 : (isTabbable ? 0 : -1)\"\n (keydown.enter)=\"isEffectiveDisabled ? null : select()\"\n (keydown.space)=\"isEffectiveDisabled ? null : select(); $event.preventDefault()\"\n [class]=\"cn(\n 'aspect-square h-4 w-4 rounded-full border border-primary text-primary transition-all flex items-center justify-center',\n 'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n !isEffectiveDisabled && 'group-hover:border-primary/70',\n isSelected ? 'bg-background' : 'bg-transparent',\n class\n )\">\n <div\n *ngIf=\"isSelected\"\n class=\"h-2.5 w-2.5 rounded-full bg-primary animate-in zoom-in-50 duration-200\"\n ></div>\n </div>\n\n <label class=\"text-sm font-medium leading-none select-none\" [class.cursor-pointer]=\"!isEffectiveDisabled\">\n <ng-content></ng-content>\n </label>\n </div>\n `\n})\nexport class RadioItemComponent implements OnInit, OnDestroy, RovingRadioItem {\n @Input() value: any;\n @Input() disabled = false;\n @Input() class = '';\n\n isSelected = false;\n groupDisabled = false;\n /** Whether this item is the single roving tab stop for the group. */\n isTabbable = false;\n\n private sub = new Subscription();\n\n get isEffectiveDisabled(): boolean {\n return this.disabled || this.groupDisabled;\n }\n\n // Inject ChangeDetectorRef to ensure UI updates when service emits\n constructor(\n private radioService: RadioService,\n private cdr: ChangeDetectorRef,\n private el: ElementRef<HTMLElement>\n ) {}\n\n // --- RovingRadioItem contract (used by the group/service for keyboard nav) ---\n get hostElement(): HTMLElement {\n return this.el.nativeElement;\n }\n\n isItemDisabled(): boolean {\n return this.isEffectiveDisabled;\n }\n\n focusItem(): void {\n const radioEl = this.el.nativeElement.querySelector('[role=\"radio\"]') as HTMLElement | null;\n radioEl?.focus();\n }\n\n ngOnInit() {\n // Listen for selection changes\n this.sub.add(\n this.radioService.selectedValue$.subscribe(val => {\n this.isSelected = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for group-level disabled state\n this.sub.add(\n this.radioService.disabled$.subscribe(dis => {\n this.groupDisabled = dis;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for roving tabindex changes (which single item is tab-reachable)\n this.sub.add(\n this.radioService.tabbableValue$.subscribe(val => {\n this.isTabbable = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n this.radioService.register(this);\n }\n\n select() {\n this.radioService.select(this.value);\n }\n\n ngOnDestroy() {\n this.radioService.unregister(this);\n this.sub.unsubscribe(); // Clean up subscriptions\n }\n\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "radio-service.ts",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "range-calendar.component.ts",
14
14
  "target": "ui/range-calendar.component.ts",
15
- "content": "import { Component, Input, OnInit, OnChanges, SimpleChanges, forwardRef, Output, EventEmitter, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport {\n addDays, addMonths, subMonths, startOfMonth, endOfMonth,\n startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth,\n isSameDay, isToday, setMonth, setYear, addYears, subYears,\n isBefore, startOfDay, isWithinInterval, format\n} from 'date-fns';\nimport { cn } from './utils/cn';\nimport {DateRange} from './types/date-range';\n\n@Component({\n selector: 'tolle-range-calendar',\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RangeCalendarComponent),\n multi: true\n }\n ],\n template: `\n <div [class]=\"cn('text-popover-foreground inline-block min-w-fit', bordered ? 'p-3 border rounded-md bg-background shadow-sm' : '', class)\">\n\n <!-- ===== Multi-month view (side-by-side, shared range) ===== -->\n <div *ngIf=\"numberOfMonths > 1\" class=\"flex flex-col gap-4 sm:flex-row\">\n <div *ngFor=\"let m of visibleMonths; let idx = index\" class=\"space-y-2\">\n <div class=\"flex items-center justify-between pt-1 pb-2\">\n <button *ngIf=\"idx === 0\" type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== 0\" class=\"h-7 w-7\"></span>\n\n <div class=\"text-sm font-semibold\">{{ m.label }}</div>\n\n <button *ngIf=\"idx === visibleMonths.length - 1\" type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== visibleMonths.length - 1\" class=\"h-7 w-7\"></span>\n </div>\n\n <div class=\"grid grid-cols-7 gap-y-1\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">{{ day }}</span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1\" (keydown)=\"onGridKeydown($event)\">\n <ng-container *ngFor=\"let date of m.days\">\n <!-- Only the month's own days are rendered; adjacent-month days are\n blank spacers so each calendar in a multi-month view is self-contained. -->\n <button\n *ngIf=\"isCurrentMonth(date, m.date); else pad\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date, m.date)\"\n >\n {{ date | date: 'd' }}\n </button>\n <ng-template #pad><span class=\"h-9 w-9\" aria-hidden=\"true\"></span></ng-template>\n </ng-container>\n </div>\n </div>\n </div>\n\n <!-- ===== Single-month view (with month / year quick pickers) ===== -->\n <ng-container *ngIf=\"numberOfMonths <= 1\">\n <div class=\"flex items-center justify-between pt-1 pb-4 gap-2\">\n <div class=\"flex items-center gap-1\">\n <button type=\"button\" (click)=\"setView('month')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'month' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'MMMM' }}\n </button>\n <button type=\"button\" (click)=\"setView('year')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'year' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'yyyy' }}\n </button>\n </div>\n <div class=\"flex items-center space-x-1\">\n <button type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\"><i class=\"ri-arrow-left-s-line text-lg\"></i></button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\"><i class=\"ri-arrow-right-s-line text-lg\"></i></button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'date'\" class=\"space-y-2 animate-in fade-in zoom-in-95 duration-200\">\n <div class=\"grid grid-cols-7 gap-y-1 w-full\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">\n {{ day }}\n </span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1 w-full\" (keydown)=\"onGridKeydown($event)\">\n <button\n *ngFor=\"let date of daysInMonth\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date)\"\n >\n {{ date | date: 'd' }}\n </button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'month'\" class=\"grid grid-cols-3 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let month of months; let i = index\" type=\"button\" (click)=\"selectMonth(i)\"\n [class]=\"cn('text-sm py-2.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', i === viewDate.getMonth() ? 'bg-primary text-primary-foreground' : '')\">\n {{ month }}\n </button>\n </div>\n\n <div *ngIf=\"currentView === 'year'\" class=\"grid grid-cols-4 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let year of years\" type=\"button\" (click)=\"selectYear(year)\"\n [class]=\"cn('text-sm py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', year === viewDate.getFullYear() ? 'bg-primary text-primary-foreground' : '')\">\n {{ year }}\n </button>\n </div>\n </ng-container>\n </div>\n `\n})\nexport class RangeCalendarComponent implements OnInit, OnChanges, ControlValueAccessor {\n @Input() class = '';\n @Input() disablePastDates = false;\n /** Renders the calendar's own border/background/shadow. Set `false` inside a\n * card/popover that already provides chrome. @default true */\n @Input() bordered = true;\n /** Number of consecutive months rendered side by side. @default 1 */\n @Input() numberOfMonths = 1;\n @Output() rangeSelect = new EventEmitter<DateRange>(); // Emits whenever selection changes\n\n currentView: 'date' | 'month' | 'year' = 'date';\n viewDate: Date = new Date();\n\n // The Range Value\n value: DateRange = { start: null, end: null };\n\n weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n daysInMonth: Date[] = [];\n /** Grids for each visible month (multi-month view). */\n visibleMonths: { date: Date; days: Date[]; label: string }[] = [];\n months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n years: number[] = [];\n\n navBtnClass = cn('h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 border border-input rounded-md flex items-center justify-center hover:bg-accent hover:text-accent-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring');\n\n onTouched: () => void = () => {};\n onChange: (value: DateRange) => void = () => {};\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef, private el: ElementRef<HTMLElement>) {}\n\n ngOnInit() {\n this.generateDays();\n this.generateYears();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Rebuild the month grids when the visible-month count changes after init\n // (e.g. a `[numberOfMonths]` toggle), so single ⇄ multi-month is reactive.\n if (changes['numberOfMonths'] && !changes['numberOfMonths'].firstChange) {\n this.generateDays();\n }\n }\n\n // --- Date Generation Logic (Same as Calendar) ---\n generateDays() {\n const start = startOfWeek(startOfMonth(this.viewDate));\n const end = endOfWeek(endOfMonth(this.viewDate));\n this.daysInMonth = eachDayOfInterval({ start, end });\n\n // Multi-month grids: viewDate is the leftmost month, then N-1 consecutive.\n const count = Math.max(1, this.numberOfMonths);\n this.visibleMonths = Array.from({ length: count }, (_, i) => {\n const monthDate = addMonths(this.viewDate, i);\n return {\n date: monthDate,\n days: eachDayOfInterval({ start: startOfWeek(startOfMonth(monthDate)), end: endOfWeek(endOfMonth(monthDate)) }),\n label: format(monthDate, 'MMMM yyyy'),\n };\n });\n }\n\n generateYears() {\n const currentYear = this.viewDate.getFullYear();\n this.years = Array.from({ length: 16 }, (_, i) => currentYear - 6 + i);\n }\n\n setView(view: 'date' | 'month' | 'year') {\n this.currentView = view;\n if (view === 'year') this.generateYears();\n }\n\n prev() {\n if (this.currentView === 'date') {\n this.viewDate = subMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = subYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n }\n }\n\n next() {\n if (this.currentView === 'date') {\n this.viewDate = addMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = addYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n }\n }\n\n // --- Range Selection Logic ---\n\n selectDate(date: Date) {\n if (this.isDateDisabled(date)) return;\n\n const { start, end } = this.value;\n\n // 1. If start exists but end doesn't\n if (start && !end) {\n if (isBefore(date, start)) {\n // User clicked earlier date -> Reset start\n this.value = { start: date, end: null };\n } else {\n // User clicked later date -> Complete range\n this.value = { start, end: date };\n }\n }\n // 2. If neither exist OR both exist (reset)\n else {\n this.value = { start: date, end: null };\n }\n\n // Only re-anchor the view when the clicked day isn't already on a visible month\n // (so clicking the right-hand month in a 2-month view doesn't jump the calendar).\n const onVisibleMonth = this.visibleMonths.some((m) => isSameMonth(date, m.date));\n if (!onVisibleMonth) {\n this.viewDate = startOfMonth(date);\n this.generateDays();\n }\n\n this.onChange(this.value);\n this.rangeSelect.emit(this.value);\n this.onTouched();\n }\n\n // --- Grid keyboard navigation (date view only) ---\n\n /**\n * Arrow keys move by day/week, Home/End to week edges, PageUp/PageDown by\n * month. Enter/Space are left to the native day <button>. The focused day is\n * read from its `data-date` key, so navigation is independent of grid layout\n * (adjacent-month days aren't rendered in the multi-month view).\n */\n onGridKeydown(event: KeyboardEvent) {\n if (this.numberOfMonths <= 1 && this.currentView !== 'date') return;\n\n const key = (event.target as HTMLElement).getAttribute('data-date');\n if (!key) return;\n const current = this.keyToDate(key);\n\n let next: Date | null = null;\n switch (event.key) {\n case 'ArrowLeft': next = addDays(current, -1); break;\n case 'ArrowRight': next = addDays(current, 1); break;\n case 'ArrowUp': next = addDays(current, -7); break;\n case 'ArrowDown': next = addDays(current, 7); break;\n case 'Home': next = startOfWeek(current); break;\n case 'End': next = endOfWeek(current); break;\n case 'PageUp': next = subMonths(current, 1); break;\n case 'PageDown': next = addMonths(current, 1); break;\n default: return;\n }\n\n event.preventDefault();\n this.focusDay(next);\n }\n\n private focusDay(date: Date) {\n const selector = `[data-date=\"${this.fmtKey(date)}\"]`;\n let btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n if (btn) { btn.focus(); return; }\n\n // Target day isn't currently rendered → page the view toward it, then retry.\n if (this.numberOfMonths > 1) {\n this.viewDate = isBefore(date, this.visibleMonths[0].date)\n ? subMonths(this.viewDate, 1)\n : addMonths(this.viewDate, 1);\n } else {\n this.viewDate = startOfMonth(date);\n }\n this.generateDays();\n this.cdr.detectChanges();\n btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n btn?.focus();\n }\n\n selectMonth(monthIndex: number) {\n this.viewDate = setMonth(this.viewDate, monthIndex);\n this.currentView = 'date';\n this.generateDays();\n }\n\n selectYear(year: number) {\n this.viewDate = setYear(this.viewDate, year);\n this.currentView = 'date';\n this.generateDays();\n }\n\n // --- Visual Styling for Range ---\n\n isSelected(date: Date): boolean {\n const { start, end } = this.value;\n return !!((start && isSameDay(date, start)) || (end && isSameDay(date, end)));\n }\n\n /** True when `date` belongs to `refMonth` (used to blank out adjacent-month cells). */\n isCurrentMonth(date: Date, refMonth: Date): boolean {\n return isSameMonth(date, refMonth);\n }\n\n /** Stable, timezone-safe `data-date` key used to locate day buttons for focus. */\n fmtKey(date: Date): string {\n return format(date, 'yyyy-MM-dd');\n }\n\n private keyToDate(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d);\n }\n\n getDayClass(date: Date, refMonth: Date = this.viewDate) {\n const { start, end } = this.value;\n const isOutside = !isSameMonth(date, refMonth);\n const isDisabled = this.isDateDisabled(date);\n\n // Range Checks\n const isStart = start && isSameDay(date, start);\n const isEnd = end && isSameDay(date, end);\n const isInside = start && end && isWithinInterval(date, { start, end });\n const isTodayDate = isToday(date);\n\n return cn(\n // Base: h-9 w-9, but we remove margins/rounding for the 'strip' effect\n 'h-9 w-9 p-0 font-normal text-sm transition-all flex items-center justify-center relative z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n\n // Default State (Not selected, Not disabled)\n !isInside && !isStart && !isEnd && !isDisabled && 'hover:bg-accent hover:text-accent-foreground rounded-md',\n\n // The \"Caps\": Start and End\n (isStart || isEnd) && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',\n\n // The \"Strip\": Dates between start and end\n isInside && !isStart && !isEnd && 'bg-accent text-accent-foreground rounded-none',\n\n // Connecting the Caps to the Strip (Rectangular inside edges)\n isStart && end && 'rounded-l-md rounded-r-none',\n isEnd && start && 'rounded-r-md rounded-l-none',\n // If no end date yet, Start should be fully rounded\n isStart && !end && 'rounded-md',\n\n // Muted/Disabled logic\n !isInside && isTodayDate && !isStart && !isEnd && 'bg-accent/50 text-accent-foreground rounded-md',\n (isOutside || isDisabled) && 'text-muted-foreground opacity-50',\n isDisabled && 'cursor-not-allowed'\n );\n }\n\n isDateDisabled(date: Date): boolean {\n return this.disablePastDates ? isBefore(date, startOfDay(new Date())) : false;\n }\n\n // --- CVA Implementation ---\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = val;\n if (val.start) this.viewDate = val.start;\n this.generateDays();\n this.generateYears();\n }\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n}\n"
15
+ "content": "import { Component, Input, OnInit, OnChanges, SimpleChanges, forwardRef, Output, EventEmitter, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport {\n addDays, addMonths, subMonths, startOfMonth, endOfMonth,\n startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth,\n isSameDay, isToday, setMonth, setYear, addYears, subYears,\n isBefore, startOfDay, isWithinInterval, format\n} from 'date-fns';\nimport { cn } from './utils/cn';\nimport {DateRange} from './types/date-range';\n\n@Component({\n selector: 'tolle-range-calendar',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RangeCalendarComponent),\n multi: true\n }\n ],\n template: `\n <div [class]=\"cn('text-popover-foreground inline-block min-w-fit', bordered ? 'p-3 border rounded-md bg-background shadow-sm' : '', class)\">\n\n <!-- ===== Multi-month view (side-by-side, shared range) ===== -->\n <div *ngIf=\"numberOfMonths > 1\" class=\"flex flex-col gap-4 sm:flex-row\">\n <div *ngFor=\"let m of visibleMonths; let idx = index\" class=\"space-y-2\">\n <div class=\"flex items-center justify-between pt-1 pb-2\">\n <button *ngIf=\"idx === 0\" type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== 0\" class=\"h-7 w-7\"></span>\n\n <div class=\"text-sm font-semibold\">{{ m.label }}</div>\n\n <button *ngIf=\"idx === visibleMonths.length - 1\" type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== visibleMonths.length - 1\" class=\"h-7 w-7\"></span>\n </div>\n\n <div class=\"grid grid-cols-7 gap-y-1\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">{{ day }}</span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1\" (keydown)=\"onGridKeydown($event)\">\n <ng-container *ngFor=\"let date of m.days\">\n <!-- Only the month's own days are rendered; adjacent-month days are\n blank spacers so each calendar in a multi-month view is self-contained. -->\n <button\n *ngIf=\"isCurrentMonth(date, m.date); else pad\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date, m.date)\"\n >\n {{ date | date: 'd' }}\n </button>\n <ng-template #pad><span class=\"h-9 w-9\" aria-hidden=\"true\"></span></ng-template>\n </ng-container>\n </div>\n </div>\n </div>\n\n <!-- ===== Single-month view (with month / year quick pickers) ===== -->\n <ng-container *ngIf=\"numberOfMonths <= 1\">\n <div class=\"flex items-center justify-between pt-1 pb-4 gap-2\">\n <div class=\"flex items-center gap-1\">\n <button type=\"button\" (click)=\"setView('month')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'month' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'MMMM' }}\n </button>\n <button type=\"button\" (click)=\"setView('year')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'year' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'yyyy' }}\n </button>\n </div>\n <div class=\"flex items-center space-x-1\">\n <button type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\"><i class=\"ri-arrow-left-s-line text-lg\"></i></button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\"><i class=\"ri-arrow-right-s-line text-lg\"></i></button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'date'\" class=\"space-y-2 animate-in fade-in zoom-in-95 duration-200\">\n <div class=\"grid grid-cols-7 gap-y-1 w-full\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">\n {{ day }}\n </span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1 w-full\" (keydown)=\"onGridKeydown($event)\">\n <button\n *ngFor=\"let date of daysInMonth\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date)\"\n >\n {{ date | date: 'd' }}\n </button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'month'\" class=\"grid grid-cols-3 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let month of months; let i = index\" type=\"button\" (click)=\"selectMonth(i)\"\n [class]=\"cn('text-sm py-2.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', i === viewDate.getMonth() ? 'bg-primary text-primary-foreground' : '')\">\n {{ month }}\n </button>\n </div>\n\n <div *ngIf=\"currentView === 'year'\" class=\"grid grid-cols-4 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let year of years\" type=\"button\" (click)=\"selectYear(year)\"\n [class]=\"cn('text-sm py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', year === viewDate.getFullYear() ? 'bg-primary text-primary-foreground' : '')\">\n {{ year }}\n </button>\n </div>\n </ng-container>\n </div>\n `\n})\nexport class RangeCalendarComponent implements OnInit, OnChanges, ControlValueAccessor {\n @Input() class = '';\n @Input() disablePastDates = false;\n /** Renders the calendar's own border/background/shadow. Set `false` inside a\n * card/popover that already provides chrome. @default true */\n @Input() bordered = true;\n /** Number of consecutive months rendered side by side. @default 1 */\n @Input() numberOfMonths = 1;\n @Output() rangeSelect = new EventEmitter<DateRange>(); // Emits whenever selection changes\n\n currentView: 'date' | 'month' | 'year' = 'date';\n viewDate: Date = new Date();\n\n // The Range Value\n value: DateRange = { start: null, end: null };\n\n weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n daysInMonth: Date[] = [];\n /** Grids for each visible month (multi-month view). */\n visibleMonths: { date: Date; days: Date[]; label: string }[] = [];\n months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n years: number[] = [];\n\n navBtnClass = cn('h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 border border-input rounded-md flex items-center justify-center hover:bg-accent hover:text-accent-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring');\n\n onTouched: () => void = () => {};\n onChange: (value: DateRange) => void = () => {};\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef, private el: ElementRef<HTMLElement>) {}\n\n ngOnInit() {\n this.generateDays();\n this.generateYears();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Rebuild the month grids when the visible-month count changes after init\n // (e.g. a `[numberOfMonths]` toggle), so single ⇄ multi-month is reactive.\n if (changes['numberOfMonths'] && !changes['numberOfMonths'].firstChange) {\n this.generateDays();\n }\n }\n\n // --- Date Generation Logic (Same as Calendar) ---\n generateDays() {\n const start = startOfWeek(startOfMonth(this.viewDate));\n const end = endOfWeek(endOfMonth(this.viewDate));\n this.daysInMonth = eachDayOfInterval({ start, end });\n\n // Multi-month grids: viewDate is the leftmost month, then N-1 consecutive.\n const count = Math.max(1, this.numberOfMonths);\n this.visibleMonths = Array.from({ length: count }, (_, i) => {\n const monthDate = addMonths(this.viewDate, i);\n return {\n date: monthDate,\n days: eachDayOfInterval({ start: startOfWeek(startOfMonth(monthDate)), end: endOfWeek(endOfMonth(monthDate)) }),\n label: format(monthDate, 'MMMM yyyy'),\n };\n });\n }\n\n generateYears() {\n const currentYear = this.viewDate.getFullYear();\n this.years = Array.from({ length: 16 }, (_, i) => currentYear - 6 + i);\n }\n\n setView(view: 'date' | 'month' | 'year') {\n this.currentView = view;\n if (view === 'year') this.generateYears();\n }\n\n prev() {\n if (this.currentView === 'date') {\n this.viewDate = subMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = subYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n }\n }\n\n next() {\n if (this.currentView === 'date') {\n this.viewDate = addMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = addYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n }\n }\n\n // --- Range Selection Logic ---\n\n selectDate(date: Date) {\n if (this.isDateDisabled(date)) return;\n\n const { start, end } = this.value;\n\n // 1. If start exists but end doesn't\n if (start && !end) {\n if (isBefore(date, start)) {\n // User clicked earlier date -> Reset start\n this.value = { start: date, end: null };\n } else {\n // User clicked later date -> Complete range\n this.value = { start, end: date };\n }\n }\n // 2. If neither exist OR both exist (reset)\n else {\n this.value = { start: date, end: null };\n }\n\n // Only re-anchor the view when the clicked day isn't already on a visible month\n // (so clicking the right-hand month in a 2-month view doesn't jump the calendar).\n const onVisibleMonth = this.visibleMonths.some((m) => isSameMonth(date, m.date));\n if (!onVisibleMonth) {\n this.viewDate = startOfMonth(date);\n this.generateDays();\n }\n\n this.onChange(this.value);\n this.rangeSelect.emit(this.value);\n this.onTouched();\n }\n\n // --- Grid keyboard navigation (date view only) ---\n\n /**\n * Arrow keys move by day/week, Home/End to week edges, PageUp/PageDown by\n * month. Enter/Space are left to the native day <button>. The focused day is\n * read from its `data-date` key, so navigation is independent of grid layout\n * (adjacent-month days aren't rendered in the multi-month view).\n */\n onGridKeydown(event: KeyboardEvent) {\n if (this.numberOfMonths <= 1 && this.currentView !== 'date') return;\n\n const key = (event.target as HTMLElement).getAttribute('data-date');\n if (!key) return;\n const current = this.keyToDate(key);\n\n let next: Date | null = null;\n switch (event.key) {\n case 'ArrowLeft': next = addDays(current, -1); break;\n case 'ArrowRight': next = addDays(current, 1); break;\n case 'ArrowUp': next = addDays(current, -7); break;\n case 'ArrowDown': next = addDays(current, 7); break;\n case 'Home': next = startOfWeek(current); break;\n case 'End': next = endOfWeek(current); break;\n case 'PageUp': next = subMonths(current, 1); break;\n case 'PageDown': next = addMonths(current, 1); break;\n default: return;\n }\n\n event.preventDefault();\n this.focusDay(next);\n }\n\n private focusDay(date: Date) {\n const selector = `[data-date=\"${this.fmtKey(date)}\"]`;\n let btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n if (btn) { btn.focus(); return; }\n\n // Target day isn't currently rendered → page the view toward it, then retry.\n if (this.numberOfMonths > 1) {\n this.viewDate = isBefore(date, this.visibleMonths[0].date)\n ? subMonths(this.viewDate, 1)\n : addMonths(this.viewDate, 1);\n } else {\n this.viewDate = startOfMonth(date);\n }\n this.generateDays();\n this.cdr.detectChanges();\n btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n btn?.focus();\n }\n\n selectMonth(monthIndex: number) {\n this.viewDate = setMonth(this.viewDate, monthIndex);\n this.currentView = 'date';\n this.generateDays();\n }\n\n selectYear(year: number) {\n this.viewDate = setYear(this.viewDate, year);\n this.currentView = 'date';\n this.generateDays();\n }\n\n // --- Visual Styling for Range ---\n\n isSelected(date: Date): boolean {\n const { start, end } = this.value;\n return !!((start && isSameDay(date, start)) || (end && isSameDay(date, end)));\n }\n\n /** True when `date` belongs to `refMonth` (used to blank out adjacent-month cells). */\n isCurrentMonth(date: Date, refMonth: Date): boolean {\n return isSameMonth(date, refMonth);\n }\n\n /** Stable, timezone-safe `data-date` key used to locate day buttons for focus. */\n fmtKey(date: Date): string {\n return format(date, 'yyyy-MM-dd');\n }\n\n private keyToDate(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d);\n }\n\n getDayClass(date: Date, refMonth: Date = this.viewDate) {\n const { start, end } = this.value;\n const isOutside = !isSameMonth(date, refMonth);\n const isDisabled = this.isDateDisabled(date);\n\n // Range Checks\n const isStart = start && isSameDay(date, start);\n const isEnd = end && isSameDay(date, end);\n const isInside = start && end && isWithinInterval(date, { start, end });\n const isTodayDate = isToday(date);\n\n return cn(\n // Base: h-9 w-9, but we remove margins/rounding for the 'strip' effect\n 'h-9 w-9 p-0 font-normal text-sm transition-all flex items-center justify-center relative z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n\n // Default State (Not selected, Not disabled)\n !isInside && !isStart && !isEnd && !isDisabled && 'hover:bg-accent hover:text-accent-foreground rounded-md',\n\n // The \"Caps\": Start and End\n (isStart || isEnd) && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',\n\n // The \"Strip\": Dates between start and end\n isInside && !isStart && !isEnd && 'bg-accent text-accent-foreground rounded-none',\n\n // Connecting the Caps to the Strip (Rectangular inside edges)\n isStart && end && 'rounded-l-md rounded-r-none',\n isEnd && start && 'rounded-r-md rounded-l-none',\n // If no end date yet, Start should be fully rounded\n isStart && !end && 'rounded-md',\n\n // Muted/Disabled logic\n !isInside && isTodayDate && !isStart && !isEnd && 'bg-accent/50 text-accent-foreground rounded-md',\n (isOutside || isDisabled) && 'text-muted-foreground opacity-50',\n isDisabled && 'cursor-not-allowed'\n );\n }\n\n isDateDisabled(date: Date): boolean {\n return this.disablePastDates ? isBefore(date, startOfDay(new Date())) : false;\n }\n\n // --- CVA Implementation ---\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = val;\n if (val.start) this.viewDate = val.start;\n this.generateDays();\n this.generateYears();\n }\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n}\n"
16
16
  },
17
17
  {
18
18
  "path": "types/date-range.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "reasoning",
3
+ "type": "registry:ui",
4
+ "title": "Reasoning",
5
+ "description": "Reasoning component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "reasoning.component.ts",
14
+ "target": "ui/reasoning.component.ts",
15
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Coordinates a `tolle-reasoning` with its trigger and content.\n *\n * The trigger and content are OnPush children, so the open/streaming/duration\n * state they render from is published here rather than read off the parent.\n *\n * Provided on `ReasoningComponent`, so each trace gets its own instance.\n */\n@Injectable()\nexport class ReasoningService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the trace is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly streamingSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the model is still producing the trace. */\n readonly streaming$ = this.streamingSource.asObservable();\n\n private readonly durationSource = new BehaviorSubject<number>(0);\n /** Emits how long the model spent reasoning, in seconds. */\n readonly duration$ = this.durationSource.asObservable();\n\n /** True while the trace is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n setOpen(value: boolean): void {\n if (this.openSource.value !== value) this.openSource.next(value);\n }\n\n toggle(): void {\n this.setOpen(!this.openSource.value);\n }\n\n setStreaming(value: boolean): void {\n if (this.streamingSource.value !== value) this.streamingSource.next(value);\n }\n\n setDuration(value: number): void {\n if (this.durationSource.value !== value) this.durationSource.next(value);\n }\n}\n\nconst reasoningVariants = cva('flex w-full flex-col gap-2', {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n lg: 'text-base',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\nexport type ReasoningProps = VariantProps<typeof reasoningVariants>;\n\n/**\n * A collapsible view of the model's reasoning, collapsed by default.\n *\n * While `streaming` is true the trigger reads \"Thinking…\"; once it finishes the\n * trigger reports how long the model thought, using `duration`.\n *\n * Compose it from `tolle-reasoning-trigger` and `tolle-reasoning-content`.\n * @new\n */\n@Component({\n selector: 'tolle-reasoning',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ReasoningService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"open ? 'open' : 'closed'\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ReasoningComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the trace is expanded. @default false */\n @Input() open = false;\n /** True while the model is still producing the trace. @default false */\n @Input() streaming = false;\n /** Seconds the model spent reasoning, shown once streaming finishes. @default 0 */\n @Input() duration = 0;\n /** Text scale of the trace. @default 'default' */\n @Input() size: ReasoningProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trace via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the trace is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n /** True while an `open` input is being pushed into the service. */\n private applyingInput = false;\n\n ngOnInit(): void {\n this.sync();\n // `skip(1)` drops the BehaviorSubject's current value so simply mounting\n // the component never emits a spurious `openChange`.\n this.subscriptions.add(\n this.service.open$.pipe(skip(1)).subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n // Only user-driven toggles are worth reporting; echoing a change the\n // parent just made would fight a `[(open)]` binding.\n if (!this.applyingInput) this.openChange.emit(open);\n })\n );\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['open']) this.applyInput(this.open);\n this.service.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\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 private applyInput(open: boolean): void {\n this.applyingInput = true;\n try {\n this.service.setOpen(open);\n } finally {\n this.applyingInput = false;\n }\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(reasoningVariants({ size: this.size }), this.class);\n }\n\n private sync(): void {\n this.service.setOpen(this.open);\n this.service.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\n }\n}\n\n/**\n * Toggle for a `tolle-reasoning`. Shows a live \"Thinking…\" label while the\n * trace is streaming and the elapsed thinking time once it is done.\n */\n@Component({\n selector: 'tolle-reasoning-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-reasoning-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i\n [class]=\"icon\"\n [class.animate-pulse]=\"streaming\"\n aria-hidden=\"true\"\n ></i>\n\n <span data-reasoning-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class ReasoningTriggerComponent 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 /** Remixicon class shown before the label. @default 'ri-brain-line' */\n @Input() icon = 'ri-brain-line';\n /** Label shown while the trace is streaming. @default 'Thinking…' */\n @Input() thinkingLabel = 'Thinking…';\n /** Label used when the trace finished without a measured duration. @default 'Thought for a few seconds' */\n @Input() fallbackLabel = 'Thought for a few seconds';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n streaming = false;\n duration = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.streaming$.subscribe((streaming) => {\n this.streaming = streaming;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.duration$.subscribe((duration) => {\n this.duration = duration;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** \"Thinking…\" while streaming, otherwise how long the model thought. */\n get label(): string {\n if (this.streaming) return this.thinkingLabel;\n if (!this.duration || this.duration < 1) return this.fallbackLabel;\n const rounded = Math.round(this.duration);\n return 'Thought for ' + rounded + (rounded === 1 ? ' second' : ' seconds');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/** The reasoning text itself. Rendered only while the trace is expanded. */\n@Component({\n selector: 'tolle-reasoning-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-reasoning-content [class]=\"computedClass\">\n <p *ngIf=\"text\" class=\"whitespace-pre-wrap\">{{ text }}</p>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ReasoningContentComponent 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 /** Reasoning text to render; project your own markup instead when richer. @default '' */\n @Input() text = '';\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'border-l-2 border-border pl-4 text-muted-foreground',\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": "segment.component.ts",
12
12
  "target": "ui/segment.component.ts",
13
- "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentedComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-[cubic-bezier(0.2,0.0,0.2,1)]\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentedComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\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 ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-[cubic-bezier(0.2,0.0,0.2,1)]\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n\n/**\n * Back-compat alias: the class was renamed to match its `tolle-segment` selector.\n *\n * A re-export binding rather than `const SegmentedComponent = SegmentComponent`,\n * because Angular's AOT compiler resolves an aliased export straight back to the\n * class declaration — so it still works in a consumer's `imports: []` array.\n *\n * @deprecated Use `SegmentComponent`. This alias will be removed in a future major.\n */\nexport { SegmentComponent as SegmentedComponent };\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "select-group.component.ts",
12
12
  "target": "ui/select-group.component.ts",
13
- "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "select-item.component.ts",
12
12
  "target": "ui/select-item.component.ts",
13
- "content": "import {Component, Input, HostListener, ElementRef, Optional} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {SelectService} from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"!hidden\"\n [id]=\"id\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\"\n >\n <!-- Single Select: Checkmark -->\n <span *ngIf=\"!multiSelect && selected\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n\n <!-- Multi-Select: Checkbox -->\n <span *ngIf=\"multiSelect\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div [class]=\"cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\">\n <i [class]=\"cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'opacity-100 scale-100' : 'opacity-0 scale-75'\n )\"></i>\n </div>\n </span>\n\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n\n <!-- Disabled indicator -->\n <span *ngIf=\"disabled && !selected\" class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n </div>\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && [\n 'cursor-pointer',\n 'hover:bg-accent hover:text-accent-foreground'\n ],\n\n // Selected state\n this.selected && [\n this.multiSelect\n ? 'bg-primary/5 text-foreground'\n : 'bg-accent text-accent-foreground'\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground'\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
13
+ "content": "import {Component, Input, HostListener, ElementRef, Optional} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {SelectService} from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"!hidden\"\n [id]=\"id\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\"\n >\n <!-- Single Select: Checkmark -->\n <span *ngIf=\"!multiSelect && selected\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n\n <!-- Multi-Select: Checkbox -->\n <span *ngIf=\"multiSelect\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div [class]=\"cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\">\n <i [class]=\"cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'opacity-100 scale-100' : 'opacity-0 scale-75'\n )\"></i>\n </div>\n </span>\n\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n\n <!-- Disabled indicator -->\n <span *ngIf=\"disabled && !selected\" class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n </div>\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && [\n 'cursor-pointer',\n 'hover:bg-accent hover:text-accent-foreground'\n ],\n\n // Selected state\n this.selected && [\n this.multiSelect\n ? 'bg-primary/5 text-foreground'\n : 'bg-accent text-accent-foreground'\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground'\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "select.service.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "select-separator.component.ts",
12
12
  "target": "ui/select-separator.component.ts",
13
- "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",