@tolle_/tolle-ui 18.2.29 → 18.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/esm2022/lib/accordion-item.component.mjs +5 -11
  2. package/esm2022/lib/accordion.component.mjs +4 -9
  3. package/esm2022/lib/alert-dialog-dynamic.component.mjs +5 -10
  4. package/esm2022/lib/alert-dialog.component.mjs +44 -59
  5. package/esm2022/lib/alert.component.mjs +5 -11
  6. package/esm2022/lib/aspect-ratio.component.mjs +119 -0
  7. package/esm2022/lib/attachment.component.mjs +334 -0
  8. package/esm2022/lib/avatar-fallback.component.mjs +3 -3
  9. package/esm2022/lib/avatar.component.mjs +4 -9
  10. package/esm2022/lib/badge.component.mjs +4 -9
  11. package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
  12. package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
  13. package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
  14. package/esm2022/lib/breadcrumb.component.mjs +3 -3
  15. package/esm2022/lib/bubble.component.mjs +301 -0
  16. package/esm2022/lib/button.component.mjs +5 -11
  17. package/esm2022/lib/calendar.component.mjs +5 -11
  18. package/esm2022/lib/card.component.mjs +14 -43
  19. package/esm2022/lib/carousel/carousel.component.mjs +5 -12
  20. package/esm2022/lib/chain-of-thought.component.mjs +380 -0
  21. package/esm2022/lib/chart-pie.component.mjs +386 -0
  22. package/esm2022/lib/chart.component.mjs +1146 -0
  23. package/esm2022/lib/chart.service.mjs +555 -0
  24. package/esm2022/lib/checkbox.component.mjs +5 -11
  25. package/esm2022/lib/checkpoint.component.mjs +134 -0
  26. package/esm2022/lib/collapsible.component.mjs +4 -11
  27. package/esm2022/lib/combobox.component.mjs +372 -0
  28. package/esm2022/lib/command-dialog.component.mjs +164 -0
  29. package/esm2022/lib/command.component.mjs +514 -0
  30. package/esm2022/lib/command.service.mjs +130 -0
  31. package/esm2022/lib/confirmation.component.mjs +219 -0
  32. package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
  33. package/esm2022/lib/context-menu.component.mjs +460 -0
  34. package/esm2022/lib/context-menu.service.mjs +121 -0
  35. package/esm2022/lib/context.component.mjs +377 -0
  36. package/esm2022/lib/conversation.component.mjs +442 -0
  37. package/esm2022/lib/country-selector.component.mjs +5 -11
  38. package/esm2022/lib/data-table.component.mjs +59 -12
  39. package/esm2022/lib/date-picker.component.mjs +251 -198
  40. package/esm2022/lib/date-range-picker.component.mjs +224 -116
  41. package/esm2022/lib/date-time-picker.component.mjs +506 -0
  42. package/esm2022/lib/direction.component.mjs +79 -0
  43. package/esm2022/lib/dropdown-item.component.mjs +3 -3
  44. package/esm2022/lib/dropdown-label.component.mjs +3 -3
  45. package/esm2022/lib/dropdown-menu.component.mjs +3 -3
  46. package/esm2022/lib/dropdown-separator.component.mjs +3 -3
  47. package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
  48. package/esm2022/lib/empty-state.component.mjs +4 -9
  49. package/esm2022/lib/field.component.mjs +394 -0
  50. package/esm2022/lib/hover-card.component.mjs +6 -16
  51. package/esm2022/lib/inline-citation.component.mjs +263 -0
  52. package/esm2022/lib/input-group.component.mjs +418 -0
  53. package/esm2022/lib/input.component.mjs +5 -11
  54. package/esm2022/lib/item.component.mjs +243 -0
  55. package/esm2022/lib/kbd.component.mjs +82 -0
  56. package/esm2022/lib/label.component.mjs +4 -9
  57. package/esm2022/lib/marker.component.mjs +135 -0
  58. package/esm2022/lib/masked-input.component.mjs +5 -11
  59. package/esm2022/lib/menubar.component.mjs +683 -0
  60. package/esm2022/lib/message-scroller.component.mjs +353 -0
  61. package/esm2022/lib/message-scroller.service.mjs +157 -0
  62. package/esm2022/lib/message.component.mjs +228 -0
  63. package/esm2022/lib/model-selector.component.mjs +310 -0
  64. package/esm2022/lib/multi-select.component.mjs +155 -72
  65. package/esm2022/lib/native-select.component.mjs +201 -0
  66. package/esm2022/lib/navigation-menu.component.mjs +642 -0
  67. package/esm2022/lib/otp-group.component.mjs +3 -3
  68. package/esm2022/lib/otp-slot.component.mjs +4 -9
  69. package/esm2022/lib/otp.component.mjs +5 -11
  70. package/esm2022/lib/pagination.component.mjs +7 -10
  71. package/esm2022/lib/plan.component.mjs +335 -0
  72. package/esm2022/lib/popover-content.component.mjs +4 -9
  73. package/esm2022/lib/popover.component.mjs +15 -13
  74. package/esm2022/lib/progress.component.mjs +4 -9
  75. package/esm2022/lib/prompt-input.component.mjs +526 -0
  76. package/esm2022/lib/queue.component.mjs +280 -0
  77. package/esm2022/lib/radio-group.component.mjs +6 -13
  78. package/esm2022/lib/radio-item.component.mjs +4 -9
  79. package/esm2022/lib/range-calendar.component.mjs +5 -11
  80. package/esm2022/lib/reasoning.component.mjs +335 -0
  81. package/esm2022/lib/resizable.component.mjs +219 -0
  82. package/esm2022/lib/segment.component.mjs +17 -7
  83. package/esm2022/lib/select-group.component.mjs +4 -8
  84. package/esm2022/lib/select-item.component.mjs +4 -9
  85. package/esm2022/lib/select-separator.component.mjs +4 -8
  86. package/esm2022/lib/select.component.mjs +133 -49
  87. package/esm2022/lib/separator.component.mjs +4 -9
  88. package/esm2022/lib/sheet.component.mjs +38 -56
  89. package/esm2022/lib/shimmer.component.mjs +84 -0
  90. package/esm2022/lib/sidebar.component.mjs +11 -1
  91. package/esm2022/lib/skeleton.component.mjs +4 -9
  92. package/esm2022/lib/sources.component.mjs +452 -0
  93. package/esm2022/lib/spinner.component.mjs +68 -0
  94. package/esm2022/lib/suggestion.component.mjs +207 -0
  95. package/esm2022/lib/switch.component.mjs +5 -11
  96. package/esm2022/lib/table.component.mjs +213 -0
  97. package/esm2022/lib/tabs.component.mjs +4 -11
  98. package/esm2022/lib/tag-input.component.mjs +5 -11
  99. package/esm2022/lib/task.component.mjs +359 -0
  100. package/esm2022/lib/textarea.component.mjs +5 -11
  101. package/esm2022/lib/theme.service.mjs +17 -6
  102. package/esm2022/lib/time-picker.component.mjs +923 -0
  103. package/esm2022/lib/toast.service.mjs +3 -2
  104. package/esm2022/lib/toaster.component.mjs +76 -39
  105. package/esm2022/lib/toggle-group.component.mjs +4 -9
  106. package/esm2022/lib/toggle.component.mjs +5 -11
  107. package/esm2022/lib/tool.component.mjs +462 -0
  108. package/esm2022/lib/typography.component.mjs +83 -0
  109. package/esm2022/lib/utils/color.mjs +140 -19
  110. package/esm2022/public-api.mjs +61 -1
  111. package/fesm2022/tolle-ui.mjs +16453 -1024
  112. package/fesm2022/tolle-ui.mjs.map +1 -1
  113. package/lib/alert-dialog.component.d.ts +10 -2
  114. package/lib/aspect-ratio.component.d.ts +20 -0
  115. package/lib/attachment.component.d.ts +101 -0
  116. package/lib/bubble.component.d.ts +112 -0
  117. package/lib/chain-of-thought.component.d.ts +166 -0
  118. package/lib/chart-pie.component.d.ts +108 -0
  119. package/lib/chart.component.d.ts +369 -0
  120. package/lib/chart.service.d.ts +222 -0
  121. package/lib/checkpoint.component.d.ts +51 -0
  122. package/lib/combobox.component.d.ts +90 -0
  123. package/lib/command-dialog.component.d.ts +53 -0
  124. package/lib/command.component.d.ts +189 -0
  125. package/lib/command.service.d.ts +67 -0
  126. package/lib/confirmation.component.d.ts +70 -0
  127. package/lib/context-menu-trigger.directive.d.ts +12 -0
  128. package/lib/context-menu.component.d.ts +41 -0
  129. package/lib/context-menu.service.d.ts +38 -0
  130. package/lib/context.component.d.ts +134 -0
  131. package/lib/conversation.component.d.ts +165 -0
  132. package/lib/data-table.component.d.ts +26 -1
  133. package/lib/date-picker.component.d.ts +72 -11
  134. package/lib/date-range-picker.component.d.ts +63 -6
  135. package/lib/date-time-picker.component.d.ts +119 -0
  136. package/lib/direction.component.d.ts +45 -0
  137. package/lib/field.component.d.ts +201 -0
  138. package/lib/inline-citation.component.d.ts +103 -0
  139. package/lib/input-group.component.d.ts +150 -0
  140. package/lib/item.component.d.ts +154 -0
  141. package/lib/kbd.component.d.ts +50 -0
  142. package/lib/marker.component.d.ts +58 -0
  143. package/lib/menubar.component.d.ts +247 -0
  144. package/lib/message-scroller.component.d.ts +159 -0
  145. package/lib/message-scroller.service.d.ts +73 -0
  146. package/lib/message.component.d.ts +128 -0
  147. package/lib/model-selector.component.d.ts +103 -0
  148. package/lib/multi-select.component.d.ts +66 -14
  149. package/lib/native-select.component.d.ts +57 -0
  150. package/lib/navigation-menu.component.d.ts +230 -0
  151. package/lib/plan.component.d.ts +120 -0
  152. package/lib/popover.component.d.ts +1 -0
  153. package/lib/prompt-input.component.d.ts +194 -0
  154. package/lib/queue.component.d.ts +106 -0
  155. package/lib/reasoning.component.d.ts +127 -0
  156. package/lib/resizable.component.d.ts +43 -0
  157. package/lib/segment.component.d.ts +13 -3
  158. package/lib/select.component.d.ts +37 -5
  159. package/lib/sheet.component.d.ts +3 -0
  160. package/lib/shimmer.component.d.ts +38 -0
  161. package/lib/sidebar.component.d.ts +2 -0
  162. package/lib/sources.component.d.ts +156 -0
  163. package/lib/spinner.component.d.ts +29 -0
  164. package/lib/suggestion.component.d.ts +96 -0
  165. package/lib/table.component.d.ts +106 -0
  166. package/lib/task.component.d.ts +151 -0
  167. package/lib/theme.service.d.ts +9 -0
  168. package/lib/time-picker.component.d.ts +243 -0
  169. package/lib/toast.service.d.ts +3 -1
  170. package/lib/toaster.component.d.ts +26 -9
  171. package/lib/tool.component.d.ts +158 -0
  172. package/lib/typography.component.d.ts +32 -0
  173. package/lib/utils/color.d.ts +19 -5
  174. package/package.json +1 -1
  175. package/public-api.d.ts +50 -0
  176. package/registry/docs-content.json +8480 -1542
  177. package/registry/llms-full.txt +2679 -319
  178. package/registry/llms.txt +43 -4
  179. package/registry/manifest.json +9090 -1742
  180. package/registry/r/accordion-item.json +1 -1
  181. package/registry/r/accordion.json +1 -1
  182. package/registry/r/alert-dialog-dynamic.json +1 -1
  183. package/registry/r/alert-dialog.json +1 -1
  184. package/registry/r/alert.json +1 -1
  185. package/registry/r/attachment.json +23 -0
  186. package/registry/r/avatar-fallback.json +1 -1
  187. package/registry/r/avatar.json +1 -1
  188. package/registry/r/badge.json +1 -1
  189. package/registry/r/breadcrumb-item.json +1 -1
  190. package/registry/r/breadcrumb-link.json +1 -1
  191. package/registry/r/breadcrumb-separator.json +1 -1
  192. package/registry/r/breadcrumb.json +1 -1
  193. package/registry/r/bubble.json +23 -0
  194. package/registry/r/button.json +1 -1
  195. package/registry/r/calendar.json +1 -1
  196. package/registry/r/card.json +1 -1
  197. package/registry/r/carousel.json +1 -1
  198. package/registry/r/chain-of-thought.json +23 -0
  199. package/registry/r/chart-pie.json +30 -0
  200. package/registry/r/chart.json +28 -0
  201. package/registry/r/checkbox.json +1 -1
  202. package/registry/r/checkpoint.json +23 -0
  203. package/registry/r/collapsible.json +1 -1
  204. package/registry/r/combobox.json +31 -0
  205. package/registry/r/command-dialog.json +21 -0
  206. package/registry/r/command.json +26 -0
  207. package/registry/r/confirmation.json +23 -0
  208. package/registry/r/context.json +26 -0
  209. package/registry/r/conversation.json +23 -0
  210. package/registry/r/country-selector.json +1 -1
  211. package/registry/r/data-table.json +1 -1
  212. package/registry/r/date-picker.json +4 -4
  213. package/registry/r/date-range-picker.json +3 -3
  214. package/registry/r/date-time-picker.json +28 -0
  215. package/registry/r/direction.json +16 -0
  216. package/registry/r/dropdown-item.json +1 -1
  217. package/registry/r/dropdown-label.json +1 -1
  218. package/registry/r/dropdown-menu.json +1 -1
  219. package/registry/r/dropdown-separator.json +1 -1
  220. package/registry/r/dropdown-trigger.json +1 -1
  221. package/registry/r/empty-state.json +1 -1
  222. package/registry/r/field.json +23 -0
  223. package/registry/r/hover-card.json +1 -1
  224. package/registry/r/inline-citation.json +26 -0
  225. package/registry/r/input-group.json +23 -0
  226. package/registry/r/input.json +1 -1
  227. package/registry/r/item.json +23 -0
  228. package/registry/r/kbd.json +23 -0
  229. package/registry/r/label.json +1 -1
  230. package/registry/r/marker.json +23 -0
  231. package/registry/r/masked-input.json +1 -1
  232. package/registry/r/menubar.json +24 -0
  233. package/registry/r/message-scroller.json +26 -0
  234. package/registry/r/message.json +23 -0
  235. package/registry/r/model-selector.json +32 -0
  236. package/registry/r/multi-select.json +2 -2
  237. package/registry/r/native-select.json +23 -0
  238. package/registry/r/navigation-menu.json +24 -0
  239. package/registry/r/otp-group.json +1 -1
  240. package/registry/r/otp-slot.json +1 -1
  241. package/registry/r/otp.json +1 -1
  242. package/registry/r/pagination.json +3 -2
  243. package/registry/r/plan.json +23 -0
  244. package/registry/r/popover-content.json +1 -1
  245. package/registry/r/popover.json +1 -1
  246. package/registry/r/progress.json +1 -1
  247. package/registry/r/prompt-input.json +23 -0
  248. package/registry/r/queue.json +23 -0
  249. package/registry/r/radio-group.json +1 -1
  250. package/registry/r/radio-item.json +1 -1
  251. package/registry/r/range-calendar.json +1 -1
  252. package/registry/r/reasoning.json +23 -0
  253. package/registry/r/segment.json +1 -1
  254. package/registry/r/select-group.json +1 -1
  255. package/registry/r/select-item.json +1 -1
  256. package/registry/r/select-separator.json +1 -1
  257. package/registry/r/select.json +4 -3
  258. package/registry/r/separator.json +1 -1
  259. package/registry/r/sheet.json +1 -1
  260. package/registry/r/shimmer.json +23 -0
  261. package/registry/r/sidebar.json +1 -1
  262. package/registry/r/skeleton.json +1 -1
  263. package/registry/r/sources.json +23 -0
  264. package/registry/r/spinner.json +23 -0
  265. package/registry/r/suggestion.json +23 -0
  266. package/registry/r/switch.json +1 -1
  267. package/registry/r/table.json +21 -0
  268. package/registry/r/tabs.json +1 -1
  269. package/registry/r/tag-input.json +1 -1
  270. package/registry/r/task.json +23 -0
  271. package/registry/r/textarea.json +1 -1
  272. package/registry/r/time-picker.json +24 -0
  273. package/registry/r/toaster.json +2 -2
  274. package/registry/r/toggle-group.json +1 -1
  275. package/registry/r/toggle.json +1 -1
  276. package/registry/r/tool.json +23 -0
  277. package/registry/r/typography.json +23 -0
  278. package/registry/registry.json +915 -22
  279. package/theme.css +109 -8
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "path": "tag-input.component.ts",
16
16
  "target": "ui/tag-input.component.ts",
17
- "content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-tag-input',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TagInputComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div\n [class]=\"computedContainerClass\"\n (click)=\"focusInput()\"\n >\n <tolle-badge\n *ngFor=\"let tag of tags; let i = index\"\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"!disabled && !readonly\"\n (onRemove)=\"removeTag(i, $event)\">\n {{ tagPrefix }}{{ tag }}{{ tagSuffix }}\n </tolle-badge>\n\n <input\n #tagInput\n [id]=\"id\"\n type=\"text\"\n [placeholder]=\"tags.length === 0 ? placeholder : ''\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly || (maxTags != null && tags.length >= maxTags)\"\n [(ngModel)]=\"inputValue\"\n (keydown)=\"onKeydown($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n />\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"text-xs text-muted-foreground px-1 transition-opacity duration-200\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n class=\"text-xs text-destructive px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class TagInputComponent implements ControlValueAccessor {\n @ViewChild('tagInput') tagInputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `tag-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() placeholder: string = 'Type and press Enter...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() class: string = '';\n\n // State inputs\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Tag-specific inputs\n @Input() delimiter: string = ',';\n @Input() maxTags: number | null = null;\n @Input() allowDuplicates: boolean = false;\n @Input() tagPrefix: string = '';\n @Input() tagSuffix: string = '';\n\n tags: string[] = [];\n inputValue: string = '';\n isFocused: boolean = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n writeValue(values: string[]): void {\n this.tags = Array.isArray(values) ? [...values] : [];\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled || this.readonly) return;\n\n const key = event.key;\n\n // Enter creates a tag\n if (key === 'Enter') {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Delimiter creates a tag (strip the delimiter)\n if (key === this.delimiter) {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Backspace on empty input removes the last tag\n if (key === 'Backspace' && this.inputValue === '') {\n if (this.tags.length > 0) {\n this.removeTag(this.tags.length - 1);\n }\n return;\n }\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.commitInput();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n removeTag(index: number, event?: MouseEvent): void {\n if (this.disabled || this.readonly) return;\n if (event) {\n event.stopPropagation();\n }\n this.tags.splice(index, 1);\n this.emitChange();\n this.focusInput();\n }\n\n focusInput(): void {\n if (!this.disabled && this.tagInputElement) {\n this.tagInputElement.nativeElement.focus();\n }\n }\n\n private commitInput(): void {\n const value = this.inputValue.trim();\n if (value === '') {\n return;\n }\n\n // Check max tags\n if (this.maxTags != null && this.tags.length >= this.maxTags) {\n this.inputValue = '';\n return;\n }\n\n // Check duplicates (case-insensitive)\n if (!this.allowDuplicates) {\n const exists = this.tags.some(\n t => t.toLowerCase() === value.toLowerCase()\n );\n if (exists) {\n this.inputValue = '';\n return;\n }\n }\n\n this.tags.push(value);\n this.inputValue = '';\n this.emitChange();\n }\n\n private emitChange(): void {\n this.onChange([...this.tags]);\n this.cdr.markForCheck();\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles — flex-wrap so tags flow onto multiple lines\n \"group relative flex flex-wrap items-center w-full rounded-md border transition-all duration-200\",\n \"bg-background\",\n\n // Border and shadow\n \"border-input shadow-sm\",\n\n // Sizing — min-h instead of fixed h so tags can wrap\n this.size === 'xs' && \"min-h-8 px-2 py-1 gap-1 text-xs\",\n this.size === 'sm' && \"min-h-9 px-3 py-1 gap-1.5 text-sm\",\n this.size === 'default' && \"min-h-10 px-3 py-1.5 gap-2 text-sm\",\n this.size === 'lg' && \"min-h-11 px-4 py-2 gap-3 text-base\",\n\n // Focus state\n !(this.readonly || this.disabled) && [\n \"focus-within:border-primary/80\",\n \"focus-within:ring-4\",\n \"focus-within:ring-ring/30\",\n \"focus-within:ring-offset-0\",\n \"focus-within:shadow-none\",\n ],\n\n // Error state\n this.error && [\n \"border-destructive\",\n !(this.readonly || this.disabled) && [\n \"focus-within:border-destructive/80\",\n \"focus-within:ring-destructive/30\"\n ]\n ],\n\n // Disabled state\n this.disabled && [\n \"cursor-not-allowed opacity-50\",\n \"border-opacity-50\"\n ],\n\n // Readonly state\n this.readonly && [\n \"cursor-default\",\n \"border-dashed\",\n !this.disabled && \"focus-within:ring-0 focus-within:border-opacity-100\"\n ],\n\n this.class\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles — inline within the flex-wrap container\n \"flex-1 bg-transparent border-none p-0 min-w-[80px]\",\n \"placeholder:text-muted-foreground\",\n\n // Remove all default focus styles\n \"focus:outline-none focus:ring-0 focus:shadow-none\",\n\n // Text sizing\n this.size === 'xs' && \"text-xs\",\n this.size === 'sm' && \"text-sm\",\n this.size === 'default' && \"text-sm\",\n this.size === 'lg' && \"text-base\",\n\n // Cursor states\n this.disabled && \"cursor-not-allowed\",\n this.readonly && \"cursor-default\",\n\n // Text color\n \"text-foreground\",\n\n // Selection color\n \"selection:bg-primary/20 selection:text-foreground\"\n // NOTE: consumer `class` is applied to the container only (computedContainerClass),\n // not duplicated onto the inner <input>.\n );\n }\n}\n"
17
+ "content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-tag-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TagInputComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div\n [class]=\"computedContainerClass\"\n (click)=\"focusInput()\"\n >\n <tolle-badge\n *ngFor=\"let tag of tags; let i = index\"\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"!disabled && !readonly\"\n (onRemove)=\"removeTag(i, $event)\">\n {{ tagPrefix }}{{ tag }}{{ tagSuffix }}\n </tolle-badge>\n\n <input\n #tagInput\n [id]=\"id\"\n type=\"text\"\n [placeholder]=\"tags.length === 0 ? placeholder : ''\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly || (maxTags != null && tags.length >= maxTags)\"\n [(ngModel)]=\"inputValue\"\n (keydown)=\"onKeydown($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n />\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"text-xs text-muted-foreground px-1 transition-opacity duration-200\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n class=\"text-xs text-destructive px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class TagInputComponent implements ControlValueAccessor {\n @ViewChild('tagInput') tagInputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `tag-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() placeholder: string = 'Type and press Enter...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() class: string = '';\n\n // State inputs\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Tag-specific inputs\n @Input() delimiter: string = ',';\n @Input() maxTags: number | null = null;\n @Input() allowDuplicates: boolean = false;\n @Input() tagPrefix: string = '';\n @Input() tagSuffix: string = '';\n\n tags: string[] = [];\n inputValue: string = '';\n isFocused: boolean = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n writeValue(values: string[]): void {\n this.tags = Array.isArray(values) ? [...values] : [];\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled || this.readonly) return;\n\n const key = event.key;\n\n // Enter creates a tag\n if (key === 'Enter') {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Delimiter creates a tag (strip the delimiter)\n if (key === this.delimiter) {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Backspace on empty input removes the last tag\n if (key === 'Backspace' && this.inputValue === '') {\n if (this.tags.length > 0) {\n this.removeTag(this.tags.length - 1);\n }\n return;\n }\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.commitInput();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n removeTag(index: number, event?: MouseEvent): void {\n if (this.disabled || this.readonly) return;\n if (event) {\n event.stopPropagation();\n }\n this.tags.splice(index, 1);\n this.emitChange();\n this.focusInput();\n }\n\n focusInput(): void {\n if (!this.disabled && this.tagInputElement) {\n this.tagInputElement.nativeElement.focus();\n }\n }\n\n private commitInput(): void {\n const value = this.inputValue.trim();\n if (value === '') {\n return;\n }\n\n // Check max tags\n if (this.maxTags != null && this.tags.length >= this.maxTags) {\n this.inputValue = '';\n return;\n }\n\n // Check duplicates (case-insensitive)\n if (!this.allowDuplicates) {\n const exists = this.tags.some(\n t => t.toLowerCase() === value.toLowerCase()\n );\n if (exists) {\n this.inputValue = '';\n return;\n }\n }\n\n this.tags.push(value);\n this.inputValue = '';\n this.emitChange();\n }\n\n private emitChange(): void {\n this.onChange([...this.tags]);\n this.cdr.markForCheck();\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles — flex-wrap so tags flow onto multiple lines\n \"group relative flex flex-wrap items-center w-full rounded-md border transition-all duration-200\",\n \"bg-background\",\n\n // Border and shadow\n \"border-input shadow-sm\",\n\n // Sizing — min-h instead of fixed h so tags can wrap\n this.size === 'xs' && \"min-h-8 px-2 py-1 gap-1 text-xs\",\n this.size === 'sm' && \"min-h-9 px-3 py-1 gap-1.5 text-sm\",\n this.size === 'default' && \"min-h-10 px-3 py-1.5 gap-2 text-sm\",\n this.size === 'lg' && \"min-h-11 px-4 py-2 gap-3 text-base\",\n\n // Focus state\n !(this.readonly || this.disabled) && [\n \"focus-within:border-primary/80\",\n \"focus-within:ring-4\",\n \"focus-within:ring-ring/30\",\n \"focus-within:ring-offset-0\",\n \"focus-within:shadow-none\",\n ],\n\n // Error state\n this.error && [\n \"border-destructive\",\n !(this.readonly || this.disabled) && [\n \"focus-within:border-destructive/80\",\n \"focus-within:ring-destructive/30\"\n ]\n ],\n\n // Disabled state\n this.disabled && [\n \"cursor-not-allowed opacity-50\",\n \"border-opacity-50\"\n ],\n\n // Readonly state\n this.readonly && [\n \"cursor-default\",\n \"border-dashed\",\n !this.disabled && \"focus-within:ring-0 focus-within:border-opacity-100\"\n ],\n\n this.class\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles — inline within the flex-wrap container\n \"flex-1 bg-transparent border-none p-0 min-w-[80px]\",\n \"placeholder:text-muted-foreground\",\n\n // Remove all default focus styles\n \"focus:outline-none focus:ring-0 focus:shadow-none\",\n\n // Text sizing\n this.size === 'xs' && \"text-xs\",\n this.size === 'sm' && \"text-sm\",\n this.size === 'default' && \"text-sm\",\n this.size === 'lg' && \"text-base\",\n\n // Cursor states\n this.disabled && \"cursor-not-allowed\",\n this.readonly && \"cursor-default\",\n\n // Text color\n \"text-foreground\",\n\n // Selection color\n \"selection:bg-primary/20 selection:text-foreground\"\n // NOTE: consumer `class` is applied to the container only (computedContainerClass),\n // not duplicated onto the inner <input>.\n );\n }\n}\n"
18
18
  },
19
19
  {
20
20
  "path": "utils/cn.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "task",
3
+ "type": "registry:ui",
4
+ "title": "Task",
5
+ "description": "Task component.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "task.component.ts",
14
+ "target": "ui/task.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/** Lifecycle of a single agent task. */\nexport type TaskStatus = 'pending' | 'in-progress' | 'completed' | 'error';\n\nconst taskVariants = cva(\n 'w-full rounded-lg border bg-card text-card-foreground transition-colors',\n {\n variants: {\n status: {\n pending: 'border-border',\n 'in-progress': 'border-info/40',\n completed: 'border-success/40',\n error: 'border-destructive/40',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nconst taskIconVariants = cva('shrink-0 text-base leading-none', {\n variants: {\n status: {\n pending: 'text-muted-foreground',\n 'in-progress': 'text-info',\n completed: 'text-success',\n error: 'text-destructive',\n },\n },\n defaultVariants: { status: 'pending' },\n});\n\nexport type TaskProps = VariantProps<typeof taskVariants>;\n\n/** Remixicon class used for each task status. */\nconst TASK_ICONS: Record<TaskStatus, string> = {\n pending: 'ri-circle-line',\n 'in-progress': 'ri-loader-4-line animate-spin',\n completed: 'ri-checkbox-circle-fill',\n error: 'ri-error-warning-fill',\n};\n\n/**\n * Coordinates a `tolle-task` and its trigger/content children.\n *\n * The children are `OnPush` and none of the parent's state arrives as their own\n * inputs, so it is published here as observables and every subscriber calls\n * `markForCheck()`.\n */\n@Injectable()\nexport class TaskService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the task's content is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly statusSource = new BehaviorSubject<TaskStatus>('pending');\n /** Emits the task's current status. */\n readonly status$ = this.statusSource.asObservable();\n\n private readonly titleSource = new BehaviorSubject<string>('');\n /** Emits the task's title. */\n readonly title$ = this.titleSource.asObservable();\n\n get open(): boolean {\n return this.openSource.value;\n }\n\n get status(): TaskStatus {\n return this.statusSource.value;\n }\n\n get title(): string {\n return this.titleSource.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 setStatus(status: TaskStatus): void {\n if (this.statusSource.value !== status) this.statusSource.next(status);\n }\n\n setTitle(title: string): void {\n if (this.titleSource.value !== title) this.titleSource.next(title);\n }\n}\n\n/**\n * A collapsible record of one unit of agent work — a title, a status, and the\n * individual steps taken, revealed on demand.\n *\n * ```html\n * <tolle-task status=\"completed\" title=\"Read the codebase\" [open]=\"true\">\n * <tolle-task-trigger></tolle-task-trigger>\n * <tolle-task-content>\n * <tolle-task-item>\n * Searched <tolle-task-item-file>utils/cn.ts</tolle-task-item-file>\n * </tolle-task-item>\n * </tolle-task-content>\n * </tolle-task>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-task',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [TaskService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\" [attr.data-status]=\"status\"><ng-content></ng-content></div>`,\n})\nexport class TaskComponent implements OnInit, OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Lifecycle state of the task; drives the icon and border colour. @default 'pending' */\n @Input() status: TaskStatus = 'pending';\n /** Title shown by `tolle-task-trigger`. @default '' */\n @Input() title = '';\n /** Whether the task's content is expanded. @default false */\n @Input() open = false;\n /** Extra Tailwind classes merged onto the task via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new expanded state whenever the task opens or closes. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(TaskService);\n private readonly sub = new Subscription();\n\n ngOnInit(): void {\n this.push();\n // skip(1) drops the BehaviorSubject's replayed value so the component does\n // not emit an `openChange` for its own initial state.\n this.sub.add(this.service.open$.pipe(skip(1)).subscribe((open) => this.openChange.emit(open)));\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 ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n private push(): void {\n this.service.setStatus(this.status);\n this.service.setTitle(this.title);\n this.service.setOpen(this.open);\n }\n\n get computedClass() {\n return cn(taskVariants({ status: this.status }), this.class);\n }\n}\n\n/** Header row of a `tolle-task`: status icon, title, and the expand chevron. */\n@Component({\n selector: 'tolle-task-trigger',\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]=\"iconClass\" [attr.data-status]=\"status\" aria-hidden=\"true\"></i>\n <span class=\"min-w-0 flex-1 truncate text-left\">{{ title }}</span>\n <ng-content></ng-content>\n <i\n [class]=\"isOpen ? 'ri-arrow-down-s-line shrink-0 text-muted-foreground' : 'ri-arrow-right-s-line shrink-0 text-muted-foreground'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `,\n})\nexport class TaskTriggerComponent 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 trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(TaskService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n status: TaskStatus = 'pending';\n title = '';\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 this.sub.add(\n this.service.status$.subscribe((status) => {\n this.status = status;\n this.cdr.markForCheck();\n })\n );\n this.sub.add(\n this.service.title$.subscribe((title) => {\n this.title = title;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get iconClass() {\n return cn(taskIconVariants({ status: this.status }), TASK_ICONS[this.status]);\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-foreground outline-none transition-colors',\n 'hover:bg-accent/50 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/** The body of a `tolle-task`; rendered only while the task is expanded. */\n@Component({\n selector: 'tolle-task-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"isOpen\" [attr.data-state]=\"'open'\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class TaskContentComponent 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(TaskService);\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('flex flex-col gap-1 border-t border-border px-3 py-2', this.class);\n }\n}\n\n/** One line of work inside a `tolle-task-content`. */\n@Component({\n selector: 'tolle-task-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <i class=\"ri-corner-down-right-line mt-0.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"min-w-0 flex-1\"><ng-content></ng-content></span>\n </div>\n `,\n})\nexport class TaskItemComponent 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 item via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex items-start gap-2 text-sm text-muted-foreground', this.class);\n }\n}\n\n/** An inline file chip, for naming the file a task step touched. */\n@Component({\n selector: 'tolle-task-item-file',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span [class]=\"computedClass\">\n <i [class]=\"icon + ' shrink-0 text-muted-foreground'\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class TaskItemFileComponent 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 /** Remixicon class for the leading file glyph. @default 'ri-file-line' */\n @Input() icon = 'ri-file-line';\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 items-center gap-1 rounded-md border border-border bg-muted px-1.5 py-0.5 align-middle text-xs font-medium text-foreground',\n this.class\n );\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "textarea.component.ts",
12
12
  "target": "ui/textarea.component.ts",
13
- "content": "import { Component, Input, forwardRef, ViewChild, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-textarea',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TextareaComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\"\n >\n {{ label }}\n </label>\n\n <div class=\"relative\">\n <textarea\n #textareaElement\n [id]=\"id\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [rows]=\"rows\"\n [(ngModel)]=\"value\"\n (input)=\"handleInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"textareaClasses\"\n [style.resize]=\"(autoGrow || readonly || disabled) ? 'none' : 'vertical'\"\n [style.overflow]=\"autoGrow ? 'hidden' : 'auto'\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n [attr.maxlength]=\"maxLength\"\n ></textarea>\n </div>\n\n <div *ngIf=\"(showCharacterCount || hint || errorMessage) && !disabled\" class=\"flex justify-between items-center px-1\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"text-xs text-destructive\"\n >\n {{ errorMessage }}\n </p>\n <p\n *ngIf=\"showCharacterCount\"\n class=\"text-[10px] uppercase tracking-wider text-muted-foreground ml-auto font-medium transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideCharacterCountOnFocus\"\n >\n {{ value.length || 0 }}{{ maxLength ? ' / ' + maxLength : '' }}\n </p>\n </div>\n </div>\n `\n})\nexport class TextareaComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('textareaElement') textareaElement!: ElementRef<HTMLTextAreaElement>;\n\n @Input() id = `textarea-${Math.random().toString(36).substring(2, 9)}`;\n @Input() label = '';\n @Input() placeholder = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() rows = 3;\n @Input() maxLength?: number;\n @Input() showCharacterCount = false;\n @Input() autoGrow = false;\n @Input() error = false;\n @Input() className = '';\n\n // Focus behavior\n @Input() hideHintOnFocus = true;\n @Input() hideCharacterCountOnFocus = false;\n\n // New States\n @Input() disabled = false;\n @Input() readonly = false;\n\n value: string = '';\n isFocused: boolean = false;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n ngAfterViewInit() {\n if (this.autoGrow) this.resize();\n }\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get textareaClasses() {\n return cn(\n // Base styles\n 'flex w-full rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-all duration-200',\n 'placeholder:text-muted-foreground',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Minimum height\n 'min-h-[80px]',\n\n // Focus state - SIMPLE LIKE ZARDUI\n !(this.readonly || this.disabled) && [\n 'focus:outline-none',\n 'focus:ring-4',\n 'focus:ring-ring/30',\n 'focus:ring-offset-0',\n 'focus:shadow-none',\n // Border darkens on focus automatically\n 'focus:border-primary/80'\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus:border-destructive/80',\n 'focus:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus:ring-0 focus:border-opacity-100'\n ],\n\n // Scrollbar styling\n 'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',\n\n this.className\n );\n }\n\n handleInput(event: any) {\n if (this.readonly || this.disabled) return;\n const val = event.target.value;\n this.value = val;\n this.onChange(val);\n if (this.autoGrow) this.resize();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n private resize() {\n const textarea = this.textareaElement.nativeElement;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }\n\n writeValue(value: any): void {\n this.value = value ?? '';\n if (this.autoGrow) setTimeout(() => this.resize());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
13
+ "content": "import { Component, Input, forwardRef, ViewChild, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-textarea',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TextareaComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\"\n >\n {{ label }}\n </label>\n\n <div class=\"relative\">\n <textarea\n #textareaElement\n [id]=\"id\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [rows]=\"rows\"\n [(ngModel)]=\"value\"\n (input)=\"handleInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"textareaClasses\"\n [style.resize]=\"(autoGrow || readonly || disabled) ? 'none' : 'vertical'\"\n [style.overflow]=\"autoGrow ? 'hidden' : 'auto'\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n [attr.maxlength]=\"maxLength\"\n ></textarea>\n </div>\n\n <div *ngIf=\"(showCharacterCount || hint || errorMessage) && !disabled\" class=\"flex justify-between items-center px-1\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"text-xs text-destructive\"\n >\n {{ errorMessage }}\n </p>\n <p\n *ngIf=\"showCharacterCount\"\n class=\"text-[10px] uppercase tracking-wider text-muted-foreground ml-auto font-medium transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideCharacterCountOnFocus\"\n >\n {{ value.length || 0 }}{{ maxLength ? ' / ' + maxLength : '' }}\n </p>\n </div>\n </div>\n `\n})\nexport class TextareaComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('textareaElement') textareaElement!: ElementRef<HTMLTextAreaElement>;\n\n @Input() id = `textarea-${Math.random().toString(36).substring(2, 9)}`;\n @Input() label = '';\n @Input() placeholder = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() rows = 3;\n @Input() maxLength?: number;\n @Input() showCharacterCount = false;\n @Input() autoGrow = false;\n @Input() error = false;\n @Input() className = '';\n\n // Focus behavior\n @Input() hideHintOnFocus = true;\n @Input() hideCharacterCountOnFocus = false;\n\n // New States\n @Input() disabled = false;\n @Input() readonly = false;\n\n value: string = '';\n isFocused: boolean = false;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n ngAfterViewInit() {\n if (this.autoGrow) this.resize();\n }\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get textareaClasses() {\n return cn(\n // Base styles\n 'flex w-full rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-all duration-200',\n 'placeholder:text-muted-foreground',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Minimum height\n 'min-h-[80px]',\n\n // Focus state - SIMPLE LIKE ZARDUI\n !(this.readonly || this.disabled) && [\n 'focus:outline-none',\n 'focus:ring-4',\n 'focus:ring-ring/30',\n 'focus:ring-offset-0',\n 'focus:shadow-none',\n // Border darkens on focus automatically\n 'focus:border-primary/80'\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus:border-destructive/80',\n 'focus:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus:ring-0 focus:border-opacity-100'\n ],\n\n // Scrollbar styling\n 'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',\n\n this.className\n );\n }\n\n handleInput(event: any) {\n if (this.readonly || this.disabled) return;\n const val = event.target.value;\n this.value = val;\n this.onChange(val);\n if (this.autoGrow) this.resize();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n private resize() {\n const textarea = this.textareaElement.nativeElement;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }\n\n writeValue(value: any): void {\n this.value = value ?? '';\n if (this.autoGrow) setTimeout(() => this.resize());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "time-picker",
3
+ "type": "registry:ui",
4
+ "title": "Time Picker",
5
+ "description": "Time Picker component with size and invalid options.",
6
+ "category": "date-time",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom",
10
+ "class-variance-authority"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "time-picker.component.ts",
15
+ "target": "ui/time-picker.component.ts",
16
+ "content": "import { Component, Input, Output, EventEmitter, forwardRef, ElementRef, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, ChangeDetectionStrategy, OnDestroy, OnInit, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/* ------------------------------------------------------------------ *\n * Pure time helpers\n *\n * The wire format is a 24-hour `HH:mm` / `HH:mm:ss` string. It is\n * timezone-free and stable across serialisation, which a `Date` is not:\n * a `Date` carries a calendar day and a UTC offset that nobody chose when\n * all the user did was pick \"2:30 PM\".\n * ------------------------------------------------------------------ */\n\n/** A wall-clock time with no date and no timezone attached. */\nexport interface TimeParts {\n hour: number;\n minute: number;\n second: number;\n}\n\nconst pad2 = (n: number): string => (n < 10 ? '0' + n : String(n));\n\n/** Seconds elapsed since midnight for a wall-clock time. */\nexport function timePartsToSeconds(parts: TimeParts): number {\n return parts.hour * 3600 + parts.minute * 60 + parts.second;\n}\n\n/**\n * Parses `HH:mm` or `HH:mm:ss` (24-hour). Returns `null` for anything that\n * is not a well-formed, in-range time — callers treat that as \"no value\".\n */\nexport function parseTimeString(value: string | null | undefined): TimeParts | null {\n if (typeof value !== 'string') return null;\n const match = /^(\\d{1,2}):(\\d{1,2})(?::(\\d{1,2}))?$/.exec(value.trim());\n if (!match) return null;\n\n const hour = Number(match[1]);\n const minute = Number(match[2]);\n const second = match[3] === undefined ? 0 : Number(match[3]);\n\n if (hour > 23 || minute > 59 || second > 59) return null;\n return { hour, minute, second };\n}\n\n/** Renders a wall-clock time as `HH:mm`, or `HH:mm:ss` when `withSeconds`. */\nexport function formatTimeString(parts: TimeParts, withSeconds = false): string {\n const base = pad2(parts.hour) + ':' + pad2(parts.minute);\n return withSeconds ? base + ':' + pad2(parts.second) : base;\n}\n\n/**\n * 24-hour hour to its 12-hour clock face. Midnight is `12 AM` and noon is\n * `12 PM` — the two values that a naive `hour % 12` gets wrong.\n */\nexport function to12Hour(hour24: number): { hour12: number; meridiem: 'AM' | 'PM' } {\n const meridiem: 'AM' | 'PM' = hour24 < 12 ? 'AM' : 'PM';\n const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;\n return { hour12, meridiem };\n}\n\n/** 12-hour clock face back to a 24-hour hour. `12 AM` is 0, `12 PM` is 12. */\nexport function from12Hour(hour12: number, meridiem: 'AM' | 'PM'): number {\n const base = hour12 % 12; // 12 -> 0, leaves 1..11 alone\n return meridiem === 'PM' ? base + 12 : base;\n}\n\n/* ------------------------------------------------------------------ *\n * Column model\n * ------------------------------------------------------------------ */\n\n/** Which unit a column of the picker scrolls through. */\nexport type TimeColumnKind = 'hour' | 'minute' | 'second' | 'meridiem';\n\n/** One entry inside a time column. */\nexport interface TimeOption {\n /** Stable identity for `trackBy` and `aria-activedescendant`. */\n id: string;\n /** Text shown to the user. */\n label: string;\n /**\n * The 24-hour value this entry commits for its column. For a `meridiem`\n * column this is the resulting 24-hour *hour*.\n */\n raw: number;\n /** Value used for the selected comparison (clock-face value for hours). */\n face: number;\n disabled: boolean;\n}\n\n/** A scrollable column of the picker. */\nexport interface TimeColumn {\n kind: TimeColumnKind;\n label: string;\n options: TimeOption[];\n}\n\nconst SECONDS_PER_DAY = 24 * 3600;\n\nconst timeOptionVariants = cva(\n 'flex w-full shrink-0 cursor-pointer select-none items-center justify-center rounded-md tabular-nums ' +\n 'transition-colors focus:outline-none disabled:pointer-events-none disabled:opacity-40',\n {\n variants: {\n size: {\n xs: 'h-6 px-2 text-[11px]',\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-2.5 text-sm',\n lg: 'h-9 px-3 text-base',\n },\n selected: {\n true: 'bg-primary text-primary-foreground hover:bg-primary',\n false: 'text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n },\n defaultVariants: { size: 'default', selected: false },\n }\n);\n\n/** Variant props for the option rows inside a time column. */\nexport type TimeColumnsProps = VariantProps<typeof timeOptionVariants>;\n\n/**\n * The bare scrollable hour / minute / second / AM-PM columns, with no trigger\n * and no popover of its own.\n *\n * `tolle-time-picker` wraps this in a floating panel, and\n * `tolle-date-time-picker` sits it beside a calendar — so the option\n * generation, the min/max disabling and the roving keyboard model live here\n * once instead of in both.\n */\n@Component({\n selector: 'tolle-time-columns',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"cn('flex items-stretch gap-1', class)\">\n <div\n *ngFor=\"let column of columns; let ci = index; trackBy: trackColumn\"\n class=\"flex flex-col\"\n >\n <div class=\"px-1 pb-1 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground\">\n {{ column.label }}\n </div>\n <div\n #listbox\n role=\"listbox\"\n tabindex=\"0\"\n [attr.aria-label]=\"column.label\"\n [attr.aria-activedescendant]=\"activeDescendantFor(ci)\"\n (keydown)=\"onColumnKeydown($event, ci)\"\n [class]=\"cn(\n 'flex flex-col gap-0.5 overflow-y-auto scroll-smooth rounded-md p-1',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n column.kind === 'meridiem' ? 'w-14' : 'w-14',\n heightClass\n )\"\n >\n <button\n *ngFor=\"let option of column.options; trackBy: trackOption\"\n type=\"button\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"option.id\"\n [attr.aria-selected]=\"isSelected(column, option)\"\n [attr.aria-disabled]=\"option.disabled\"\n [attr.data-selected]=\"isSelected(column, option)\"\n [attr.data-active]=\"isActive(ci, option)\"\n [disabled]=\"option.disabled\"\n [class]=\"optionClass(column, option, ci)\"\n (click)=\"choose(column, option, ci)\"\n >\n {{ option.label }}\n </button>\n </div>\n </div>\n </div>\n `,\n})\nexport class TimeColumnsComponent implements OnInit, OnChanges {\n /** Selected hour in 24-hour form, or `null` when nothing is chosen yet. @default null */\n @Input() hour: number | null = null;\n /** Selected minute, or `null` when nothing is chosen yet. @default null */\n @Input() minute: number | null = null;\n /** Selected second, or `null` when nothing is chosen yet. @default null */\n @Input() second: number | null = null;\n /** Renders a 12-hour clock face with an AM/PM column. @default false */\n @Input() use12Hours = false;\n /** Adds the seconds column. @default false */\n @Input() showSeconds = false;\n /** Interval between minute entries, in minutes. @default 1 */\n @Input() minuteStep = 1;\n /** Interval between second entries, in seconds. @default 1 */\n @Input() secondStep = 1;\n /** Earliest selectable time, as seconds since midnight. @default 0 */\n @Input() minSeconds = 0;\n /** Latest selectable time, as seconds since midnight. @default 86399 */\n @Input() maxSeconds = SECONDS_PER_DAY - 1;\n /** Row height and text size of the entries. @default 'default' */\n @Input() size: TimeColumnsProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the column strip via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the full wall-clock time whenever an entry is chosen. */\n @Output() timeChange = new EventEmitter<TimeParts>();\n\n @ViewChildren('listbox') listboxes?: QueryList<ElementRef<HTMLElement>>;\n\n readonly columnsId = 'time-col-' + Math.random().toString(36).slice(2, 11);\n\n /**\n * Built once per input change rather than read from a getter: a getter would\n * hand `*ngFor` a fresh array on every change-detection pass, so every row —\n * each carrying a click handler — would be torn down and rebuilt on every\n * pass.\n */\n columns: TimeColumn[] = [];\n /** Roving active entry per column, for `aria-activedescendant`. */\n activeIndex: number[] = [];\n\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngOnInit(): void {\n if (!this.columns.length) this.rebuild();\n }\n\n ngOnChanges(): void {\n this.rebuild();\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n get heightClass(): string {\n switch (this.size) {\n case 'xs':\n return 'max-h-[144px]';\n case 'sm':\n return 'max-h-[168px]';\n case 'lg':\n return 'max-h-[216px]';\n default:\n return 'max-h-[192px]';\n }\n }\n\n /* ---------------- option generation ---------------- */\n\n private rebuild(): void {\n const step = (value: number, fallback: number) =>\n Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback;\n\n const minuteStep = step(this.minuteStep, 1);\n const secondStep = step(this.secondStep, 1);\n const effectiveHour = this.hour ?? 0;\n const effectiveMinute = this.minute ?? 0;\n const meridiem = to12Hour(effectiveHour).meridiem;\n\n // How far a partially-specified time can stretch. With no seconds column\n // the seconds are pinned to :00, so an hour only reaches :59:00.\n const hourSpan = this.showSeconds ? 3599 : 3540;\n const minuteSpan = this.showSeconds ? 59 : 0;\n\n const columns: TimeColumn[] = [];\n\n // Hours\n const hourOptions: TimeOption[] = [];\n if (this.use12Hours) {\n // Clock-face order: 12, 1, 2 … 11.\n const faces = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];\n for (const face of faces) {\n const raw = from12Hour(face, meridiem);\n hourOptions.push({\n id: this.columnsId + '-hour-' + face,\n label: pad2(face),\n raw,\n face,\n disabled: this.rangeDisabled(raw * 3600, hourSpan),\n });\n }\n } else {\n for (let h = 0; h < 24; h++) {\n hourOptions.push({\n id: this.columnsId + '-hour-' + h,\n label: pad2(h),\n raw: h,\n face: h,\n disabled: this.rangeDisabled(h * 3600, hourSpan),\n });\n }\n }\n columns.push({ kind: 'hour', label: 'Hour', options: hourOptions });\n\n // Minutes\n const minuteOptions: TimeOption[] = [];\n for (let m = 0; m < 60; m += minuteStep) {\n const start = effectiveHour * 3600 + m * 60;\n minuteOptions.push({\n id: this.columnsId + '-minute-' + m,\n label: pad2(m),\n raw: m,\n face: m,\n disabled: this.rangeDisabled(start, minuteSpan),\n });\n }\n columns.push({ kind: 'minute', label: 'Min', options: minuteOptions });\n\n // Seconds\n if (this.showSeconds) {\n const secondOptions: TimeOption[] = [];\n for (let s = 0; s < 60; s += secondStep) {\n const at = effectiveHour * 3600 + effectiveMinute * 60 + s;\n secondOptions.push({\n id: this.columnsId + '-second-' + s,\n label: pad2(s),\n raw: s,\n face: s,\n disabled: this.rangeDisabled(at, 0),\n });\n }\n columns.push({ kind: 'second', label: 'Sec', options: secondOptions });\n }\n\n // Meridiem\n if (this.use12Hours) {\n const face12 = to12Hour(effectiveHour).hour12;\n const meridiemOptions: TimeOption[] = (['AM', 'PM'] as const).map((m, i) => {\n const raw = from12Hour(face12, m);\n const halfStart = m === 'AM' ? 0 : 12 * 3600;\n return {\n id: this.columnsId + '-meridiem-' + m,\n label: m,\n raw,\n face: i, // 0 = AM, 1 = PM\n disabled: this.rangeDisabled(halfStart, 12 * 3600 - 1),\n };\n });\n columns.push({ kind: 'meridiem', label: 'AM/PM', options: meridiemOptions });\n }\n\n this.columns = columns;\n this.syncActiveToSelection();\n }\n\n /**\n * True when nothing in `[start, start + span]` falls inside the allowed\n * window — i.e. picking this entry could not produce a legal time.\n */\n private rangeDisabled(start: number, span: number): boolean {\n return start + span < this.minSeconds || start > this.maxSeconds;\n }\n\n private syncActiveToSelection(): void {\n this.activeIndex = this.columns.map((column) => {\n const idx = column.options.findIndex((o) => this.isSelected(column, o));\n if (idx >= 0) return idx;\n const firstEnabled = column.options.findIndex((o) => !o.disabled);\n return firstEnabled >= 0 ? firstEnabled : 0;\n });\n }\n\n /* ---------------- selection ---------------- */\n\n isSelected(column: TimeColumn, option: TimeOption): boolean {\n switch (column.kind) {\n case 'hour':\n if (this.hour === null) return false;\n return this.use12Hours ? to12Hour(this.hour).hour12 === option.face : this.hour === option.face;\n case 'minute':\n return this.minute !== null && this.minute === option.face;\n case 'second':\n return this.second !== null && this.second === option.face;\n case 'meridiem':\n if (this.hour === null) return false;\n return (to12Hour(this.hour).meridiem === 'AM' ? 0 : 1) === option.face;\n }\n }\n\n isActive(columnIndex: number, option: TimeOption): boolean {\n const column = this.columns[columnIndex];\n if (!column) return false;\n return column.options[this.activeIndex[columnIndex]] === option;\n }\n\n activeDescendantFor(columnIndex: number): string | null {\n const column = this.columns[columnIndex];\n return column?.options[this.activeIndex[columnIndex]]?.id ?? null;\n }\n\n optionClass(column: TimeColumn, option: TimeOption, columnIndex: number): string {\n const selected = this.isSelected(column, option);\n return cn(\n timeOptionVariants({ size: this.size, selected }),\n // The roving highlight has to read as distinct from the committed value,\n // otherwise arrowing around looks like it is already selecting.\n !selected && this.isActive(columnIndex, option) && 'bg-accent text-accent-foreground'\n );\n }\n\n /** Commits `option` in its column and emits the resulting whole time. */\n choose(column: TimeColumn, option: TimeOption, columnIndex: number): void {\n if (option.disabled) return;\n\n const next: TimeParts = {\n hour: this.hour ?? 0,\n minute: this.minute ?? 0,\n second: this.second ?? 0,\n };\n\n switch (column.kind) {\n case 'hour':\n case 'meridiem':\n next.hour = option.raw;\n break;\n case 'minute':\n next.minute = option.raw;\n break;\n case 'second':\n next.second = option.raw;\n break;\n }\n\n this.activeIndex[columnIndex] = column.options.indexOf(option);\n this.timeChange.emit(next);\n this.cdr.markForCheck();\n }\n\n /* ---------------- keyboard ---------------- */\n\n onColumnKeydown(event: KeyboardEvent, columnIndex: number): void {\n const column = this.columns[columnIndex];\n if (!column) return;\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(columnIndex, 1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(columnIndex, -1);\n break;\n case 'Home':\n event.preventDefault();\n this.setActive(columnIndex, 0, 1);\n break;\n case 'End':\n event.preventDefault();\n this.setActive(columnIndex, column.options.length - 1, -1);\n break;\n case 'ArrowLeft':\n event.preventDefault();\n this.focusColumn(columnIndex - 1);\n break;\n case 'ArrowRight':\n event.preventDefault();\n this.focusColumn(columnIndex + 1);\n break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n {\n const option = column.options[this.activeIndex[columnIndex]];\n if (option) this.choose(column, option, columnIndex);\n }\n break;\n default:\n return;\n }\n }\n\n private moveActive(columnIndex: number, delta: number): void {\n const current = this.activeIndex[columnIndex] ?? 0;\n this.setActive(columnIndex, current + delta, delta);\n }\n\n /** Lands on the first enabled entry at or past `index`, walking `direction`. */\n private setActive(columnIndex: number, index: number, direction: number): void {\n const column = this.columns[columnIndex];\n if (!column) return;\n\n let i = Math.max(0, Math.min(index, column.options.length - 1));\n while (column.options[i]?.disabled) {\n i += direction;\n if (i < 0 || i >= column.options.length) return; // no enabled entry that way\n }\n\n this.activeIndex[columnIndex] = i;\n this.scrollOptionIntoView(columnIndex, i);\n this.cdr.markForCheck();\n }\n\n private focusColumn(columnIndex: number): void {\n if (columnIndex < 0 || columnIndex >= this.columns.length) return;\n this.listboxes?.get(columnIndex)?.nativeElement.focus();\n }\n\n /** Focuses the first column — used when the panel opens. */\n focusFirstColumn(): void {\n this.focusColumn(0);\n }\n\n /**\n * Centres each column on its selected entry. Scrolls the listbox directly\n * rather than calling `scrollIntoView`, which would also scroll the page\n * behind a fixed-position panel.\n */\n scrollSelectedIntoView(): void {\n this.listboxes?.forEach((ref) => {\n const list = ref.nativeElement;\n const selected = list.querySelector('[data-selected=\"true\"]') as HTMLElement | null;\n if (!selected) return;\n list.scrollTop = selected.offsetTop - list.clientHeight / 2 + selected.clientHeight / 2;\n });\n }\n\n private scrollOptionIntoView(columnIndex: number, optionIndex: number): void {\n const list = this.listboxes?.get(columnIndex)?.nativeElement;\n if (!list) return;\n const el = list.children[optionIndex] as HTMLElement | undefined;\n if (!el) return;\n\n if (el.offsetTop < list.scrollTop) {\n list.scrollTop = el.offsetTop;\n } else if (el.offsetTop + el.clientHeight > list.scrollTop + list.clientHeight) {\n list.scrollTop = el.offsetTop + el.clientHeight - list.clientHeight;\n }\n }\n\n trackColumn = (_: number, column: TimeColumn) => column.kind;\n trackOption = (_: number, option: TimeOption) => option.id;\n}\n\n/* ------------------------------------------------------------------ *\n * The picker\n * ------------------------------------------------------------------ */\n\nconst timePickerTriggerVariants = 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\n/** Variant props for the time picker trigger. */\nexport type TimePickerProps = VariantProps<typeof timePickerTriggerVariants>;\n\n/**\n * A trigger that opens scrollable hour / minute / second columns in a floating\n * panel.\n *\n * The value is a **24-hour `HH:mm` string** (or `HH:mm:ss` when `showSeconds`),\n * regardless of whether the face is rendered as 12-hour — a wall-clock time has\n * no date and no timezone, and a `Date` would invent both. Set\n * `valueType=\"date\"` to exchange `Date` objects instead, in which case the time\n * is written onto today's date (or onto the date already held by the control).\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-time-picker` when a date is needed alongside.\n * @new\n */\n@Component({\n selector: 'tolle-time-picker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, TimeColumnsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TimePickerComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\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', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-time-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div\n *ngIf=\"isOpen\"\n #panel\n role=\"dialog\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel || 'Choose a time'\"\n class=\"fixed left-0 top-0 z-[9999]\"\n style=\"visibility: hidden\"\n >\n <div class=\"rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md\">\n <tolle-time-columns\n #columns\n [hour]=\"hour\"\n [minute]=\"minute\"\n [second]=\"second\"\n [use12Hours]=\"use12Hours\"\n [showSeconds]=\"showSeconds\"\n [minuteStep]=\"minuteStep\"\n [secondStep]=\"secondStep\"\n [minSeconds]=\"minSeconds\"\n [maxSeconds]=\"maxSeconds\"\n [size]=\"size\"\n (timeChange)=\"onTimeChange($event)\"\n ></tolle-time-columns>\n </div>\n </div>\n `,\n})\nexport class TimePickerComponent implements OnChanges, ControlValueAccessor, 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 on the trigger when no time is selected. @default 'Select a time' */\n @Input() placeholder = 'Select a time';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Renders a 12-hour face with an AM/PM column. The emitted value stays 24-hour. @default false */\n @Input() use12Hours = false;\n /** Adds a seconds column and emits `HH:mm:ss`. @default false */\n @Input() showSeconds = false;\n /** Interval between minute entries, in minutes. @default 1 */\n @Input() minuteStep = 1;\n /** Interval between second entries, in seconds. @default 1 */\n @Input() secondStep = 1;\n /** Earliest selectable time as a 24-hour `HH:mm` string; earlier entries are disabled. */\n @Input() min?: string;\n /** Latest selectable time as a 24-hour `HH:mm` string; later entries are disabled. */\n @Input() max?: string;\n /** Height and text size of the trigger and entries. @default 'default' */\n @Input() size: TimePickerProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Exchange `Date` objects instead of `HH:mm` strings through the form control. @default 'string' */\n @Input() valueType: 'string' | 'date' = 'string';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen time whenever it changes. */\n @Output() valueChange = new EventEmitter<string | Date | null>();\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 @ViewChild('columns') columnsCmp?: TimeColumnsComponent;\n\n readonly panelId = 'time-picker-panel-' + Math.random().toString(36).slice(2, 11);\n\n hour: number | null = null;\n minute: number | null = null;\n second: number | null = null;\n isOpen = false;\n\n protected cn = cn;\n\n private cleanup?: () => void;\n /** The date half of a `Date` value, kept so round-tripping does not move the day. */\n private dateAnchor: Date | null = null;\n private onChange: (value: string | Date | null) => 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 /* ---------------- derived ---------------- */\n\n /** The current selection as wall-clock parts, or `null` when incomplete. */\n get parts(): TimeParts | null {\n if (this.hour === null || this.minute === null) return null;\n return { hour: this.hour, minute: this.minute, second: this.second ?? 0 };\n }\n\n get displayValue(): string {\n const parts = this.parts;\n if (!parts) return '';\n\n if (this.use12Hours) {\n const { hour12, meridiem } = to12Hour(parts.hour);\n const body =\n String(hour12) +\n ':' +\n pad2(parts.minute) +\n (this.showSeconds ? ':' + pad2(parts.second) : '');\n return body + ' ' + meridiem;\n }\n return formatTimeString(parts, this.showSeconds);\n }\n\n get minSeconds(): number {\n const parsed = parseTimeString(this.min);\n return parsed ? timePartsToSeconds(parsed) : 0;\n }\n\n get maxSeconds(): number {\n const parsed = parseTimeString(this.max);\n return parsed ? timePartsToSeconds(parsed) : SECONDS_PER_DAY - 1;\n }\n\n get computedTriggerClass(): string {\n return cn(timePickerTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n /* ---------------- open / close ---------------- */\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 this.columnsCmp?.scrollSelectedIntoView();\n this.columnsCmp?.focusFirstColumn();\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 /* ---------------- value ---------------- */\n\n onTimeChange(parts: TimeParts): void {\n this.hour = parts.hour;\n this.minute = parts.minute;\n this.second = parts.second;\n this.emit();\n this.cdr.markForCheck();\n }\n\n private emit(): void {\n const value = this.currentValue();\n this.onChange(value);\n this.valueChange.emit(value);\n }\n\n private currentValue(): string | Date | null {\n const parts = this.parts;\n if (!parts) return null;\n if (this.valueType === 'date') {\n const base = this.dateAnchor ?? new Date();\n return new Date(\n base.getFullYear(),\n base.getMonth(),\n base.getDate(),\n parts.hour,\n parts.minute,\n parts.second,\n 0\n );\n }\n return formatTimeString(parts, this.showSeconds);\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 strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [offset(4), flip(), shift({ padding: 8 })],\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 /* ---------------- ControlValueAccessor ---------------- */\n\n writeValue(value: string | Date | null | undefined): void {\n let parts: TimeParts | null = null;\n\n if (value instanceof Date && !isNaN(value.getTime())) {\n this.dateAnchor = value;\n parts = { hour: value.getHours(), minute: value.getMinutes(), second: value.getSeconds() };\n } else if (typeof value === 'string') {\n parts = parseTimeString(value);\n }\n\n this.hour = parts ? parts.hour : null;\n this.minute = parts ? parts.minute : null;\n this.second = parts ? parts.second : null;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string | Date | null) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n if (isDisabled) this.close();\n this.cdr.markForCheck();\n }\n}\n"
17
+ },
18
+ {
19
+ "path": "utils/cn.ts",
20
+ "target": "ui/utils/cn.ts",
21
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
22
+ }
23
+ ]
24
+ }
@@ -10,12 +10,12 @@
10
10
  {
11
11
  "path": "toast.service.ts",
12
12
  "target": "ui/toast.service.ts",
13
- "content": "// projects/tolle/src/lib/toast.service.ts\nimport { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\nexport interface Toast {\n id: number;\n title?: string;\n description: string;\n variant?: 'default' | 'destructive' | 'success';\n duration?: number; // Custom duration in ms\n remainingTime: number;\n isPaused: boolean;\n}\n\nexport type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';\n@Injectable({ providedIn: 'root' })\nexport class ToastService {\n private toasts: Toast[] = [];\n private toastSubject = new Subject<Toast[]>();\n toasts$ = this.toastSubject.asObservable();\n\n constructor() {\n setInterval(() => this.tick(), 100);\n }\n show(toast: Omit<Toast, 'id' | 'remainingTime' | 'isPaused'>) {\n const duration = toast.duration || 3000;\n const newToast: Toast = {\n ...toast,\n id: Date.now(),\n duration,\n remainingTime: duration,\n isPaused: false\n };\n this.toasts.push(newToast);\n this.notify();\n }\n\n private tick() {\n let changed = false;\n this.toasts.forEach(t => {\n if (!t.isPaused) {\n t.remainingTime -= 100;\n changed = true;\n }\n });\n\n const initialCount = this.toasts.length;\n this.toasts = this.toasts.filter(t => t.remainingTime > 0);\n\n if (changed || this.toasts.length !== initialCount) {\n this.notify();\n }\n }\n\n setPaused(id: number, paused: boolean) {\n const toast = this.toasts.find(t => t.id === id);\n if (toast) {\n toast.isPaused = paused;\n this.notify();\n }\n }\n\n remove(id: number) {\n this.toasts = this.toasts.filter(t => t.id !== id);\n this.notify();\n }\n\n private notify() {\n this.toastSubject.next([...this.toasts]);\n }\n}\n"
13
+ "content": "// projects/tolle/src/lib/toast.service.ts\nimport { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\nexport type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';\n\nexport interface Toast {\n id: number;\n title?: string;\n description: string;\n variant?: ToastVariant;\n duration?: number; // Custom duration in ms\n remainingTime: number;\n isPaused: boolean;\n}\n\nexport type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';\n@Injectable({ providedIn: 'root' })\nexport class ToastService {\n private toasts: Toast[] = [];\n private nextId = 0;\n private toastSubject = new Subject<Toast[]>();\n toasts$ = this.toastSubject.asObservable();\n\n constructor() {\n setInterval(() => this.tick(), 100);\n }\n show(toast: Omit<Toast, 'id' | 'remainingTime' | 'isPaused'>) {\n const duration = toast.duration || 3000;\n const newToast: Toast = {\n ...toast,\n id: ++this.nextId,\n duration,\n remainingTime: duration,\n isPaused: false\n };\n this.toasts.push(newToast);\n this.notify();\n }\n\n private tick() {\n let changed = false;\n this.toasts.forEach(t => {\n if (!t.isPaused) {\n t.remainingTime -= 100;\n changed = true;\n }\n });\n\n const initialCount = this.toasts.length;\n this.toasts = this.toasts.filter(t => t.remainingTime > 0);\n\n if (changed || this.toasts.length !== initialCount) {\n this.notify();\n }\n }\n\n setPaused(id: number, paused: boolean) {\n const toast = this.toasts.find(t => t.id === id);\n if (toast) {\n toast.isPaused = paused;\n this.notify();\n }\n }\n\n remove(id: number) {\n this.toasts = this.toasts.filter(t => t.id !== id);\n this.notify();\n }\n\n private notify() {\n this.toastSubject.next([...this.toasts]);\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "toaster.component.ts",
17
17
  "target": "ui/toaster.component.ts",
18
- "content": "// projects/tolle/src/lib/toast-container.component.ts\nimport { Component, inject, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ToastService, Toast, ToastPosition } from './toast.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-toaster',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('fixed z-[100] flex flex-col gap-2 w-full max-w-[380px] p-4', positionClasses)\">\n <div\n *ngFor=\"let toast of toasts$ | async\"\n role=\"status\"\n [attr.aria-live]=\"toast.variant === 'destructive' ? 'assertive' : 'polite'\"\n aria-atomic=\"true\"\n (mouseenter)=\"toastService.setPaused(toast.id, true)\"\n (mouseleave)=\"toastService.setPaused(toast.id, false)\"\n [class]=\"cn(\n 'relative overflow-hidden p-4 rounded-md border shadow-lg flex items-start justify-between gap-4 transition-all duration-300',\n getVariantClasses(toast.variant)\n )\"\n >\n <div class=\"flex gap-3\">\n <i *ngIf=\"toast.variant && icons[toast.variant]\" [class]=\"cn('text-lg', icons[toast.variant])\"></i>\n \n <div class=\"grid gap-1\">\n <div *ngIf=\"toast.title\" class=\"text-sm font-semibold leading-none\">{{ toast.title }}</div>\n <div class=\"text-xs opacity-90 leading-relaxed\">{{ toast.description }}</div>\n </div>\n </div>\n\n <button type=\"button\" aria-label=\"Close\" (click)=\"toastService.remove(toast.id)\" class=\"opacity-50 hover:opacity-100 transition-opacity\">\n <span class=\"sr-only\">Close</span>\n <i class=\"ri-close-line text-lg\"></i>\n </button>\n\n <div\n aria-hidden=\"true\"\n class=\"absolute bottom-0 left-0 h-1 transition-all duration-100 ease-linear\"\n [style.width.%]=\"(toast.remainingTime / (toast.duration || 3000)) * 100\"\n [class]=\"getProgressClasses(toast.variant)\"\n ></div>\n </div>\n </div>\n `\n})\nexport class ToastContainerComponent {\n toastService = inject(ToastService);\n @Input() position: ToastPosition = 'bottom-right';\n toasts$ = this.toastService.toasts$;\n\n icons = {\n destructive: 'ri-error-warning-line text-destructive',\n success: 'ri-checkbox-circle-line text-success',\n default: 'ri-information-line text-primary'\n };\n\n getVariantClasses(variant: string = 'default') {\n switch (variant) {\n case 'destructive':\n return 'border-destructive/50 bg-destructive/10 text-destructive';\n case 'success':\n return 'border-success/50 bg-success/10 text-success';\n default:\n return 'bg-background text-foreground border-border';\n }\n }\n\n getProgressClasses(variant: string = 'default') {\n switch (variant) {\n case 'destructive':\n return 'bg-destructive';\n case 'success':\n return 'bg-success';\n default:\n return 'bg-primary';\n }\n }\n\n get positionClasses() {\n const pos = {\n 'top-right': 'top-0 right-0 flex-col-reverse',\n 'top-left': 'top-0 left-0 flex-col-reverse',\n 'bottom-right': 'bottom-0 right-0',\n 'bottom-left': 'bottom-0 left-0',\n 'top-center': 'top-0 left-1/2 -translate-x-1/2 flex-col-reverse',\n 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2'\n };\n return pos[this.position];\n }\n protected cn = cn;\n}\n"
18
+ "content": "// projects/tolle/src/lib/toast-container.component.ts\nimport { Component, ElementRef, OnDestroy, OnInit, inject, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ToastService, Toast, ToastPosition, ToastVariant } from './toast.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-toaster',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('fixed z-[10000] pointer-events-none flex flex-col gap-2 w-full max-w-[380px] p-4', positionClasses)\">\n <div\n *ngFor=\"let toast of toasts$ | async\"\n role=\"status\"\n [attr.aria-live]=\"toast.variant === 'destructive' ? 'assertive' : 'polite'\"\n aria-atomic=\"true\"\n (mouseenter)=\"toastService.setPaused(toast.id, true)\"\n (mouseleave)=\"toastService.setPaused(toast.id, false)\"\n [class]=\"cn(\n 'pointer-events-auto relative overflow-hidden p-4 rounded-md border shadow-lg flex items-start justify-between gap-4 transition-all duration-300',\n 'bg-background text-foreground',\n getVariantClasses(toast.variant)\n )\"\n >\n <!-- Opaque surface above, variant tint layered on top of it. -->\n <div aria-hidden=\"true\" [class]=\"cn('absolute inset-0 pointer-events-none', getTintClasses(toast.variant))\"></div>\n\n <div class=\"relative flex gap-3\">\n <i *ngIf=\"toast.variant && icons[toast.variant]\" [class]=\"cn('text-lg', icons[toast.variant])\"></i>\n \n <div class=\"grid gap-1\">\n <div *ngIf=\"toast.title\" class=\"text-sm font-semibold leading-none\">{{ toast.title }}</div>\n <div class=\"text-xs opacity-90 leading-relaxed\">{{ toast.description }}</div>\n </div>\n </div>\n\n <button type=\"button\" aria-label=\"Close\" (click)=\"toastService.remove(toast.id)\" class=\"relative opacity-50 hover:opacity-100 transition-opacity\">\n <span class=\"sr-only\">Close</span>\n <i class=\"ri-close-line text-lg\"></i>\n </button>\n\n <div\n aria-hidden=\"true\"\n class=\"absolute bottom-0 left-0 h-1 transition-all duration-100 ease-linear z-10\"\n [style.width.%]=\"(toast.remainingTime / (toast.duration || 3000)) * 100\"\n [class]=\"getProgressClasses(toast.variant)\"\n ></div>\n </div>\n </div>\n `,\n styles: [':host { display: contents; }']\n})\nexport class ToastContainerComponent implements OnInit, OnDestroy {\n toastService = inject(ToastService);\n private elementRef: ElementRef<HTMLElement> = inject(ElementRef);\n @Input() position: ToastPosition = 'bottom-right';\n toasts$ = this.toastService.toasts$;\n\n /**\n * Move the host to <body> so the fixed container is never clipped or\n * z-index-clamped by a consumer ancestor that creates a stacking context\n * (transform / filter / backdrop-blur / opacity). Being a direct child of\n * <body> and sitting above the CDK overlay container keeps toasts on top\n * of modals, sheets and alert dialogs.\n */\n ngOnInit() {\n document.body.appendChild(this.elementRef.nativeElement);\n }\n\n ngOnDestroy() {\n this.elementRef.nativeElement.remove();\n }\n\n icons: Record<ToastVariant, string> = {\n default: 'ri-information-line text-primary',\n destructive: 'ri-error-warning-line text-destructive',\n success: 'ri-checkbox-circle-line text-success',\n warning: 'ri-alert-line text-warning',\n info: 'ri-information-line text-info'\n };\n\n /**\n * Border + text only. The toast root always carries an opaque `bg-background`;\n * the variant colour is applied as a separate tint layer (see `getTintClasses`)\n * so toasts are never see-through over page content.\n */\n private variantClasses: Record<ToastVariant, string> = {\n default: 'border-border',\n destructive: 'border-destructive/50 text-destructive',\n success: 'border-success/50 text-success',\n warning: 'border-warning/50 text-warning',\n info: 'border-info/50 text-info'\n };\n\n private tintClasses: Record<ToastVariant, string> = {\n default: '',\n destructive: 'bg-destructive/10',\n success: 'bg-success/10',\n warning: 'bg-warning/10',\n info: 'bg-info/10'\n };\n\n private progressClasses: Record<ToastVariant, string> = {\n default: 'bg-primary',\n destructive: 'bg-destructive',\n success: 'bg-success',\n warning: 'bg-warning',\n info: 'bg-info'\n };\n\n private resolve(variant: string | undefined): ToastVariant {\n return variant && variant in this.variantClasses ? (variant as ToastVariant) : 'default';\n }\n\n getVariantClasses(variant?: string) {\n return this.variantClasses[this.resolve(variant)];\n }\n\n getTintClasses(variant?: string) {\n return this.tintClasses[this.resolve(variant)];\n }\n\n getProgressClasses(variant?: string) {\n return this.progressClasses[this.resolve(variant)];\n }\n\n get positionClasses() {\n const pos = {\n 'top-right': 'top-0 right-0 flex-col-reverse',\n 'top-left': 'top-0 left-0 flex-col-reverse',\n 'bottom-right': 'bottom-0 right-0',\n 'bottom-left': 'bottom-0 left-0',\n 'top-center': 'top-0 left-1/2 -translate-x-1/2 flex-col-reverse',\n 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2'\n };\n return pos[this.position];\n }\n protected cn = cn;\n}\n"
19
19
  },
20
20
  {
21
21
  "path": "utils/cn.ts",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "path": "toggle-group.component.ts",
16
16
  "target": "ui/toggle-group.component.ts",
17
- "content": "import { Component, Input, Output, EventEmitter, forwardRef, ContentChildren, QueryList, AfterContentInit, AfterViewInit, inject, ChangeDetectorRef, ElementRef, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { ToggleComponent } from './toggle.component';\n\n@Component({\n selector: 'tolle-toggle-group',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleGroupComponent),\n multi: true\n }\n ],\n host: {\n '[class]': 'computedClass',\n 'role': 'group',\n '(keydown)': 'onKeydown($event)',\n '(focusin)': 'onFocusIn($event)'\n },\n styles: [`\n :host {\n display: inline-flex;\n }\n\n /* 1. First Item: Remove right rounding */\n :host ::ng-deep tolle-toggle-group-item:first-child tolle-toggle button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Items: Remove all rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:not(:first-child):not(:last-child) tolle-toggle button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Item: Remove left rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:last-child:not(:first-child) tolle-toggle button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Higher z-index to show borders/rings correctly */\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:hover,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:focus,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button[data-state=on] {\n @apply z-10 relative;\n }\n `]\n})\nexport class ToggleGroupComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() variant: 'default' | 'outline' = 'default';\n @Input() size: 'default' | 'sm' | 'lg' = 'default';\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n @Output() valueChange = new EventEmitter<any>();\n\n @ContentChildren(forwardRef(() => ToggleGroupItemComponent), { descendants: true }) items!: QueryList<ToggleGroupItemComponent>;\n\n private _value: any;\n private cdr = inject(ChangeDetectorRef);\n private host = inject(ElementRef<HTMLElement>);\n\n /** Index (into the full button list) of the current roving tab stop. */\n private activeIndex = 0;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get value() { return this._value; }\n set value(v: any) {\n if (v !== this._value) {\n this._value = v;\n this.updateItemsState();\n this.onChange(v);\n this.valueChange.emit(v);\n }\n }\n\n ngAfterContentInit() {\n this.updateItemsState();\n this.items.changes.subscribe(() => {\n this.updateItemsState();\n this.cdr.markForCheck();\n // Re-apply roving tabindex once the new toggle views have rendered.\n Promise.resolve().then(() => this.applyRovingTabindex());\n });\n }\n\n ngAfterViewInit() {\n this.applyRovingTabindex();\n }\n\n get computedClass() {\n return cn(\"inline-flex items-center justify-center -space-x-px rounded-md\", this.class);\n }\n\n /** All toggle button elements in DOM order. */\n private get toggleButtons(): HTMLButtonElement[] {\n return Array.from(\n this.host.nativeElement.querySelectorAll('tolle-toggle button')\n ) as HTMLButtonElement[];\n }\n\n /**\n * Roving tabindex: exactly one toggle button is tab-reachable at a time.\n * Defaults to the current active button, falling back to the first enabled.\n */\n private applyRovingTabindex() {\n const btns = this.toggleButtons;\n if (!btns.length) return;\n\n let active = this.activeIndex;\n if (active < 0 || active >= btns.length || btns[active].disabled) {\n active = btns.findIndex(b => !b.disabled);\n if (active < 0) active = 0;\n }\n this.activeIndex = active;\n btns.forEach((b, i) => { b.tabIndex = i === active ? 0 : -1; });\n }\n\n /**\n * Toolbar keyboard nav: Arrow Left/Right (and Home/End) move focus between\n * toggle buttons without activating them (toggles activate on Enter/Space/click).\n */\n onKeydown(event: KeyboardEvent) {\n const key = event.key;\n if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;\n\n const btns = this.toggleButtons;\n const enabled = btns.map((b, i) => ({ b, i })).filter(x => !x.b.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLButtonElement | null;\n const focusedIndex = active ? btns.indexOf(active) : -1;\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n\n let targetPos: number;\n if (key === 'Home') {\n targetPos = 0;\n } else if (key === 'End') {\n targetPos = enabled.length - 1;\n } else if (key === 'ArrowRight') {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n // Focusing the button triggers focusin, which updates the roving tab stop.\n enabled[targetPos].b.focus();\n }\n\n /** Keep the roving tab stop in sync when a button gains focus (tab/click). */\n onFocusIn(event: FocusEvent) {\n const btns = this.toggleButtons;\n const idx = btns.indexOf(event.target as HTMLButtonElement);\n if (idx < 0) return;\n this.activeIndex = idx;\n btns.forEach((b, i) => { b.tabIndex = i === idx ? 0 : -1; });\n }\n\n writeValue(value: any): void {\n this._value = value;\n this.updateItemsState();\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 {\n this.disabled = isDisabled;\n this.updateItemsState();\n }\n\n toggleItem(itemValue: string) {\n if (this.disabled) return;\n\n if (this.type === 'single') {\n this.value = this.value === itemValue ? null : itemValue;\n } else {\n const current = Array.isArray(this.value) ? [...this.value] : [];\n const index = current.indexOf(itemValue);\n if (index > -1) {\n current.splice(index, 1);\n } else {\n current.push(itemValue);\n }\n this.value = current;\n }\n }\n\n private updateItemsState() {\n if (!this.items) return;\n this.items.forEach(item => {\n if (this.type === 'single') {\n item.pressed = this.value === item.value;\n } else {\n item.pressed = Array.isArray(this.value) && this.value.includes(item.value);\n }\n item.variant = this.variant;\n item.size = this.size;\n item.disabled = this.disabled || item.disabled;\n item.cdr.markForCheck();\n });\n }\n}\n\n@Component({\n selector: 'tolle-toggle-group-item',\n standalone: true,\n imports: [CommonModule, ToggleComponent],\n template: `\n <tolle-toggle\n [pressed]=\"pressed\"\n [variant]=\"variant\"\n [size]=\"size\"\n [disabled]=\"disabled\"\n (pressedChange)=\"onToggle()\"\n [class]=\"class\"\n >\n <ng-content></ng-content>\n </tolle-toggle>\n `\n})\nexport class ToggleGroupItemComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n pressed = false;\n variant: 'default' | 'outline' = 'default';\n size: 'default' | 'sm' | 'lg' = 'default';\n\n private group = inject(ToggleGroupComponent);\n public cdr = inject(ChangeDetectorRef);\n\n onToggle() {\n this.group.toggleItem(this.value);\n }\n}\n"
17
+ "content": "import { Component, Input, Output, EventEmitter, forwardRef, ContentChildren, QueryList, AfterContentInit, AfterViewInit, inject, ChangeDetectorRef, ElementRef, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { ToggleComponent } from './toggle.component';\n\n@Component({\n selector: 'tolle-toggle-group',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleGroupComponent),\n multi: true\n }\n ],\n host: {\n '[class]': 'computedClass',\n 'role': 'group',\n '(keydown)': 'onKeydown($event)',\n '(focusin)': 'onFocusIn($event)'\n },\n styles: [`\n :host {\n display: inline-flex;\n }\n\n /* 1. First Item: Remove right rounding */\n :host ::ng-deep tolle-toggle-group-item:first-child tolle-toggle button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Items: Remove all rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:not(:first-child):not(:last-child) tolle-toggle button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Item: Remove left rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:last-child:not(:first-child) tolle-toggle button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Higher z-index to show borders/rings correctly */\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:hover,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:focus,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button[data-state=on] {\n @apply z-10 relative;\n }\n `]\n})\nexport class ToggleGroupComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() variant: 'default' | 'outline' = 'default';\n @Input() size: 'default' | 'sm' | 'lg' = 'default';\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n @Output() valueChange = new EventEmitter<any>();\n\n @ContentChildren(forwardRef(() => ToggleGroupItemComponent), { descendants: true }) items!: QueryList<ToggleGroupItemComponent>;\n\n private _value: any;\n private cdr = inject(ChangeDetectorRef);\n private host = inject(ElementRef<HTMLElement>);\n\n /** Index (into the full button list) of the current roving tab stop. */\n private activeIndex = 0;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get value() { return this._value; }\n set value(v: any) {\n if (v !== this._value) {\n this._value = v;\n this.updateItemsState();\n this.onChange(v);\n this.valueChange.emit(v);\n }\n }\n\n ngAfterContentInit() {\n this.updateItemsState();\n this.items.changes.subscribe(() => {\n this.updateItemsState();\n this.cdr.markForCheck();\n // Re-apply roving tabindex once the new toggle views have rendered.\n Promise.resolve().then(() => this.applyRovingTabindex());\n });\n }\n\n ngAfterViewInit() {\n this.applyRovingTabindex();\n }\n\n get computedClass() {\n return cn(\"inline-flex items-center justify-center -space-x-px rounded-md\", this.class);\n }\n\n /** All toggle button elements in DOM order. */\n private get toggleButtons(): HTMLButtonElement[] {\n return Array.from(\n this.host.nativeElement.querySelectorAll('tolle-toggle button')\n ) as HTMLButtonElement[];\n }\n\n /**\n * Roving tabindex: exactly one toggle button is tab-reachable at a time.\n * Defaults to the current active button, falling back to the first enabled.\n */\n private applyRovingTabindex() {\n const btns = this.toggleButtons;\n if (!btns.length) return;\n\n let active = this.activeIndex;\n if (active < 0 || active >= btns.length || btns[active].disabled) {\n active = btns.findIndex(b => !b.disabled);\n if (active < 0) active = 0;\n }\n this.activeIndex = active;\n btns.forEach((b, i) => { b.tabIndex = i === active ? 0 : -1; });\n }\n\n /**\n * Toolbar keyboard nav: Arrow Left/Right (and Home/End) move focus between\n * toggle buttons without activating them (toggles activate on Enter/Space/click).\n */\n onKeydown(event: KeyboardEvent) {\n const key = event.key;\n if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;\n\n const btns = this.toggleButtons;\n const enabled = btns.map((b, i) => ({ b, i })).filter(x => !x.b.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLButtonElement | null;\n const focusedIndex = active ? btns.indexOf(active) : -1;\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n\n let targetPos: number;\n if (key === 'Home') {\n targetPos = 0;\n } else if (key === 'End') {\n targetPos = enabled.length - 1;\n } else if (key === 'ArrowRight') {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n // Focusing the button triggers focusin, which updates the roving tab stop.\n enabled[targetPos].b.focus();\n }\n\n /** Keep the roving tab stop in sync when a button gains focus (tab/click). */\n onFocusIn(event: FocusEvent) {\n const btns = this.toggleButtons;\n const idx = btns.indexOf(event.target as HTMLButtonElement);\n if (idx < 0) return;\n this.activeIndex = idx;\n btns.forEach((b, i) => { b.tabIndex = i === idx ? 0 : -1; });\n }\n\n writeValue(value: any): void {\n this._value = value;\n this.updateItemsState();\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 {\n this.disabled = isDisabled;\n this.updateItemsState();\n }\n\n toggleItem(itemValue: string) {\n if (this.disabled) return;\n\n if (this.type === 'single') {\n this.value = this.value === itemValue ? null : itemValue;\n } else {\n const current = Array.isArray(this.value) ? [...this.value] : [];\n const index = current.indexOf(itemValue);\n if (index > -1) {\n current.splice(index, 1);\n } else {\n current.push(itemValue);\n }\n this.value = current;\n }\n }\n\n private updateItemsState() {\n if (!this.items) return;\n this.items.forEach(item => {\n if (this.type === 'single') {\n item.pressed = this.value === item.value;\n } else {\n item.pressed = Array.isArray(this.value) && this.value.includes(item.value);\n }\n item.variant = this.variant;\n item.size = this.size;\n item.disabled = this.disabled || item.disabled;\n item.cdr.markForCheck();\n });\n }\n}\n\n@Component({\n selector: 'tolle-toggle-group-item',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule, ToggleComponent],\n template: `\n <tolle-toggle\n [pressed]=\"pressed\"\n [variant]=\"variant\"\n [size]=\"size\"\n [disabled]=\"disabled\"\n (pressedChange)=\"onToggle()\"\n [class]=\"class\"\n >\n <ng-content></ng-content>\n </tolle-toggle>\n `\n})\nexport class ToggleGroupItemComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n pressed = false;\n variant: 'default' | 'outline' = 'default';\n size: 'default' | 'sm' | 'lg' = 'default';\n\n private group = inject(ToggleGroupComponent);\n public cdr = inject(ChangeDetectorRef);\n\n onToggle() {\n this.group.toggleItem(this.value);\n }\n}\n"
18
18
  },
19
19
  {
20
20
  "path": "utils/cn.ts",
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "toggle.component.ts",
14
14
  "target": "ui/toggle.component.ts",
15
- "content": "import { Component, Input, Output, EventEmitter, forwardRef } 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 toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3\",\n sm: \"h-9 px-2.5\",\n lg: \"h-11 px-5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport type ToggleProps = VariantProps<typeof toggleVariants>;\n\n@Component({\n selector: 'tolle-toggle',\n standalone: true,\n imports: [CommonModule],\n template: `\n <button\n type=\"button\"\n [class]=\"computedClass\"\n [disabled]=\"disabled\"\n [attr.aria-pressed]=\"pressed\"\n [attr.data-state]=\"pressed ? 'on' : 'off'\"\n (click)=\"toggle()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleComponent),\n multi: true\n }\n ]\n})\nexport class ToggleComponent implements ControlValueAccessor {\n @Input() variant: ToggleProps['variant'] = 'default';\n @Input() size: ToggleProps['size'] = 'default';\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @Input() set pressed(val: boolean) {\n this._pressed = val;\n this.onChange(val);\n }\n get pressed() { return this._pressed; }\n private _pressed = false;\n\n @Output() pressedChange = new EventEmitter<boolean>();\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get computedClass() {\n return cn(\n toggleVariants({\n variant: this.variant,\n size: this.size,\n }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.pressed = !this.pressed;\n this.pressedChange.emit(this.pressed);\n this.onTouched();\n }\n\n writeValue(value: any): void {\n this._pressed = !!value;\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
15
+ "content": "import { Component, Input, Output, EventEmitter, forwardRef } 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 toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3\",\n sm: \"h-9 px-2.5\",\n lg: \"h-11 px-5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport type ToggleProps = VariantProps<typeof toggleVariants>;\n\n@Component({\n selector: 'tolle-toggle',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <button\n type=\"button\"\n [class]=\"computedClass\"\n [disabled]=\"disabled\"\n [attr.aria-pressed]=\"pressed\"\n [attr.data-state]=\"pressed ? 'on' : 'off'\"\n (click)=\"toggle()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleComponent),\n multi: true\n }\n ]\n})\nexport class ToggleComponent implements ControlValueAccessor {\n @Input() variant: ToggleProps['variant'] = 'default';\n @Input() size: ToggleProps['size'] = 'default';\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @Input() set pressed(val: boolean) {\n this._pressed = val;\n this.onChange(val);\n }\n get pressed() { return this._pressed; }\n private _pressed = false;\n\n @Output() pressedChange = new EventEmitter<boolean>();\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get computedClass() {\n return cn(\n toggleVariants({\n variant: this.variant,\n size: this.size,\n }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.pressed = !this.pressed;\n this.pressedChange.emit(this.pressed);\n this.onTouched();\n }\n\n writeValue(value: any): void {\n this._pressed = !!value;\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
16
16
  },
17
17
  {
18
18
  "path": "utils/cn.ts",
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "tool",
3
+ "type": "registry:ui",
4
+ "title": "Tool",
5
+ "description": "Tool component.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "tool.component.ts",
14
+ "target": "ui/tool.component.ts",
15
+ "content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst toolStateVariants = cva(\n 'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',\n {\n variants: {\n state: {\n pending: 'bg-muted text-muted-foreground',\n running: 'bg-info/10 text-info',\n success: 'bg-success/10 text-success',\n error: 'bg-destructive/10 text-destructive',\n },\n },\n defaultVariants: {\n state: 'pending',\n },\n }\n);\n\nexport type ToolStateProps = VariantProps<typeof toolStateVariants>;\n\n/** Lifecycle of a tool call. */\nexport type ToolState = NonNullable<ToolStateProps['state']>;\n\n/** Remixicon class shown in the state chip for each state. */\nconst TOOL_STATE_ICONS: Record<ToolState, string> = {\n pending: 'ri-time-line',\n running: 'ri-loader-4-line animate-spin',\n success: 'ri-check-line',\n error: 'ri-error-warning-line',\n};\n\n/** Default chip text for each state. */\nconst TOOL_STATE_LABELS: Record<ToolState, string> = {\n pending: 'Pending',\n running: 'Running',\n success: 'Completed',\n error: 'Error',\n};\n\n/**\n * Coordinates a `tolle-tool` with its header, input and output.\n *\n * Header/input/output are OnPush siblings projected into the tool, so the\n * shared open + state they render from is published here.\n *\n * Provided on `ToolComponent`, so each tool call gets its own instance.\n */\n@Injectable()\nexport class ToolService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the tool call is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly stateSource = new BehaviorSubject<ToolState>('pending');\n /** Emits the tool call's lifecycle state. */\n readonly state$ = this.stateSource.asObservable();\n\n /** True while the tool call is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n /** The tool call's current state. */\n get state(): ToolState {\n return this.stateSource.value;\n }\n\n setOpen(value: boolean): void {\n if (this.openSource.value !== value) this.openSource.next(value);\n }\n\n toggle(): void {\n this.setOpen(!this.openSource.value);\n }\n\n setState(state: ToolState): void {\n if (this.stateSource.value !== state) this.stateSource.next(state);\n }\n}\n\nconst toolVariants = cva('w-full overflow-hidden rounded-md border bg-card text-card-foreground', {\n variants: {\n state: {\n pending: 'border-border',\n running: 'border-info/40',\n success: 'border-border',\n error: 'border-destructive/40',\n },\n },\n defaultVariants: {\n state: 'pending',\n },\n});\n\nexport type ToolProps = VariantProps<typeof toolVariants>;\n\n/**\n * A collapsible record of one tool/function call: what was invoked, the\n * arguments it received and the result it returned.\n *\n * Compose it from `tolle-tool-header`, `tolle-tool-input` and\n * `tolle-tool-output`.\n * @new\n */\n@Component({\n selector: 'tolle-tool',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ToolService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"open ? 'open' : 'closed'\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ToolComponent implements OnInit, OnChanges, OnDestroy {\n /** Lifecycle of the call, driving the header chip and border. @default 'pending' */\n @Input() state: ToolState = 'pending';\n /** Whether the call's input and output are expanded. @default false */\n @Input() open = false;\n /** Extra Tailwind classes merged onto the tool via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the call is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ToolService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n /** True while an `open` input is being pushed into the service. */\n private applyingInput = false;\n\n ngOnInit(): void {\n this.service.setOpen(this.open);\n this.service.setState(this.state);\n // `skip(1)` drops the BehaviorSubject's current value so mounting never\n // emits a spurious `openChange`.\n this.subscriptions.add(\n this.service.open$.pipe(skip(1)).subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n // Only user-driven toggles are worth reporting; echoing a change the\n // parent just made would fight a `[(open)]` binding.\n if (!this.applyingInput) this.openChange.emit(open);\n })\n );\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['open']) this.applyInput(this.open);\n this.service.setState(this.state);\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private applyInput(open: boolean): void {\n this.applyingInput = true;\n try {\n this.service.setOpen(open);\n } finally {\n this.applyingInput = false;\n }\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(toolVariants({ state: this.state }), this.class);\n }\n}\n\n/** Clickable header of a `tolle-tool`: the tool's name plus its state chip. */\n@Component({\n selector: 'tolle-tool-header',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-tool-header\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i [class]=\"icon\" class=\"shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n\n <span class=\"truncate font-medium text-foreground\" data-tool-name>{{ name }}</span>\n\n <span [class]=\"chipClass\" data-tool-state>\n <i [class]=\"stateIcon\" aria-hidden=\"true\"></i>\n {{ stateLabel }}\n </span>\n\n <i\n class=\"ri-arrow-down-s-line shrink-0 text-muted-foreground transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class ToolHeaderComponent 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 /** Name of the tool that was called. @default '' */\n @Input() name = '';\n /** Remixicon class shown before the tool name. @default 'ri-tools-line' */\n @Input() icon = 'ri-tools-line';\n /** Overrides the chip text; defaults to a label derived from the state. @default '' */\n @Input() stateLabelOverride = '';\n /** Extra Tailwind classes merged onto the header via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the state chip via `cn()` (last-wins). */\n @Input() chipClassName = '';\n\n private readonly service = inject(ToolService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n state: ToolState = 'pending';\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.state$.subscribe((state) => {\n this.state = state;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get stateIcon(): string {\n return TOOL_STATE_ICONS[this.state];\n }\n\n get stateLabel(): string {\n return this.stateLabelOverride || TOOL_STATE_LABELS[this.state];\n }\n\n get chipClass() {\n return cn(toolStateVariants({ state: this.state }), this.chipClassName);\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors',\n 'hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/**\n * Base for the two payload panels. Owns the shared \"visible only while the\n * tool is expanded\" wiring and the payload-to-text formatting.\n *\n * Decorated with a selector-less `@Directive()` because it uses Angular\n * features (DI and lifecycle hooks) on behalf of its subclasses.\n */\n@Directive()\nexport abstract class ToolPayloadBase 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 subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Payload to render; objects are pretty-printed as JSON, strings are shown as-is. */\n payload: unknown = null;\n\n protected readonly service = inject(ToolService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** True once there is a payload worth rendering. */\n get hasPayload(): boolean {\n return this.payload !== null && this.payload !== undefined && this.payload !== '';\n }\n\n /** The payload as display text. Falls back to `String()` for cyclic values. */\n get formatted(): string {\n if (typeof this.payload === 'string') return this.payload;\n try {\n return JSON.stringify(this.payload, null, 2) ?? String(this.payload);\n } catch {\n return String(this.payload);\n }\n }\n}\n\n/** Arguments the tool was called with. Visible while the `tolle-tool` is expanded. */\n@Component({\n selector: 'tolle-tool-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-tool-input [class]=\"computedClass\">\n <p *ngIf=\"label\" class=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {{ label }}\n </p>\n\n <pre\n *ngIf=\"hasPayload; else projected\"\n class=\"max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground\"\n >{{ formatted }}</pre>\n\n <ng-template #projected>\n <div class=\"max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground\">\n <ng-content></ng-content>\n </div>\n </ng-template>\n </div>\n `,\n})\nexport class ToolInputComponent extends ToolPayloadBase {\n /** Arguments passed to the tool; objects are pretty-printed as JSON. @default null */\n @Input() override payload: unknown = null;\n /** Heading above the payload. @default 'Parameters' */\n @Input() label = 'Parameters';\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-col gap-1.5 border-t border-border px-3 py-2', this.class);\n }\n}\n\n/** Result the tool returned. Visible while the `tolle-tool` is expanded. */\n@Component({\n selector: 'tolle-tool-output',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-tool-output [class]=\"computedClass\">\n <p *ngIf=\"label\" class=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {{ label }}\n </p>\n\n <pre *ngIf=\"hasPayload; else projected\" [class]=\"payloadClass\">{{ formatted }}</pre>\n\n <ng-template #projected>\n <div [class]=\"payloadClass\">\n <ng-content></ng-content>\n </div>\n </ng-template>\n </div>\n `,\n})\nexport class ToolOutputComponent extends ToolPayloadBase {\n /** Result returned by the tool; objects are pretty-printed as JSON. @default null */\n @Input() override payload: unknown = null;\n /** Heading above the payload. @default 'Result' */\n @Input() label = 'Result';\n /** Styles the payload as a failure. @default false */\n @Input() error = false;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n get payloadClass() {\n return cn(\n 'max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground',\n this.error && 'bg-destructive/10 text-destructive'\n );\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1.5 border-t border-border px-3 py-2', this.class);\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "typography",
3
+ "type": "registry:ui",
4
+ "title": "Typography",
5
+ "description": "Typography component with variant options.",
6
+ "category": "layout",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "typography.component.ts",
14
+ "target": "ui/typography.component.ts",
15
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst typographyVariants = cva('', {\n variants: {\n variant: {\n h1: 'scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl',\n h2: 'scroll-m-20 border-b border-border pb-2 text-3xl font-semibold tracking-tight first:mt-0',\n h3: 'scroll-m-20 text-2xl font-semibold tracking-tight',\n h4: 'scroll-m-20 text-xl font-semibold tracking-tight',\n p: 'leading-7 [&:not(:first-child)]:mt-6',\n blockquote: 'mt-6 border-l-2 border-border pl-6 italic',\n lead: 'text-xl text-muted-foreground',\n large: 'text-lg font-semibold',\n small: 'text-sm font-medium leading-none',\n muted: 'text-sm text-muted-foreground',\n code: 'relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold',\n },\n },\n defaultVariants: { variant: 'p' },\n});\n\nexport type TypographyProps = VariantProps<typeof typographyVariants>;\n\n/**\n * Applies the theme's type scale to projected content. Renders the semantically\n * correct host tag for the chosen `variant` (`h1`–`h4`, `blockquote`, `code`,\n * otherwise `p`), so headings stay real headings for assistive tech.\n * @new\n */\n@Component({\n selector: 'tolle-typography',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n // `<ng-content>` may only appear once — a duplicated slot per branch would leave\n // every branch but the first empty. Project once into a template and stamp that\n // template into whichever tag the variant selects.\n template: `\n <ng-template #body><ng-content></ng-content></ng-template>\n\n <h1 *ngIf=\"variant === 'h1'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></h1>\n <h2 *ngIf=\"variant === 'h2'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></h2>\n <h3 *ngIf=\"variant === 'h3'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></h3>\n <h4 *ngIf=\"variant === 'h4'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></h4>\n <blockquote *ngIf=\"variant === 'blockquote'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></blockquote>\n <code *ngIf=\"variant === 'code'\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></code>\n <p *ngIf=\"isParagraph\" [class]=\"computedClass\"><ng-container *ngTemplateOutlet=\"body\"></ng-container></p>\n `,\n})\nexport class TypographyComponent 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 /** Type scale step; also selects the rendered HTML tag. @default 'p' */\n @Input() variant: TypographyProps['variant'] = 'p';\n /** Extra Tailwind classes merged onto the element via `cn()` (last-wins). */\n @Input() class = '';\n\n /** True for the variants that have no dedicated tag and render as `<p>`. */\n get isParagraph() {\n return !['h1', 'h2', 'h3', 'h4', 'blockquote', 'code'].includes(this.variant ?? 'p');\n }\n\n get computedClass() {\n return cn(typographyVariants({ variant: this.variant }), this.class);\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
+ }