@tolle_/tolle-ui 18.2.29 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/esm2022/lib/accordion-item.component.mjs +5 -11
  2. package/esm2022/lib/accordion.component.mjs +4 -9
  3. package/esm2022/lib/alert-dialog-dynamic.component.mjs +12 -12
  4. package/esm2022/lib/alert-dialog.component.mjs +82 -64
  5. package/esm2022/lib/alert-dialog.service.mjs +38 -6
  6. package/esm2022/lib/alert.component.mjs +5 -11
  7. package/esm2022/lib/aspect-ratio.component.mjs +119 -0
  8. package/esm2022/lib/attachment.component.mjs +334 -0
  9. package/esm2022/lib/avatar-fallback.component.mjs +3 -3
  10. package/esm2022/lib/avatar.component.mjs +4 -9
  11. package/esm2022/lib/badge.component.mjs +4 -9
  12. package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
  13. package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
  14. package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
  15. package/esm2022/lib/breadcrumb.component.mjs +3 -3
  16. package/esm2022/lib/bubble.component.mjs +301 -0
  17. package/esm2022/lib/button.component.mjs +5 -11
  18. package/esm2022/lib/calendar.component.mjs +5 -11
  19. package/esm2022/lib/card.component.mjs +14 -43
  20. package/esm2022/lib/carousel/carousel.component.mjs +5 -12
  21. package/esm2022/lib/chain-of-thought.component.mjs +380 -0
  22. package/esm2022/lib/chart-pie.component.mjs +386 -0
  23. package/esm2022/lib/chart.component.mjs +1146 -0
  24. package/esm2022/lib/chart.service.mjs +555 -0
  25. package/esm2022/lib/checkbox.component.mjs +5 -11
  26. package/esm2022/lib/checkpoint.component.mjs +134 -0
  27. package/esm2022/lib/collapsible.component.mjs +4 -11
  28. package/esm2022/lib/combobox.component.mjs +372 -0
  29. package/esm2022/lib/command-dialog.component.mjs +164 -0
  30. package/esm2022/lib/command.component.mjs +514 -0
  31. package/esm2022/lib/command.service.mjs +130 -0
  32. package/esm2022/lib/confirmation.component.mjs +219 -0
  33. package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
  34. package/esm2022/lib/context-menu.component.mjs +460 -0
  35. package/esm2022/lib/context-menu.service.mjs +125 -0
  36. package/esm2022/lib/context.component.mjs +377 -0
  37. package/esm2022/lib/conversation.component.mjs +442 -0
  38. package/esm2022/lib/country-selector.component.mjs +5 -11
  39. package/esm2022/lib/data-table.component.mjs +59 -12
  40. package/esm2022/lib/date-picker.component.mjs +251 -198
  41. package/esm2022/lib/date-range-picker.component.mjs +224 -116
  42. package/esm2022/lib/date-time-picker.component.mjs +506 -0
  43. package/esm2022/lib/direction.component.mjs +79 -0
  44. package/esm2022/lib/dropdown-item.component.mjs +3 -3
  45. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  46. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  47. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  48. package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
  49. package/esm2022/lib/empty-state.component.mjs +4 -9
  50. package/esm2022/lib/field.component.mjs +394 -0
  51. package/esm2022/lib/hover-card.component.mjs +6 -16
  52. package/esm2022/lib/inline-citation.component.mjs +263 -0
  53. package/esm2022/lib/input-group.component.mjs +418 -0
  54. package/esm2022/lib/input.component.mjs +5 -11
  55. package/esm2022/lib/item.component.mjs +243 -0
  56. package/esm2022/lib/kbd.component.mjs +82 -0
  57. package/esm2022/lib/label.component.mjs +4 -9
  58. package/esm2022/lib/marker.component.mjs +135 -0
  59. package/esm2022/lib/masked-input.component.mjs +5 -11
  60. package/esm2022/lib/menubar.component.mjs +683 -0
  61. package/esm2022/lib/message-scroller.component.mjs +353 -0
  62. package/esm2022/lib/message-scroller.service.mjs +157 -0
  63. package/esm2022/lib/message.component.mjs +228 -0
  64. package/esm2022/lib/modal-ref.mjs +36 -7
  65. package/esm2022/lib/modal.component.mjs +42 -28
  66. package/esm2022/lib/modal.mjs +2 -29
  67. package/esm2022/lib/modal.service.mjs +26 -4
  68. package/esm2022/lib/model-selector.component.mjs +310 -0
  69. package/esm2022/lib/multi-select.component.mjs +155 -72
  70. package/esm2022/lib/native-select.component.mjs +201 -0
  71. package/esm2022/lib/navigation-menu.component.mjs +642 -0
  72. package/esm2022/lib/otp-group.component.mjs +3 -3
  73. package/esm2022/lib/otp-slot.component.mjs +4 -9
  74. package/esm2022/lib/otp.component.mjs +5 -11
  75. package/esm2022/lib/pagination.component.mjs +7 -10
  76. package/esm2022/lib/plan.component.mjs +335 -0
  77. package/esm2022/lib/popover-content.component.mjs +4 -9
  78. package/esm2022/lib/popover.component.mjs +15 -13
  79. package/esm2022/lib/progress.component.mjs +4 -9
  80. package/esm2022/lib/prompt-input.component.mjs +526 -0
  81. package/esm2022/lib/queue.component.mjs +280 -0
  82. package/esm2022/lib/radio-group.component.mjs +6 -13
  83. package/esm2022/lib/radio-item.component.mjs +4 -9
  84. package/esm2022/lib/range-calendar.component.mjs +5 -11
  85. package/esm2022/lib/reasoning.component.mjs +335 -0
  86. package/esm2022/lib/resizable-panel.component.mjs +5 -5
  87. package/esm2022/lib/resizable.component.mjs +219 -0
  88. package/esm2022/lib/segment.component.mjs +19 -9
  89. package/esm2022/lib/select-group.component.mjs +4 -8
  90. package/esm2022/lib/select-item.component.mjs +4 -9
  91. package/esm2022/lib/select-separator.component.mjs +4 -8
  92. package/esm2022/lib/select.component.mjs +133 -49
  93. package/esm2022/lib/separator.component.mjs +4 -9
  94. package/esm2022/lib/sheet.component.mjs +38 -56
  95. package/esm2022/lib/shimmer.component.mjs +84 -0
  96. package/esm2022/lib/sidebar.component.mjs +11 -1
  97. package/esm2022/lib/skeleton.component.mjs +4 -9
  98. package/esm2022/lib/sources.component.mjs +452 -0
  99. package/esm2022/lib/spinner.component.mjs +68 -0
  100. package/esm2022/lib/suggestion.component.mjs +207 -0
  101. package/esm2022/lib/switch.component.mjs +5 -11
  102. package/esm2022/lib/table.component.mjs +213 -0
  103. package/esm2022/lib/tabs.component.mjs +4 -11
  104. package/esm2022/lib/tag-input.component.mjs +5 -11
  105. package/esm2022/lib/task.component.mjs +359 -0
  106. package/esm2022/lib/textarea.component.mjs +5 -11
  107. package/esm2022/lib/theme.service.mjs +17 -6
  108. package/esm2022/lib/time-picker.component.mjs +923 -0
  109. package/esm2022/lib/toast.service.mjs +3 -2
  110. package/esm2022/lib/toaster.component.mjs +76 -39
  111. package/esm2022/lib/toggle-group.component.mjs +4 -9
  112. package/esm2022/lib/toggle.component.mjs +5 -11
  113. package/esm2022/lib/tool.component.mjs +462 -0
  114. package/esm2022/lib/typography.component.mjs +83 -0
  115. package/esm2022/lib/utils/color.mjs +140 -19
  116. package/esm2022/public-api.mjs +61 -1
  117. package/fesm2022/tolle-ui.mjs +16642 -1105
  118. package/fesm2022/tolle-ui.mjs.map +1 -1
  119. package/lib/alert-dialog-dynamic.component.d.ts +5 -0
  120. package/lib/alert-dialog.component.d.ts +23 -2
  121. package/lib/aspect-ratio.component.d.ts +20 -0
  122. package/lib/attachment.component.d.ts +101 -0
  123. package/lib/bubble.component.d.ts +112 -0
  124. package/lib/chain-of-thought.component.d.ts +166 -0
  125. package/lib/chart-pie.component.d.ts +108 -0
  126. package/lib/chart.component.d.ts +369 -0
  127. package/lib/chart.service.d.ts +222 -0
  128. package/lib/checkpoint.component.d.ts +51 -0
  129. package/lib/combobox.component.d.ts +90 -0
  130. package/lib/command-dialog.component.d.ts +53 -0
  131. package/lib/command.component.d.ts +189 -0
  132. package/lib/command.service.d.ts +67 -0
  133. package/lib/confirmation.component.d.ts +70 -0
  134. package/lib/context-menu-trigger.directive.d.ts +12 -0
  135. package/lib/context-menu.component.d.ts +41 -0
  136. package/lib/context-menu.service.d.ts +38 -0
  137. package/lib/context.component.d.ts +134 -0
  138. package/lib/conversation.component.d.ts +165 -0
  139. package/lib/data-table.component.d.ts +26 -1
  140. package/lib/date-picker.component.d.ts +72 -11
  141. package/lib/date-range-picker.component.d.ts +63 -6
  142. package/lib/date-time-picker.component.d.ts +119 -0
  143. package/lib/direction.component.d.ts +45 -0
  144. package/lib/field.component.d.ts +201 -0
  145. package/lib/inline-citation.component.d.ts +103 -0
  146. package/lib/input-group.component.d.ts +150 -0
  147. package/lib/item.component.d.ts +154 -0
  148. package/lib/kbd.component.d.ts +50 -0
  149. package/lib/marker.component.d.ts +58 -0
  150. package/lib/menubar.component.d.ts +247 -0
  151. package/lib/message-scroller.component.d.ts +159 -0
  152. package/lib/message-scroller.service.d.ts +73 -0
  153. package/lib/message.component.d.ts +128 -0
  154. package/lib/modal-ref.d.ts +6 -1
  155. package/lib/modal.component.d.ts +7 -4
  156. package/lib/modal.d.ts +9 -3
  157. package/lib/model-selector.component.d.ts +103 -0
  158. package/lib/multi-select.component.d.ts +66 -14
  159. package/lib/native-select.component.d.ts +57 -0
  160. package/lib/navigation-menu.component.d.ts +230 -0
  161. package/lib/plan.component.d.ts +120 -0
  162. package/lib/popover.component.d.ts +1 -0
  163. package/lib/prompt-input.component.d.ts +194 -0
  164. package/lib/queue.component.d.ts +106 -0
  165. package/lib/reasoning.component.d.ts +127 -0
  166. package/lib/resizable.component.d.ts +43 -0
  167. package/lib/segment.component.d.ts +13 -3
  168. package/lib/select.component.d.ts +37 -5
  169. package/lib/sheet.component.d.ts +3 -0
  170. package/lib/shimmer.component.d.ts +38 -0
  171. package/lib/sidebar.component.d.ts +2 -0
  172. package/lib/sources.component.d.ts +156 -0
  173. package/lib/spinner.component.d.ts +29 -0
  174. package/lib/suggestion.component.d.ts +96 -0
  175. package/lib/table.component.d.ts +106 -0
  176. package/lib/task.component.d.ts +151 -0
  177. package/lib/theme.service.d.ts +9 -0
  178. package/lib/time-picker.component.d.ts +243 -0
  179. package/lib/toast.service.d.ts +3 -1
  180. package/lib/toaster.component.d.ts +26 -9
  181. package/lib/tool.component.d.ts +158 -0
  182. package/lib/typography.component.d.ts +32 -0
  183. package/lib/utils/color.d.ts +19 -5
  184. package/package.json +3 -2
  185. package/preset.js +7 -1
  186. package/public-api.d.ts +50 -0
  187. package/registry/docs-content.json +8480 -1542
  188. package/registry/llms-full.txt +2679 -319
  189. package/registry/llms.txt +43 -4
  190. package/registry/manifest.json +9090 -1742
  191. package/registry/r/accordion-item.json +1 -1
  192. package/registry/r/accordion.json +1 -1
  193. package/registry/r/alert-dialog-dynamic.json +1 -1
  194. package/registry/r/alert-dialog.json +1 -1
  195. package/registry/r/alert.json +1 -1
  196. package/registry/r/attachment.json +23 -0
  197. package/registry/r/avatar-fallback.json +1 -1
  198. package/registry/r/avatar.json +1 -1
  199. package/registry/r/badge.json +1 -1
  200. package/registry/r/breadcrumb-item.json +1 -1
  201. package/registry/r/breadcrumb-link.json +1 -1
  202. package/registry/r/breadcrumb-separator.json +1 -1
  203. package/registry/r/breadcrumb.json +1 -1
  204. package/registry/r/bubble.json +23 -0
  205. package/registry/r/button.json +1 -1
  206. package/registry/r/calendar.json +1 -1
  207. package/registry/r/card.json +1 -1
  208. package/registry/r/carousel.json +1 -1
  209. package/registry/r/chain-of-thought.json +23 -0
  210. package/registry/r/chart-pie.json +30 -0
  211. package/registry/r/chart.json +28 -0
  212. package/registry/r/checkbox.json +1 -1
  213. package/registry/r/checkpoint.json +23 -0
  214. package/registry/r/collapsible.json +1 -1
  215. package/registry/r/combobox.json +31 -0
  216. package/registry/r/command-dialog.json +21 -0
  217. package/registry/r/command.json +26 -0
  218. package/registry/r/confirmation.json +23 -0
  219. package/registry/r/context-menu-trigger.json +1 -1
  220. package/registry/r/context-menu.json +1 -1
  221. package/registry/r/context.json +26 -0
  222. package/registry/r/conversation.json +23 -0
  223. package/registry/r/country-selector.json +1 -1
  224. package/registry/r/data-table.json +1 -1
  225. package/registry/r/date-picker.json +4 -4
  226. package/registry/r/date-range-picker.json +3 -3
  227. package/registry/r/date-time-picker.json +28 -0
  228. package/registry/r/direction.json +16 -0
  229. package/registry/r/dropdown-item.json +1 -1
  230. package/registry/r/dropdown-label.json +1 -1
  231. package/registry/r/dropdown-menu.json +1 -1
  232. package/registry/r/dropdown-separator.json +1 -1
  233. package/registry/r/dropdown-trigger.json +1 -1
  234. package/registry/r/empty-state.json +1 -1
  235. package/registry/r/field.json +23 -0
  236. package/registry/r/hover-card.json +1 -1
  237. package/registry/r/inline-citation.json +26 -0
  238. package/registry/r/input-group.json +23 -0
  239. package/registry/r/input.json +1 -1
  240. package/registry/r/item.json +23 -0
  241. package/registry/r/kbd.json +23 -0
  242. package/registry/r/label.json +1 -1
  243. package/registry/r/marker.json +23 -0
  244. package/registry/r/masked-input.json +1 -1
  245. package/registry/r/menubar.json +24 -0
  246. package/registry/r/message-scroller.json +26 -0
  247. package/registry/r/message.json +23 -0
  248. package/registry/r/modal.json +3 -3
  249. package/registry/r/model-selector.json +32 -0
  250. package/registry/r/multi-select.json +2 -2
  251. package/registry/r/native-select.json +23 -0
  252. package/registry/r/navigation-menu.json +24 -0
  253. package/registry/r/otp-group.json +1 -1
  254. package/registry/r/otp-slot.json +1 -1
  255. package/registry/r/otp.json +1 -1
  256. package/registry/r/pagination.json +3 -2
  257. package/registry/r/plan.json +23 -0
  258. package/registry/r/popover-content.json +1 -1
  259. package/registry/r/popover.json +1 -1
  260. package/registry/r/progress.json +1 -1
  261. package/registry/r/prompt-input.json +23 -0
  262. package/registry/r/queue.json +23 -0
  263. package/registry/r/radio-group.json +1 -1
  264. package/registry/r/radio-item.json +1 -1
  265. package/registry/r/range-calendar.json +1 -1
  266. package/registry/r/reasoning.json +23 -0
  267. package/registry/r/resizable-panel.json +1 -1
  268. package/registry/r/segment.json +1 -1
  269. package/registry/r/select-group.json +1 -1
  270. package/registry/r/select-item.json +1 -1
  271. package/registry/r/select-separator.json +1 -1
  272. package/registry/r/select.json +4 -3
  273. package/registry/r/separator.json +1 -1
  274. package/registry/r/sheet.json +1 -1
  275. package/registry/r/shimmer.json +23 -0
  276. package/registry/r/sidebar.json +1 -1
  277. package/registry/r/skeleton.json +1 -1
  278. package/registry/r/sources.json +23 -0
  279. package/registry/r/spinner.json +23 -0
  280. package/registry/r/suggestion.json +23 -0
  281. package/registry/r/switch.json +1 -1
  282. package/registry/r/table.json +21 -0
  283. package/registry/r/tabs.json +1 -1
  284. package/registry/r/tag-input.json +1 -1
  285. package/registry/r/task.json +23 -0
  286. package/registry/r/textarea.json +1 -1
  287. package/registry/r/time-picker.json +24 -0
  288. package/registry/r/toaster.json +2 -2
  289. package/registry/r/toggle-group.json +1 -1
  290. package/registry/r/toggle.json +1 -1
  291. package/registry/r/tool.json +23 -0
  292. package/registry/r/typography.json +23 -0
  293. package/registry/registry.json +915 -22
  294. package/theme.css +121 -8
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "navigation-menu",
3
+ "type": "registry:ui",
4
+ "title": "Navigation Menu",
5
+ "description": "Navigation Menu component with align options.",
6
+ "category": "navigation",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom",
10
+ "class-variance-authority"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "navigation-menu.component.ts",
15
+ "target": "ui/navigation-menu.component.ts",
16
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnDestroy, OnInit, Output, ViewChild, inject, OnChanges } from '@angular/core';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet navigationMenuItemCounter = 0;\n\n/**\n * Coordinates the items of a single `tolle-navigation-menu`. Provided by\n * `NavigationMenuComponent`, injected by its descendants.\n *\n * Closing is deferred by `closeDelay` so a pointer travelling diagonally from a\n * trigger into its panel does not dismiss the menu mid-flight; re-entering\n * either surface calls `cancelClose()` and the pending close is dropped.\n */\n@Injectable()\nexport class NavigationMenuService {\n private openIdSource = new BehaviorSubject<string | null>(null);\n /** Emits the id of the currently open item, or `null` when all are closed. */\n readonly openId$ = this.openIdSource.asObservable();\n\n /** Milliseconds to wait before closing after the pointer leaves. */\n closeDelay = 150;\n\n private closeTimer: ReturnType<typeof setTimeout> | undefined;\n\n /** Id of the open item, or `null`. */\n get openId(): string | null {\n return this.openIdSource.value;\n }\n\n /** True while any panel is open. */\n get isAnyOpen(): boolean {\n return this.openIdSource.value !== null;\n }\n\n /** True while a deferred close is pending — exposed for tests/diagnostics. */\n get isCloseScheduled(): boolean {\n return this.closeTimer !== undefined;\n }\n\n isOpen(id: string): boolean {\n return this.openId === id;\n }\n\n /** Opens an item immediately and cancels any pending close. */\n open(id: string): void {\n this.cancelClose();\n if (this.openId !== id) this.openIdSource.next(id);\n }\n\n /** Closes immediately, dropping any pending close. */\n closeNow(): void {\n this.cancelClose();\n if (this.openId !== null) this.openIdSource.next(null);\n }\n\n /** Queues a close in `closeDelay` ms; a later `open`/`cancelClose` wins. */\n scheduleClose(): void {\n this.cancelClose();\n this.closeTimer = setTimeout(() => {\n this.closeTimer = undefined;\n this.closeNow();\n }, this.closeDelay);\n }\n\n /** Drops a pending close (pointer came back). */\n cancelClose(): void {\n if (this.closeTimer !== undefined) {\n clearTimeout(this.closeTimer);\n this.closeTimer = undefined;\n }\n }\n\n toggle(id: string): void {\n if (this.openId === id) this.closeNow();\n else this.open(id);\n }\n}\n\nconst navigationMenuVariants = cva('relative z-10 flex max-w-max flex-1 items-center justify-center', {\n variants: {\n align: {\n start: 'justify-start',\n center: 'justify-center',\n end: 'justify-end',\n },\n },\n defaultVariants: {\n align: 'center',\n },\n});\n\nexport type NavigationMenuProps = VariantProps<typeof navigationMenuVariants>;\n\nconst navigationMenuTriggerVariants = cva(\n 'group inline-flex w-max select-none items-center justify-center gap-1 rounded-md bg-background font-medium transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none ' +\n 'data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n size: {\n sm: 'h-8 px-3 text-xs',\n default: 'h-9 px-4 py-2 text-sm',\n lg: 'h-10 px-5 text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type NavigationMenuTriggerProps = VariantProps<typeof navigationMenuTriggerVariants>;\n\nconst navigationMenuLinkVariants = cva(\n 'flex select-none flex-col gap-1 rounded-md p-3 leading-none no-underline outline-none transition-colors ' +\n 'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n {\n variants: {\n variant: {\n default: 'text-foreground',\n muted: 'text-muted-foreground hover:text-accent-foreground',\n },\n active: {\n true: 'bg-accent text-accent-foreground',\n false: '',\n },\n },\n defaultVariants: {\n variant: 'default',\n active: false,\n },\n }\n);\n\nexport type NavigationMenuLinkProps = VariantProps<typeof navigationMenuLinkVariants>;\n\n/**\n * Site navigation menu: a row of links and triggers, where each trigger reveals\n * a floating panel on hover, focus or click.\n *\n * ```html\n * <tolle-navigation-menu>\n * <tolle-navigation-menu-list>\n * <tolle-navigation-menu-item>\n * <tolle-navigation-menu-trigger>Products</tolle-navigation-menu-trigger>\n * <tolle-navigation-menu-content class=\"w-[420px]\">\n * <tolle-navigation-menu-link href=\"/analytics\">Analytics</tolle-navigation-menu-link>\n * </tolle-navigation-menu-content>\n * </tolle-navigation-menu-item>\n * </tolle-navigation-menu-list>\n * </tolle-navigation-menu>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-navigation-menu',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n providers: [NavigationMenuService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <nav [attr.aria-label]=\"ariaLabel\" [class]=\"computedClass\" (keydown)=\"onKeydown($event)\">\n <ng-content></ng-content>\n </nav>\n `,\n})\nexport class NavigationMenuComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Horizontal alignment of the list within the nav. @default 'center' */\n @Input() align: NavigationMenuProps['align'] = 'center';\n /** Accessible name for the `<nav>` landmark. @default 'Main' */\n @Input() ariaLabel = 'Main';\n /**\n * Milliseconds before a panel closes after the pointer leaves — the grace\n * period that lets the pointer travel diagonally into the panel. @default 150\n */\n @Input() set closeDelay(value: number) {\n this.service.closeDelay = value;\n }\n get closeDelay(): number {\n return this.service.closeDelay;\n }\n /** Extra Tailwind classes merged onto the `<nav>` via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the id of the item that opened, or `null` when the menu closes. */\n @Output() openChange = new EventEmitter<string | null>();\n\n private readonly service = inject(NavigationMenuService);\n private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly sub = new Subscription();\n private listening = false;\n\n protected get computedClass(): string {\n return cn(navigationMenuVariants({ align: this.align }), this.class);\n }\n\n ngOnInit(): void {\n this.sub.add(\n this.service.openId$.subscribe(id => {\n this.openChange.emit(id);\n if (id !== null) this.startListening();\n else this.stopListening();\n })\n );\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (event.key === 'Escape' && this.service.isAnyOpen) {\n event.preventDefault();\n this.service.closeNow();\n }\n }\n\n // Capture phase so containers that stopPropagation() on their root cannot\n // prevent the outside click from reaching us.\n private outsideClick = (event: Event) => {\n if (!this.host.nativeElement.contains(event.target as Node)) this.service.closeNow();\n };\n\n private startListening(): void {\n if (this.listening) return;\n this.listening = true;\n document.addEventListener('pointerdown', this.outsideClick, true);\n }\n\n private stopListening(): void {\n if (!this.listening) return;\n this.listening = false;\n document.removeEventListener('pointerdown', this.outsideClick, true);\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopListening();\n this.service.cancelClose();\n }\n}\n\n/** The horizontal list that holds the navigation items. */\n@Component({\n selector: 'tolle-navigation-menu-list',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"cn('group flex flex-1 list-none items-center justify-center gap-1', class)\">\n <ng-content></ng-content>\n </ul>\n `,\n})\nexport class NavigationMenuListComponent 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 `<ul>` via `cn()` (last-wins). */\n @Input() class = '';\n protected readonly cn = cn;\n}\n\n/** One entry in the list — a plain link, or a trigger plus its content panel. */\n@Component({\n selector: 'tolle-navigation-menu-item',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': \"'contents'\" },\n template: `\n <li [class]=\"cn('relative', class)\">\n <ng-content></ng-content>\n </li>\n `,\n})\nexport class NavigationMenuItemComponent implements OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Explicit id for the item; one is generated when omitted. */\n @Input() id = `tolle-navigation-menu-item-${navigationMenuItemCounter++}`;\n /** Extra Tailwind classes merged onto the `<li>` via `cn()` (last-wins). */\n @Input() class = '';\n\n readonly service = inject(NavigationMenuService);\n protected readonly cn = cn;\n\n triggerElement: HTMLElement | null = null;\n\n get triggerId(): string {\n return `${this.id}-trigger`;\n }\n\n get contentId(): string {\n return `${this.id}-content`;\n }\n\n get isOpen(): boolean {\n return this.service.isOpen(this.id);\n }\n\n ngOnDestroy(): void {\n if (this.isOpen) this.service.closeNow();\n }\n}\n\n/** Button that reveals its item's panel on hover, focus or click. */\n@Component({\n selector: 'tolle-navigation-menu-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n #trigger\n type=\"button\"\n [id]=\"item.triggerId\"\n [disabled]=\"disabled\"\n aria-haspopup=\"true\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? item.contentId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n (focus)=\"onPointerEnter()\"\n (keydown)=\"onKeydown($event)\"\n >\n <ng-content></ng-content>\n <i\n class=\"ri-arrow-down-s-line text-[16px] transition-transform duration-200\"\n [class.rotate-180]=\"isOpen\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `,\n})\nexport class NavigationMenuTriggerComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Prevents the panel from opening. @default false */\n @Input() disabled = false;\n /** Height/padding scale of the trigger. @default 'default' */\n @Input() size: NavigationMenuTriggerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('trigger') set triggerRef(ref: ElementRef<HTMLButtonElement> | undefined) {\n this.item.triggerElement = ref?.nativeElement ?? null;\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(navigationMenuTriggerVariants({ size: this.size }), this.class);\n }\n\n protected onClick(): void {\n if (this.disabled) return;\n this.service.toggle(this.item.id);\n }\n\n protected onPointerEnter(): void {\n if (this.disabled) return;\n this.service.open(this.item.id);\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n protected onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.service.open(this.item.id);\n }\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n}\n\n/** The floating panel revealed by a trigger. Rendered only while open. */\n@Component({\n selector: 'tolle-navigation-menu-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (isOpen) {\n <div\n #panel\n [id]=\"item.contentId\"\n [attr.aria-labelledby]=\"item.triggerId\"\n data-state=\"open\"\n [class]=\"computedClass\"\n style=\"position: fixed; top: 0; left: 0;\"\n (mouseenter)=\"onPointerEnter()\"\n (mouseleave)=\"onPointerLeave()\"\n >\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class NavigationMenuContentComponent implements OnChanges, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Preferred side of the trigger to render on; flips when space is tight. @default 'bottom' */\n @Input() placement: 'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end' =\n 'bottom';\n /** Gap in pixels between the trigger and the panel. @default 8 */\n @Input() sideOffset = 8;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly item = inject(NavigationMenuItemComponent);\n private readonly service = inject(NavigationMenuService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub: Subscription;\n private cleanup?: () => void;\n\n constructor() {\n this.sub = this.service.openId$.subscribe(() => this.cdr.markForCheck());\n }\n\n @ViewChild('panel') set panelRef(ref: ElementRef<HTMLElement> | undefined) {\n this.stopPositioning();\n if (ref?.nativeElement) this.startPositioning(ref.nativeElement);\n }\n\n protected get isOpen(): boolean {\n return this.item.isOpen;\n }\n\n protected get computedClass(): string {\n return cn(\n 'z-[1000] overflow-auto rounded-md border border-border bg-popover p-2 text-popover-foreground',\n 'shadow-lg animate-in fade-in zoom-in-95',\n this.class\n );\n }\n\n protected onPointerEnter(): void {\n this.service.cancelClose();\n }\n\n protected onPointerLeave(): void {\n this.service.scheduleClose();\n }\n\n private startPositioning(panel: HTMLElement): void {\n const trigger = this.item.triggerElement;\n if (!trigger) return;\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: this.placement,\n middleware: [\n offset(this.sideOffset),\n flip(),\n shift({ padding: 8 }),\n // Wide panels are common here — clamp to what the viewport can show\n // instead of letting them overflow.\n size({\n padding: 8,\n apply({ elements, availableWidth, availableHeight }) {\n Object.assign(elements.floating.style, {\n maxWidth: `${Math.max(0, availableWidth)}px`,\n maxHeight: `${Math.max(0, availableHeight)}px`,\n });\n },\n }),\n ],\n }).then(({ x, y }) => {\n Object.assign(panel.style, { left: `${x}px`, top: `${y}px` });\n });\n });\n }\n\n private stopPositioning(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n this.stopPositioning();\n }\n}\n\n/** A link inside a panel (or directly in the list, for flat entries). */\n@Component({\n selector: 'tolle-navigation-menu-link',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n [attr.href]=\"href || null\"\n [attr.target]=\"target || null\"\n [attr.rel]=\"target === '_blank' ? 'noreferrer noopener' : null\"\n [attr.aria-current]=\"active ? 'page' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n (click)=\"navigate.emit($event)\"\n >\n <ng-content select=\"[label]\"></ng-content>\n <ng-content></ng-content>\n <ng-content select=\"[description]\"></ng-content>\n </a>\n `,\n})\nexport class NavigationMenuLinkComponent 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 /** Destination URL. Omit to render a non-navigating item. */\n @Input() href = '';\n /** Anchor target; `_blank` also sets a safe `rel`. */\n @Input() target = '';\n /** Marks the link as the current page. @default false */\n @Input() active = false;\n /** Visual style of the link. @default 'default' */\n @Input() variant: NavigationMenuLinkProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the anchor via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the click event when the link is activated. */\n @Output() navigate = new EventEmitter<MouseEvent>();\n\n protected get computedClass(): string {\n return cn(\n navigationMenuLinkVariants({ variant: this.variant, active: this.active }),\n this.class\n );\n }\n}\n"
17
+ },
18
+ {
19
+ "path": "utils/cn.ts",
20
+ "target": "ui/utils/cn.ts",
21
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
22
+ }
23
+ ]
24
+ }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "otp-group.component.ts",
12
12
  "target": "ui/otp-group.component.ts",
13
- "content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n standalone: true,\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles: ``\n})\nexport class OtpGroupComponent {\n\n}\n"
13
+ "content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n standalone: true,\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles: `:host { display: block; }`\n})\nexport class OtpGroupComponent {\n\n}\n"
14
14
  }
15
15
  ]
16
16
  }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "otp-slot.component.ts",
12
12
  "target": "ui/otp-slot.component.ts",
13
- "content": "import { Component, Input } from '@angular/core';\nimport { cn } from \"./utils/cn\";\nimport { NgIf } from '@angular/common';\n\n@Component({\n selector: 'tolle-otp-slot',\n standalone: true,\n imports: [NgIf],\n template: `\n <div [class]=\"computedClass\">\n <span class=\"text-lg font-medium\">{{ char || '' }}</span>\n <div *ngIf=\"isActive && !char\" class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div class=\"h-6 w-0.5 animate-caret-blink bg-foreground\"></div>\n </div>\n </div>\n `\n})\nexport class OtpSlotComponent {\n @Input() char: string | undefined = '';\n @Input() isActive: boolean = false;\n @Input() isFirst: boolean = false;\n @Input() isLast: boolean = false;\n @Input() class: string = '';\n protected cn = cn;\n\n get computedClass() {\n return cn(\n // Base styles (matching input container)\n 'relative flex h-10 w-10 items-center justify-center',\n 'transition-all duration-200',\n 'text-center font-medium select-none',\n\n // Border styling - exactly like input\n 'border border-input shadow-sm bg-background',\n\n // FOCUS STYLING - EXACTLY LIKE INPUT COMPONENT\n this.isActive && [\n // Darker border on focus\n 'border-primary/80',\n // Remove shadow on focus\n 'shadow-none',\n // Focus ring with same styling as input\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n // Ensure it's above other slots\n 'z-10'\n ],\n\n // Filled state\n this.char && !this.isActive && 'border-primary/60',\n\n // Border radius\n this.isFirst && 'rounded-l-md',\n this.isLast && 'rounded-r-md',\n\n // Remove left border for all but first slot\n !this.isFirst && '-ml-px',\n\n this.class\n );\n }\n}\n"
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { cn } from \"./utils/cn\";\nimport { NgIf } from '@angular/common';\n\n@Component({\n selector: 'tolle-otp-slot',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [NgIf],\n template: `\n <div [class]=\"computedClass\">\n <span class=\"text-lg font-medium\">{{ char || '' }}</span>\n <div *ngIf=\"isActive && !char\" class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div class=\"h-6 w-0.5 animate-caret-blink bg-foreground\"></div>\n </div>\n </div>\n `\n})\nexport class OtpSlotComponent {\n @Input() char: string | undefined = '';\n @Input() isActive: boolean = false;\n @Input() isFirst: boolean = false;\n @Input() isLast: boolean = false;\n @Input() class: string = '';\n protected cn = cn;\n\n get computedClass() {\n return cn(\n // Base styles (matching input container)\n 'relative flex h-10 w-10 items-center justify-center',\n 'transition-all duration-200',\n 'text-center font-medium select-none',\n\n // Border styling - exactly like input\n 'border border-input shadow-sm bg-background',\n\n // FOCUS STYLING - EXACTLY LIKE INPUT COMPONENT\n this.isActive && [\n // Darker border on focus\n 'border-primary/80',\n // Remove shadow on focus\n 'shadow-none',\n // Focus ring with same styling as input\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n // Ensure it's above other slots\n 'z-10'\n ],\n\n // Filled state\n this.char && !this.isActive && 'border-primary/60',\n\n // Border radius\n this.isFirst && 'rounded-l-md',\n this.isLast && 'rounded-r-md',\n\n // Remove left border for all but first slot\n !this.isFirst && '-ml-px',\n\n this.class\n );\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "otp.component.ts",
12
12
  "target": "ui/otp.component.ts",
13
- "content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [{\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => OtpComponent),\n multi: true\n }],\n template: `\n <div class=\"relative flex items-center gap-2\">\n <input\n #hiddenInput\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete=\"one-time-code\"\n aria-label=\"One-time code\"\n [maxLength]=\"length\"\n class=\"absolute inset-0 opacity-0 cursor-default z-20\"\n [(ngModel)]=\"value\"\n (input)=\"onInputChange($event)\"\n (focus)=\"isFocused = true\"\n (blur)=\"isFocused = false; onTouched()\"\n [disabled]=\"disabled\"\n />\n\n <div *ngIf=\"auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <div class=\"flex items-center\">\n <div *ngFor=\"let _ of slots; let i = index\"\n [class]=\"getSlotClass(i)\"\n >\n {{ value[i] || '' }}\n <div *ngIf=\"isFocused && value.length === i\" class=\"absolute inset-0 flex items-center justify-center\">\n <div class=\"h-4 w-px animate-caret-blink bg-foreground duration-1000\"></div>\n </div>\n </div>\n </div>\n </div>\n\n <div *ngIf=\"!auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <ng-content></ng-content>\n </div>\n </div>\n `\n})\nexport class OtpComponent implements ControlValueAccessor {\n @Input()\n set length(val: number) {\n const numericVal = Number(val);\n this._length = numericVal;\n this.slots = Array(numericVal).fill(0);\n }\n get length() { return this._length; }\n private _length = 6;\n\n @Input() disabled = false;\n @Input() auto = false;\n\n protected slots: number[] = Array(6).fill(0);\n\n value: string = '';\n isFocused = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n onInputChange(event: any) {\n const val = event.target.value.replace(/\\D/g, '');\n this.value = val;\n this.onChange(val);\n this.cdr.markForCheck();\n }\n\n // Visual logic for the automated slots\n getSlotClass(index: number) {\n const isActive = this.isFocused && this.value.length === index;\n\n return cn(\n 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all bg-background',\n index === 0 && 'rounded-l-md border-l',\n index === this.length - 1 && 'rounded-r-md',\n isActive && 'z-30 ring-2 ring-ring ring-offset-background'\n );\n }\n\n writeValue(value: any): void { this.value = value || ''; this.cdr.markForCheck(); }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
13
+ "content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-otp',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [{\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => OtpComponent),\n multi: true\n }],\n template: `\n <div class=\"relative flex items-center gap-2\">\n <input\n #hiddenInput\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete=\"one-time-code\"\n aria-label=\"One-time code\"\n [maxLength]=\"length\"\n class=\"absolute inset-0 opacity-0 cursor-default z-20\"\n [(ngModel)]=\"value\"\n (input)=\"onInputChange($event)\"\n (focus)=\"isFocused = true\"\n (blur)=\"isFocused = false; onTouched()\"\n [disabled]=\"disabled\"\n />\n\n <div *ngIf=\"auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <div class=\"flex items-center\">\n <div *ngFor=\"let _ of slots; let i = index\"\n [class]=\"getSlotClass(i)\"\n >\n {{ value[i] || '' }}\n <div *ngIf=\"isFocused && value.length === i\" class=\"absolute inset-0 flex items-center justify-center\">\n <div class=\"h-4 w-px animate-caret-blink bg-foreground duration-1000\"></div>\n </div>\n </div>\n </div>\n </div>\n\n <div *ngIf=\"!auto\" class=\"flex items-center gap-2 pointer-events-none\">\n <ng-content></ng-content>\n </div>\n </div>\n `\n})\nexport class OtpComponent implements ControlValueAccessor {\n @Input()\n set length(val: number) {\n const numericVal = Number(val);\n this._length = numericVal;\n this.slots = Array(numericVal).fill(0);\n }\n get length() { return this._length; }\n private _length = 6;\n\n @Input() disabled = false;\n @Input() auto = false;\n\n protected slots: number[] = Array(6).fill(0);\n\n value: string = '';\n isFocused = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n onInputChange(event: any) {\n const val = event.target.value.replace(/\\D/g, '');\n this.value = val;\n this.onChange(val);\n this.cdr.markForCheck();\n }\n\n // Visual logic for the automated slots\n getSlotClass(index: number) {\n const isActive = this.isFocused && this.value.length === index;\n\n return cn(\n 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all bg-background',\n index === 0 && 'rounded-l-md border-l',\n index === this.length - 1 && 'rounded-r-md',\n isActive && 'z-30 ring-2 ring-ring ring-offset-background'\n );\n }\n\n writeValue(value: any): void { this.value = value || ''; this.cdr.markForCheck(); }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -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",