@tolle_/tolle-ui 21.1.1 → 21.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/fesm2022/tolle-ui.mjs +30099 -5285
  2. package/fesm2022/tolle-ui.mjs.map +1 -1
  3. package/package.json +17 -5
  4. package/preset.js +35 -1
  5. package/registry/docs-content.json +11486 -0
  6. package/registry/llms-full.txt +4067 -0
  7. package/registry/llms.txt +127 -0
  8. package/registry/manifest.json +12656 -0
  9. package/registry/r/accordion-item.json +21 -0
  10. package/registry/r/accordion.json +23 -0
  11. package/registry/r/alert-dialog-dynamic.json +31 -0
  12. package/registry/r/alert-dialog.json +26 -0
  13. package/registry/r/alert.json +23 -0
  14. package/registry/r/aspect-ratio.json +21 -0
  15. package/registry/r/attachment.json +23 -0
  16. package/registry/r/avatar-fallback.json +16 -0
  17. package/registry/r/avatar.json +23 -0
  18. package/registry/r/badge.json +23 -0
  19. package/registry/r/bar-list.json +21 -0
  20. package/registry/r/breadcrumb-item.json +16 -0
  21. package/registry/r/breadcrumb-link.json +16 -0
  22. package/registry/r/breadcrumb-separator.json +16 -0
  23. package/registry/r/breadcrumb.json +21 -0
  24. package/registry/r/bubble.json +23 -0
  25. package/registry/r/button-group.json +21 -0
  26. package/registry/r/button.json +23 -0
  27. package/registry/r/calendar.json +23 -0
  28. package/registry/r/card.json +21 -0
  29. package/registry/r/carousel.json +18 -0
  30. package/registry/r/category-bar.json +21 -0
  31. package/registry/r/chain-of-thought.json +23 -0
  32. package/registry/r/chart-pie.json +30 -0
  33. package/registry/r/chart-spark.json +30 -0
  34. package/registry/r/chart.json +28 -0
  35. package/registry/r/checkbox.json +23 -0
  36. package/registry/r/checkpoint.json +23 -0
  37. package/registry/r/collapsible.json +21 -0
  38. package/registry/r/combobox.json +31 -0
  39. package/registry/r/command-dialog.json +21 -0
  40. package/registry/r/command.json +26 -0
  41. package/registry/r/confirmation.json +23 -0
  42. package/registry/r/context-menu-trigger.json +23 -0
  43. package/registry/r/context-menu.json +28 -0
  44. package/registry/r/context.json +26 -0
  45. package/registry/r/conversation.json +23 -0
  46. package/registry/r/country-selector.json +31 -0
  47. package/registry/r/data-table.json +38 -0
  48. package/registry/r/date-picker.json +27 -0
  49. package/registry/r/date-range-picker.json +32 -0
  50. package/registry/r/date-time-picker.json +28 -0
  51. package/registry/r/direction.json +16 -0
  52. package/registry/r/dropdown-item.json +21 -0
  53. package/registry/r/dropdown-label.json +16 -0
  54. package/registry/r/dropdown-menu.json +16 -0
  55. package/registry/r/dropdown-separator.json +16 -0
  56. package/registry/r/dropdown-trigger.json +20 -0
  57. package/registry/r/empty-state.json +23 -0
  58. package/registry/r/field.json +23 -0
  59. package/registry/r/hover-card.json +23 -0
  60. package/registry/r/inline-citation.json +26 -0
  61. package/registry/r/input-group.json +23 -0
  62. package/registry/r/input.json +21 -0
  63. package/registry/r/item.json +23 -0
  64. package/registry/r/kbd.json +23 -0
  65. package/registry/r/label.json +21 -0
  66. package/registry/r/marker.json +23 -0
  67. package/registry/r/masked-input.json +21 -0
  68. package/registry/r/menubar.json +24 -0
  69. package/registry/r/message-scroller.json +26 -0
  70. package/registry/r/message.json +23 -0
  71. package/registry/r/modal.json +36 -0
  72. package/registry/r/model-selector.json +32 -0
  73. package/registry/r/multi-select.json +33 -0
  74. package/registry/r/native-select.json +23 -0
  75. package/registry/r/navigation-menu.json +24 -0
  76. package/registry/r/otp-group.json +16 -0
  77. package/registry/r/otp-slot.json +21 -0
  78. package/registry/r/otp.json +21 -0
  79. package/registry/r/pagination.json +33 -0
  80. package/registry/r/phone-number-input.json +34 -0
  81. package/registry/r/plan.json +23 -0
  82. package/registry/r/popover-content.json +21 -0
  83. package/registry/r/popover.json +18 -0
  84. package/registry/r/progress-circle.json +21 -0
  85. package/registry/r/progress.json +21 -0
  86. package/registry/r/prompt-input.json +23 -0
  87. package/registry/r/queue.json +23 -0
  88. package/registry/r/radio-group.json +26 -0
  89. package/registry/r/radio-item.json +26 -0
  90. package/registry/r/range-calendar.json +28 -0
  91. package/registry/r/reasoning.json +23 -0
  92. package/registry/r/resizable-panel-item.json +23 -0
  93. package/registry/r/resizable-panel.json +23 -0
  94. package/registry/r/resizable.json +21 -0
  95. package/registry/r/scroll-area.json +21 -0
  96. package/registry/r/segment.json +21 -0
  97. package/registry/r/select-group.json +21 -0
  98. package/registry/r/select-item.json +26 -0
  99. package/registry/r/select-separator.json +21 -0
  100. package/registry/r/select.json +32 -0
  101. package/registry/r/separator.json +21 -0
  102. package/registry/r/sheet-wrapper.json +31 -0
  103. package/registry/r/sheet.json +21 -0
  104. package/registry/r/shimmer.json +23 -0
  105. package/registry/r/sidebar.json +21 -0
  106. package/registry/r/skeleton.json +23 -0
  107. package/registry/r/slider.json +21 -0
  108. package/registry/r/sources.json +23 -0
  109. package/registry/r/spinner.json +23 -0
  110. package/registry/r/suggestion.json +23 -0
  111. package/registry/r/switch.json +23 -0
  112. package/registry/r/table.json +21 -0
  113. package/registry/r/tabs.json +23 -0
  114. package/registry/r/tag-input.json +25 -0
  115. package/registry/r/task.json +23 -0
  116. package/registry/r/textarea.json +21 -0
  117. package/registry/r/time-picker.json +24 -0
  118. package/registry/r/toaster.json +26 -0
  119. package/registry/r/toggle-group.json +25 -0
  120. package/registry/r/toggle.json +23 -0
  121. package/registry/r/tolle-cell.json +16 -0
  122. package/registry/r/tool.json +23 -0
  123. package/registry/r/tooltip.json +23 -0
  124. package/registry/r/tracker.json +25 -0
  125. package/registry/r/typography.json +23 -0
  126. package/registry/registry.json +2545 -0
  127. package/theme.css +230 -10
  128. package/types/tolle-ui.d.ts +7433 -823
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "segment",
3
+ "type": "registry:ui",
4
+ "title": "Segment",
5
+ "description": "Segment component.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "segment.component.ts",
12
+ "target": "ui/segment.component.ts",
13
+ "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n};\n\n@Component({\n selector: 'tolle-segment',\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentComponent),\n multi: true,\n },\n ],\n template: `\n <div\n #container\n [class]=\"\n cn('relative flex w-full select-none items-center gap-1 rounded-lg bg-muted p-1', class)\n \"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\">\n <div\n class=\"absolute bottom-1 top-1 rounded-md bg-primary shadow-sm transition-all duration-300 ease-tolle\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"></div>\n\n @for (item of items; track item) {\n <button\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"\n cn(\n 'relative z-10 flex-1 rounded-md px-3 py-1.5 text-center text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'cursor-not-allowed opacity-50',\n item.class\n )\n \">\n @if (itemTemplate) {\n <ng-container\n *ngTemplateOutlet=\"\n itemTemplate;\n context: { $implicit: item, selected: value === item.value }\n \">\n </ng-container>\n } @else {\n @if (item.icon) {\n <i [class]=\"item.icon\"></i>\n }\n <span class=\"truncate\">{{ item.label }}</span>\n }\n </button>\n }\n </div>\n `,\n styles: [\n `\n :host {\n display: block;\n }\n `,\n ],\n})\nexport class SegmentComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n\n/**\n * Back-compat alias: the class was renamed to match its `tolle-segment` selector.\n *\n * A re-export binding rather than `const SegmentedComponent = SegmentComponent`,\n * because Angular's AOT compiler resolves an aliased export straight back to the\n * class declaration — so it still works in a consumer's `imports: []` array.\n *\n * @deprecated Use `SegmentComponent`. This alias will be removed in a future major.\n */\nexport { SegmentComponent as SegmentedComponent };\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "select-group",
3
+ "type": "registry:ui",
4
+ "title": "Select Group",
5
+ "description": "Select Group component.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "select-group.component.ts",
12
+ "target": "ui/select-group.component.ts",
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "select-item",
3
+ "type": "registry:ui",
4
+ "title": "Select Item",
5
+ "description": "Select Item component.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "select-item.component.ts",
12
+ "target": "ui/select-item.component.ts",
13
+ "content": "import { Component, Input, HostListener, ElementRef, Optional } from '@angular/core';\n\nimport { cn } from './utils/cn';\nimport { SelectService } from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n @if (!hidden) {\n <div\n [id]=\"id\"\n [class]=\"\n cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\n \"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\">\n <!-- Single Select: Checkmark -->\n @if (!multiSelect && selected) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n }\n <!-- Multi-Select: Checkbox -->\n @if (multiSelect) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div\n [class]=\"\n cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\n \">\n <i\n [class]=\"\n cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'scale-100 opacity-100' : 'scale-75 opacity-0'\n )\n \"></i>\n </div>\n </span>\n }\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n <!-- Disabled indicator -->\n @if (disabled && !selected) {\n <span class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n }\n </div>\n }\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && ['cursor-pointer', 'hover:bg-accent hover:text-accent-foreground'],\n\n // Selected state\n this.selected && [\n this.multiSelect ? 'bg-primary/5 text-foreground' : 'bg-accent text-accent-foreground',\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground',\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
14
+ },
15
+ {
16
+ "path": "select.service.ts",
17
+ "target": "ui/select.service.ts",
18
+ "content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "utils/cn.ts",
22
+ "target": "ui/utils/cn.ts",
23
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "select-separator",
3
+ "type": "registry:ui",
4
+ "title": "Select Separator",
5
+ "description": "Select Separator component.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "select-separator.component.ts",
12
+ "target": "ui/select-separator.component.ts",
13
+ "content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "select",
3
+ "type": "registry:ui",
4
+ "title": "Select",
5
+ "description": "Select component with size and disabled and readonly options.",
6
+ "category": "forms",
7
+ "registryDependencies": [
8
+ "input",
9
+ "select-item"
10
+ ],
11
+ "dependencies": [
12
+ "@floating-ui/dom",
13
+ "class-variance-authority"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "select.component.ts",
18
+ "target": "ui/select.component.ts",
19
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n OnDestroy,\n ContentChildren,\n QueryList,\n AfterContentInit,\n OnChanges,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { SelectItemComponent } from './select-item.component';\nimport { Subscription } from 'rxjs';\nimport { SelectService } from './select.service';\nimport { InputComponent } from './input.component';\n\nconst selectTriggerVariants = cva(\n 'flex w-full items-center justify-between rounded-md border border-input bg-background text-foreground shadow-sm transition-all duration-200',\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 disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n readonly: {\n true: 'cursor-default border-dashed',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n readonly: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // Readonly still takes focus, but without the emphasis ring.\n {\n disabled: false,\n readonly: true,\n class: 'focus-visible:border-opacity-100 focus-visible:ring-0',\n },\n ],\n defaultVariants: { size: 'default', disabled: false, readonly: false },\n }\n);\n\nconst selectIconVariants = cva(\n 'ri-arrow-down-s-line ml-2 text-muted-foreground transition-transform duration-200',\n {\n variants: {\n size: {\n xs: 'text-[14px]',\n sm: 'text-[14px]',\n default: 'text-[18px]',\n lg: 'text-[18px]',\n },\n open: { true: 'rotate-180', false: '' },\n muted: { true: 'opacity-30', false: '' },\n },\n defaultVariants: { size: 'default', open: false, muted: false },\n }\n);\n\nexport type SelectProps = VariantProps<typeof selectTriggerVariants>;\n\n/**\n * A single-select listbox: a trigger that opens a floating panel of\n * `tolle-select-item` options, with optional inline search. Implements\n * `ControlValueAccessor`, so it works with `ngModel` and reactive forms.\n */\n@Component({\n selector: 'tolle-select',\n imports: [FormsModule, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SelectComponent),\n multi: true,\n },\n ],\n styles: [':host { display: block; }', '.hidden-dropdown { display: none !important; }'],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n type=\"button\"\n #trigger\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n <span class=\"truncate\" [class.text-muted-foreground]=\"!selectedLabel\">\n {{ selectedLabel || placeholder }}\n </span>\n <i [class]=\"iconClass\"></i>\n </button>\n\n <div\n #popover\n [class.hidden-dropdown]=\"!isOpen\"\n class=\"fixed z-[999] flex flex-col overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n @if (searchable) {\n <div class=\"h-auto border-b border-border bg-popover p-2\">\n <tolle-input\n #searchInput\n size=\"xs\"\n placeholder=\"Search...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"onSearchChange($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n }\n\n <div role=\"listbox\" class=\"h-full w-full grow overflow-y-auto p-1\">\n <ng-content></ng-content>\n @if (noResults) {\n <div class=\"py-6 text-center text-sm text-muted-foreground\">No results found.</div>\n }\n </div>\n </div>\n </div>\n `,\n})\nexport class SelectComponent\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy\n{\n /** Text shown on the trigger when nothing is selected. @default 'Select an option' */\n @Input() placeholder = 'Select an option';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: SelectProps['size'] = 'default';\n /** Shows the current value but blocks opening the panel. @default false */\n @Input() readonly = false;\n\n /** Emitted with the chosen value whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('container') container!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true })\n items!: QueryList<SelectItemComponent>;\n\n private sub = new Subscription();\n private itemsChangeSub?: Subscription;\n private pendingValue: any = undefined;\n\n searchQuery = '';\n noResults = false;\n isOpen = false;\n value: any = null;\n selectedLabel = '';\n cleanupAutoUpdate?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n protected cn = cn;\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n this.value = val;\n this.onChange(val);\n this.valueChange.emit(val);\n this.updateItemSelection();\n this.cdr.markForCheck();\n })\n );\n\n this.sub.add(\n this.selectService.selectedLabel$.subscribe(label => {\n this.selectedLabel = label;\n this.close();\n this.cdr.markForCheck();\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n selectTriggerVariants({\n size: this.size,\n disabled: this.disabled,\n readonly: this.readonly,\n }),\n this.class\n );\n }\n\n get iconClass() {\n return cn(\n selectIconVariants({\n size: this.size,\n open: this.isOpen,\n muted: this.disabled || this.readonly,\n })\n );\n }\n\n /**\n * Under OnPush a `[class]` binding is applied through Angular's *styling*\n * path, which writes the input but — unlike a plain property binding — never\n * marks the component dirty. Without this the trigger would keep rendering\n * the class it was born with. Covering every input here is cheap and leaves\n * no second instance of that trap.\n */\n ngOnChanges() {\n this.cdr.markForCheck();\n }\n\n ngAfterContentInit() {\n // Subscribe to items changes to handle dynamic content\n this.itemsChangeSub = this.items.changes.subscribe(() => {\n this.updateItemSelection();\n this.applyPendingValue();\n this.cdr.markForCheck();\n });\n\n // Apply initial selection if items are already available\n this.updateItemSelection();\n this.applyPendingValue();\n }\n\n private applyPendingValue(): void {\n if (this.pendingValue !== undefined && this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === this.pendingValue);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n this.pendingValue = undefined;\n }\n }\n\n private updateItemSelection() {\n if (this.items) {\n this.items.forEach(item => {\n item.selected = item.value === this.value;\n });\n }\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (\n !this.trigger.nativeElement.contains(event.target) &&\n !this.popover?.nativeElement.contains(event.target)\n ) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled || this.readonly) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n const wasOpen = this.isOpen;\n this.isOpen = true;\n this.trigger.nativeElement.focus();\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // Match the panel to the trigger width and cap its height to the\n // space actually available, so the list scrolls instead of\n // overflowing.\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: rects.reference.width + 'px',\n maxHeight: availableHeight + 'px',\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n onSearchChange(query: string) {\n const filter = (query || '').toLowerCase().trim();\n let visibleCount = 0;\n\n this.items.forEach(item => {\n const text = item.getLabel().toLowerCase();\n const isVisible = text.includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n this.cdr.markForCheck();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? (list[this.activeIndex] ?? null) : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n this.cdr.markForCheck();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next =\n this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => i.value === this.value);\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== ' ' &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey\n );\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Single-select: notify parent (which closes and syncs the label).\n this.selectService.registerClick(item.value, item.getLabel());\n this.trigger.nativeElement.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (\n key === 'ArrowDown' ||\n key === 'ArrowUp' ||\n key === 'Enter' ||\n key === ' ' ||\n key === 'Home' ||\n key === 'End'\n ) {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(-1);\n break;\n case 'Home':\n event.preventDefault();\n this.setActive(0);\n break;\n case 'End':\n event.preventDefault();\n this.setActive(this.navigableItems.length - 1);\n break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(-1);\n break;\n case 'Enter':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.updateItemSelection();\n if (this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === value);\n if (found) {\n this.selectedLabel = found.getLabel();\n }\n } else {\n // Queue the value for when items become available\n this.pendingValue = value;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n this.itemsChangeSub?.unsubscribe();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n}\n"
20
+ },
21
+ {
22
+ "path": "select.service.ts",
23
+ "target": "ui/select.service.ts",
24
+ "content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
25
+ },
26
+ {
27
+ "path": "utils/cn.ts",
28
+ "target": "ui/utils/cn.ts",
29
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "separator",
3
+ "type": "registry:ui",
4
+ "title": "Separator",
5
+ "description": "Separator component.",
6
+ "category": "layout",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "separator.component.ts",
12
+ "target": "ui/separator.component.ts",
13
+ "content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-separator',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"decorative ? 'none' : 'separator'\"\n [attr.aria-hidden]=\"decorative ? 'true' : null\"\n [attr.aria-orientation]=\"!decorative && orientation === 'vertical' ? 'vertical' : null\"\n [attr.data-orientation]=\"orientation\"></div>\n `,\n})\nexport class SeparatorComponent {\n /** Orientation of the rule. @default 'horizontal' */\n @Input() orientation: 'horizontal' | 'vertical' = 'horizontal';\n /** When true, the separator is purely visual (aria-hidden, role=\"none\"). @default true */\n @Input() decorative: boolean = true;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n 'shrink-0 bg-border',\n this.orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',\n this.class\n );\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "sheet-wrapper",
3
+ "type": "registry:ui",
4
+ "title": "Sheet Wrapper",
5
+ "description": "Sheet Wrapper component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "sheet-ref.ts",
12
+ "target": "ui/sheet-ref.ts",
13
+ "content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { Subject } from 'rxjs';\nimport { SheetConfig } from './sheet.types';\n\nexport class SheetRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public config: SheetConfig\n ) {\n if (this.config.hasBackdrop && this.config.backdropClose !== false) {\n this.overlay.backdropClick().subscribe(() => {\n this.close();\n });\n }\n\n // Escape-to-close, unless this is a blocking sheet (backdropClose === false).\n this.overlay.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.config.backdropClose !== false) {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n /**\n * Closes the sheet and allows for the exit animation.\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n // Notify the wrapper component to trigger exit animation if needed\n // In our service implementation, the wrapper handles [attr.data-state]\n // We just need to give it time to animate out before disposing\n\n // We'll update the state to 'closed' which triggers CSS animations\n // The service handles the actual disposal after a timeout\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n }\n\n /**\n * Internal use only: Disposes the overlay\n */\n dispose(): void {\n this.overlay.dispose();\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "sheet-wrapper.component.ts",
17
+ "target": "ui/sheet-wrapper.component.ts",
18
+ "content": "import { Component, OnInit, TemplateRef, Type, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { SheetRef } from './sheet-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet-wrapper',\n imports: [CommonModule, A11yModule],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.config.title ? titleId : null\"\n [attr.aria-describedby]=\"ref.config.description ? descId : null\"\n cdkTrapFocus\n cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <!-- Header -->\n @if (ref.config.showCloseButton !== false || ref.config.title) {\n <div class=\"mb-4 flex flex-col space-y-2\">\n <div class=\"flex items-center justify-between\">\n @if (ref.config.title) {\n <h3 [id]=\"titleId\" class=\"text-lg font-semibold tracking-tight text-foreground\">\n {{ ref.config.title }}\n </h3>\n }\n @if (ref.config.showCloseButton !== false) {\n <button\n (click)=\"close()\"\n class=\"ml-auto rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\">\n <i class=\"ri-close-line text-2xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n }\n </div>\n @if (ref.config.description) {\n <p [id]=\"descId\" class=\"text-sm text-muted-foreground\">\n {{ ref.config.description }}\n </p>\n }\n </div>\n }\n\n <!-- Content -->\n <div class=\"grow overflow-y-auto\">\n @switch (contentType) {\n @case ('string') {\n <div>{{ content }}</div>\n }\n @case ('template') {\n <ng-container\n *ngTemplateOutlet=\"asTemplate; context: ref.config.context\"></ng-container>\n }\n @case ('component') {\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n }\n }\n </div>\n </div>\n `,\n styles: [\n `\n :host {\n display: contents;\n }\n `,\n ],\n})\nexport class SheetWrapperComponent implements OnInit {\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n isOpen = false;\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-sheet-title-${this._uid}`;\n readonly descId = `tolle-sheet-description-${this._uid}`;\n\n public ref = inject(SheetRef);\n\n ngOnInit() {\n this.content = this.ref.config.content;\n this.determineContentType();\n\n // Trigger entry animation\n setTimeout(() => {\n this.isOpen = true;\n }, 0);\n\n // Listen for close requests\n this.ref.afterClosed$.subscribe(() => {\n this.isOpen = false;\n });\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() {\n return this.content as TemplateRef<any>;\n }\n get asComponent() {\n return this.content as Type<any>;\n }\n\n close() {\n this.ref.close();\n }\n\n get computedClass() {\n const side = this.ref.config.side || 'right';\n const sideClasses = {\n top: 'inset-x-0 top-0 border-b slide-in-from-top data-[state=closed]:slide-out-to-top',\n bottom:\n 'inset-x-0 bottom-0 border-t slide-in-from-bottom data-[state=closed]:slide-out-to-bottom',\n left: 'inset-y-0 left-0 h-full w-3/4 border-r slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm',\n right:\n 'inset-y-0 right-0 h-full w-3/4 border-l slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm',\n };\n\n const roundingClasses = {\n top: 'rounded-b-2xl',\n bottom: 'rounded-t-2xl',\n left: 'rounded-r-2xl',\n right: 'rounded-l-2xl',\n };\n\n return cn(\n 'fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out duration-300',\n sideClasses[side],\n this.ref.config.rounded && roundingClasses[side],\n this.ref.config.class\n );\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "sheet.types.ts",
22
+ "target": "ui/sheet.types.ts",
23
+ "content": "import { TemplateRef, Type } from \"@angular/core\";\n\nexport type SheetSide = 'top' | 'bottom' | 'left' | 'right';\n\nexport interface SheetConfig<T = any> {\n /** The content to display (String, Component, or Template) */\n content: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** Optional description for the standard header */\n description?: string;\n\n /** The edge of the screen where the component will appear. Defaults to 'right' */\n side?: SheetSide;\n\n /** Whether the sheet has a backdrop. Defaults to true */\n hasBackdrop?: boolean;\n\n /** If true, clicking the backdrop closes the sheet. Defaults to true */\n backdropClose?: boolean;\n\n /** Whether to show the close button. Defaults to true */\n showCloseButton?: boolean;\n\n /** Whether the sheet content has rounded corners on its inner side. Defaults to false */\n rounded?: boolean;\n\n /** Data to pass to a Component content */\n data?: { [key: string]: any };\n\n /** Context to pass to a TemplateRef content */\n context?: T;\n\n /** Additional CSS classes for the sheet content */\n class?: string;\n}\n"
24
+ },
25
+ {
26
+ "path": "utils/cn.ts",
27
+ "target": "ui/utils/cn.ts",
28
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "sheet",
3
+ "type": "registry:ui",
4
+ "title": "Sheet",
5
+ "description": "Sheet component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "sheet.component.ts",
12
+ "target": "ui/sheet.component.ts",
13
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n inject,\n TemplateRef,\n ViewChild,\n ViewContainerRef,\n OnDestroy,\n ContentChild,\n forwardRef,\n ElementRef,\n AfterContentInit,\n} from '@angular/core';\n\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Pending teardown scheduled by `hide()` for the exit animation. */\n private disposeTimer?: ReturnType<typeof setTimeout>;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n // Re-opening inside the 300ms exit window: cancel the pending teardown so the\n // still-attached overlay is reused. Without this the timer disposes the overlay\n // after `show()` has already bailed out, leaving isOpen=true with no overlay —\n // and every later open() short-circuits on the guard above.\n this.cancelPendingDispose();\n this.show();\n }\n\n private cancelPendingDispose() {\n if (this.disposeTimer !== undefined) {\n clearTimeout(this.disposeTimer);\n this.disposeTimer = undefined;\n }\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(), // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n this.cancelPendingDispose();\n this.disposeTimer = setTimeout(() => {\n this.disposeTimer = undefined;\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n this.cancelPendingDispose();\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n class: 'inline-block',\n },\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n styles: [':host { display: block; }'],\n imports: [A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus\n cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\">\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `,\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent))\n descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: 'inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',\n bottom:\n 'inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',\n left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',\n right:\n 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',\n };\n\n const roundingClasses = {\n top: 'rounded-b-2xl',\n bottom: 'rounded-t-2xl',\n left: 'rounded-r-2xl',\n right: 'rounded-l-2xl',\n };\n\n return cn(\n 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300',\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SheetHeaderComponent {}\n\n@Component({\n selector: 'tolle-sheet-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SheetFooterComponent {}\n\n@Component({\n selector: 'tolle-sheet-title',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' },\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' },\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "shimmer",
3
+ "type": "registry:ui",
4
+ "title": "Shimmer",
5
+ "description": "Shimmer component with size and active options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "shimmer.component.ts",
14
+ "target": "ui/shimmer.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n Component,\n Input,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst shimmerVariants = cva('inline-block max-w-full align-baseline', {\n variants: {\n size: {\n xs: 'text-xs',\n sm: 'text-sm',\n default: 'text-base',\n lg: 'text-lg',\n xl: 'text-xl',\n },\n active: {\n // `.shimmer` is the animated gradient-sweep utility from theme.css.\n true: 'shimmer',\n false: 'text-muted-foreground',\n },\n },\n defaultVariants: { size: 'default', active: true },\n});\n\nexport type ShimmerProps = VariantProps<typeof shimmerVariants>;\n\n/**\n * Shimmering text for a live, in-progress status — \"Thinking…\", \"Generating\n * response…\". While `active` the projected text sweeps with the `.shimmer`\n * utility; when it is false the same text renders plain and static, so the\n * label can stay mounted as the state settles.\n *\n * ```html\n * <tolle-shimmer [active]=\"isStreaming\" size=\"sm\">Generating response…</tolle-shimmer>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-shimmer',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span\n [attr.role]=\"active ? 'status' : null\"\n [attr.aria-live]=\"active ? 'polite' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\">\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ShimmerComponent 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 /** Runs the shimmer animation; false renders plain static text. @default true */\n @Input() active = true;\n /** Text size of the label. @default 'default' */\n @Input() size: ShimmerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(shimmerVariants({ size: this.size, active: this.active }), 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,21 @@
1
+ {
2
+ "name": "sidebar",
3
+ "type": "registry:ui",
4
+ "title": "Sidebar",
5
+ "description": "Sidebar component.",
6
+ "category": "layout",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "sidebar.component.ts",
12
+ "target": "ui/sidebar.component.ts",
13
+ "content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\n\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n /** Short trailing pill, e.g. 'New' or 'Beta'. Hidden while the sidebar is collapsed. */\n badge?: string;\n};\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n};\n\n@Component({\n selector: 'tolle-sidebar',\n imports: [RouterModule],\n template: `\n <aside\n [class]=\"\n cn(\n 'flex h-full shrink-0 flex-col overflow-hidden bg-background transition-[width] duration-300 ease-in-out',\n collapsed ? 'w-16' : 'w-64',\n class\n )\n \">\n <div class=\"flex h-14 shrink-0 items-center overflow-hidden whitespace-nowrap px-3\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-4\">\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n <div\n [class]=\"\n cn(\n 'mb-2 overflow-hidden px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200',\n collapsed ? 'mb-0 h-0 px-0 opacity-0' : 'h-auto opacity-100'\n )\n \">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"\n cn(\n 'group relative flex w-full items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i)\n ? 'bg-accent/50 text-foreground'\n : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\n \">\n <i\n aria-hidden=\"true\"\n [class]=\"\n cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 shrink-0 text-lg transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\n \"></i>\n\n <span\n [class]=\"\n cn(\n 'truncate transition-all duration-300',\n collapsed ? 'ml-0 w-0 opacity-0' : 'ml-3 w-auto opacity-100'\n )\n \">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i\n [class]=\"\n cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\n \"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div\n [class]=\"\n cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n )\n \">\n <div class=\"overflow-hidden\">\n <div class=\"ml-4 mt-1 flex flex-col gap-1 border-l border-border/50 pl-2\">\n @for (\n subItem of item.items;\n track subItem.id || subItem.title || $index;\n let j = $index\n ) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"\n toggleChild(group, item, subItem, gIndex, i, j);\n $event.stopPropagation()\n \"\n [attr.aria-expanded]=\"\n isChildExpanded(group, item, subItem, gIndex, i, j)\n \"\n [class]=\"\n cn(\n 'group relative flex w-full items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j)\n ? 'bg-accent/30 text-foreground'\n : ''\n )\n \">\n <span class=\"h-1.5 w-1.5 shrink-0 rounded-full bg-border\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i\n [class]=\"\n cn(\n 'ri-arrow-right-s-line ml-auto text-xs transition-transform duration-300',\n isChildExpanded(group, item, subItem, gIndex, i, j)\n ? 'rotate-90'\n : ''\n )\n \"></i>\n </button>\n\n <div\n [class]=\"\n cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j)\n ? 'grid-rows-[1fr] opacity-100'\n : 'grid-rows-[0fr] opacity-0'\n )\n \">\n <div class=\"overflow-hidden\">\n <div\n class=\"ml-4 mt-1 flex flex-col gap-1 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <span\n class=\"h-1.5 w-1.5 shrink-0 rounded-full bg-border/70\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n } @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <span class=\"h-1.5 w-1.5 shrink-0 rounded-full bg-border\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n } @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"\n cn(\n 'group relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\n \">\n <i\n aria-hidden=\"true\"\n [class]=\"\n cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 shrink-0 text-lg transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\n \"></i>\n <span\n [class]=\"\n cn(\n 'truncate transition-all duration-300',\n collapsed ? 'ml-0 w-0 opacity-0' : 'ml-3 w-auto opacity-100'\n )\n \">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i\n aria-hidden=\"true\"\n class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n } @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"\n cn(\n 'group relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\n \">\n <i\n aria-hidden=\"true\"\n [class]=\"\n cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 shrink-0 text-lg transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\n \"></i>\n <span\n [class]=\"\n cn(\n 'truncate transition-all duration-300',\n collapsed ? 'ml-0 w-0 opacity-0' : 'ml-3 w-auto opacity-100'\n )\n \">\n {{ item.title }}\n </span>\n @if (item.badge && !collapsed) {\n <span\n class=\"ml-auto shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary\">\n {{ item.badge }}\n </span>\n }\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 overflow-hidden whitespace-nowrap p-3\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [\n `\n :host {\n display: block;\n height: 100%;\n }\n button:disabled {\n cursor: not-allowed;\n opacity: 0.6;\n }\n * {\n transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n }\n `,\n ],\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' &&\n 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' &&\n 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' &&\n 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(\n group: SidebarGroup,\n parent: SidebarItem,\n subItem: SidebarItem,\n gIndex: number,\n iIndex: number,\n sIndex: number\n ): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(\n group: SidebarGroup,\n parent: SidebarItem,\n subItem: SidebarItem,\n gIndex: number,\n iIndex: number,\n sIndex: number\n ) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(\n group: SidebarGroup,\n item: SidebarItem,\n gIndex: number,\n iIndex: number\n ): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(\n group: SidebarGroup,\n parent: SidebarItem,\n subItem: SidebarItem,\n gIndex: number,\n iIndex: number,\n sIndex: number\n ): boolean {\n return this.expandedChildren.has(\n this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex)\n );\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "skeleton",
3
+ "type": "registry:ui",
4
+ "title": "Skeleton",
5
+ "description": "Skeleton component with variant options.",
6
+ "category": "feedback",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "skeleton.component.ts",
14
+ "target": "ui/skeleton.component.ts",
15
+ "content": "import { Component, Input } from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n styles: [':host { display: block; }'],\n imports: [],\n template: ` <div [class]=\"computedClass\"></div> `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ 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
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "slider",
3
+ "type": "registry:ui",
4
+ "title": "Slider",
5
+ "description": "Slider component.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "slider.component.ts",
12
+ "target": "ui/slider.component.ts",
13
+ "content": "import { Component, Input, forwardRef, ElementRef, ViewChild, HostListener } from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-slider',\n imports: [],\n template: `\n <div\n #container\n [class]=\"\n cn(\n 'relative flex w-full cursor-pointer touch-none select-none items-center py-4',\n class,\n disabled ? 'pointer-events-none opacity-50' : ''\n )\n \"\n (pointerdown)=\"onPointerDown($event)\">\n <!-- Track -->\n <div class=\"relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20\">\n <div\n class=\"absolute h-full bg-primary transition-all duration-75\"\n [style.left.%]=\"rangeStart\"\n [style.width.%]=\"rangeWidth\"></div>\n </div>\n\n <!-- Thumbs -->\n @for (val of values; track val; let i = $index) {\n <div\n class=\"absolute z-10 block h-4 w-4 cursor-grab rounded-full border border-primary/50 bg-background shadow transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing\"\n [style.left.%]=\"getPercentage(val)\"\n [style.transform]=\"'translateX(-50%)'\"\n [attr.tabindex]=\"disabled ? null : 0\"\n [attr.role]=\"'slider'\"\n [attr.aria-valuenow]=\"val\"\n [attr.aria-valuemin]=\"min\"\n [attr.aria-valuemax]=\"max\"\n (keydown)=\"onKeyDown($event, i)\"\n (focus)=\"activeIndex = i\"></div>\n }\n </div>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SliderComponent),\n multi: true,\n },\n ],\n styles: [\n `\n :host {\n display: block;\n width: 100%;\n }\n `,\n ],\n})\nexport class SliderComponent implements ControlValueAccessor {\n @Input() min: number = 0;\n @Input() max: number = 100;\n @Input() step: number = 1;\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('container', { static: true }) container!: ElementRef<HTMLElement>;\n\n values: number[] = [0];\n activeIndex: number = -1;\n isDragging = false;\n cn = cn;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n get isRange() {\n return this.values.length > 1;\n }\n\n get rangeStart() {\n if (!this.isRange) return 0;\n const minVal = Math.min(...this.values);\n return this.getPercentage(minVal);\n }\n\n get rangeWidth() {\n if (!this.isRange) {\n return this.getPercentage(this.values[0]);\n }\n const minVal = Math.min(...this.values);\n const maxVal = Math.max(...this.values);\n return this.getPercentage(maxVal) - this.getPercentage(minVal);\n }\n\n getPercentage(value: number) {\n const p = ((value - this.min) / (this.max - this.min)) * 100;\n return Math.max(0, Math.min(100, p));\n }\n\n getValueFromPosition(clientX: number) {\n const rect = this.container.nativeElement.getBoundingClientRect();\n const percentage = (clientX - rect.left) / rect.width;\n let value = this.min + percentage * (this.max - this.min);\n\n // Snapping to step\n value = Math.round(value / this.step) * this.step;\n return Math.max(this.min, Math.min(this.max, value));\n }\n\n onPointerDown(event: PointerEvent) {\n if (this.disabled) return;\n\n this.isDragging = true;\n this.onTouched();\n this.container.nativeElement.setPointerCapture(event.pointerId);\n\n const newValue = this.getValueFromPosition(event.clientX);\n\n // Find closest thumb\n let closestIndex = 0;\n let minDistance = Math.abs(this.values[0] - newValue);\n\n for (let i = 1; i < this.values.length; i++) {\n const distance = Math.abs(this.values[i] - newValue);\n if (distance < minDistance) {\n minDistance = distance;\n closestIndex = i;\n }\n }\n\n this.activeIndex = closestIndex;\n this.updateValue(newValue, closestIndex);\n }\n\n @HostListener('pointermove', ['$event'])\n onPointerMove(event: PointerEvent) {\n if (!this.isDragging || this.activeIndex === -1) return;\n\n const newValue = this.getValueFromPosition(event.clientX);\n this.updateValue(newValue, this.activeIndex);\n }\n\n @HostListener('pointerup', ['$event'])\n onPointerUp(event: PointerEvent) {\n if (!this.isDragging) return;\n this.isDragging = false;\n this.container.nativeElement.releasePointerCapture(event.pointerId);\n }\n\n onKeyDown(event: KeyboardEvent, index: number) {\n if (this.disabled) return;\n\n let delta = 0;\n if (event.key === 'ArrowRight' || event.key === 'ArrowUp') delta = this.step;\n if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') delta = -this.step;\n\n if (delta !== 0) {\n event.preventDefault();\n const newValue = Math.max(this.min, Math.min(this.max, this.values[index] + delta));\n this.updateValue(newValue, index);\n }\n }\n\n private updateValue(value: number, index: number) {\n if (this.values[index] === value) return;\n\n const newValues = [...this.values];\n newValues[index] = value;\n\n if (this.isRange) {\n // Track the value we are dragging\n const draggingValue = newValues[index];\n\n // Sort\n newValues.sort((a, b) => a - b);\n\n // Find new index of the dragging value\n this.activeIndex = newValues.indexOf(draggingValue);\n }\n\n this.values = newValues;\n this.emitValue();\n }\n\n private emitValue() {\n const valueToEmit = this.isRange ? this.values : this.values[0];\n this.onChange(valueToEmit);\n }\n\n writeValue(value: any): void {\n if (value === null || value === undefined) {\n this.values = [this.min];\n } else if (Array.isArray(value)) {\n this.values = [...value].sort((a, b) => a - b);\n } else {\n this.values = [+value];\n }\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
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "sources",
3
+ "type": "registry:ui",
4
+ "title": "Sources",
5
+ "description": "Sources component with variant options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "sources.component.ts",
14
+ "target": "ui/sources.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n SimpleChanges,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet sourceId = 0;\n\n/**\n * Coordinates a `tolle-sources` with its trigger, content and sources.\n *\n * The trigger has to report how many sources were projected into a sibling\n * component, so every `tolle-source` registers here and the trigger renders\n * from the resulting count.\n *\n * Provided on `SourcesComponent`, so each citation list gets its own instance.\n */\n@Injectable()\nexport class SourcesService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the citation list is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly idsSource = new BehaviorSubject<string[]>([]);\n /** Emits the ids of the registered sources, in registration (DOM) order. */\n readonly ids$ = this.idsSource.asObservable();\n\n /** True while the citation list is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n /** How many sources are currently registered. */\n get count(): number {\n return this.idsSource.value.length;\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 register(id: string): void {\n if (this.idsSource.value.includes(id)) return;\n this.idsSource.next([...this.idsSource.value, id]);\n }\n\n unregister(id: string): void {\n this.idsSource.next(this.idsSource.value.filter(existing => existing !== id));\n }\n\n /** Zero-based position of a source, or -1 when it is not registered. */\n indexOf(id: string): number {\n return this.idsSource.value.indexOf(id);\n }\n}\n\nconst sourcesVariants = cva('flex w-full flex-col gap-2 text-sm', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-md border border-border bg-card p-3 text-card-foreground',\n muted: 'rounded-md bg-muted p-3',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\nexport type SourcesProps = VariantProps<typeof sourcesVariants>;\n\n/**\n * A collapsible list of the citations behind a response.\n *\n * Compose it from `tolle-sources-trigger`, `tolle-sources-content` and one\n * `tolle-source` per citation — the trigger counts the sources for you.\n * @new\n */\n@Component({\n selector: 'tolle-sources',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [SourcesService],\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 SourcesComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the citation list is expanded. @default false */\n @Input() open = false;\n /** Visual treatment of the list container. @default 'default' */\n @Input() variant: SourcesProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the list is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(SourcesService);\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 // `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\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(sourcesVariants({ variant: this.variant }), this.class);\n }\n}\n\n/** Toggle for a `tolle-sources`, labelled with how many sources there are. */\n@Component({\n selector: 'tolle-sources-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-sources-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\">\n <i class=\"ri-links-line\" aria-hidden=\"true\"></i>\n\n <span data-sources-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class SourcesTriggerComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Overrides the counted number of sources. @default null */\n @Input() count: number | null = null;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n registered = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe(open => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.ids$.subscribe(ids => {\n this.registered = ids.length;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** How many sources the trigger reports. */\n get total(): number {\n return this.count ?? this.registered;\n }\n\n get label(): string {\n return 'Used ' + this.total + (this.total === 1 ? ' source' : ' sources');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/**\n * Holds the `tolle-source` entries of a `tolle-sources`.\n *\n * Stays in the DOM while collapsed — hidden and `inert` rather than removed —\n * so the sources inside it stay registered and the trigger's count is right\n * before the list has ever been opened.\n */\n@Component({\n selector: 'tolle-sources-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n data-sources-content\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [attr.inert]=\"open ? null : ''\"\n [attr.aria-hidden]=\"open ? null : 'true'\"\n [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SourcesContentComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook 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(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe(open => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1.5', !this.open && 'hidden', this.class);\n }\n}\n\nconst sourceVariants = cva(\n 'group flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type SourceProps = VariantProps<typeof sourceVariants>;\n\n/** A single citation inside a `tolle-sources-content`. */\n@Component({\n selector: 'tolle-source',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n data-source\n [attr.href]=\"href || null\"\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n [class]=\"computedClass\">\n @if (favicon) {\n <img [attr.src]=\"favicon\" alt=\"\" aria-hidden=\"true\" class=\"size-4 shrink-0 rounded-sm\" />\n } @else {\n <span\n class=\"flex size-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground\"\n aria-hidden=\"true\"\n >{{ position }}</span\n >\n }\n\n <span class=\"truncate text-foreground\" data-source-title>{{ title || href }}</span>\n\n <i\n class=\"ri-external-link-line ml-auto shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100\"\n aria-hidden=\"true\"></i>\n\n <ng-content></ng-content>\n </a>\n `,\n})\nexport class SourceComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** URL the citation points at. @default '' */\n @Input() href = '';\n /** Human-readable title; falls back to the href when empty. @default '' */\n @Input() title = '';\n /** URL of a favicon shown instead of the numbered chip. @default '' */\n @Input() favicon = '';\n /** Overrides the auto-numbered position chip. @default null */\n @Input() index: number | null = null;\n /** Text scale of the citation. @default 'default' */\n @Input() size: SourceProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the citation via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n private readonly id = 'source-' + sourceId++;\n\n order = 0;\n\n ngOnInit(): void {\n this.service.register(this.id);\n this.subscriptions.add(\n this.service.ids$.subscribe(() => {\n this.order = this.service.indexOf(this.id);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n this.subscriptions.unsubscribe();\n }\n\n /** One-based number shown in the chip. */\n get position(): number {\n return this.index ?? this.order + 1;\n }\n\n get computedClass() {\n return cn(sourceVariants({ size: this.size }), 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": "spinner",
3
+ "type": "registry:ui",
4
+ "title": "Spinner",
5
+ "description": "Spinner component with size and variant options.",
6
+ "category": "feedback",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "spinner.component.ts",
14
+ "target": "ui/spinner.component.ts",
15
+ "content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst spinnerVariants = cva(\n 'inline-block animate-spin rounded-full border-solid border-current border-r-transparent align-[-0.125em]',\n {\n variants: {\n size: {\n xs: 'h-3 w-3 border-[1.5px]',\n sm: 'h-4 w-4 border-2',\n default: 'h-5 w-5 border-2',\n lg: 'h-8 w-8 border-[3px]',\n xl: 'h-12 w-12 border-4',\n },\n variant: {\n default: 'text-foreground',\n primary: 'text-primary',\n muted: 'text-muted-foreground',\n destructive: 'text-destructive',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n }\n);\n\nexport type SpinnerProps = VariantProps<typeof spinnerVariants>;\n\n/**\n * An indeterminate loading indicator. Renders as `role=\"status\"` with a\n * visually-hidden label so screen readers announce the loading state.\n * @new\n */\n@Component({\n selector: 'tolle-spinner',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span role=\"status\" [attr.aria-label]=\"label\" [class]=\"computedClass\">\n <span class=\"sr-only\">{{ label }}</span>\n </span>\n `,\n})\nexport class SpinnerComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Diameter of the spinner. @default 'default' */\n @Input() size: SpinnerProps['size'] = 'default';\n /** Colour of the spinner, inherited from the theme tokens. @default 'default' */\n @Input() variant: SpinnerProps['variant'] = 'default';\n /** Accessible label announced while the spinner is visible. @default 'Loading' */\n @Input() label = 'Loading';\n /** Extra Tailwind classes merged onto the spinner via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(spinnerVariants({ size: this.size, 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
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "suggestion",
3
+ "type": "registry:ui",
4
+ "title": "Suggestion",
5
+ "description": "Suggestion component with variant and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "suggestion.component.ts",
14
+ "target": "ui/suggestion.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n Component,\n EventEmitter,\n Injectable,\n Input,\n OnDestroy,\n OnInit,\n Output,\n inject,\n ChangeDetectorRef,\n OnChanges,\n} from '@angular/core';\n\nimport { Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Relays a chosen suggestion from a `tolle-suggestion` up to the\n * `tolle-suggestions` row that contains it, so consumers can listen in one\n * place instead of binding every pill.\n *\n * Provided on `SuggestionsComponent`; injected optionally, since a pill is\n * perfectly usable on its own.\n */\n@Injectable()\nexport class SuggestionsService {\n private readonly selectedSource = new Subject<string>();\n /** Emits the value of whichever suggestion the user picked. */\n readonly selected$ = this.selectedSource.asObservable();\n\n select(value: string): void {\n this.selectedSource.next(value);\n }\n}\n\nconst suggestionsVariants = cva('flex w-full items-center overflow-x-auto scroll-fade-x', {\n variants: {\n gap: {\n sm: 'gap-1',\n default: 'gap-2',\n lg: 'gap-3',\n },\n },\n defaultVariants: {\n gap: 'default',\n },\n});\n\nexport type SuggestionsProps = VariantProps<typeof suggestionsVariants>;\n\n/**\n * A horizontally scrollable row of suggested follow-up prompts.\n *\n * Fill it with `tolle-suggestion` pills and listen to this component's\n * `selected` output to react to any of them.\n */\n@Component({\n selector: 'tolle-suggestions',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [SuggestionsService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" role=\"group\" [attr.aria-label]=\"ariaLabel || null\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SuggestionsComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Spacing between the pills. @default 'default' */\n @Input() gap: SuggestionsProps['gap'] = 'default';\n /** Accessible name for the row. @default 'Suggestions' */\n @Input() ariaLabel = 'Suggestions';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the `value` of whichever suggestion in the row was picked. */\n @Output() selected = new EventEmitter<string>();\n\n private readonly service = inject(SuggestionsService);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.service.selected$.subscribe(value => this.selected.emit(value)));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(suggestionsVariants({ gap: this.gap }), this.class);\n }\n}\n\nconst suggestionVariants = cva(\n 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n variant: {\n outline:\n 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',\n secondary:\n 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n ghost:\n 'border-transparent bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2.5 text-xs',\n default: 'h-8 px-3 text-sm',\n lg: 'h-10 px-4 text-sm',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n },\n }\n);\n\nexport type SuggestionProps = VariantProps<typeof suggestionVariants>;\n\n/**\n * A single suggested follow-up prompt, rendered as a pill button.\n *\n * Projected content is the label; `value` is what gets emitted when it is\n * picked, so the two can differ.\n * @new\n */\n@Component({\n selector: 'tolle-suggestion',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-suggestion\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (click)=\"onClick()\">\n @if (icon) {\n <i [class]=\"icon\" aria-hidden=\"true\"></i>\n }\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class SuggestionComponent 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 /** Value emitted when the pill is picked. @default '' */\n @Input() value = '';\n /** Visual style of the pill. @default 'outline' */\n @Input() variant: SuggestionProps['variant'] = 'outline';\n /** Size of the pill. @default 'default' */\n @Input() size: SuggestionProps['size'] = 'default';\n /** Remixicon class shown before the label. @default '' */\n @Input() icon = '';\n /** Blocks interaction with the pill. @default false */\n @Input() disabled = false;\n /** Accessible name, when the projected label is not descriptive enough. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the pill via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with this pill's `value` when it is picked. */\n @Output() selected = new EventEmitter<string>();\n\n private readonly parent = inject(SuggestionsService, { optional: true });\n\n get computedClass() {\n return cn(suggestionVariants({ variant: this.variant, size: this.size }), this.class);\n }\n\n onClick(): void {\n if (this.disabled) return;\n this.selected.emit(this.value);\n this.parent?.select(this.value);\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
+ }