@tolle_/tolle-ui 18.2.29 → 18.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/esm2022/lib/accordion-item.component.mjs +5 -11
  2. package/esm2022/lib/accordion.component.mjs +4 -9
  3. package/esm2022/lib/alert-dialog-dynamic.component.mjs +12 -12
  4. package/esm2022/lib/alert-dialog.component.mjs +82 -64
  5. package/esm2022/lib/alert-dialog.service.mjs +38 -6
  6. package/esm2022/lib/alert.component.mjs +5 -11
  7. package/esm2022/lib/aspect-ratio.component.mjs +119 -0
  8. package/esm2022/lib/attachment.component.mjs +334 -0
  9. package/esm2022/lib/avatar-fallback.component.mjs +3 -3
  10. package/esm2022/lib/avatar.component.mjs +4 -9
  11. package/esm2022/lib/badge.component.mjs +4 -9
  12. package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
  13. package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
  14. package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
  15. package/esm2022/lib/breadcrumb.component.mjs +3 -3
  16. package/esm2022/lib/bubble.component.mjs +301 -0
  17. package/esm2022/lib/button.component.mjs +5 -11
  18. package/esm2022/lib/calendar.component.mjs +5 -11
  19. package/esm2022/lib/card.component.mjs +14 -43
  20. package/esm2022/lib/carousel/carousel.component.mjs +5 -12
  21. package/esm2022/lib/chain-of-thought.component.mjs +380 -0
  22. package/esm2022/lib/chart-pie.component.mjs +386 -0
  23. package/esm2022/lib/chart.component.mjs +1146 -0
  24. package/esm2022/lib/chart.service.mjs +555 -0
  25. package/esm2022/lib/checkbox.component.mjs +5 -11
  26. package/esm2022/lib/checkpoint.component.mjs +134 -0
  27. package/esm2022/lib/collapsible.component.mjs +4 -11
  28. package/esm2022/lib/combobox.component.mjs +372 -0
  29. package/esm2022/lib/command-dialog.component.mjs +164 -0
  30. package/esm2022/lib/command.component.mjs +514 -0
  31. package/esm2022/lib/command.service.mjs +130 -0
  32. package/esm2022/lib/confirmation.component.mjs +219 -0
  33. package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
  34. package/esm2022/lib/context-menu.component.mjs +460 -0
  35. package/esm2022/lib/context-menu.service.mjs +125 -0
  36. package/esm2022/lib/context.component.mjs +377 -0
  37. package/esm2022/lib/conversation.component.mjs +442 -0
  38. package/esm2022/lib/country-selector.component.mjs +5 -11
  39. package/esm2022/lib/data-table.component.mjs +59 -12
  40. package/esm2022/lib/date-picker.component.mjs +251 -198
  41. package/esm2022/lib/date-range-picker.component.mjs +224 -116
  42. package/esm2022/lib/date-time-picker.component.mjs +506 -0
  43. package/esm2022/lib/direction.component.mjs +79 -0
  44. package/esm2022/lib/dropdown-item.component.mjs +3 -3
  45. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  46. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  47. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  48. package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
  49. package/esm2022/lib/empty-state.component.mjs +4 -9
  50. package/esm2022/lib/field.component.mjs +394 -0
  51. package/esm2022/lib/hover-card.component.mjs +6 -16
  52. package/esm2022/lib/inline-citation.component.mjs +263 -0
  53. package/esm2022/lib/input-group.component.mjs +418 -0
  54. package/esm2022/lib/input.component.mjs +5 -11
  55. package/esm2022/lib/item.component.mjs +243 -0
  56. package/esm2022/lib/kbd.component.mjs +82 -0
  57. package/esm2022/lib/label.component.mjs +4 -9
  58. package/esm2022/lib/marker.component.mjs +135 -0
  59. package/esm2022/lib/masked-input.component.mjs +5 -11
  60. package/esm2022/lib/menubar.component.mjs +683 -0
  61. package/esm2022/lib/message-scroller.component.mjs +353 -0
  62. package/esm2022/lib/message-scroller.service.mjs +157 -0
  63. package/esm2022/lib/message.component.mjs +228 -0
  64. package/esm2022/lib/modal-ref.mjs +36 -7
  65. package/esm2022/lib/modal.component.mjs +42 -28
  66. package/esm2022/lib/modal.mjs +2 -29
  67. package/esm2022/lib/modal.service.mjs +26 -4
  68. package/esm2022/lib/model-selector.component.mjs +310 -0
  69. package/esm2022/lib/multi-select.component.mjs +155 -72
  70. package/esm2022/lib/native-select.component.mjs +201 -0
  71. package/esm2022/lib/navigation-menu.component.mjs +642 -0
  72. package/esm2022/lib/otp-group.component.mjs +3 -3
  73. package/esm2022/lib/otp-slot.component.mjs +4 -9
  74. package/esm2022/lib/otp.component.mjs +5 -11
  75. package/esm2022/lib/pagination.component.mjs +7 -10
  76. package/esm2022/lib/plan.component.mjs +335 -0
  77. package/esm2022/lib/popover-content.component.mjs +4 -9
  78. package/esm2022/lib/popover.component.mjs +15 -13
  79. package/esm2022/lib/progress.component.mjs +4 -9
  80. package/esm2022/lib/prompt-input.component.mjs +526 -0
  81. package/esm2022/lib/queue.component.mjs +280 -0
  82. package/esm2022/lib/radio-group.component.mjs +6 -13
  83. package/esm2022/lib/radio-item.component.mjs +4 -9
  84. package/esm2022/lib/range-calendar.component.mjs +5 -11
  85. package/esm2022/lib/reasoning.component.mjs +335 -0
  86. package/esm2022/lib/resizable-panel.component.mjs +5 -5
  87. package/esm2022/lib/resizable.component.mjs +219 -0
  88. package/esm2022/lib/segment.component.mjs +19 -9
  89. package/esm2022/lib/select-group.component.mjs +4 -8
  90. package/esm2022/lib/select-item.component.mjs +4 -9
  91. package/esm2022/lib/select-separator.component.mjs +4 -8
  92. package/esm2022/lib/select.component.mjs +133 -49
  93. package/esm2022/lib/separator.component.mjs +4 -9
  94. package/esm2022/lib/sheet.component.mjs +38 -56
  95. package/esm2022/lib/shimmer.component.mjs +84 -0
  96. package/esm2022/lib/sidebar.component.mjs +11 -1
  97. package/esm2022/lib/skeleton.component.mjs +4 -9
  98. package/esm2022/lib/sources.component.mjs +452 -0
  99. package/esm2022/lib/spinner.component.mjs +68 -0
  100. package/esm2022/lib/suggestion.component.mjs +207 -0
  101. package/esm2022/lib/switch.component.mjs +5 -11
  102. package/esm2022/lib/table.component.mjs +213 -0
  103. package/esm2022/lib/tabs.component.mjs +4 -11
  104. package/esm2022/lib/tag-input.component.mjs +5 -11
  105. package/esm2022/lib/task.component.mjs +359 -0
  106. package/esm2022/lib/textarea.component.mjs +5 -11
  107. package/esm2022/lib/theme.service.mjs +17 -6
  108. package/esm2022/lib/time-picker.component.mjs +923 -0
  109. package/esm2022/lib/toast.service.mjs +3 -2
  110. package/esm2022/lib/toaster.component.mjs +76 -39
  111. package/esm2022/lib/toggle-group.component.mjs +4 -9
  112. package/esm2022/lib/toggle.component.mjs +5 -11
  113. package/esm2022/lib/tool.component.mjs +462 -0
  114. package/esm2022/lib/typography.component.mjs +83 -0
  115. package/esm2022/lib/utils/color.mjs +140 -19
  116. package/esm2022/public-api.mjs +61 -1
  117. package/fesm2022/tolle-ui.mjs +16642 -1105
  118. package/fesm2022/tolle-ui.mjs.map +1 -1
  119. package/lib/alert-dialog-dynamic.component.d.ts +5 -0
  120. package/lib/alert-dialog.component.d.ts +23 -2
  121. package/lib/aspect-ratio.component.d.ts +20 -0
  122. package/lib/attachment.component.d.ts +101 -0
  123. package/lib/bubble.component.d.ts +112 -0
  124. package/lib/chain-of-thought.component.d.ts +166 -0
  125. package/lib/chart-pie.component.d.ts +108 -0
  126. package/lib/chart.component.d.ts +369 -0
  127. package/lib/chart.service.d.ts +222 -0
  128. package/lib/checkpoint.component.d.ts +51 -0
  129. package/lib/combobox.component.d.ts +90 -0
  130. package/lib/command-dialog.component.d.ts +53 -0
  131. package/lib/command.component.d.ts +189 -0
  132. package/lib/command.service.d.ts +67 -0
  133. package/lib/confirmation.component.d.ts +70 -0
  134. package/lib/context-menu-trigger.directive.d.ts +12 -0
  135. package/lib/context-menu.component.d.ts +41 -0
  136. package/lib/context-menu.service.d.ts +38 -0
  137. package/lib/context.component.d.ts +134 -0
  138. package/lib/conversation.component.d.ts +165 -0
  139. package/lib/data-table.component.d.ts +26 -1
  140. package/lib/date-picker.component.d.ts +72 -11
  141. package/lib/date-range-picker.component.d.ts +63 -6
  142. package/lib/date-time-picker.component.d.ts +119 -0
  143. package/lib/direction.component.d.ts +45 -0
  144. package/lib/field.component.d.ts +201 -0
  145. package/lib/inline-citation.component.d.ts +103 -0
  146. package/lib/input-group.component.d.ts +150 -0
  147. package/lib/item.component.d.ts +154 -0
  148. package/lib/kbd.component.d.ts +50 -0
  149. package/lib/marker.component.d.ts +58 -0
  150. package/lib/menubar.component.d.ts +247 -0
  151. package/lib/message-scroller.component.d.ts +159 -0
  152. package/lib/message-scroller.service.d.ts +73 -0
  153. package/lib/message.component.d.ts +128 -0
  154. package/lib/modal-ref.d.ts +6 -1
  155. package/lib/modal.component.d.ts +7 -4
  156. package/lib/modal.d.ts +9 -3
  157. package/lib/model-selector.component.d.ts +103 -0
  158. package/lib/multi-select.component.d.ts +66 -14
  159. package/lib/native-select.component.d.ts +57 -0
  160. package/lib/navigation-menu.component.d.ts +230 -0
  161. package/lib/plan.component.d.ts +120 -0
  162. package/lib/popover.component.d.ts +1 -0
  163. package/lib/prompt-input.component.d.ts +194 -0
  164. package/lib/queue.component.d.ts +106 -0
  165. package/lib/reasoning.component.d.ts +127 -0
  166. package/lib/resizable.component.d.ts +43 -0
  167. package/lib/segment.component.d.ts +13 -3
  168. package/lib/select.component.d.ts +37 -5
  169. package/lib/sheet.component.d.ts +3 -0
  170. package/lib/shimmer.component.d.ts +38 -0
  171. package/lib/sidebar.component.d.ts +2 -0
  172. package/lib/sources.component.d.ts +156 -0
  173. package/lib/spinner.component.d.ts +29 -0
  174. package/lib/suggestion.component.d.ts +96 -0
  175. package/lib/table.component.d.ts +106 -0
  176. package/lib/task.component.d.ts +151 -0
  177. package/lib/theme.service.d.ts +9 -0
  178. package/lib/time-picker.component.d.ts +243 -0
  179. package/lib/toast.service.d.ts +3 -1
  180. package/lib/toaster.component.d.ts +26 -9
  181. package/lib/tool.component.d.ts +158 -0
  182. package/lib/typography.component.d.ts +32 -0
  183. package/lib/utils/color.d.ts +19 -5
  184. package/package.json +3 -2
  185. package/preset.js +7 -1
  186. package/public-api.d.ts +50 -0
  187. package/registry/docs-content.json +8480 -1542
  188. package/registry/llms-full.txt +2679 -319
  189. package/registry/llms.txt +43 -4
  190. package/registry/manifest.json +9090 -1742
  191. package/registry/r/accordion-item.json +1 -1
  192. package/registry/r/accordion.json +1 -1
  193. package/registry/r/alert-dialog-dynamic.json +1 -1
  194. package/registry/r/alert-dialog.json +1 -1
  195. package/registry/r/alert.json +1 -1
  196. package/registry/r/attachment.json +23 -0
  197. package/registry/r/avatar-fallback.json +1 -1
  198. package/registry/r/avatar.json +1 -1
  199. package/registry/r/badge.json +1 -1
  200. package/registry/r/breadcrumb-item.json +1 -1
  201. package/registry/r/breadcrumb-link.json +1 -1
  202. package/registry/r/breadcrumb-separator.json +1 -1
  203. package/registry/r/breadcrumb.json +1 -1
  204. package/registry/r/bubble.json +23 -0
  205. package/registry/r/button.json +1 -1
  206. package/registry/r/calendar.json +1 -1
  207. package/registry/r/card.json +1 -1
  208. package/registry/r/carousel.json +1 -1
  209. package/registry/r/chain-of-thought.json +23 -0
  210. package/registry/r/chart-pie.json +30 -0
  211. package/registry/r/chart.json +28 -0
  212. package/registry/r/checkbox.json +1 -1
  213. package/registry/r/checkpoint.json +23 -0
  214. package/registry/r/collapsible.json +1 -1
  215. package/registry/r/combobox.json +31 -0
  216. package/registry/r/command-dialog.json +21 -0
  217. package/registry/r/command.json +26 -0
  218. package/registry/r/confirmation.json +23 -0
  219. package/registry/r/context-menu-trigger.json +1 -1
  220. package/registry/r/context-menu.json +1 -1
  221. package/registry/r/context.json +26 -0
  222. package/registry/r/conversation.json +23 -0
  223. package/registry/r/country-selector.json +1 -1
  224. package/registry/r/data-table.json +1 -1
  225. package/registry/r/date-picker.json +4 -4
  226. package/registry/r/date-range-picker.json +3 -3
  227. package/registry/r/date-time-picker.json +28 -0
  228. package/registry/r/direction.json +16 -0
  229. package/registry/r/dropdown-item.json +1 -1
  230. package/registry/r/dropdown-label.json +1 -1
  231. package/registry/r/dropdown-menu.json +1 -1
  232. package/registry/r/dropdown-separator.json +1 -1
  233. package/registry/r/dropdown-trigger.json +1 -1
  234. package/registry/r/empty-state.json +1 -1
  235. package/registry/r/field.json +23 -0
  236. package/registry/r/hover-card.json +1 -1
  237. package/registry/r/inline-citation.json +26 -0
  238. package/registry/r/input-group.json +23 -0
  239. package/registry/r/input.json +1 -1
  240. package/registry/r/item.json +23 -0
  241. package/registry/r/kbd.json +23 -0
  242. package/registry/r/label.json +1 -1
  243. package/registry/r/marker.json +23 -0
  244. package/registry/r/masked-input.json +1 -1
  245. package/registry/r/menubar.json +24 -0
  246. package/registry/r/message-scroller.json +26 -0
  247. package/registry/r/message.json +23 -0
  248. package/registry/r/modal.json +3 -3
  249. package/registry/r/model-selector.json +32 -0
  250. package/registry/r/multi-select.json +2 -2
  251. package/registry/r/native-select.json +23 -0
  252. package/registry/r/navigation-menu.json +24 -0
  253. package/registry/r/otp-group.json +1 -1
  254. package/registry/r/otp-slot.json +1 -1
  255. package/registry/r/otp.json +1 -1
  256. package/registry/r/pagination.json +3 -2
  257. package/registry/r/plan.json +23 -0
  258. package/registry/r/popover-content.json +1 -1
  259. package/registry/r/popover.json +1 -1
  260. package/registry/r/progress.json +1 -1
  261. package/registry/r/prompt-input.json +23 -0
  262. package/registry/r/queue.json +23 -0
  263. package/registry/r/radio-group.json +1 -1
  264. package/registry/r/radio-item.json +1 -1
  265. package/registry/r/range-calendar.json +1 -1
  266. package/registry/r/reasoning.json +23 -0
  267. package/registry/r/resizable-panel.json +1 -1
  268. package/registry/r/segment.json +1 -1
  269. package/registry/r/select-group.json +1 -1
  270. package/registry/r/select-item.json +1 -1
  271. package/registry/r/select-separator.json +1 -1
  272. package/registry/r/select.json +4 -3
  273. package/registry/r/separator.json +1 -1
  274. package/registry/r/sheet.json +1 -1
  275. package/registry/r/shimmer.json +23 -0
  276. package/registry/r/sidebar.json +1 -1
  277. package/registry/r/skeleton.json +1 -1
  278. package/registry/r/sources.json +23 -0
  279. package/registry/r/spinner.json +23 -0
  280. package/registry/r/suggestion.json +23 -0
  281. package/registry/r/switch.json +1 -1
  282. package/registry/r/table.json +21 -0
  283. package/registry/r/tabs.json +1 -1
  284. package/registry/r/tag-input.json +1 -1
  285. package/registry/r/task.json +23 -0
  286. package/registry/r/textarea.json +1 -1
  287. package/registry/r/time-picker.json +24 -0
  288. package/registry/r/toaster.json +2 -2
  289. package/registry/r/toggle-group.json +1 -1
  290. package/registry/r/toggle.json +1 -1
  291. package/registry/r/tool.json +23 -0
  292. package/registry/r/typography.json +23 -0
  293. package/registry/registry.json +915 -22
  294. package/theme.css +121 -8
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "chain-of-thought",
3
+ "type": "registry:ui",
4
+ "title": "Chain Of Thought",
5
+ "description": "Chain Of Thought component with size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "chain-of-thought.component.ts",
14
+ "target": "ui/chain-of-thought.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 { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Progress of a single reasoning step. */\nexport type ChainOfThoughtStepStatus = 'pending' | 'active' | 'complete';\n\nconst chainOfThoughtVariants = cva('w-full text-sm', {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\nexport type ChainOfThoughtProps = VariantProps<typeof chainOfThoughtVariants>;\n\nconst chainOfThoughtStepVariants = cva(\n // The rail is hidden on the final step so the line stops at the last marker\n // instead of trailing off the bottom of the list.\n 'relative flex gap-3 pb-4 last:pb-0 [&:last-child_.tolle-cot-rail]:hidden',\n {\n variants: {\n status: {\n pending: 'text-muted-foreground',\n active: 'text-foreground',\n complete: 'text-muted-foreground',\n },\n },\n defaultVariants: { status: 'complete' },\n }\n);\n\nconst chainOfThoughtMarkerVariants = cva(\n 'relative z-[1] flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px]',\n {\n variants: {\n status: {\n pending: 'border-border bg-background text-muted-foreground',\n active: 'border-info bg-info/10 text-info',\n complete: 'border-success/50 bg-success/10 text-success',\n },\n },\n defaultVariants: { status: 'complete' },\n }\n);\n\nexport type ChainOfThoughtStepProps = VariantProps<typeof chainOfThoughtStepVariants>;\n\n/**\n * Coordinates a `tolle-chain-of-thought` and its header/content children.\n *\n * The children are `OnPush` and receive none of this state as inputs, so it\n * travels as observables and each subscriber calls `markForCheck()`.\n */\n@Injectable()\nexport class ChainOfThoughtService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the reasoning trace is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n get open(): boolean {\n return this.openSource.value;\n }\n\n setOpen(open: boolean): void {\n if (this.openSource.value !== open) this.openSource.next(open);\n }\n\n toggle(): void {\n this.openSource.next(!this.openSource.value);\n }\n}\n\n/**\n * A collapsible reasoning trace — the numbered steps an agent worked through\n * before answering, connected by a vertical rail.\n *\n * ```html\n * <tolle-chain-of-thought [open]=\"true\">\n * <tolle-chain-of-thought-header label=\"Thought for 4s\"></tolle-chain-of-thought-header>\n * <tolle-chain-of-thought-content>\n * <tolle-chain-of-thought-step label=\"Searched the docs\" status=\"complete\" icon=\"ri-search-line\">\n * <tolle-chain-of-thought-search-results>\n * <tolle-chain-of-thought-search-result>tailwind.config</tolle-chain-of-thought-search-result>\n * </tolle-chain-of-thought-search-results>\n * </tolle-chain-of-thought-step>\n * </tolle-chain-of-thought-content>\n * </tolle-chain-of-thought>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-chain-of-thought',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ChainOfThoughtService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\"><ng-content></ng-content></div>`,\n})\nexport class ChainOfThoughtComponent implements OnInit, OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Whether the reasoning steps are expanded. @default false */\n @Input() open = false;\n /** Text scale of the whole trace. @default 'default' */\n @Input() size: ChainOfThoughtProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trace via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new expanded state whenever the trace opens or closes. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly sub = new Subscription();\n\n ngOnInit(): void {\n this.service.setOpen(this.open);\n // skip(1) drops the replayed initial value so no `openChange` fires on init.\n this.sub.add(this.service.open$.pipe(skip(1)).subscribe((open) => this.openChange.emit(open)));\n }\n\n ngOnChanges(): void {\n this.service.setOpen(this.open);\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 get computedClass() {\n return cn(chainOfThoughtVariants({ size: this.size }), this.class);\n }\n}\n\n/** Clickable header that expands and collapses the reasoning steps. */\n@Component({\n selector: 'tolle-chain-of-thought-header',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"toggle()\"\n >\n <i class=\"ri-brain-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ label }}</span>\n <ng-content></ng-content>\n <i\n [class]=\"isOpen ? 'ri-arrow-down-s-line shrink-0' : 'ri-arrow-right-s-line shrink-0'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `,\n})\nexport class ChainOfThoughtHeaderComponent 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 /** Text shown in the header. @default 'Chain of Thought' */\n @Input() label = 'Chain of Thought';\n /** Extra Tailwind classes merged onto the header via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe((open) => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 rounded-md py-1 text-sm font-medium text-muted-foreground outline-none transition-colors',\n 'hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n toggle(): void {\n this.service.toggle();\n }\n}\n\n/** Holds the steps; rendered only while the trace is expanded. */\n@Component({\n selector: 'tolle-chain-of-thought-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"isOpen\" data-state=\"open\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ChainOfThoughtContentComponent 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 /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ChainOfThoughtService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe((open) => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn('mt-2 flex flex-col', this.class);\n }\n}\n\n/** One reasoning step: a marker on the rail, a label, and any projected detail. */\n@Component({\n selector: 'tolle-chain-of-thought-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-cot-rail absolute bottom-0 left-3 top-6 w-px -translate-x-1/2 bg-border\" aria-hidden=\"true\"></span>\n <span [class]=\"markerClass\">\n <i [class]=\"icon\" aria-hidden=\"true\"></i>\n </span>\n <div class=\"min-w-0 flex-1 pt-0.5\">\n <p [class]=\"labelClass\">{{ label }}</p>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ChainOfThoughtStepComponent 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 /** Text describing what happened in this step. @default '' */\n @Input() label = '';\n /** Progress of this step; drives the marker colour. @default 'complete' */\n @Input() status: ChainOfThoughtStepStatus = 'complete';\n /** Remixicon class shown inside the step marker. @default 'ri-checkbox-blank-circle-fill' */\n @Input() icon = 'ri-checkbox-blank-circle-fill';\n /** Extra Tailwind classes merged onto the step via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(chainOfThoughtStepVariants({ status: this.status }), this.class);\n }\n\n get markerClass() {\n return cn(chainOfThoughtMarkerVariants({ status: this.status }));\n }\n\n get labelClass() {\n return cn('truncate text-sm', this.status === 'active' ? 'font-medium text-foreground' : 'text-muted-foreground');\n }\n}\n\n/** Wraps the result chips produced by a search step. */\n@Component({\n selector: 'tolle-chain-of-thought-search-results',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('mt-1.5 flex flex-wrap items-center gap-1', class)\"><ng-content></ng-content></div>`,\n})\nexport class ChainOfThoughtSearchResultsComponent 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 results row via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** A single search result rendered as a small chip. */\n@Component({\n selector: 'tolle-chain-of-thought-search-result',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span [class]=\"computedClass\">\n <i *ngIf=\"icon\" [class]=\"icon + ' shrink-0'\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ChainOfThoughtSearchResultComponent 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 /** Optional remixicon class shown before the label. @default '' */\n @Input() icon = '';\n /** Extra Tailwind classes merged onto the chip via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(\n 'inline-flex max-w-full items-center gap-1 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-muted-foreground',\n this.class\n );\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "chart-pie",
3
+ "type": "registry:ui",
4
+ "title": "Chart Pie",
5
+ "description": "Chart Pie component with variant options.",
6
+ "category": "data",
7
+ "registryDependencies": [
8
+ "chart"
9
+ ],
10
+ "dependencies": [
11
+ "class-variance-authority"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "chart-pie.component.ts",
16
+ "target": "ui/chart-pie.component.ts",
17
+ "content": "import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { ChartService, ChartColorScale } from './chart.service';\nimport { ChartTableComponent } from './chart.component';\n\nlet chartPieId = 0;\n\n/** One resolved slice: its geometry, its paint, and the numbers behind it. */\nexport interface ChartPieSlice {\n label: string;\n value: number;\n percent: number;\n color: string;\n /** The arc path, already padded for the surface gap. */\n d: string;\n /** Mid-angle point on the outer edge, used to place the tooltip. */\n anchorX: number;\n anchorY: number;\n}\n\n/** Converts an angle measured clockwise from 12 o'clock into svg coordinates. */\nexport function polarPoint(cx: number, cy: number, r: number, angle: number): [number, number] {\n return [cx + r * Math.sin(angle), cy - r * Math.cos(angle)];\n}\n\n/**\n * Builds one slice. `innerRadius` above zero produces a donut segment; a span\n * covering the full circle is drawn as two half arcs, because an SVG arc whose\n * start and end points coincide renders nothing at all.\n */\nexport function arcPath(\n cx: number,\n cy: number,\n outer: number,\n inner: number,\n start: number,\n end: number\n): string {\n const span = end - start;\n if (span <= 0) return '';\n\n if (span >= Math.PI * 2 - 1e-6) {\n const mid = start + Math.PI;\n return arcPath(cx, cy, outer, inner, start, mid) + ' ' + arcPath(cx, cy, outer, inner, mid, end);\n }\n\n const large = span > Math.PI ? 1 : 0;\n const [ox0, oy0] = polarPoint(cx, cy, outer, start);\n const [ox1, oy1] = polarPoint(cx, cy, outer, end);\n\n if (inner > 0) {\n const [ix1, iy1] = polarPoint(cx, cy, inner, end);\n const [ix0, iy0] = polarPoint(cx, cy, inner, start);\n return (\n 'M ' + ox0 + ' ' + oy0 +\n ' A ' + outer + ' ' + outer + ' 0 ' + large + ' 1 ' + ox1 + ' ' + oy1 +\n ' L ' + ix1 + ' ' + iy1 +\n ' A ' + inner + ' ' + inner + ' 0 ' + large + ' 0 ' + ix0 + ' ' + iy0 +\n ' Z'\n );\n }\n\n return (\n 'M ' + cx + ' ' + cy +\n ' L ' + ox0 + ' ' + oy0 +\n ' A ' + outer + ' ' + outer + ' 0 ' + large + ' 1 ' + ox1 + ' ' + oy1 +\n ' Z'\n );\n}\n\nconst chartPieVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type ChartPieProps = VariantProps<typeof chartPieVariants>;\n\n/**\n * A pie or donut drawn from hand-written SVG arcs.\n *\n * Slices are separated by a real 2px angular gap rather than a stroke, so what\n * divides them is surface rather than added ink. Colour is assigned by slice\n * label, so re-sorting or filtering the data never repaints a slice, and a\n * sixth slice falls back to a neutral rather than reusing `--chart-1`.\n *\n * Renders its own legend at two or more slices, a per-slice hover tooltip, and\n * the same accessible `tolle-chart-table` fallback as `tolle-chart`.\n * @new\n */\n@Component({\n selector: 'tolle-chart-pie',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\">\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"size\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full\"\n (pointerleave)=\"onLeave()\"\n >\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n <svg:path\n *ngFor=\"let slice of slices; let i = index; trackBy: trackSlice\"\n [attr.d]=\"slice.d\"\n [attr.fill]=\"slice.color\"\n [attr.fill-opacity]=\"activeIndex === i ? 1 : 0.85\"\n (pointerenter)=\"onEnter(i)\"\n ></svg:path>\n </svg>\n\n <div\n *ngIf=\"activeSlice\"\n role=\"status\"\n aria-live=\"polite\"\n class=\"pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md\"\n [style.left.px]=\"tooltipLeft\"\n [style.top.px]=\"tooltipTop\"\n >\n <div class=\"flex items-center gap-2 text-xs\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"activeSlice.color\"></span>\n <span class=\"text-muted-foreground\">{{ activeSlice.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">\n {{ formatValue(activeSlice.value) }}\n </span>\n </div>\n <p class=\"mt-0.5 text-right text-xs text-muted-foreground tabular-nums\">\n {{ formatPercent(activeSlice.percent) }}\n </p>\n </div>\n </div>\n\n <ul *ngIf=\"slices.length > 1\" class=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-2 text-xs\">\n <li *ngFor=\"let slice of slices; trackBy: trackSlice\" class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"slice.color\"></span>\n <span class=\"text-muted-foreground\">{{ slice.label }}</span>\n </li>\n </ul>\n\n <tolle-chart-table\n [tableId]=\"tableId\"\n [visible]=\"showTable\"\n [xHeader]=\"labelHeader\"\n [caption]=\"ariaLabel || 'Chart data'\"\n ></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartPieComponent implements OnChanges {\n /** Rows to plot, one slice each. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Row key holding each slice's numeric value. @default 'value' */\n @Input() valueKey = 'value';\n /** Row key holding each slice's label. @default 'label' */\n @Input() labelKey = 'label';\n /** Cuts a hole in the middle, making it a donut. @default false */\n @Input() donut = false;\n /** Hole size as a fraction of the outer radius, used when `donut` is set. @default 0.6 */\n @Input() innerRadius = 0.6;\n /** Width and height of the square plot in px. @default 240 */\n @Input() size = 240;\n /** Angular gap between slices, expressed in px of arc at the outer edge. @default 2 */\n @Input() gap = 2;\n /** Accessible name for the chart, used as the svg `<title>`. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg `<desc>`. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the label column. @default 'Category' */\n @Input() labelHeader = 'Category';\n /** Column header the table fallback gives the value column. @default 'Value' */\n @Input() valueHeader = 'Value';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartPieProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered slice index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n private readonly cdr = inject(ChangeDetectorRef);\n /** Only used to feed the shared table fallback; the pie owns its own colours. */\n private readonly tableSource = inject(ChartService);\n private readonly colors = new ChartColorScale();\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-pie-table-' + chartPieId++;\n\n /** Index of the hovered slice, or null. */\n activeIndex: number | null = null;\n\n ngOnChanges(): void {\n // Reserve palette slots in data order, so a slice keeps its colour when the\n // data is later re-sorted or filtered.\n this.colors.register(this.labels);\n this.tableSource.configure({\n data: this.data ?? [],\n xKey: this.labelKey,\n series: [{ key: this.valueKey, label: this.valueHeader }],\n });\n this._slices = this.computeSlices();\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private get labels(): string[] {\n return (this.data ?? []).map((row) => {\n const raw = row?.[this.labelKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + this.size + ' ' + this.size;\n }\n\n private get outerRadius(): number {\n return this.size / 2;\n }\n\n /**\n * Resolved slices in data order, each already padded for the surface gap.\n *\n * Computed once per input change, NOT per read. Three template bindings read\n * this each pass; when it rebuilt the array every time, `*ngFor` saw fresh\n * identities and tore down every `<path>` on every change-detection run.\n * Destroying the node under the pointer re-fires `pointerenter`, which calls\n * `markForCheck`, which schedules another pass — the chart locked the page up.\n */\n get slices(): ChartPieSlice[] {\n return this._slices;\n }\n\n private _slices: ChartPieSlice[] = [];\n\n /** Identity for `*ngFor`, so a redraw patches attributes instead of replacing nodes. */\n trackSlice = (_: number, slice: ChartPieSlice): string => slice.label;\n\n private computeSlices(): ChartPieSlice[] {\n const rows = this.data ?? [];\n const values = rows.map((row) => {\n const num = Number(row?.[this.valueKey]);\n // A pie divides a whole; a negative share has no meaning in one.\n return Number.isFinite(num) && num > 0 ? num : 0;\n });\n const total = values.reduce((sum, v) => sum + v, 0);\n if (total <= 0) return [];\n\n const cx = this.size / 2;\n const cy = this.size / 2;\n const outer = this.outerRadius;\n const inner = this.donut ? outer * Math.min(0.95, Math.max(0, this.innerRadius)) : 0;\n // Turn a px gap at the rim into the angle that subtends it.\n const pad = outer > 0 ? this.gap / outer : 0;\n\n const out: ChartPieSlice[] = [];\n let cursor = 0;\n\n for (let i = 0; i < rows.length; i++) {\n const value = values[i];\n if (value <= 0) continue;\n\n const span = (value / total) * Math.PI * 2;\n const start = cursor;\n const end = cursor + span;\n cursor = end;\n\n // Never let the gap eat a slice: a sliver keeps a hairline of itself.\n const usable = Math.max(span * 0.2, span - pad);\n const inset = (span - usable) / 2;\n const label = this.labels[i];\n const mid = start + span / 2;\n const [anchorX, anchorY] = polarPoint(cx, cy, (outer + inner) / 2, mid);\n\n out.push({\n label,\n value,\n percent: value / total,\n color: this.colors.colorFor(label),\n d: arcPath(cx, cy, outer, inner, start + inset, end - inset),\n anchorX,\n anchorY,\n });\n }\n\n return out;\n }\n\n get activeSlice(): ChartPieSlice | null {\n if (this.activeIndex === null) return null;\n return this.slices[this.activeIndex] ?? null;\n }\n\n get tooltipLeft(): number {\n const slice = this.activeSlice;\n if (!slice) return 0;\n // Flip toward the centre rather than let the readout leave the frame.\n return slice.anchorX > this.size / 2\n ? Math.max(0, slice.anchorX - 140)\n : Math.min(this.size, slice.anchorX + 8);\n }\n\n get tooltipTop(): number {\n return this.activeSlice ? this.activeSlice.anchorY : 0;\n }\n\n formatValue(value: number): string {\n return value.toLocaleString('en-US');\n }\n\n formatPercent(percent: number): string {\n return (percent * 100).toFixed(1) + '%';\n }\n\n onEnter(index: number): void {\n if (this.activeIndex === index) return;\n this.activeIndex = index;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n }\n\n onLeave(): void {\n if (this.activeIndex === null) return;\n this.activeIndex = null;\n this.activeIndexChange.emit(null);\n this.cdr.markForCheck();\n }\n\n get computedClass() {\n return cn(chartPieVariants({ variant: this.variant }), this.class);\n }\n}\n"
18
+ },
19
+ {
20
+ "path": "chart.service.ts",
21
+ "target": "ui/chart.service.ts",
22
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to an svg y. SVG y grows downward, so the domain maximum\n * lands on `plotTop` and the minimum on `plotBottom`.\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The svg y of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.plotWidth / this.count;\n }\n\n /** Geometry of the band at `index`. */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.plotLeft + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.plotLeft + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
23
+ },
24
+ {
25
+ "path": "utils/cn.ts",
26
+ "target": "ui/utils/cn.ts",
27
+ "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"
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "chart",
3
+ "type": "registry:ui",
4
+ "title": "Chart",
5
+ "description": "Chart component with variant and density options.",
6
+ "category": "data",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "chart.component.ts",
14
+ "target": "ui/chart.component.ts",
15
+ "content": "import { Component, Directive, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, ViewChild, OnInit, OnChanges, OnDestroy, AfterViewInit, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { ChartService, type ChartSeries, type ChartMargin } from './chart.service';\n\nexport type { ChartSeries, ChartMargin, ChartTick, ChartBand } from './chart.service';\n\nlet chartId = 0;\n\n/**\n * Base for every chart part that has to redraw when the shared scales change.\n * Parts subscribe to the service rather than reading the container component,\n * so an OnPush child still updates when the container's inputs or width change.\n */\n@Directive()\nexport abstract class ChartChild implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n protected readonly chart = inject(ChartService);\n protected readonly cdr = inject(ChangeDetectorRef);\n protected readonly subscriptions = new Subscription();\n\n /**\n * Identity for the mark `*ngFor`s. The collections behind them are rebuilt on\n * every read, so without this `*ngFor` sees new identities each pass and\n * replaces every node instead of patching it. Marks are positional, so the\n * index IS the identity.\n */\n trackByIndex = (index: number): number => index;\n\n ngOnInit(): void {\n this.register();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Hook for parts that need to influence the shared scales before first paint. */\n protected register(): void {}\n}\n\n/**\n * The floating readout for the hovered x position. Lists every series at that\n * x, so the pointer never has to land on a particular line to get a value.\n * Rendered automatically by `tolle-chart` when `hover` is on.\n */\n@Component({\n selector: 'tolle-chart-tooltip',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n *ngIf=\"index !== null\"\n role=\"status\"\n aria-live=\"polite\"\n [class]=\"computedClass\"\n [style.left.px]=\"left\"\n [style.top.px]=\"top\"\n >\n <p class=\"mb-1 text-xs font-medium text-foreground\">{{ xLabel }}</p>\n <div *ngFor=\"let row of rows; trackBy: trackByIndex\" class=\"flex items-center gap-2 text-xs\">\n <span class=\"h-0.5 w-3 shrink-0 rounded-full\" [style.background]=\"row.color\"></span>\n <span class=\"text-muted-foreground\">{{ row.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">{{ row.value }}</span>\n </div>\n </div>\n `,\n})\nexport class ChartTooltipComponent 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 /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Distance in px the tooltip sits from the crosshair. @default 12 */\n @Input() offset = 12;\n /** Assumed tooltip width in px, used to flip it near the right edge. @default 160 */\n @Input() estimatedWidth = 160;\n /** Extra Tailwind classes merged onto the tooltip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n /** Row index the tooltip is describing, or null when nothing is hovered. */\n index: number | null = null;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.index = index;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get xLabel(): string {\n return this.index === null ? '' : this.chart.xLabels[this.index] ?? '';\n }\n\n /** One row per series: colour key, label, and the formatted value. */\n get rows(): { label: string; value: string; color: string }[] {\n const index = this.index;\n if (index === null) return [];\n return this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, index);\n return {\n label: item.label,\n value: value == null ? '—' : this.chart.formatValue(value),\n color: this.chart.colorFor(item.key),\n };\n });\n }\n\n get left(): number {\n if (this.index === null) return 0;\n const x = this.chart.xFor(this.index);\n // Flip to the left of the crosshair rather than overflow the container.\n if (x + this.offset + this.estimatedWidth > this.chart.width) {\n return Math.max(0, x - this.offset - this.estimatedWidth);\n }\n return x + this.offset;\n }\n\n get top(): number {\n return this.chart.plotTop;\n }\n\n get computedClass() {\n return cn(\n 'pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md',\n this.class\n );\n }\n}\n\nconst chartLegendVariants = cva('flex flex-wrap items-center gap-x-4 gap-y-1 pt-2', {\n variants: {\n align: { start: 'justify-start', center: 'justify-center', end: 'justify-end' },\n size: { sm: 'text-[11px]', default: 'text-xs' },\n },\n defaultVariants: { align: 'center', size: 'default' },\n});\n\nexport type ChartLegendProps = VariantProps<typeof chartLegendVariants>;\n\n/**\n * Swatch and label per series. Rendered automatically by `tolle-chart` whenever\n * there are two or more series, so identity is never carried by colour alone.\n * A single series gets no legend — the chart's own label already names it.\n */\n@Component({\n selector: 'tolle-chart-legend',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"computedClass\">\n <li *ngFor=\"let entry of entries; trackBy: trackByIndex\" class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"entry.color\"></span>\n <span class=\"text-muted-foreground\">{{ entry.label }}</span>\n </li>\n </ul>\n `,\n})\nexport class ChartLegendComponent 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 /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Horizontal alignment of the legend row. @default 'center' */\n @Input() align: ChartLegendProps['align'] = 'center';\n /** Text size of the legend. @default 'default' */\n @Input() size: ChartLegendProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the legend via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One entry per series, coloured by series identity. */\n get entries(): { label: string; color: string }[] {\n return this.chart.series.map((item) => ({\n label: item.label,\n color: this.chart.colorFor(item.key),\n }));\n }\n\n get computedClass() {\n return cn(chartLegendVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst chartTableVariants = cva('w-full text-left text-xs', {\n variants: {\n visible: {\n true: 'mt-4 border-collapse',\n false: 'sr-only',\n },\n },\n defaultVariants: { visible: false },\n});\n\nexport type ChartTableProps = VariantProps<typeof chartTableVariants>;\n\n/**\n * The chart's data as a real `<table>`. Visually hidden by default and pointed\n * at by the svg's `aria-describedby`, so every value the chart encodes in\n * colour stays reachable without seeing or hovering it. Set `visible` to show it.\n */\n@Component({\n selector: 'tolle-chart-table',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [attr.id]=\"tableId || null\">\n <table [class]=\"computedClass\">\n <caption class=\"sr-only\">{{ caption }}</caption>\n <thead>\n <tr>\n <th scope=\"col\" class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\">\n {{ xHeader }}\n </th>\n <th\n *ngFor=\"let item of chart.series; trackBy: trackByIndex\"\n scope=\"col\"\n class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\"\n >\n {{ item.label }}\n </th>\n </tr>\n </thead>\n <tbody>\n <tr *ngFor=\"let row of rows; trackBy: trackByIndex\">\n <th scope=\"row\" class=\"border-b border-border px-2 py-1 font-normal text-foreground\">\n {{ row.label }}\n </th>\n <td\n *ngFor=\"let cell of row.cells; trackBy: trackByIndex\"\n class=\"border-b border-border px-2 py-1 tabular-nums text-foreground\"\n >\n {{ cell }}\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n `,\n})\nexport class ChartTableComponent 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 /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Shows the table instead of hiding it from sighted readers. @default false */\n @Input() visible = false;\n /** Id applied to the wrapper, for the chart's `aria-describedby`. @default '' */\n @Input() tableId = '';\n /** Column header for the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Accessible caption for the table. @default 'Chart data' */\n @Input() caption = 'Chart data';\n /** Extra Tailwind classes merged onto the table via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One row per x position, with a formatted cell per series. */\n get rows(): { label: string; cells: string[] }[] {\n return this.chart.xLabels.map((label, i) => ({\n label,\n cells: this.chart.series.map((item) => {\n const value = this.chart.valueAt(item.key, i);\n return value == null ? '—' : this.chart.formatValue(value);\n }),\n }));\n }\n\n get computedClass() {\n return cn(chartTableVariants({ visible: this.visible }), this.class);\n }\n}\n\nconst chartVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n density: {\n default: '',\n compact: 'text-xs',\n },\n },\n defaultVariants: { variant: 'default', density: 'default' },\n});\n\nexport type ChartProps = VariantProps<typeof chartVariants>;\n\n/**\n * Container for a hand-rolled SVG chart. Owns the responsive `<svg>`, the\n * shared scales (via the `ChartService` it provides), the hover layer, the\n * legend and the accessible table fallback.\n *\n * Marks and axes are projected as SVG children. They use attribute selectors on\n * `svg:g` rather than element selectors, because a custom element is created in\n * the HTML namespace and an HTML element inside an `<svg>` never renders its\n * SVG subtree:\n *\n * ```html\n * <tolle-chart [data]=\"rows\" [series]=\"series\" xKey=\"month\" ariaLabel=\"Revenue\">\n * <svg:g tolle-chart-grid></svg:g>\n * <svg:g tolle-chart-y-axis></svg:g>\n * <svg:g tolle-chart-x-axis></svg:g>\n * <svg:g tolle-chart-line seriesKey=\"revenue\" curve=\"smooth\"></svg:g>\n * </tolle-chart>\n * ```\n *\n * There is deliberately no secondary y-axis: two measures on two scales in one\n * frame let the author choose where the lines cross, so the reader sees a\n * relationship the data does not contain. Use two charts, small multiples, or\n * index both series to a common base instead.\n * @new\n */\n@Component({\n selector: 'tolle-chart',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, ChartTooltipComponent, ChartLegendComponent, ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\" #plot>\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"height\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full overflow-visible\"\n (pointerleave)=\"onPointerLeave()\"\n >\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n <svg:g>\n <ng-content></ng-content>\n </svg:g>\n\n <svg:line\n *ngIf=\"crosshairX !== null\"\n [attr.x1]=\"crosshairX\"\n [attr.x2]=\"crosshairX\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n class=\"pointer-events-none stroke-border\"\n stroke-width=\"1\"\n ></svg:line>\n\n <svg:rect\n *ngFor=\"let band of hitBands; let i = index; trackBy: trackByIndex\"\n [attr.x]=\"band.start\"\n [attr.y]=\"chart.plotTop\"\n [attr.width]=\"band.width\"\n [attr.height]=\"chart.plotHeight\"\n fill=\"transparent\"\n (pointerenter)=\"onEnterBand(i)\"\n ></svg:rect>\n </svg>\n\n <tolle-chart-tooltip *ngIf=\"hover\"></tolle-chart-tooltip>\n </div>\n\n <tolle-chart-legend *ngIf=\"series.length > 1\"></tolle-chart-legend>\n\n <tolle-chart-table [tableId]=\"tableId\" [visible]=\"showTable\" [xHeader]=\"xHeader\"></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** Rows to plot, in x order. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Series to draw, in the order they take palette steps `--chart-1`..`--chart-5`. @default [] */\n @Input() series: ChartSeries[] = [];\n /** Row key holding each row's x category label. @default '' */\n @Input() xKey = '';\n /** Height of the chart in px. @default 260 */\n @Input() height = 260;\n /** Space reserved around the plot for axis labels. @default { top: 8, right: 8, bottom: 24, left: 44 } */\n @Input() margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 44 };\n /** Accessible name for the chart, used as the svg `<title>`. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg `<desc>`. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Stacks marks on a shared baseline instead of grouping them. @default false */\n @Input() stacked = false;\n /** Renders the crosshair, hit layer and shared tooltip. @default true */\n @Input() hover = true;\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartProps['variant'] = 'default';\n /** Text density of the chart chrome. @default 'default' */\n @Input() density: ChartProps['density'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered row index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n @ViewChild('plot') plotRef?: ElementRef<HTMLElement>;\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n private resizeObserver?: ResizeObserver;\n\n /** Width used until the container has been measured. */\n static readonly fallbackWidth = 600;\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-table-' + chartId++;\n\n /** X of the crosshair, or null when nothing is hovered or the scale is banded. */\n crosshairX: number | null = null;\n\n ngOnInit(): void {\n this.push();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n // Bars carry their own hover state; a crosshair belongs to point scales.\n this.crosshairX =\n index != null && this.chart.mode === 'point' ? this.chart.xFor(index) : null;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.push();\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 const host = this.plotRef?.nativeElement;\n if (!host) return;\n this.measure(host);\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => {\n this.measure(host);\n this.cdr.markForCheck();\n });\n this.resizeObserver.observe(host);\n }\n }\n\n ngOnDestroy(): void {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n this.subscriptions.unsubscribe();\n }\n\n private measure(host: HTMLElement): void {\n const width = host.clientWidth || ChartComponent.fallbackWidth;\n if (width === this.chart.width) return;\n this.chart.configure({ width });\n }\n\n private push(): void {\n this.chart.configure({\n data: this.data ?? [],\n series: this.series ?? [],\n xKey: this.xKey,\n stacked: this.stacked,\n height: this.height,\n margin: this.margin,\n width: this.chart.width || ChartComponent.fallbackWidth,\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + (this.chart.width || ChartComponent.fallbackWidth) + ' ' + this.height;\n }\n\n /**\n * One catchment rect per x position, deliberately wider than the marks it\n * covers — readers aim at a category, never at a 2px line or an 8px dot.\n */\n get hitBands() {\n if (!this.hover) return [];\n return this.data.map((_, i) => this.chart.hitBandFor(i));\n }\n\n onEnterBand(index: number): void {\n this.chart.setActiveIndex(index);\n }\n\n onPointerLeave(): void {\n this.chart.setActiveIndex(null);\n }\n\n get computedClass() {\n return cn(chartVariants({ variant: this.variant, density: this.density }), this.class);\n }\n}\n\n/** Horizontal rules at the y ticks, with optional vertical rules at the x positions. */\n@Component({\n selector: 'svg:g[tolle-chart-grid]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:line\n *ngFor=\"let tick of chart.ticks; trackBy: trackByIndex\"\n [attr.x1]=\"chart.plotLeft\"\n [attr.x2]=\"chart.plotRight\"\n [attr.y1]=\"tick.y\"\n [attr.y2]=\"tick.y\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n <svg:line\n *ngFor=\"let x of verticalXs; trackBy: trackByIndex\"\n [attr.x1]=\"x\"\n [attr.x2]=\"x\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n [class]=\"lineClass\"\n stroke-width=\"1\"\n ></svg:line>\n `,\n})\nexport class ChartGridComponent extends ChartChild {\n /** Also draws a rule at every x position. @default false */\n @Input() vertical = false;\n /** Extra Tailwind classes merged onto each rule via `cn()` (last-wins). */\n @Input() class = '';\n\n get verticalXs(): number[] {\n if (!this.vertical) return [];\n return this.chart.xLabels.map((_, i) => this.chart.xFor(i));\n }\n\n get lineClass(): string {\n // Recessive by design: a grid is a reading aid, never a mark.\n return cn('stroke-border opacity-60', this.class);\n }\n}\n\n/** X-axis tick labels, thinned so they never collide. */\n@Component({\n selector: 'svg:g[tolle-chart-x-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of visibleTicks; trackBy: trackByIndex\"\n [attr.x]=\"tick.x\"\n [attr.y]=\"labelY\"\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartXAxisComponent extends ChartChild {\n /** Approximate px per character, used to decide when labels would collide. @default 7 */\n @Input() charWidth = 7;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n /**\n * Draw every nth label. Derived from the widest label and the space each one\n * gets, so a dense axis thins out instead of overprinting itself.\n */\n get stride(): number {\n const labels = this.chart.xLabels;\n if (labels.length === 0) return 1;\n const widest = labels.reduce((max, label) => Math.max(max, label.length), 0);\n const needed = widest * this.charWidth + 8;\n const perLabel = this.chart.plotWidth / labels.length;\n if (perLabel <= 0) return 1;\n return Math.max(1, Math.ceil(needed / perLabel));\n }\n\n get visibleTicks(): { x: number; label: string }[] {\n const stride = this.stride;\n return this.chart.xLabels\n .map((label, i) => ({ label, x: this.chart.xFor(i), i }))\n .filter((tick) => tick.i % stride === 0);\n }\n\n get labelY(): number {\n return this.chart.plotBottom + 8;\n }\n\n get labelClass(): string {\n // Text wears a text token, never the series colour.\n return cn('fill-muted-foreground text-xs', this.class);\n }\n}\n\n/** Y-axis tick labels at the nice tick values. */\n@Component({\n selector: 'svg:g[tolle-chart-y-axis]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:text\n *ngFor=\"let tick of chart.ticks; trackBy: trackByIndex\"\n [attr.x]=\"labelX\"\n [attr.y]=\"tick.y\"\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n [class]=\"labelClass\"\n >{{ tick.label }}</svg:text>\n `,\n})\nexport class ChartYAxisComponent extends ChartChild {\n /** Gap in px between the labels and the plot edge. @default 8 */\n @Input() offset = 8;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n get labelX(): number {\n return this.chart.plotLeft - this.offset;\n }\n\n get labelClass(): string {\n return cn('fill-muted-foreground text-xs tabular-nums', this.class);\n }\n}\n\n/** A line mark for one series, optionally monotone-smoothed and dotted. */\n@Component({\n selector: 'svg:g[tolle-chart-line]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"path\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n <svg:circle\n *ngFor=\"let point of dots; trackBy: trackByIndex\"\n [attr.cx]=\"point.x\"\n [attr.cy]=\"point.y\"\n [attr.r]=\"dotRadius\"\n [attr.fill]=\"color\"\n class=\"stroke-background\"\n stroke-width=\"2\"\n ></svg:circle>\n `,\n})\nexport class ChartLineComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points; smooth is monotone cubic, so it never overshoots. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Draws a marker at every point. @default false */\n @Input() showDots = false;\n /** Marker radius in px; 4 gives the 8px minimum diameter. @default 4 */\n @Input() dotRadius = 4;\n /** Extra Tailwind classes merged onto the path via `cn()` (last-wins). */\n @Input() class = '';\n\n get path(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n get dots(): { x: number; y: number; index: number }[] {\n return this.showDots ? this.chart.pointsFor(this.seriesKey) : [];\n }\n}\n\n/** A filled area for one series: a low-opacity wash under a 2px stroke. */\n@Component({\n selector: 'svg:g[tolle-chart-area]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"areaPath\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"fillOpacity\"\n stroke=\"none\"\n ></svg:path>\n <svg:path\n [attr.d]=\"linePath\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"\n ></svg:path>\n `,\n})\nexport class ChartAreaComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Opacity of the fill — a wash, never a saturated block. @default 0.1 */\n @Input() fillOpacity = 0.1;\n /** Extra Tailwind classes merged onto the stroke via `cn()` (last-wins). */\n @Input() class = '';\n\n /** An area is anchored to a baseline, so zero has to be in the domain. */\n protected override register(): void {\n this.chart.requireZero();\n }\n\n get areaPath(): string {\n return this.chart.areaPath(this.seriesKey, this.curve === 'smooth');\n }\n\n get linePath(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n}\n\n/**\n * Builds a bar whose data end is rounded and whose baseline end stays square.\n * Handles both directions, so a negative value rounds downward.\n */\nexport function barPath(\n x: number,\n width: number,\n yBase: number,\n yValue: number,\n radius: number\n): string {\n const height = Math.abs(yBase - yValue);\n const r = Math.max(0, Math.min(radius, width / 2, height));\n const right = x + width;\n // Which way the corner curves depends on which side of the baseline we are.\n const sign = yValue <= yBase ? 1 : -1;\n const corner = yValue + sign * r;\n\n return (\n 'M ' + x + ' ' + yBase +\n ' L ' + x + ' ' + corner +\n ' Q ' + x + ' ' + yValue + ' ' + (x + r) + ' ' + yValue +\n ' L ' + (right - r) + ' ' + yValue +\n ' Q ' + right + ' ' + yValue + ' ' + right + ' ' + corner +\n ' L ' + right + ' ' + yBase + ' Z'\n );\n}\n\n/**\n * A bar mark for one series. Grouped beside its siblings by default, stacked\n * when the container's `stacked` is set. The data end carries a 4px radius, the\n * baseline end stays square, and neighbours are separated by surface, not ink.\n */\n@Component({\n selector: 'svg:g[tolle-chart-bar]',\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n *ngFor=\"let bar of bars; trackBy: trackByIndex\"\n [attr.d]=\"bar.d\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"bar.active ? 1 : 0.85\"\n ></svg:path>\n `,\n})\nexport class ChartBarComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Corner radius on the data end in px. @default 4 */\n @Input() radius = 4;\n /** Largest bar thickness in px; a wider band becomes air, not a fatter bar. @default 24 */\n @Input() maxWidth = 24;\n /** Gap in px between adjacent bars and between stacked segments. @default 2 */\n @Input() gap = 2;\n /** Extra Tailwind classes merged onto each bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Index currently hovered, so the bar under the pointer can lift. */\n activeIndex: number | null = null;\n\n /** Bars need a band scale and a zero baseline; both belong to the whole chart. */\n protected override register(): void {\n this.chart.useBandScale();\n }\n\n override ngOnInit(): void {\n super.ngOnInit();\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe((index) => {\n this.activeIndex = index;\n this.cdr.markForCheck();\n })\n );\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n /** Geometry for every bar this series contributes. */\n get bars(): {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] {\n const out: {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] = [];\n const seriesCount = Math.max(1, this.chart.series.length);\n const found = this.chart.series.findIndex((item) => item.key === this.seriesKey);\n const slotIndex = found < 0 ? 0 : found;\n\n for (let i = 0; i < this.chart.count; i++) {\n const value = this.chart.valueAt(this.seriesKey, i);\n if (value == null) continue;\n\n const band = this.chart.bandFor(i);\n let x: number;\n let width: number;\n\n if (this.chart.stacked) {\n width = Math.min(this.maxWidth, band.width);\n x = band.centre - width / 2;\n } else {\n const slot = band.width / seriesCount;\n width = Math.min(this.maxWidth, Math.max(1, slot - this.gap));\n x = band.start + slot * slotIndex + (slot - width) / 2;\n }\n\n const base = this.chart.stacked ? this.chart.stackBase(this.seriesKey, i) : 0;\n const yBase = this.chart.yFor(base);\n let yValue = this.chart.yFor(base + value);\n\n // The gap between stacked segments comes off the data end, so what\n // separates them is surface showing through rather than a stroke.\n if (this.chart.stacked && Math.abs(yBase - yValue) > this.gap) {\n yValue += value >= 0 ? this.gap : -this.gap;\n }\n\n out.push({\n d: barPath(x, width, yBase, yValue, this.radius),\n active: this.activeIndex === i,\n x,\n width,\n yBase,\n yValue,\n });\n }\n return out;\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "chart.service.ts",
19
+ "target": "ui/chart.service.ts",
20
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to an svg y. SVG y grows downward, so the domain maximum\n * lands on `plotTop` and the minimum on `plotBottom`.\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The svg y of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.plotWidth / this.count;\n }\n\n /** Geometry of the band at `index`. */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.plotLeft + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.plotLeft + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
21
+ },
22
+ {
23
+ "path": "utils/cn.ts",
24
+ "target": "ui/utils/cn.ts",
25
+ "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"
26
+ }
27
+ ]
28
+ }
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "checkbox.component.ts",
14
14
  "target": "ui/checkbox.component.ts",
15
- "content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst checkboxVariants = cva(\n 'group flex shrink-0 cursor-pointer items-center justify-center p-0 rounded-sm border border-primary ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-3.5 w-3.5',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: 'bg-primary text-primary-foreground',\n false: 'bg-transparent',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type CheckboxProps = VariantProps<typeof checkboxVariants>;\n\n@Component({\n selector: 'tolle-checkbox',\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CheckboxComponent),\n multi: true\n }\n ],\n template: `\n <button\n type=\"button\"\n role=\"checkbox\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"computedClass\"\n >\n <i\n *ngIf=\"checked\"\n class=\"ri-check-line font-bold\"\n [class]=\"size === 'xs' ? 'text-[10px]' : 'text-[14px]'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `\n})\nexport class CheckboxComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the checkbox via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the checkbox and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the checkbox. @default 'default' */\n @Input() size: CheckboxProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(\n checkboxVariants({ size: this.size, checked: this.checked }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
15
+ "content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst checkboxVariants = cva(\n 'group flex shrink-0 cursor-pointer items-center justify-center p-0 rounded-sm border border-primary ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-3.5 w-3.5',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: 'bg-primary text-primary-foreground',\n false: 'bg-transparent',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type CheckboxProps = VariantProps<typeof checkboxVariants>;\n\n@Component({\n selector: 'tolle-checkbox',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CheckboxComponent),\n multi: true\n }\n ],\n template: `\n <button\n type=\"button\"\n role=\"checkbox\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"computedClass\"\n >\n <i\n *ngIf=\"checked\"\n class=\"ri-check-line font-bold\"\n [class]=\"size === 'xs' ? 'text-[10px]' : 'text-[14px]'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `\n})\nexport class CheckboxComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the checkbox via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the checkbox and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the checkbox. @default 'default' */\n @Input() size: CheckboxProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(\n checkboxVariants({ size: this.size, checked: this.checked }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
16
16
  },
17
17
  {
18
18
  "path": "utils/cn.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "checkpoint",
3
+ "type": "registry:ui",
4
+ "title": "Checkpoint",
5
+ "description": "Checkpoint component with active and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "checkpoint.component.ts",
14
+ "target": "ui/checkpoint.component.ts",
15
+ "content": "import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst checkpointPillVariants = cva(\n 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ' +\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n active: {\n true: 'border-primary/50 bg-primary/10 text-primary',\n false: 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground',\n },\n size: {\n sm: 'px-2 py-0.5 text-[11px]',\n default: 'px-2.5 py-1 text-xs',\n },\n },\n defaultVariants: { active: false, size: 'default' },\n }\n);\n\nexport type CheckpointProps = VariantProps<typeof checkpointPillVariants>;\n\n/**\n * A labelled marker in a conversation that the user can rewind to. Renders as a\n * horizontal rule with a centred, clickable pill.\n *\n * ```html\n * <tolle-checkpoint label=\"Before refactor\" timestamp=\"14:02\" (restore)=\"rewind()\"></tolle-checkpoint>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-checkpoint',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-active]=\"active ? '' : null\">\n <span class=\"h-px flex-1 bg-border\" aria-hidden=\"true\"></span>\n\n <button\n type=\"button\"\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || restoreLabel + ': ' + label\"\n [attr.aria-current]=\"active ? 'step' : null\"\n [class]=\"pillClass\"\n (click)=\"restore.emit()\"\n >\n <i [class]=\"icon + ' shrink-0'\" aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ label }}</span>\n <span *ngIf=\"timestamp\" class=\"shrink-0 text-muted-foreground\">{{ timestamp }}</span>\n <ng-content></ng-content>\n </button>\n\n <span class=\"h-px flex-1 bg-border\" aria-hidden=\"true\"></span>\n </div>\n `,\n})\nexport class CheckpointComponent 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 /** Name of the checkpoint shown in the pill. @default 'Checkpoint' */\n @Input() label = 'Checkpoint';\n /** Pre-formatted time shown after the label. @default '' */\n @Input() timestamp = '';\n /** Highlights this checkpoint as the one currently restored to. @default false */\n @Input() active = false;\n /** Prevents the pill from being activated. @default false */\n @Input() disabled = false;\n /** Verb used to build the pill's accessible name. @default 'Restore checkpoint' */\n @Input() restoreLabel = 'Restore checkpoint';\n /** Overrides the pill's accessible name entirely. @default '' */\n @Input() ariaLabel = '';\n /** Remixicon class shown before the label. @default 'ri-history-line' */\n @Input() icon = 'ri-history-line';\n /** Size of the pill. @default 'default' */\n @Input() size: CheckpointProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user asks to restore this checkpoint. */\n @Output() restore = new EventEmitter<void>();\n\n get computedClass() {\n return cn('flex w-full items-center gap-3', this.class);\n }\n\n get pillClass() {\n return cn(checkpointPillVariants({ active: this.active, size: this.size }));\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": "collapsible.component.ts",
12
12
  "target": "ui/collapsible.component.ts",
13
- "content": "import { Component, Input, Output, EventEmitter, Injectable, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { trigger, state, style, transition, animate } from '@angular/animations';\n\n@Injectable()\nclass CollapsibleService {\n private openSubject = new BehaviorSubject<boolean>(false);\n open$ = this.openSubject.asObservable();\n\n setOpen(value: boolean) {\n this.openSubject.next(value);\n }\n\n getOpen() {\n return this.openSubject.getValue();\n }\n\n toggle() {\n this.setOpen(!this.getOpen());\n }\n}\n\n@Component({\n selector: 'tolle-collapsible',\n standalone: true,\n imports: [CommonModule],\n providers: [CollapsibleService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleComponent {\n @Input() set open(val: boolean) {\n this.collapsibleService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n\n ngOnInit() {\n this.collapsibleService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n\n get computedClass() {\n return cn(\"w-full\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'button',\n '[attr.tabindex]': '0',\n '[attr.aria-expanded]': 'isOpen',\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n '(click)': 'onToggle()',\n '(keydown.enter)': 'onToggle()',\n '(keydown.space)': 'onToggle(); $event.preventDefault()',\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleTriggerComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n onToggle() {\n this.collapsibleService.toggle();\n }\n\n get computedClass() {\n return cn(\"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-content',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [@expandCollapse]=\"isOpen ? 'open' : 'closed'\" class=\"overflow-hidden\">\n <ng-content></ng-content>\n </div>\n `,\n animations: [\n trigger('expandCollapse', [\n state('closed', style({ height: '0px', opacity: 0 })),\n state('open', style({ height: '*', opacity: 1 })),\n transition('closed <=> open', animate('300ms cubic-bezier(0.4, 0, 0.2, 1)'))\n ])\n ],\n host: {\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n // `inert` (not `hidden`) so the height/opacity collapse animation still runs\n // while removing collapsed content from the tab order + pointer interaction.\n '[attr.inert]': 'isOpen ? null : \"\"',\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleContentComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n get computedClass() {\n return cn(\"\", this.class);\n }\n}\n"
13
+ "content": "import { Component, Input, Output, EventEmitter, Injectable, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { trigger, state, style, transition, animate } from '@angular/animations';\n\n@Injectable()\nclass CollapsibleService {\n private openSubject = new BehaviorSubject<boolean>(false);\n open$ = this.openSubject.asObservable();\n\n setOpen(value: boolean) {\n this.openSubject.next(value);\n }\n\n getOpen() {\n return this.openSubject.getValue();\n }\n\n toggle() {\n this.setOpen(!this.getOpen());\n }\n}\n\n@Component({\n selector: 'tolle-collapsible',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [CollapsibleService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleComponent {\n @Input() set open(val: boolean) {\n this.collapsibleService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n\n ngOnInit() {\n this.collapsibleService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n\n get computedClass() {\n return cn(\"w-full\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'button',\n '[attr.tabindex]': '0',\n '[attr.aria-expanded]': 'isOpen',\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n '(click)': 'onToggle()',\n '(keydown.enter)': 'onToggle()',\n '(keydown.space)': 'onToggle(); $event.preventDefault()',\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleTriggerComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n onToggle() {\n this.collapsibleService.toggle();\n }\n\n get computedClass() {\n return cn(\"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-content',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [@expandCollapse]=\"isOpen ? 'open' : 'closed'\" class=\"overflow-hidden\">\n <ng-content></ng-content>\n </div>\n `,\n animations: [\n trigger('expandCollapse', [\n state('closed', style({ height: '0px', opacity: 0 })),\n state('open', style({ height: '*', opacity: 1 })),\n transition('closed <=> open', animate('300ms cubic-bezier(0.4, 0, 0.2, 1)'))\n ])\n ],\n host: {\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n // `inert` (not `hidden`) so the height/opacity collapse animation still runs\n // while removing collapsed content from the tab order + pointer interaction.\n '[attr.inert]': 'isOpen ? null : \"\"',\n '[class]': 'computedClass'\n }\n})\nexport class CollapsibleContentComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n get computedClass() {\n return cn(\"\", this.class);\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "combobox",
3
+ "type": "registry:ui",
4
+ "title": "Combobox",
5
+ "description": "Combobox component with size and invalid options.",
6
+ "category": "forms",
7
+ "registryDependencies": [
8
+ "command"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "combobox.component.ts",
17
+ "target": "ui/combobox.component.ts",
18
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n forwardRef,\n ChangeDetectorRef,\n OnDestroy,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandItemComponent,\n} from './command.component';\n\nconst comboboxTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\nexport type ComboboxProps = VariantProps<typeof comboboxTriggerVariants>;\n\nexport type ComboboxOption = {\n label: string;\n value: any;\n disabled?: boolean;\n /** Extra terms that should also match this option when searching. */\n keywords?: string[];\n};\n\n/**\n * A searchable single-select: a button that opens a `tolle-command` list in a\n * floating panel. Implements `ControlValueAccessor`, so it works with\n * `ngModel` and reactive forms.\n *\n * Use `tolle-select` when the list is short and needs no search, and\n * `tolle-multi-select` when more than one value can be chosen.\n * @new\n */\n@Component({\n selector: 'tolle-combobox',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandItemComponent,\n ],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ComboboxComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? panelId : null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n >\n <span [class]=\"cn('truncate', selectedLabel ? 'text-foreground' : 'text-muted-foreground')\">\n {{ selectedLabel || placeholder }}\n </span>\n <i\n class=\"ri-expand-up-down-line shrink-0 text-muted-foreground transition-transform\"\n aria-hidden=\"true\"\n ></i>\n </button>\n\n <div\n *ngIf=\"isOpen\"\n #panel\n [id]=\"panelId\"\n class=\"fixed left-0 top-0 z-[9999]\"\n style=\"visibility: hidden\"\n >\n <div class=\"overflow-hidden rounded-md border border-border bg-popover shadow-md\">\n <tolle-command [shouldFilter]=\"shouldFilter\" (selected)=\"onSelect($event)\" (queryChange)=\"searchChange.emit($event)\">\n <tolle-command-input [placeholder]=\"searchPlaceholder\"></tolle-command-input>\n <tolle-command-list>\n <tolle-command-empty>{{ emptyMessage }}</tolle-command-empty>\n <tolle-command-item\n *ngFor=\"let option of options\"\n [value]=\"option.value\"\n [label]=\"option.label\"\n [keywords]=\"option.keywords || []\"\n [disabled]=\"option.disabled || false\"\n >\n <i\n class=\"ri-check-line shrink-0\"\n [class.opacity-0]=\"option.value !== value\"\n aria-hidden=\"true\"\n ></i>\n <span class=\"truncate\">{{ option.label }}</span>\n </tolle-command-item>\n </tolle-command-list>\n </tolle-command>\n </div>\n </div>\n `,\n})\nexport class ComboboxComponent implements ControlValueAccessor, OnDestroy {\n /** Options to choose from. @default [] */\n @Input() options: ComboboxOption[] = [];\n /** Text shown on the trigger when nothing is selected. @default 'Select an option…' */\n @Input() placeholder = 'Select an option…';\n /** Placeholder inside the search box. @default 'Search…' */\n @Input() searchPlaceholder = 'Search…';\n /** Message shown when the query matches no option. @default 'No results found.' */\n @Input() emptyMessage = 'No results found.';\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: ComboboxProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Set false to filter the options yourself, e.g. server-side. @default true */\n @Input() shouldFilter = true;\n /** Close the panel after a value is chosen. @default true */\n @Input() closeOnSelect = true;\n /** Where the panel opens relative to the trigger. @default 'bottom-start' */\n @Input() placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' = 'bottom-start';\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen value whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any>();\n /** Emitted with the query text as the user searches. */\n @Output() searchChange = new EventEmitter<string>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl?: ElementRef<HTMLElement>;\n @ViewChild('panel') panelEl?: ElementRef<HTMLElement>;\n\n readonly panelId = `combobox-panel-${Math.random().toString(36).slice(2, 11)}`;\n\n value: any = null;\n isOpen = false;\n protected cn = cn;\n\n private cleanup?: () => void;\n private onChange: (value: any) => void = () => {};\n private onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n private readonly outsideClickHandler = (event: MouseEvent) => {\n const target = event.target as Node;\n if (\n !this.triggerEl?.nativeElement.contains(target) &&\n !this.panelEl?.nativeElement.contains(target)\n ) {\n this.close();\n }\n };\n\n private readonly escapeHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.isOpen) {\n event.preventDefault();\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\n };\n\n get selectedLabel(): string {\n return this.options.find((o) => o.value === this.value)?.label ?? '';\n }\n\n get computedTriggerClass() {\n return cn(comboboxTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n open(): void {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\n this.cdr.markForCheck();\n\n setTimeout(() => {\n this.updatePosition();\n // Focus the search box so the user can type straight away.\n this.panelEl?.nativeElement.querySelector('input')?.focus();\n document.addEventListener('pointerdown', this.outsideClickHandler, true);\n document.addEventListener('keydown', this.escapeHandler, true);\n });\n }\n\n close(): void {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.onTouched();\n this.teardown();\n this.cdr.markForCheck();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n onSelect(value: any): void {\n this.value = value;\n this.onChange(value);\n this.valueChange.emit(value);\n if (this.closeOnSelect) {\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\n this.cdr.markForCheck();\n }\n\n private updatePosition(): void {\n const trigger = this.triggerEl?.nativeElement;\n const panel = this.panelEl?.nativeElement;\n if (!trigger || !panel) return;\n\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n placement: this.placement,\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // Match the panel to the trigger width and cap its height to the space\n // actually available, so the list scrolls instead of overflowing.\n size({\n padding: 8,\n apply({ rects, availableHeight, elements }) {\n Object.assign(elements.floating.style, {\n minWidth: `${rects.reference.width}px`,\n maxHeight: `${Math.max(160, availableHeight)}px`,\n });\n },\n }),\n ],\n }).then(({ x, y }) => {\n Object.assign(panel.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n });\n }\n\n private teardown(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n document.removeEventListener('pointerdown', this.outsideClickHandler, true);\n document.removeEventListener('keydown', this.escapeHandler, true);\n }\n\n ngOnDestroy(): void {\n this.teardown();\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: any) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "command.service.ts",
22
+ "target": "ui/command.service.ts",
23
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject, Subject } from 'rxjs';\n\n/** One registered `tolle-command-item`, as seen by the service. */\nexport interface CommandItemState {\n id: string;\n /** Text the query is matched against — the item's label plus any `keywords`. */\n searchText: string;\n value: any;\n disabled: boolean;\n /** Registration order, used to keep the visible list in DOM order. */\n order: number;\n}\n\n/**\n * Coordinates a `tolle-command` and its descendants: owns the query, decides\n * which items survive filtering, and tracks the active (highlighted) item for\n * `aria-activedescendant`.\n *\n * Provided on `CommandComponent`, so each command menu gets its own instance.\n */\n@Injectable()\nexport class CommandService {\n private readonly items = new Map<string, CommandItemState>();\n private nextOrder = 0;\n\n private readonly querySubject = new BehaviorSubject<string>('');\n /** Current search query. */\n readonly query$ = this.querySubject.asObservable();\n\n private readonly visibleSubject = new BehaviorSubject<string[]>([]);\n /** Ids of the items surviving the current query, in DOM order. */\n readonly visible$ = this.visibleSubject.asObservable();\n\n private readonly activeIdSubject = new BehaviorSubject<string | null>(null);\n /** Id of the highlighted item, or null when nothing matches. */\n readonly activeId$ = this.activeIdSubject.asObservable();\n\n private readonly selectedSubject = new Subject<any>();\n /** Emits the value of the item the user chose. */\n readonly selected$ = this.selectedSubject.asObservable();\n\n /**\n * When set, filtering is left entirely to the consumer — every registered\n * item stays visible regardless of the query. Use for server-side search.\n */\n shouldFilter = true;\n\n get query(): string {\n return this.querySubject.value;\n }\n\n get visibleIds(): string[] {\n return this.visibleSubject.value;\n }\n\n get activeId(): string | null {\n return this.activeIdSubject.value;\n }\n\n /**\n * True when there is nothing to show.\n *\n * With built-in filtering that means items exist but the query excluded them\n * all. With `shouldFilter = false` the consumer renders the result set itself,\n * so \"no items registered\" IS the empty result — requiring items to exist\n * would make `tolle-command-empty` permanently invisible in that mode.\n */\n get isEmpty(): boolean {\n if (!this.shouldFilter) return this.items.size === 0;\n return this.items.size > 0 && this.visibleSubject.value.length === 0;\n }\n\n register(item: Omit<CommandItemState, 'order'>): void {\n this.items.set(item.id, { ...item, order: this.nextOrder++ });\n this.recompute();\n }\n\n /** Keeps the stored search text/value in sync when an item's inputs change. */\n update(id: string, patch: Partial<Omit<CommandItemState, 'id' | 'order'>>): void {\n const existing = this.items.get(id);\n if (!existing) return;\n this.items.set(id, { ...existing, ...patch });\n this.recompute();\n }\n\n unregister(id: string): void {\n this.items.delete(id);\n this.recompute();\n }\n\n setQuery(query: string): void {\n this.querySubject.next(query);\n this.recompute();\n }\n\n isVisible(id: string): boolean {\n return this.visibleSubject.value.includes(id);\n }\n\n /** Moves the highlight by `delta` positions, wrapping at both ends. */\n move(delta: number): void {\n const ids = this.visibleSubject.value;\n if (!ids.length) return;\n const current = ids.indexOf(this.activeIdSubject.value ?? '');\n // From \"nothing active\", ArrowUp should land on the last item, not the second.\n const start = current < 0 ? (delta > 0 ? -1 : 0) : current;\n const next = (start + delta + ids.length) % ids.length;\n this.activeIdSubject.next(ids[next]);\n }\n\n setActive(id: string | null): void {\n if (id !== null && !this.isVisible(id)) return;\n this.activeIdSubject.next(id);\n }\n\n /** Selects the highlighted item, if there is one. */\n selectActive(): void {\n const id = this.activeIdSubject.value;\n if (!id) return;\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n selectById(id: string): void {\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n /** Recomputes the visible set and keeps the highlight on something valid. */\n private recompute(): void {\n const q = this.querySubject.value.trim().toLowerCase();\n\n const visible = [...this.items.values()]\n .filter((item) => !this.shouldFilter || !q || item.searchText.toLowerCase().includes(q))\n .sort((a, b) => a.order - b.order)\n .map((item) => item.id);\n\n this.visibleSubject.next(visible);\n\n // If the highlight fell out of the visible set (or there was none), move it\n // to the first selectable item so Enter always has a sensible target.\n const active = this.activeIdSubject.value;\n if (!active || !visible.includes(active)) {\n const firstEnabled = visible.find((id) => !this.items.get(id)?.disabled) ?? null;\n this.activeIdSubject.next(firstEnabled);\n }\n }\n}\n"
24
+ },
25
+ {
26
+ "path": "utils/cn.ts",
27
+ "target": "ui/utils/cn.ts",
28
+ "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"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "command-dialog",
3
+ "type": "registry:ui",
4
+ "title": "Command Dialog",
5
+ "description": "Command Dialog component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "command-dialog.component.ts",
12
+ "target": "ui/command-dialog.component.ts",
13
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n OnInit,\n OnDestroy,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n/**\n * The ⌘K command palette: a `tolle-command` in a centred modal overlay.\n *\n * Project a full command menu inside:\n *\n * ```html\n * <tolle-command-dialog [(open)]=\"paletteOpen\">\n * <tolle-command (selected)=\"run($event)\">\n * <tolle-command-input></tolle-command-input>\n * <tolle-command-list>…</tolle-command-list>\n * </tolle-command>\n * </tolle-command-dialog>\n * ```\n *\n * Set `shortcut` to bind a global hotkey (default ⌘K / Ctrl+K).\n * @new\n */\n@Component({\n selector: 'tolle-command-dialog',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"open\"\n class=\"fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-[15vh]\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel\"\n >\n <div\n class=\"fixed inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\"\n ></div>\n\n <div\n #panel\n [class]=\"cn(\n 'relative z-10 w-full max-w-lg overflow-hidden rounded-lg border border-border bg-popover shadow-lg',\n class\n )\"\n >\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class CommandDialogComponent implements OnInit, OnDestroy {\n /** Whether the palette is visible. Supports two-way binding via `[(open)]`. @default false */\n @Input() open = false;\n /**\n * Global hotkey that toggles the palette, as `mod+key` where `mod` maps to ⌘\n * on macOS and Ctrl elsewhere. Set to `''` to disable the binding.\n * @default 'mod+k'\n */\n @Input() shortcut = 'mod+k';\n /** Close the palette when the backdrop is clicked. @default true */\n @Input() closeOnBackdrop = true;\n /** Accessible name for the dialog. @default 'Command palette' */\n @Input() ariaLabel = 'Command palette';\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Two-way binding partner for `open`. */\n @Output() openChange = new EventEmitter<boolean>();\n /** Emitted when the palette opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the palette closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('panel') panelEl?: ElementRef<HTMLElement>;\n\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n private readonly onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.open) {\n event.preventDefault();\n this.setOpen(false);\n return;\n }\n\n if (!this.shortcut) return;\n const parts = this.shortcut.toLowerCase().split('+');\n const key = parts[parts.length - 1];\n const needsMod = parts.includes('mod') || parts.includes('meta') || parts.includes('ctrl');\n const modHeld = event.metaKey || event.ctrlKey;\n\n if (event.key.toLowerCase() === key && (!needsMod || modHeld)) {\n event.preventDefault();\n this.setOpen(!this.open);\n }\n };\n\n ngOnInit(): void {\n document.addEventListener('keydown', this.onKeyDown);\n }\n\n ngOnDestroy(): void {\n document.removeEventListener('keydown', this.onKeyDown);\n }\n\n onBackdropClick(): void {\n if (this.closeOnBackdrop) this.setOpen(false);\n }\n\n private setOpen(next: boolean): void {\n if (this.open === next) return;\n this.open = next;\n this.openChange.emit(next);\n (next ? this.opened : this.closed).emit();\n this.cdr.markForCheck();\n\n if (next) {\n // Focus the projected search box once the panel is in the DOM, so typing\n // works immediately on open.\n setTimeout(() => this.panelEl?.nativeElement.querySelector('input')?.focus());\n }\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "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"
19
+ }
20
+ ]
21
+ }