@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.
- package/fesm2022/tolle-ui.mjs +30099 -5285
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/package.json +17 -5
- package/preset.js +35 -1
- package/registry/docs-content.json +11486 -0
- package/registry/llms-full.txt +4067 -0
- package/registry/llms.txt +127 -0
- package/registry/manifest.json +12656 -0
- package/registry/r/accordion-item.json +21 -0
- package/registry/r/accordion.json +23 -0
- package/registry/r/alert-dialog-dynamic.json +31 -0
- package/registry/r/alert-dialog.json +26 -0
- package/registry/r/alert.json +23 -0
- package/registry/r/aspect-ratio.json +21 -0
- package/registry/r/attachment.json +23 -0
- package/registry/r/avatar-fallback.json +16 -0
- package/registry/r/avatar.json +23 -0
- package/registry/r/badge.json +23 -0
- package/registry/r/bar-list.json +21 -0
- package/registry/r/breadcrumb-item.json +16 -0
- package/registry/r/breadcrumb-link.json +16 -0
- package/registry/r/breadcrumb-separator.json +16 -0
- package/registry/r/breadcrumb.json +21 -0
- package/registry/r/bubble.json +23 -0
- package/registry/r/button-group.json +21 -0
- package/registry/r/button.json +23 -0
- package/registry/r/calendar.json +23 -0
- package/registry/r/card.json +21 -0
- package/registry/r/carousel.json +18 -0
- package/registry/r/category-bar.json +21 -0
- package/registry/r/chain-of-thought.json +23 -0
- package/registry/r/chart-pie.json +30 -0
- package/registry/r/chart-spark.json +30 -0
- package/registry/r/chart.json +28 -0
- package/registry/r/checkbox.json +23 -0
- package/registry/r/checkpoint.json +23 -0
- package/registry/r/collapsible.json +21 -0
- package/registry/r/combobox.json +31 -0
- package/registry/r/command-dialog.json +21 -0
- package/registry/r/command.json +26 -0
- package/registry/r/confirmation.json +23 -0
- package/registry/r/context-menu-trigger.json +23 -0
- package/registry/r/context-menu.json +28 -0
- package/registry/r/context.json +26 -0
- package/registry/r/conversation.json +23 -0
- package/registry/r/country-selector.json +31 -0
- package/registry/r/data-table.json +38 -0
- package/registry/r/date-picker.json +27 -0
- package/registry/r/date-range-picker.json +32 -0
- package/registry/r/date-time-picker.json +28 -0
- package/registry/r/direction.json +16 -0
- package/registry/r/dropdown-item.json +21 -0
- package/registry/r/dropdown-label.json +16 -0
- package/registry/r/dropdown-menu.json +16 -0
- package/registry/r/dropdown-separator.json +16 -0
- package/registry/r/dropdown-trigger.json +20 -0
- package/registry/r/empty-state.json +23 -0
- package/registry/r/field.json +23 -0
- package/registry/r/hover-card.json +23 -0
- package/registry/r/inline-citation.json +26 -0
- package/registry/r/input-group.json +23 -0
- package/registry/r/input.json +21 -0
- package/registry/r/item.json +23 -0
- package/registry/r/kbd.json +23 -0
- package/registry/r/label.json +21 -0
- package/registry/r/marker.json +23 -0
- package/registry/r/masked-input.json +21 -0
- package/registry/r/menubar.json +24 -0
- package/registry/r/message-scroller.json +26 -0
- package/registry/r/message.json +23 -0
- package/registry/r/modal.json +36 -0
- package/registry/r/model-selector.json +32 -0
- package/registry/r/multi-select.json +33 -0
- package/registry/r/native-select.json +23 -0
- package/registry/r/navigation-menu.json +24 -0
- package/registry/r/otp-group.json +16 -0
- package/registry/r/otp-slot.json +21 -0
- package/registry/r/otp.json +21 -0
- package/registry/r/pagination.json +33 -0
- package/registry/r/phone-number-input.json +34 -0
- package/registry/r/plan.json +23 -0
- package/registry/r/popover-content.json +21 -0
- package/registry/r/popover.json +18 -0
- package/registry/r/progress-circle.json +21 -0
- package/registry/r/progress.json +21 -0
- package/registry/r/prompt-input.json +23 -0
- package/registry/r/queue.json +23 -0
- package/registry/r/radio-group.json +26 -0
- package/registry/r/radio-item.json +26 -0
- package/registry/r/range-calendar.json +28 -0
- package/registry/r/reasoning.json +23 -0
- package/registry/r/resizable-panel-item.json +23 -0
- package/registry/r/resizable-panel.json +23 -0
- package/registry/r/resizable.json +21 -0
- package/registry/r/scroll-area.json +21 -0
- package/registry/r/segment.json +21 -0
- package/registry/r/select-group.json +21 -0
- package/registry/r/select-item.json +26 -0
- package/registry/r/select-separator.json +21 -0
- package/registry/r/select.json +32 -0
- package/registry/r/separator.json +21 -0
- package/registry/r/sheet-wrapper.json +31 -0
- package/registry/r/sheet.json +21 -0
- package/registry/r/shimmer.json +23 -0
- package/registry/r/sidebar.json +21 -0
- package/registry/r/skeleton.json +23 -0
- package/registry/r/slider.json +21 -0
- package/registry/r/sources.json +23 -0
- package/registry/r/spinner.json +23 -0
- package/registry/r/suggestion.json +23 -0
- package/registry/r/switch.json +23 -0
- package/registry/r/table.json +21 -0
- package/registry/r/tabs.json +23 -0
- package/registry/r/tag-input.json +25 -0
- package/registry/r/task.json +23 -0
- package/registry/r/textarea.json +21 -0
- package/registry/r/time-picker.json +24 -0
- package/registry/r/toaster.json +26 -0
- package/registry/r/toggle-group.json +25 -0
- package/registry/r/toggle.json +23 -0
- package/registry/r/tolle-cell.json +16 -0
- package/registry/r/tool.json +23 -0
- package/registry/r/tooltip.json +23 -0
- package/registry/r/tracker.json +25 -0
- package/registry/r/typography.json +23 -0
- package/registry/registry.json +2545 -0
- package/theme.css +230 -10
- package/types/tolle-ui.d.ts +7433 -823
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plan",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Plan",
|
|
5
|
+
"description": "Plan component with size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "plan.component.ts",
|
|
14
|
+
"target": "ui/plan.component.ts",
|
|
15
|
+
"content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Progress of a single step in an agent plan. */\nexport type PlanStepStatus = 'pending' | 'active' | 'done' | 'skipped';\n\nlet planStepId = 0;\n\n/** How far through the plan the agent is. */\nexport interface PlanProgress {\n /** 1-based position of the step being worked on, or of the last finished one. */\n current: number;\n /** Number of registered steps. */\n total: number;\n}\n\nconst planVariants = cva(\n 'w-full rounded-lg border border-border bg-card p-3 text-card-foreground',\n {\n variants: {\n size: {\n sm: 'p-2 text-xs',\n default: 'p-3 text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n }\n);\n\nexport type PlanProps = VariantProps<typeof planVariants>;\n\nconst planStepVariants = cva(\n // The connector is hidden on the final step so the line stops at the last badge.\n 'relative flex gap-3 pb-3 last:pb-0 [&:last-child_.tolle-plan-rail]:hidden',\n {\n variants: {\n status: {\n pending: 'opacity-100',\n active: 'opacity-100',\n done: 'opacity-100',\n skipped: 'opacity-60',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nconst planBadgeVariants = cva(\n 'relative z-[1] flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium tabular-nums',\n {\n variants: {\n status: {\n pending: 'border-border bg-background text-muted-foreground',\n active: 'border-primary bg-primary text-primary-foreground',\n done: 'border-success/50 bg-success/10 text-success',\n skipped: 'border-dashed border-border bg-background text-muted-foreground',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nexport type PlanStepProps = VariantProps<typeof planStepVariants>;\n\n/**\n * Coordinates a `tolle-plan` and its steps: assigns each step its number and\n * derives the \"Step N of M\" summary.\n *\n * The header is `OnPush` and the step count is not one of its inputs, so the\n * summary is published as an observable and every subscriber calls\n * `markForCheck()`.\n */\n@Injectable()\nexport class PlanService {\n private readonly statuses = new Map<string, PlanStepStatus>();\n private readonly order: string[] = [];\n\n private readonly progressSource = new BehaviorSubject<PlanProgress>({ current: 0, total: 0 });\n /** Emits the plan's current position and step count. */\n readonly progress$ = this.progressSource.asObservable();\n\n private readonly collapsedSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the plan is showing only its summary row. */\n readonly collapsed$ = this.collapsedSource.asObservable();\n\n get progress(): PlanProgress {\n return this.progressSource.value;\n }\n\n get collapsed(): boolean {\n return this.collapsedSource.value;\n }\n\n setCollapsed(collapsed: boolean): void {\n if (this.collapsedSource.value !== collapsed) this.collapsedSource.next(collapsed);\n }\n\n toggleCollapsed(): void {\n this.collapsedSource.next(!this.collapsedSource.value);\n }\n\n /** Registers a step and returns its 1-based number. */\n register(id: string, status: PlanStepStatus): number {\n if (!this.statuses.has(id)) this.order.push(id);\n this.statuses.set(id, status);\n this.recompute();\n return this.order.indexOf(id) + 1;\n }\n\n setStatus(id: string, status: PlanStepStatus): void {\n if (!this.statuses.has(id)) return;\n if (this.statuses.get(id) === status) return;\n this.statuses.set(id, status);\n this.recompute();\n }\n\n unregister(id: string): void {\n const index = this.order.indexOf(id);\n if (index > -1) this.order.splice(index, 1);\n this.statuses.delete(id);\n this.recompute();\n }\n\n /**\n * \"Current\" is the first step still in flight — the active one if there is\n * one, otherwise however many have already been resolved.\n */\n private recompute(): void {\n const total = this.order.length;\n const activeIndex = this.order.findIndex(id => this.statuses.get(id) === 'active');\n const resolved = this.order.filter(id => {\n const status = this.statuses.get(id);\n return status === 'done' || status === 'skipped';\n }).length;\n\n const current = activeIndex > -1 ? activeIndex + 1 : Math.min(resolved, total);\n this.progressSource.next({ current, total });\n }\n}\n\n/**\n * An ordered plan the agent intends to work through. Each `tolle-plan-step`\n * numbers itself and joins the connector line; collapsing the plan swaps the\n * steps for a single \"Step N of M\" summary.\n *\n * ```html\n * <tolle-plan title=\"Migration plan\">\n * <tolle-plan-step label=\"Read the schema\" status=\"done\"></tolle-plan-step>\n * <tolle-plan-step label=\"Write the migration\" status=\"active\"></tolle-plan-step>\n * <tolle-plan-step label=\"Run it\" status=\"pending\"></tolle-plan-step>\n * </tolle-plan>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-plan',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [PlanService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-collapsed]=\"isCollapsed ? '' : null\">\n <button\n type=\"button\"\n [disabled]=\"!collapsible\"\n [attr.aria-expanded]=\"collapsible ? !isCollapsed : null\"\n [class]=\"headerClass\"\n (click)=\"toggle()\">\n @if (collapsible) {\n <i\n [class]=\"\n isCollapsed\n ? 'ri-arrow-right-s-line shrink-0 text-muted-foreground'\n : 'ri-arrow-down-s-line shrink-0 text-muted-foreground'\n \"\n aria-hidden=\"true\"></i>\n }\n <span class=\"truncate font-medium text-foreground\">{{ title }}</span>\n <span class=\"ml-auto shrink-0 text-xs tabular-nums text-muted-foreground\">{{\n summary\n }}</span>\n </button>\n\n <div [class]=\"isCollapsed ? 'hidden' : 'mt-3 flex flex-col'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class PlanComponent implements OnInit, OnChanges, OnDestroy {\n /** Heading shown above the steps. @default 'Plan' */\n @Input() title = 'Plan';\n /** Shows only the header and its \"Step N of M\" summary. @default false */\n @Input() collapsed = false;\n /** Lets the user collapse and expand the plan by clicking the header. @default true */\n @Input() collapsible = true;\n /** Padding and text scale of the plan. @default 'default' */\n @Input() size: PlanProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the plan via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new collapsed state when the header is toggled. */\n @Output() collapsedChange = new EventEmitter<boolean>();\n\n private readonly service = inject(PlanService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isCollapsed = false;\n progress: PlanProgress = { current: 0, total: 0 };\n\n ngOnInit(): void {\n this.service.setCollapsed(this.collapsed);\n // Seeded before subscribing so the replayed initial value is not mistaken\n // for a user toggle and echoed back out as a `collapsedChange`.\n this.isCollapsed = this.collapsed;\n this.sub.add(\n this.service.collapsed$.subscribe(collapsed => {\n const changed = this.isCollapsed !== collapsed;\n this.isCollapsed = collapsed;\n this.cdr.markForCheck();\n if (changed) this.collapsedChange.emit(collapsed);\n })\n );\n this.sub.add(\n this.service.progress$.subscribe(progress => {\n this.progress = progress;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.service.setCollapsed(this.collapsed);\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** The compact \"Step N of M\" label, empty until at least one step registers. */\n get summary(): string {\n if (!this.progress.total) return '';\n return 'Step ' + Math.max(1, this.progress.current) + ' of ' + this.progress.total;\n }\n\n get computedClass() {\n return cn(planVariants({ size: this.size }), this.class);\n }\n\n get headerClass() {\n return cn(\n 'flex w-full items-center gap-1.5 rounded-md text-left text-sm outline-none transition-colors',\n this.collapsible ? 'cursor-pointer' : 'cursor-default',\n 'focus-visible:ring-2 focus-visible:ring-ring'\n );\n }\n\n toggle(): void {\n if (!this.collapsible) return;\n this.service.toggleCollapsed();\n }\n}\n\n/** One numbered step of a `tolle-plan`. */\n@Component({\n selector: 'tolle-plan-step',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n host: { '[class]': 'computedClass', '[attr.data-status]': 'status' },\n template: `\n <span\n class=\"tolle-plan-rail absolute bottom-0 left-3 top-6 w-px -translate-x-1/2 bg-border\"\n aria-hidden=\"true\"></span>\n\n <span [class]=\"badgeClass\">\n @if (status === 'done') {\n <i class=\"ri-check-line\" aria-hidden=\"true\"></i>\n }\n @if (status === 'skipped') {\n <i class=\"ri-subtract-line\" aria-hidden=\"true\"></i>\n }\n @if (status !== 'done' && status !== 'skipped') {\n {{ index }}\n }\n </span>\n\n <div class=\"min-w-0 flex-1 pt-0.5\">\n <p [class]=\"labelClass\">{{ label }}</p>\n @if (description) {\n <p class=\"mt-0.5 text-xs text-muted-foreground\">{{ description }}</p>\n }\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PlanStepComponent implements OnInit, OnChanges, OnDestroy {\n /** What this step does. @default '' */\n @Input() label = '';\n /** Supporting detail shown under the label. @default '' */\n @Input() description = '';\n /** Progress of this step; drives the badge colour and glyph. @default 'pending' */\n @Input() status: PlanStepStatus = 'pending';\n /** Extra Tailwind classes merged onto the step via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(PlanService);\n private readonly id = 'tolle-plan-step-' + planStepId++;\n\n /** 1-based position of this step, assigned on registration. */\n index = 0;\n\n ngOnInit(): void {\n this.index = this.service.register(this.id, this.status);\n }\n\n ngOnChanges(): void {\n this.service.setStatus(this.id, this.status);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n }\n\n get computedClass() {\n return cn(planStepVariants({ status: this.status }), this.class);\n }\n\n get badgeClass() {\n return cn(planBadgeVariants({ status: this.status }));\n }\n\n get labelClass() {\n return cn(\n 'text-sm',\n this.status === 'active' && 'font-medium text-foreground',\n this.status === 'done' && 'text-foreground',\n this.status === 'pending' && 'text-muted-foreground',\n this.status === 'skipped' && 'text-muted-foreground line-through'\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "popover-content",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Popover Content",
|
|
5
|
+
"description": "Popover Content component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "popover-content.component.ts",
|
|
12
|
+
"target": "ui/popover-content.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-popover-content',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div\n [class]=\"\n cn(\n 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',\n 'duration-200 animate-in fade-in zoom-in-95',\n class\n )\n \"\n role=\"dialog\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PopoverContentComponent {\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,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "popover",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Popover",
|
|
5
|
+
"description": "Popover component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "popover.component.ts",
|
|
14
|
+
"target": "ui/popover.component.ts",
|
|
15
|
+
"content": "import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport {\n Component,\n ElementRef,\n EventEmitter,\n Input,\n OnDestroy,\n Output,\n ViewChild,\n} from '@angular/core';\n\n@Component({\n selector: 'tolle-popover',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div class=\"inline-block\" #trigger (click)=\"toggle()\">\n <ng-content select=\"[trigger]\"></ng-content>\n </div>\n\n @if (isOpen) {\n <div #popover class=\"fixed left-0 top-0 z-[9999] w-max\" style=\"visibility: hidden\">\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class PopoverComponent implements OnDestroy {\n @Input() placement:\n | 'top'\n | 'bottom'\n | 'left'\n | 'right'\n | 'top-start'\n | 'top-end'\n | 'bottom-start'\n | 'bottom-end'\n | 'left-start'\n | 'left-end'\n | 'right-start'\n | 'right-end' = 'bottom';\n @Output() onOpen = new EventEmitter<void>();\n @Output() onClose = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl!: ElementRef;\n @ViewChild('popover') popoverEl!: ElementRef;\n\n isOpen = false;\n private cleanup?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (\n !this.triggerEl?.nativeElement.contains(event.target) &&\n !this.popoverEl?.nativeElement.contains(event.target)\n ) {\n this.close();\n }\n };\n\n private _escapeHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape') this.close();\n };\n\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.onOpen.emit();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n document.addEventListener('keydown', this._escapeHandler, true);\n });\n }\n\n close() {\n this.isOpen = false;\n this.onClose.emit();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n document.removeEventListener('keydown', this._escapeHandler, true);\n }\n\n private updatePosition() {\n if (!this.triggerEl || !this.popoverEl) return;\n\n this.cleanup = autoUpdate(this.triggerEl.nativeElement, this.popoverEl.nativeElement, () => {\n computePosition(this.triggerEl.nativeElement, this.popoverEl.nativeElement, {\n placement: this.placement,\n strategy: 'fixed',\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(this.popoverEl.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n });\n }\n\n ngOnDestroy() {\n if (this.cleanup) this.cleanup();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n document.removeEventListener('keydown', this._escapeHandler, true);\n }\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "progress-circle",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Progress Circle",
|
|
5
|
+
"description": "Progress Circle component.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "progress-circle.component.ts",
|
|
12
|
+
"target": "ui/progress-circle.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n/**\n * Radial counterpart to `tolle-progress`: a ring that fills clockwise from\n * 12 o'clock. Project content to label the centre — a percentage, an icon,\n * whatever the caller wants; the ring renders nothing there on its own.\n * @new\n */\n@Component({\n selector: 'tolle-progress-circle',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\">\n <svg [attr.width]=\"size\" [attr.height]=\"size\" [attr.viewBox]=\"viewBox\" class=\"block\">\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n class=\"stroke-primary/20\"></svg:circle>\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n stroke-linecap=\"round\"\n [attr.stroke-dasharray]=\"circumference\"\n [attr.stroke-dashoffset]=\"dashOffset\"\n [attr.transform]=\"'rotate(-90 ' + center + ' ' + center + ')'\"\n class=\"stroke-primary transition-[stroke-dashoffset] duration-300 ease-in-out\"></svg:circle>\n </svg>\n <div\n class=\"absolute inset-0 flex items-center justify-center text-sm font-medium tabular-nums text-foreground\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class ProgressCircleComponent implements OnChanges {\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 /** Percent complete, 0-100. @default 0 */\n @Input() value: number | null = 0;\n /** Diameter of the ring in px. @default 120 */\n @Input() size = 120;\n /** Thickness of the ring in px. @default 8 */\n @Input() strokeWidth = 8;\n /** Extra Tailwind classes merged onto the ring via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get viewBox(): string {\n return '0 0 ' + this.size + ' ' + this.size;\n }\n\n get center(): number {\n return this.size / 2;\n }\n\n /** Leaves half the stroke width as margin so the ring never clips the frame. */\n get radius(): number {\n return Math.max(0, (this.size - this.strokeWidth) / 2);\n }\n\n get circumference(): number {\n return 2 * Math.PI * this.radius;\n }\n\n get dashOffset(): number {\n return this.circumference * (1 - this.clampedValue / 100);\n }\n\n get computedClass() {\n return cn('relative inline-flex shrink-0 items-center justify-center', this.class);\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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "progress",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Progress",
|
|
5
|
+
"description": "Progress component.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "progress.component.ts",
|
|
12
|
+
"target": "ui/progress.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-progress',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div\n [class]=\"computedClass\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\">\n <div\n class=\"h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out\"\n [style.transform]=\"'translateX(-' + (100 - clampedValue) + '%)'\"></div>\n </div>\n `,\n})\nexport class ProgressComponent {\n @Input() value: number | null = 0;\n @Input() class: string = '';\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get computedClass() {\n return cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', this.class);\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": "prompt-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Prompt Input",
|
|
5
|
+
"description": "Prompt Input component with status and size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "prompt-input.component.ts",
|
|
14
|
+
"target": "ui/prompt-input.component.ts",
|
|
15
|
+
"content": "import {\n AfterViewInit,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n ElementRef,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n ViewChild,\n forwardRef,\n inject,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { BehaviorSubject, Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst promptInputVariants = cva(\n 'flex w-full flex-col gap-2 rounded-lg border bg-background p-2 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-offset-background',\n {\n variants: {\n status: {\n ready: 'border-input focus-within:ring-ring',\n streaming: 'border-info focus-within:ring-info',\n error: 'border-destructive focus-within:ring-destructive',\n },\n size: {\n sm: 'p-1.5 text-xs',\n default: 'p-2 text-sm',\n lg: 'p-3 text-base',\n },\n },\n defaultVariants: {\n status: 'ready',\n size: 'default',\n },\n }\n);\n\nexport type PromptInputProps = VariantProps<typeof promptInputVariants>;\n\n/** Lifecycle of the response the composer is driving. */\nexport type PromptInputStatus = NonNullable<PromptInputProps['status']>;\n\n/**\n * Coordinates a `tolle-prompt-input` with its projected toolbar controls.\n *\n * The submit button is an OnPush child that must flip to a stop button the\n * moment the status changes, so the composer publishes its state here instead\n * of the button reading the parent's fields.\n *\n * Provided on `PromptInputComponent`, so each composer gets its own instance.\n */\n@Injectable()\nexport class PromptInputService {\n private readonly statusSource = new BehaviorSubject<PromptInputStatus>('ready');\n /** Emits the composer's current status. */\n readonly status$ = this.statusSource.asObservable();\n\n private readonly canSubmitSource = new BehaviorSubject<boolean>(false);\n /** Emits whether there is submittable text and the composer is enabled. */\n readonly canSubmit$ = this.canSubmitSource.asObservable();\n\n private readonly submitSource = new Subject<void>();\n /** Emits when a toolbar control asks the composer to submit. */\n readonly submit$ = this.submitSource.asObservable();\n\n private readonly stopSource = new Subject<void>();\n /** Emits when a toolbar control asks the composer to stop streaming. */\n readonly stop$ = this.stopSource.asObservable();\n\n /** The composer's current status. */\n get status(): PromptInputStatus {\n return this.statusSource.value;\n }\n\n /** True when the composer has submittable text. */\n get canSubmit(): boolean {\n return this.canSubmitSource.value;\n }\n\n setStatus(status: PromptInputStatus): void {\n if (this.statusSource.value !== status) this.statusSource.next(status);\n }\n\n setCanSubmit(value: boolean): void {\n if (this.canSubmitSource.value !== value) this.canSubmitSource.next(value);\n }\n\n requestSubmit(): void {\n this.submitSource.next();\n }\n\n requestStop(): void {\n this.stopSource.next();\n }\n}\n\n/**\n * A message composer: an auto-growing textarea plus a toolbar, wired as a\n * `ControlValueAccessor` so it drops into template- or reactive-driven forms.\n *\n * Enter submits and Shift+Enter inserts a newline (swap with `submitOnEnter`).\n * While `status` is 'streaming' the projected `tolle-prompt-input-submit`\n * becomes a stop button that emits `stopped`.\n * @new\n */\n@Component({\n selector: 'tolle-prompt-input',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [\n PromptInputService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => PromptInputComponent),\n multi: true,\n },\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <textarea\n #textarea\n data-prompt-input-textarea\n rows=\"1\"\n [value]=\"value\"\n [attr.placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || placeholder || 'Message'\"\n [attr.aria-invalid]=\"status === 'error' ? 'true' : null\"\n [class]=\"textareaComputedClass\"\n (input)=\"onInput($event)\"\n (keydown)=\"onKeyDown($event)\"\n (blur)=\"onBlur()\"></textarea>\n\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputComponent\n implements ControlValueAccessor, OnInit, OnChanges, AfterViewInit, OnDestroy\n{\n /** Placeholder shown while the composer is empty. @default 'Send a message…' */\n @Input() placeholder = 'Send a message…';\n /** Blocks typing and submission. @default false */\n @Input() disabled = false;\n /** Rows the textarea may grow to before it starts scrolling. @default 8 */\n @Input() maxRows = 8;\n /** Lifecycle of the response being composed. @default 'ready' */\n @Input() status: PromptInputStatus = 'ready';\n /** Size scale of the composer. @default 'default' */\n @Input() size: PromptInputProps['size'] = 'default';\n /** Submit on Enter, keeping Shift+Enter for newlines. @default true */\n @Input() submitOnEnter = true;\n /** Clears the composer after a successful submit. @default true */\n @Input() clearOnSubmit = true;\n /** Accessible name for the textarea. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the composer via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the textarea via `cn()` (last-wins). */\n @Input() textareaClass = '';\n\n /** Emitted with the trimmed message text when the user submits. */\n @Output() submitted = new EventEmitter<string>();\n /** Emitted when the user cancels an in-flight streaming response. */\n @Output() stopped = new EventEmitter<void>();\n /** Emitted with the message text whenever it changes. */\n @Output() valueChange = new EventEmitter<string>();\n\n @ViewChild('textarea') private textarea?: ElementRef<HTMLTextAreaElement>;\n\n private readonly service = inject(PromptInputService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n value = '';\n\n private onChange: (value: string) => void = () => {};\n private onTouched: () => void = () => {};\n\n ngOnInit(): void {\n this.syncService();\n this.subscriptions.add(this.service.submit$.subscribe(() => this.submit()));\n this.subscriptions.add(this.service.stop$.subscribe(() => this.stop()));\n }\n\n ngOnChanges(): void {\n this.syncService();\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngAfterViewInit(): void {\n this.resize();\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n promptInputVariants({ status: this.status, size: this.size }),\n this.disabled && 'cursor-not-allowed opacity-60',\n this.class\n );\n }\n\n get textareaComputedClass() {\n return cn(\n 'w-full resize-none bg-transparent px-2 py-1.5 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed',\n this.textareaClass\n );\n }\n\n onInput(event: Event): void {\n const next = (event.target as HTMLTextAreaElement).value;\n this.setValue(next);\n this.resize();\n }\n\n onKeyDown(event: KeyboardEvent): void {\n if (event.key !== 'Enter' || !this.submitOnEnter) return;\n // Shift+Enter is the newline escape hatch; modifier chords and IME\n // composition must never steal the keystroke.\n if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;\n if ((event as KeyboardEvent & { isComposing?: boolean }).isComposing) return;\n\n event.preventDefault();\n this.submit();\n }\n\n onBlur(): void {\n this.onTouched();\n }\n\n /** Submits the current text, unless the composer is empty, disabled or busy. */\n submit(): void {\n if (this.disabled || this.status === 'streaming') return;\n const text = this.value.trim();\n if (!text) return;\n\n this.submitted.emit(text);\n\n if (this.clearOnSubmit) {\n this.setValue('');\n if (this.textarea) this.textarea.nativeElement.value = '';\n this.resize();\n }\n }\n\n /** Cancels an in-flight streaming response. */\n stop(): void {\n this.stopped.emit();\n }\n\n /** Moves focus into the textarea. */\n focus(): void {\n this.textarea?.nativeElement.focus();\n }\n\n writeValue(value: string | null): void {\n this.value = value ?? '';\n if (this.textarea) this.textarea.nativeElement.value = this.value;\n this.service.setCanSubmit(this.canSubmit);\n this.cdr.markForCheck();\n this.resize();\n }\n\n registerOnChange(fn: (value: string) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.syncService();\n this.cdr.markForCheck();\n }\n\n private get canSubmit(): boolean {\n return !this.disabled && this.value.trim().length > 0;\n }\n\n private setValue(next: string): void {\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n this.service.setCanSubmit(this.canSubmit);\n }\n\n private syncService(): void {\n this.service.setStatus(this.status);\n this.service.setCanSubmit(this.canSubmit);\n }\n\n /**\n * Grows the textarea with its content up to `maxRows`, then lets it scroll.\n * Measured from the computed line height so it tracks the inherited font.\n */\n private resize(): void {\n const el = this.textarea?.nativeElement;\n if (!el) return;\n\n el.style.height = 'auto';\n\n const styles = getComputedStyle(el);\n const parsedLineHeight = parseFloat(styles.lineHeight);\n const lineHeight = Number.isFinite(parsedLineHeight)\n ? parsedLineHeight\n : parseFloat(styles.fontSize) * 1.5 || 20;\n const vertical =\n parseFloat(styles.paddingTop || '0') +\n parseFloat(styles.paddingBottom || '0') +\n parseFloat(styles.borderTopWidth || '0') +\n parseFloat(styles.borderBottomWidth || '0');\n const max = lineHeight * Math.max(1, this.maxRows) + vertical;\n\n const next = Math.min(el.scrollHeight, max);\n el.style.height = next + 'px';\n el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';\n }\n}\n\n/** Bottom bar of a `tolle-prompt-input`: tools on the left, submit on the right. */\n@Component({\n selector: 'tolle-prompt-input-toolbar',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputToolbarComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the toolbar via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex items-center justify-between gap-2 px-1', this.class);\n }\n}\n\n/** Left-aligned cluster of tool buttons inside a `tolle-prompt-input-toolbar`. */\n@Component({\n selector: 'tolle-prompt-input-tools',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PromptInputToolsComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the tool row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-1 items-center gap-1 text-muted-foreground', this.class);\n }\n}\n\nconst promptInputSubmitVariants = cva(\n 'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'size-8 text-sm',\n lg: 'h-10 px-4 text-sm',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\nexport type PromptInputSubmitProps = VariantProps<typeof promptInputSubmitVariants>;\n\n/**\n * Send button for a `tolle-prompt-input`. Turns into a stop button while the\n * composer's status is 'streaming', so one control covers both directions.\n */\n@Component({\n selector: 'tolle-prompt-input-submit',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-prompt-input-submit\n [attr.data-state]=\"status\"\n [attr.aria-label]=\"currentLabel\"\n [disabled]=\"isDisabled\"\n [class]=\"computedClass\"\n (click)=\"onClick()\">\n <i [class]=\"currentIcon\" aria-hidden=\"true\"></i>\n @if (showLabel) {\n <span>{{ currentLabel }}</span>\n }\n </button>\n `,\n})\nexport class PromptInputSubmitComponent 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 /** Visual style of the button. @default 'default' */\n @Input() variant: PromptInputSubmitProps['variant'] = 'default';\n /** Size of the button. @default 'default' */\n @Input() size: PromptInputSubmitProps['size'] = 'default';\n /** Accessible name while the composer is idle. @default 'Send message' */\n @Input() submitLabel = 'Send message';\n /** Accessible name while the composer is streaming. @default 'Stop generating' */\n @Input() stopLabel = 'Stop generating';\n /** Renders the label next to the icon. @default false */\n @Input() showLabel = false;\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(PromptInputService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n status: PromptInputStatus = 'ready';\n canSubmit = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.status$.subscribe(status => {\n this.status = status;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.canSubmit$.subscribe(canSubmit => {\n this.canSubmit = canSubmit;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** True while the button is showing its stop affordance. */\n get isStreaming(): boolean {\n return this.status === 'streaming';\n }\n\n get currentIcon(): string {\n return this.isStreaming ? 'ri-stop-circle-line' : 'ri-send-plane-2-fill';\n }\n\n get currentLabel(): string {\n return this.isStreaming ? this.stopLabel : this.submitLabel;\n }\n\n /** Stopping stays available with an empty composer; sending does not. */\n get isDisabled(): boolean {\n return !this.isStreaming && !this.canSubmit;\n }\n\n get computedClass() {\n return cn(\n promptInputSubmitVariants({ variant: this.variant, size: this.size }),\n this.isStreaming && 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n this.class\n );\n }\n\n onClick(): void {\n if (this.isStreaming) {\n this.service.requestStop();\n } else {\n this.service.requestSubmit();\n }\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "queue",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Queue",
|
|
5
|
+
"description": "Queue component with size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "queue.component.ts",
|
|
14
|
+
"target": "ui/queue.component.ts",
|
|
15
|
+
"content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n EventEmitter,\n Injectable,\n Input,\n OnChanges,\n OnDestroy,\n OnInit,\n Output,\n inject,\n} from '@angular/core';\n\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Where a queued entry is in its lifecycle. */\nexport type QueueItemStatus = 'queued' | 'running' | 'done';\n\nlet queueItemId = 0;\n\n/** Tallies of the entries currently in a queue. */\nexport interface QueueCounts {\n total: number;\n queued: number;\n running: number;\n done: number;\n}\n\nconst queueVariants = cva('w-full rounded-lg border border-border bg-card text-card-foreground', {\n variants: {\n size: {\n sm: 'p-2 text-xs',\n default: 'p-3 text-sm',\n },\n },\n defaultVariants: { size: 'default' },\n});\n\nexport type QueueProps = VariantProps<typeof queueVariants>;\n\nconst queueItemVariants = cva(\n 'group/queue-item flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm transition-colors',\n {\n variants: {\n status: {\n queued: 'border-border bg-background text-muted-foreground',\n running: 'border-info/40 bg-info/5 text-foreground',\n done: 'border-transparent bg-transparent text-muted-foreground',\n },\n },\n defaultVariants: { status: 'queued' },\n }\n);\n\nconst queueItemIconVariants = cva('shrink-0 text-base leading-none', {\n variants: {\n status: {\n queued: 'text-muted-foreground',\n running: 'text-info',\n done: 'text-success',\n },\n },\n defaultVariants: { status: 'queued' },\n});\n\nexport type QueueItemProps = VariantProps<typeof queueItemVariants>;\n\n/** Remixicon class shown for each queue item status. */\nconst QUEUE_ICONS: Record<QueueItemStatus, string> = {\n queued: 'ri-time-line',\n running: 'ri-loader-4-line animate-spin',\n done: 'ri-check-line',\n};\n\n/**\n * Counts the items of a single `tolle-queue`.\n *\n * The header is `OnPush` and the item count is not one of its inputs, so it is\n * published as an observable and the subscriber calls `markForCheck()`.\n */\n@Injectable()\nexport class QueueService {\n private readonly statuses = new Map<string, QueueItemStatus>();\n\n private readonly countsSource = new BehaviorSubject<QueueCounts>({\n total: 0,\n queued: 0,\n running: 0,\n done: 0,\n });\n /** Emits the queue's tallies whenever an item is added, updated or removed. */\n readonly counts$ = this.countsSource.asObservable();\n\n get counts(): QueueCounts {\n return this.countsSource.value;\n }\n\n register(id: string, status: QueueItemStatus): void {\n this.statuses.set(id, status);\n this.recompute();\n }\n\n setStatus(id: string, status: QueueItemStatus): void {\n if (!this.statuses.has(id)) return;\n if (this.statuses.get(id) === status) return;\n this.statuses.set(id, status);\n this.recompute();\n }\n\n unregister(id: string): void {\n if (this.statuses.delete(id)) this.recompute();\n }\n\n private recompute(): void {\n const values = [...this.statuses.values()];\n this.countsSource.next({\n total: values.length,\n queued: values.filter(status => status === 'queued').length,\n running: values.filter(status => status === 'running').length,\n done: values.filter(status => status === 'done').length,\n });\n }\n}\n\n/**\n * Messages or tasks waiting their turn. The header counts the entries for you,\n * so the host only has to render `tolle-queue-item` children.\n *\n * ```html\n * <tolle-queue title=\"Queued\">\n * <tolle-queue-item label=\"Summarise the thread\" status=\"running\"></tolle-queue-item>\n * <tolle-queue-item label=\"Draft a reply\" (remove)=\"drop(1)\"></tolle-queue-item>\n * </tolle-queue>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-queue',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [QueueService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"flex items-center gap-2\">\n <i class=\"ri-stack-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"truncate font-medium text-foreground\">{{ title }}</span>\n <span [class]=\"countClass\" [attr.aria-label]=\"counts.total + ' items queued'\">{{\n counts.total\n }}</span>\n </div>\n\n @if (counts.total === 0) {\n <p class=\"mt-2 text-sm text-muted-foreground\">{{ emptyMessage }}</p>\n }\n\n <div role=\"list\" [class]=\"listClass\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class QueueComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Heading shown above the queued items. @default 'Queue' */\n @Input() title = 'Queue';\n /** Text shown while the queue holds nothing. @default 'Nothing queued.' */\n @Input() emptyMessage = 'Nothing queued.';\n /** Padding and text scale of the queue. @default 'default' */\n @Input() size: QueueProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the queue via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the queue's tallies whenever its items change. */\n @Output() countsChange = new EventEmitter<QueueCounts>();\n\n private readonly service = inject(QueueService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n counts: QueueCounts = this.service.counts;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.counts$.subscribe(counts => {\n this.counts = counts;\n this.cdr.markForCheck();\n this.countsChange.emit(counts);\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn(queueVariants({ size: this.size }), this.class);\n }\n\n get countClass() {\n return cn(\n 'ml-auto shrink-0 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground'\n );\n }\n\n get listClass() {\n return cn(this.counts.total === 0 ? 'flex flex-col' : 'mt-2 flex flex-col gap-1');\n }\n}\n\n/** A single entry in a `tolle-queue`. */\n@Component({\n selector: 'tolle-queue-item',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div role=\"listitem\" [class]=\"computedClass\" [attr.data-status]=\"status\">\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n\n <span [class]=\"labelClass\">{{ label }}</span>\n <ng-content></ng-content>\n\n @if (removable) {\n <button\n type=\"button\"\n [attr.aria-label]=\"removeLabel\"\n class=\"ml-auto shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n (click)=\"remove.emit()\">\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n </button>\n }\n </div>\n `,\n})\nexport class QueueItemComponent implements OnInit, OnChanges, OnDestroy {\n /** Text describing the queued work. @default '' */\n @Input() label = '';\n /** Lifecycle state of this entry. @default 'queued' */\n @Input() status: QueueItemStatus = 'queued';\n /** Shows the trailing dismiss button that emits `remove`. @default true */\n @Input() removable = true;\n /** Accessible label for the dismiss button. @default 'Remove from queue' */\n @Input() removeLabel = 'Remove from queue';\n /** Extra Tailwind classes merged onto the entry via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user dismisses this entry. */\n @Output() remove = new EventEmitter<void>();\n\n private readonly service = inject(QueueService);\n private readonly id = 'tolle-queue-item-' + queueItemId++;\n\n ngOnInit(): void {\n this.service.register(this.id, this.status);\n }\n\n ngOnChanges(): void {\n this.service.setStatus(this.id, this.status);\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n }\n\n get computedClass() {\n return cn(queueItemVariants({ status: this.status }), this.class);\n }\n\n get iconClass() {\n return cn(queueItemIconVariants({ status: this.status }), QUEUE_ICONS[this.status]);\n }\n\n get labelClass() {\n return cn('min-w-0 flex-1 truncate', this.status === 'done' && 'line-through');\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "radio-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Radio Group",
|
|
5
|
+
"description": "Radio Group component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "radio-group.component.ts",
|
|
12
|
+
"target": "ui/radio-group.component.ts",
|
|
13
|
+
"content": "import { Component, Input, forwardRef, AfterContentInit, OnChanges } from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { RadioService } from './radio-service';\n\n@Component({\n selector: 'tolle-radio-group',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [\n RadioService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RadioGroupComponent),\n multi: true,\n },\n ],\n host: {\n '(keydown)': 'onKeydown($event)',\n },\n template: `\n <div [class]=\"cn('grid gap-2', class)\" role=\"radiogroup\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class RadioGroupComponent implements ControlValueAccessor, OnChanges {\n @Input() class = '';\n @Input() disabled = false;\n @Input() name = `radio-group-${Math.random().toString(36).substring(2, 9)}`;\n\n value: any;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n /** Suppresses onChange while applying a programmatic writeValue(). */\n private suppress = false;\n\n constructor(private radioService: RadioService) {\n this.radioService.selectedValue$.subscribe(val => {\n this.value = val;\n if (!this.suppress) {\n this.onChange(val);\n this.onTouched();\n }\n });\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.radioService.setDisabled(isDisabled);\n }\n\n ngOnChanges() {\n this.radioService.setDisabled(this.disabled);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.suppress = true;\n this.radioService.select(value);\n this.suppress = false;\n }\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Down/Right select+focus the next\n * enabled item, Arrow Up/Left the previous. Wraps around and skips disabled\n * items; moving selection also moves focus.\n */\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowDown' || key === 'ArrowRight';\n const backward = key === 'ArrowUp' || key === 'ArrowLeft';\n if (!forward && !backward) return;\n\n const items = this.radioService.getEnabledItems();\n if (!items.length) return;\n\n event.preventDefault();\n\n const target = event.target as HTMLElement | null;\n let current = target\n ? items.findIndex(i => i.hostElement === target || i.hostElement.contains(target))\n : -1;\n if (current < 0) {\n // Fall back to the currently selected item.\n current = items.findIndex(i => i.value === this.value);\n }\n\n let next: number;\n if (current < 0) {\n next = forward ? 0 : items.length - 1;\n } else {\n next = forward ? (current + 1) % items.length : (current - 1 + items.length) % items.length;\n }\n\n const targetItem = items[next];\n this.radioService.select(targetItem.value);\n targetItem.focusItem();\n }\n\n protected readonly cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "radio-service.ts",
|
|
17
|
+
"target": "ui/radio-service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/**\n * Contract implemented by `tolle-radio-item` so the group/service can drive\n * roving tabindex + arrow-key navigation without importing the component\n * (which would create a circular dependency).\n */\nexport interface RovingRadioItem {\n value: any;\n isItemDisabled(): boolean;\n hostElement: HTMLElement;\n focusItem(): void;\n}\n\n@Injectable()\nexport class RadioService {\n private selectedValueSource = new BehaviorSubject<any>(null);\n selectedValue$ = this.selectedValueSource.asObservable();\n\n private disabledSource = new BehaviorSubject<boolean>(false);\n disabled$ = this.disabledSource.asObservable();\n\n /**\n * The single value that is allowed in the tab order (roving tabindex).\n * It is the selected value, or - when nothing is selected - the first\n * enabled item's value. Recomputed asynchronously so the registry is\n * complete before any item reads it (avoids partial-registry glitches).\n */\n private tabbableValueSource = new BehaviorSubject<any>(undefined);\n tabbableValue$ = this.tabbableValueSource.asObservable();\n\n /** Registered items in DOM (registration) order. */\n private items: RovingRadioItem[] = [];\n private recomputeScheduled = false;\n\n get value() {\n return this.selectedValueSource.value;\n }\n\n select(value: any) {\n if (!this.disabledSource.value) {\n this.selectedValueSource.next(value);\n this.scheduleRecompute();\n }\n }\n\n setDisabled(isDisabled: boolean) {\n this.disabledSource.next(isDisabled);\n this.scheduleRecompute();\n }\n\n register(item: RovingRadioItem) {\n if (this.items.indexOf(item) === -1) {\n this.items.push(item);\n this.scheduleRecompute();\n }\n }\n\n unregister(item: RovingRadioItem) {\n const i = this.items.indexOf(item);\n if (i > -1) {\n this.items.splice(i, 1);\n this.scheduleRecompute();\n }\n }\n\n /** Ordered, non-disabled items - used for arrow-key navigation. */\n getEnabledItems(): RovingRadioItem[] {\n return this.items.filter(i => !i.isItemDisabled());\n }\n\n private scheduleRecompute() {\n if (this.recomputeScheduled) return;\n this.recomputeScheduled = true;\n // Defer to a microtask so all items have registered (and the current\n // change-detection pass has completed) before the tabbable value updates.\n Promise.resolve().then(() => {\n this.recomputeScheduled = false;\n this.recomputeTabbable();\n });\n }\n\n private recomputeTabbable() {\n const selected = this.selectedValueSource.value;\n const selectedItem = this.items.find(i => i.value === selected && !i.isItemDisabled());\n\n let tabbable: any;\n if (selectedItem) {\n tabbable = selectedItem.value;\n } else {\n const firstEnabled = this.items.find(i => !i.isItemDisabled());\n tabbable = firstEnabled ? firstEnabled.value : undefined;\n }\n\n if (tabbable !== this.tabbableValueSource.value) {\n this.tabbableValueSource.next(tabbable);\n }\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,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "radio-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Radio Item",
|
|
5
|
+
"description": "Radio Item component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "radio-item.component.ts",
|
|
12
|
+
"target": "ui/radio-item.component.ts",
|
|
13
|
+
"content": "import { Component, Input, OnInit, OnDestroy, ChangeDetectorRef, ElementRef } from '@angular/core';\n\nimport { cn } from './utils/cn';\nimport { RadioService, RovingRadioItem } from './radio-service';\nimport { Subscription } from 'rxjs';\n\n@Component({\n selector: 'tolle-radio-item',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div\n (click)=\"!isEffectiveDisabled && select()\"\n [class]=\"\n cn(\n 'group flex items-center space-x-2 py-1',\n isEffectiveDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'\n )\n \">\n <div\n role=\"radio\"\n [attr.aria-checked]=\"isSelected\"\n [attr.aria-disabled]=\"isEffectiveDisabled || null\"\n [attr.data-state]=\"isSelected ? 'checked' : 'unchecked'\"\n [attr.tabindex]=\"isEffectiveDisabled ? -1 : isTabbable ? 0 : -1\"\n (keydown.enter)=\"isEffectiveDisabled ? null : select()\"\n (keydown.space)=\"isEffectiveDisabled ? null : select(); $event.preventDefault()\"\n [class]=\"\n cn(\n 'flex aspect-square h-4 w-4 items-center justify-center rounded-full border border-primary text-primary transition-all',\n 'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n !isEffectiveDisabled && 'group-hover:border-primary/70',\n isSelected ? 'bg-background' : 'bg-transparent',\n class\n )\n \">\n @if (isSelected) {\n <div class=\"h-2.5 w-2.5 rounded-full bg-primary duration-200 animate-in zoom-in-50\"></div>\n }\n </div>\n\n <label\n class=\"select-none text-sm font-medium leading-none\"\n [class.cursor-pointer]=\"!isEffectiveDisabled\">\n <ng-content></ng-content>\n </label>\n </div>\n `,\n})\nexport class RadioItemComponent implements OnInit, OnDestroy, RovingRadioItem {\n @Input() value: any;\n @Input() disabled = false;\n @Input() class = '';\n\n isSelected = false;\n groupDisabled = false;\n /** Whether this item is the single roving tab stop for the group. */\n isTabbable = false;\n\n private sub = new Subscription();\n\n get isEffectiveDisabled(): boolean {\n return this.disabled || this.groupDisabled;\n }\n\n // Inject ChangeDetectorRef to ensure UI updates when service emits\n constructor(\n private radioService: RadioService,\n private cdr: ChangeDetectorRef,\n private el: ElementRef<HTMLElement>\n ) {}\n\n // --- RovingRadioItem contract (used by the group/service for keyboard nav) ---\n get hostElement(): HTMLElement {\n return this.el.nativeElement;\n }\n\n isItemDisabled(): boolean {\n return this.isEffectiveDisabled;\n }\n\n focusItem(): void {\n const radioEl = this.el.nativeElement.querySelector('[role=\"radio\"]') as HTMLElement | null;\n radioEl?.focus();\n }\n\n ngOnInit() {\n // Listen for selection changes\n this.sub.add(\n this.radioService.selectedValue$.subscribe(val => {\n this.isSelected = val === this.value;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for group-level disabled state\n this.sub.add(\n this.radioService.disabled$.subscribe(dis => {\n this.groupDisabled = dis;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for roving tabindex changes (which single item is tab-reachable)\n this.sub.add(\n this.radioService.tabbableValue$.subscribe(val => {\n this.isTabbable = val === this.value;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n this.radioService.register(this);\n }\n\n select() {\n this.radioService.select(this.value);\n }\n\n ngOnDestroy() {\n this.radioService.unregister(this);\n this.sub.unsubscribe(); // Clean up subscriptions\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "radio-service.ts",
|
|
17
|
+
"target": "ui/radio-service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/**\n * Contract implemented by `tolle-radio-item` so the group/service can drive\n * roving tabindex + arrow-key navigation without importing the component\n * (which would create a circular dependency).\n */\nexport interface RovingRadioItem {\n value: any;\n isItemDisabled(): boolean;\n hostElement: HTMLElement;\n focusItem(): void;\n}\n\n@Injectable()\nexport class RadioService {\n private selectedValueSource = new BehaviorSubject<any>(null);\n selectedValue$ = this.selectedValueSource.asObservable();\n\n private disabledSource = new BehaviorSubject<boolean>(false);\n disabled$ = this.disabledSource.asObservable();\n\n /**\n * The single value that is allowed in the tab order (roving tabindex).\n * It is the selected value, or - when nothing is selected - the first\n * enabled item's value. Recomputed asynchronously so the registry is\n * complete before any item reads it (avoids partial-registry glitches).\n */\n private tabbableValueSource = new BehaviorSubject<any>(undefined);\n tabbableValue$ = this.tabbableValueSource.asObservable();\n\n /** Registered items in DOM (registration) order. */\n private items: RovingRadioItem[] = [];\n private recomputeScheduled = false;\n\n get value() {\n return this.selectedValueSource.value;\n }\n\n select(value: any) {\n if (!this.disabledSource.value) {\n this.selectedValueSource.next(value);\n this.scheduleRecompute();\n }\n }\n\n setDisabled(isDisabled: boolean) {\n this.disabledSource.next(isDisabled);\n this.scheduleRecompute();\n }\n\n register(item: RovingRadioItem) {\n if (this.items.indexOf(item) === -1) {\n this.items.push(item);\n this.scheduleRecompute();\n }\n }\n\n unregister(item: RovingRadioItem) {\n const i = this.items.indexOf(item);\n if (i > -1) {\n this.items.splice(i, 1);\n this.scheduleRecompute();\n }\n }\n\n /** Ordered, non-disabled items - used for arrow-key navigation. */\n getEnabledItems(): RovingRadioItem[] {\n return this.items.filter(i => !i.isItemDisabled());\n }\n\n private scheduleRecompute() {\n if (this.recomputeScheduled) return;\n this.recomputeScheduled = true;\n // Defer to a microtask so all items have registered (and the current\n // change-detection pass has completed) before the tabbable value updates.\n Promise.resolve().then(() => {\n this.recomputeScheduled = false;\n this.recomputeTabbable();\n });\n }\n\n private recomputeTabbable() {\n const selected = this.selectedValueSource.value;\n const selectedItem = this.items.find(i => i.value === selected && !i.isItemDisabled());\n\n let tabbable: any;\n if (selectedItem) {\n tabbable = selectedItem.value;\n } else {\n const firstEnabled = this.items.find(i => !i.isItemDisabled());\n tabbable = firstEnabled ? firstEnabled.value : undefined;\n }\n\n if (tabbable !== this.tabbableValueSource.value) {\n this.tabbableValueSource.next(tabbable);\n }\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,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "range-calendar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Range Calendar",
|
|
5
|
+
"description": "Range Calendar component.",
|
|
6
|
+
"category": "date-time",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"date-fns"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "range-calendar.component.ts",
|
|
14
|
+
"target": "ui/range-calendar.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n OnInit,\n OnChanges,\n SimpleChanges,\n forwardRef,\n Output,\n EventEmitter,\n ChangeDetectorRef,\n ElementRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport {\n addDays,\n addMonths,\n subMonths,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n eachDayOfInterval,\n isSameMonth,\n isSameDay,\n isToday,\n setMonth,\n setYear,\n addYears,\n subYears,\n isBefore,\n startOfDay,\n isWithinInterval,\n format,\n} from 'date-fns';\nimport { cn } from './utils/cn';\nimport { DateRange } from './types/date-range';\n\n@Component({\n selector: 'tolle-range-calendar',\n styles: [':host { display: block; }'],\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RangeCalendarComponent),\n multi: true,\n },\n ],\n template: `\n <div\n [class]=\"\n cn(\n 'inline-block min-w-fit text-popover-foreground',\n bordered ? 'rounded-md border bg-background p-3 shadow-sm' : '',\n class\n )\n \">\n <!-- ===== Month / year quick pickers (shared by the single- and multi-month views) ===== -->\n @if (currentView !== 'date') {\n <div class=\"flex items-center justify-between gap-2 pb-4 pt-1\">\n <div class=\"flex items-center gap-1\">\n <button\n type=\"button\"\n (click)=\"setView('month', pickerMonthIndex)\"\n [class]=\"headerBtnClass('month')\">\n {{ pickerDate | date: 'MMMM' }}\n </button>\n <button\n type=\"button\"\n (click)=\"setView('year', pickerMonthIndex)\"\n [class]=\"headerBtnClass('year')\">\n {{ pickerDate | date: 'yyyy' }}\n </button>\n </div>\n <div class=\"flex items-center space-x-1\">\n <button type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n </div>\n </div>\n @if (currentView === 'month') {\n <div class=\"grid w-64 grid-cols-3 gap-2 duration-200 animate-in fade-in zoom-in-95\">\n @for (month of months; track month; let i = $index) {\n <button\n type=\"button\"\n (click)=\"selectMonth(i)\"\n [class]=\"\n cn(\n 'rounded-md py-2.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',\n i === pickerDate.getMonth() ? 'bg-primary text-primary-foreground' : ''\n )\n \">\n {{ month }}\n </button>\n }\n </div>\n }\n @if (currentView === 'year') {\n <div class=\"grid w-64 grid-cols-4 gap-2 duration-200 animate-in fade-in zoom-in-95\">\n @for (year of years; track year) {\n <button\n type=\"button\"\n (click)=\"selectYear(year)\"\n [class]=\"\n cn(\n 'rounded-md py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',\n year === pickerDate.getFullYear() ? 'bg-primary text-primary-foreground' : ''\n )\n \">\n {{ year }}\n </button>\n }\n </div>\n }\n }\n\n <!-- ===== Multi-month view (side-by-side, shared range) ===== -->\n @if (currentView === 'date' && numberOfMonths > 1) {\n <div class=\"flex flex-col gap-4 sm:flex-row\">\n @for (m of visibleMonths; track m; let idx = $index) {\n <div class=\"space-y-2\">\n <div class=\"flex items-center justify-between pb-2 pt-1\">\n @if (idx === 0) {\n <button\n type=\"button\"\n (click)=\"prev()\"\n [class]=\"navBtnClass\"\n aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n }\n @if (idx !== 0) {\n <span class=\"h-7 w-7\"></span>\n }\n <!-- Each panel's month and year open the quick pickers for that panel,\n so a two-month view jumps the same way the single-month one does. -->\n <div class=\"flex items-center gap-1\">\n <button\n type=\"button\"\n (click)=\"setView('month', idx)\"\n [class]=\"headerBtnClass('month')\">\n {{ m.date | date: 'MMMM' }}\n </button>\n <button\n type=\"button\"\n (click)=\"setView('year', idx)\"\n [class]=\"headerBtnClass('year')\">\n {{ m.date | date: 'yyyy' }}\n </button>\n </div>\n @if (idx === visibleMonths.length - 1) {\n <button\n type=\"button\"\n (click)=\"next()\"\n [class]=\"navBtnClass\"\n aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n }\n @if (idx !== visibleMonths.length - 1) {\n <span class=\"h-7 w-7\"></span>\n }\n </div>\n <div class=\"grid grid-cols-7 gap-y-1\">\n @for (day of weekDays; track day) {\n <span class=\"w-9 text-center text-[0.8rem] font-normal text-muted-foreground\">{{\n day\n }}</span>\n }\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1\" (keydown)=\"onGridKeydown($event)\">\n @for (date of m.days; track date) {\n <!-- Only the month's own days are rendered; adjacent-month days are\n blank spacers so each calendar in a multi-month view is self-contained. -->\n @if (isCurrentMonth(date, m.date)) {\n <button\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"date | date: 'fullDate'\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date, m.date)\">\n {{ date | date: 'd' }}\n </button>\n } @else {\n <span class=\"h-9 w-9\" aria-hidden=\"true\"></span>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n\n <!-- ===== Single-month view ===== -->\n @if (currentView === 'date' && numberOfMonths <= 1) {\n <div class=\"flex items-center justify-between gap-2 pb-4 pt-1\">\n <div class=\"flex items-center gap-1\">\n <button type=\"button\" (click)=\"setView('month')\" [class]=\"headerBtnClass('month')\">\n {{ viewDate | date: 'MMMM' }}\n </button>\n <button type=\"button\" (click)=\"setView('year')\" [class]=\"headerBtnClass('year')\">\n {{ viewDate | date: 'yyyy' }}\n </button>\n </div>\n <div class=\"flex items-center space-x-1\">\n <button\n type=\"button\"\n (click)=\"prev()\"\n [class]=\"navBtnClass\"\n aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n </div>\n </div>\n <div class=\"space-y-2 duration-200 animate-in fade-in zoom-in-95\">\n <div class=\"grid w-full grid-cols-7 gap-y-1\">\n @for (day of weekDays; track day) {\n <span class=\"w-9 text-center text-[0.8rem] font-normal text-muted-foreground\">\n {{ day }}\n </span>\n }\n </div>\n <div\n role=\"grid\"\n class=\"grid w-full grid-cols-7 gap-y-1\"\n (keydown)=\"onGridKeydown($event)\">\n @for (date of daysInMonth; track date) {\n <button\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"date | date: 'fullDate'\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date)\">\n {{ date | date: 'd' }}\n </button>\n }\n </div>\n </div>\n }\n </div>\n `,\n})\nexport class RangeCalendarComponent implements OnInit, OnChanges, ControlValueAccessor {\n @Input() class = '';\n @Input() disablePastDates = false;\n /** Renders the calendar's own border/background/shadow. Set `false` inside a\n * card/popover that already provides chrome. @default true */\n @Input() bordered = true;\n /** Number of consecutive months rendered side by side. @default 1 */\n @Input() numberOfMonths = 1;\n @Output() rangeSelect = new EventEmitter<DateRange>(); // Emits whenever selection changes\n\n currentView: 'date' | 'month' | 'year' = 'date';\n viewDate: Date = new Date();\n /** Which visible month the quick pickers are editing; always 0 in the single-month view. */\n pickerMonthIndex = 0;\n\n // The Range Value\n value: DateRange = { start: null, end: null };\n\n weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n daysInMonth: Date[] = [];\n /** Grids for each visible month (multi-month view). */\n visibleMonths: { date: Date; days: Date[] }[] = [];\n months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n years: number[] = [];\n\n navBtnClass = cn(\n 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 border border-input rounded-md flex items-center justify-center hover:bg-accent hover:text-accent-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n onTouched: () => void = () => {};\n onChange: (value: DateRange) => void = () => {};\n protected cn = cn;\n\n constructor(\n private cdr: ChangeDetectorRef,\n private el: ElementRef<HTMLElement>\n ) {}\n\n ngOnInit() {\n this.generateDays();\n this.generateYears();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Rebuild the month grids when the visible-month count changes after init\n // (e.g. a `[numberOfMonths]` toggle), so single ⇄ multi-month is reactive.\n if (changes['numberOfMonths'] && !changes['numberOfMonths'].firstChange) {\n this.generateDays();\n }\n }\n\n // --- Date Generation Logic (Same as Calendar) ---\n generateDays() {\n const start = startOfWeek(startOfMonth(this.viewDate));\n const end = endOfWeek(endOfMonth(this.viewDate));\n this.daysInMonth = eachDayOfInterval({ start, end });\n\n // Multi-month grids: viewDate is the leftmost month, then N-1 consecutive.\n const count = Math.max(1, this.numberOfMonths);\n this.visibleMonths = Array.from({ length: count }, (_, i) => {\n const monthDate = addMonths(this.viewDate, i);\n return {\n date: monthDate,\n days: eachDayOfInterval({\n start: startOfWeek(startOfMonth(monthDate)),\n end: endOfWeek(endOfMonth(monthDate)),\n }),\n };\n });\n }\n\n generateYears() {\n const currentYear = this.pickerDate.getFullYear();\n this.years = Array.from({ length: 16 }, (_, i) => currentYear - 6 + i);\n }\n\n /** The month the quick pickers act on — `viewDate` shifted to the clicked panel. */\n get pickerDate(): Date {\n return addMonths(this.viewDate, this.pickerMonthIndex);\n }\n\n /** `monthIndex` is the index of the visible month whose header was clicked. */\n setView(view: 'date' | 'month' | 'year', monthIndex = 0) {\n this.pickerMonthIndex = view === 'date' ? 0 : monthIndex;\n this.currentView = view;\n if (view === 'year') this.generateYears();\n }\n\n headerBtnClass(view: 'month' | 'year') {\n return cn(\n 'text-sm font-semibold px-2 py-1 rounded transition-colors',\n this.currentView === view\n ? 'bg-secondary text-secondary-foreground'\n : 'hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n prev() {\n if (this.currentView === 'date') {\n this.viewDate = subMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = subYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n }\n }\n\n next() {\n if (this.currentView === 'date') {\n this.viewDate = addMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = addYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n }\n }\n\n // --- Range Selection Logic ---\n\n selectDate(date: Date) {\n if (this.isDateDisabled(date)) return;\n\n const { start, end } = this.value;\n\n // 1. If start exists but end doesn't\n if (start && !end) {\n if (isBefore(date, start)) {\n // User clicked earlier date -> Reset start\n this.value = { start: date, end: null };\n } else {\n // User clicked later date -> Complete range\n this.value = { start, end: date };\n }\n }\n // 2. If neither exist OR both exist (reset)\n else {\n this.value = { start: date, end: null };\n }\n\n // Only re-anchor the view when the clicked day isn't already on a visible month\n // (so clicking the right-hand month in a 2-month view doesn't jump the calendar).\n const onVisibleMonth = this.visibleMonths.some(m => isSameMonth(date, m.date));\n if (!onVisibleMonth) {\n this.viewDate = startOfMonth(date);\n this.generateDays();\n }\n\n this.onChange(this.value);\n this.rangeSelect.emit(this.value);\n this.onTouched();\n }\n\n // --- Grid keyboard navigation (date view only) ---\n\n /**\n * Arrow keys move by day/week, Home/End to week edges, PageUp/PageDown by\n * month. Enter/Space are left to the native day <button>. The focused day is\n * read from its `data-date` key, so navigation is independent of grid layout\n * (adjacent-month days aren't rendered in the multi-month view).\n */\n onGridKeydown(event: KeyboardEvent) {\n if (this.numberOfMonths <= 1 && this.currentView !== 'date') return;\n\n const key = (event.target as HTMLElement).getAttribute('data-date');\n if (!key) return;\n const current = this.keyToDate(key);\n\n let next: Date | null = null;\n switch (event.key) {\n case 'ArrowLeft':\n next = addDays(current, -1);\n break;\n case 'ArrowRight':\n next = addDays(current, 1);\n break;\n case 'ArrowUp':\n next = addDays(current, -7);\n break;\n case 'ArrowDown':\n next = addDays(current, 7);\n break;\n case 'Home':\n next = startOfWeek(current);\n break;\n case 'End':\n next = endOfWeek(current);\n break;\n case 'PageUp':\n next = subMonths(current, 1);\n break;\n case 'PageDown':\n next = addMonths(current, 1);\n break;\n default:\n return;\n }\n\n event.preventDefault();\n this.focusDay(next);\n }\n\n private focusDay(date: Date) {\n const selector = `[data-date=\"${this.fmtKey(date)}\"]`;\n let btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n if (btn) {\n btn.focus();\n return;\n }\n\n // Target day isn't currently rendered → page the view toward it, then retry.\n if (this.numberOfMonths > 1) {\n this.viewDate = isBefore(date, this.visibleMonths[0].date)\n ? subMonths(this.viewDate, 1)\n : addMonths(this.viewDate, 1);\n } else {\n this.viewDate = startOfMonth(date);\n }\n this.generateDays();\n this.cdr.detectChanges();\n btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n btn?.focus();\n }\n\n selectMonth(monthIndex: number) {\n this.applyPickerDate(setMonth(this.pickerDate, monthIndex));\n }\n\n selectYear(year: number) {\n this.applyPickerDate(setYear(this.pickerDate, year));\n }\n\n /**\n * Re-anchors the view so the panel the picker was opened from lands on `date`,\n * then returns to the day grid. In the single-month view the offset is 0, so\n * this is just \"show that month\".\n */\n private applyPickerDate(date: Date) {\n this.viewDate = startOfMonth(subMonths(date, this.pickerMonthIndex));\n this.pickerMonthIndex = 0;\n this.currentView = 'date';\n this.generateDays();\n }\n\n // --- Visual Styling for Range ---\n\n isSelected(date: Date): boolean {\n const { start, end } = this.value;\n return !!((start && isSameDay(date, start)) || (end && isSameDay(date, end)));\n }\n\n /** True when `date` belongs to `refMonth` (used to blank out adjacent-month cells). */\n isCurrentMonth(date: Date, refMonth: Date): boolean {\n return isSameMonth(date, refMonth);\n }\n\n /** Stable, timezone-safe `data-date` key used to locate day buttons for focus. */\n fmtKey(date: Date): string {\n return format(date, 'yyyy-MM-dd');\n }\n\n private keyToDate(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d);\n }\n\n getDayClass(date: Date, refMonth: Date = this.viewDate) {\n const { start, end } = this.value;\n const isOutside = !isSameMonth(date, refMonth);\n const isDisabled = this.isDateDisabled(date);\n\n // Range Checks\n const isStart = start && isSameDay(date, start);\n const isEnd = end && isSameDay(date, end);\n const isInside = start && end && isWithinInterval(date, { start, end });\n const isTodayDate = isToday(date);\n\n return cn(\n // Base: h-9 w-9, but we remove margins/rounding for the 'strip' effect\n 'h-9 w-9 p-0 font-normal text-sm transition-all flex items-center justify-center relative z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n\n // Default State (Not selected, Not disabled)\n !isInside &&\n !isStart &&\n !isEnd &&\n !isDisabled &&\n 'hover:bg-accent hover:text-accent-foreground rounded-md',\n\n // The \"Caps\": Start and End\n (isStart || isEnd) &&\n 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',\n\n // The \"Strip\": Dates between start and end\n isInside && !isStart && !isEnd && 'bg-accent text-accent-foreground rounded-none',\n\n // Connecting the Caps to the Strip (Rectangular inside edges)\n isStart && end && 'rounded-l-md rounded-r-none',\n isEnd && start && 'rounded-r-md rounded-l-none',\n // If no end date yet, Start should be fully rounded\n isStart && !end && 'rounded-md',\n\n // Muted/Disabled logic\n !isInside &&\n isTodayDate &&\n !isStart &&\n !isEnd &&\n 'bg-accent/50 text-accent-foreground rounded-md',\n (isOutside || isDisabled) && 'text-muted-foreground opacity-50',\n isDisabled && 'cursor-not-allowed'\n );\n }\n\n isDateDisabled(date: Date): boolean {\n return this.disablePastDates ? isBefore(date, startOfDay(new Date())) : false;\n }\n\n // --- CVA Implementation ---\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = val;\n if (val.start) this.viewDate = val.start;\n this.generateDays();\n this.generateYears();\n }\n }\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "types/date-range.ts",
|
|
19
|
+
"target": "ui/types/date-range.ts",
|
|
20
|
+
"content": "export type DateRange = {\n start: Date | null;\n end: Date | null;\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "utils/cn.ts",
|
|
24
|
+
"target": "ui/utils/cn.ts",
|
|
25
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reasoning",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Reasoning",
|
|
5
|
+
"description": "Reasoning component with size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "reasoning.component.ts",
|
|
14
|
+
"target": "ui/reasoning.component.ts",
|
|
15
|
+
"content": "import {\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\n/**\n * Coordinates a `tolle-reasoning` with its trigger and content.\n *\n * The trigger and content are OnPush children, so the open/streaming/duration\n * state they render from is published here rather than read off the parent.\n *\n * Provided on `ReasoningComponent`, so each trace gets its own instance.\n */\n@Injectable()\nexport class ReasoningService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the trace is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly streamingSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the model is still producing the trace. */\n readonly streaming$ = this.streamingSource.asObservable();\n\n private readonly durationSource = new BehaviorSubject<number>(0);\n /** Emits how long the model spent reasoning, in seconds. */\n readonly duration$ = this.durationSource.asObservable();\n\n /** True while the trace is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n setOpen(value: boolean): void {\n if (this.openSource.value !== value) this.openSource.next(value);\n }\n\n toggle(): void {\n this.setOpen(!this.openSource.value);\n }\n\n setStreaming(value: boolean): void {\n if (this.streamingSource.value !== value) this.streamingSource.next(value);\n }\n\n setDuration(value: number): void {\n if (this.durationSource.value !== value) this.durationSource.next(value);\n }\n}\n\nconst reasoningVariants = cva('flex w-full flex-col gap-2', {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n lg: 'text-base',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\nexport type ReasoningProps = VariantProps<typeof reasoningVariants>;\n\n/**\n * A collapsible view of the model's reasoning, collapsed by default.\n *\n * While `streaming` is true the trigger reads \"Thinking…\"; once it finishes the\n * trigger reports how long the model thought, using `duration`.\n *\n * Compose it from `tolle-reasoning-trigger` and `tolle-reasoning-content`.\n * @new\n */\n@Component({\n selector: 'tolle-reasoning',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [ReasoningService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"open ? 'open' : 'closed'\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ReasoningComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the trace is expanded. @default false */\n @Input() open = false;\n /** True while the model is still producing the trace. @default false */\n @Input() streaming = false;\n /** Seconds the model spent reasoning, shown once streaming finishes. @default 0 */\n @Input() duration = 0;\n /** Text scale of the trace. @default 'default' */\n @Input() size: ReasoningProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trace via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the trace is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n /** True while an `open` input is being pushed into the service. */\n private applyingInput = false;\n\n ngOnInit(): void {\n this.sync();\n // `skip(1)` drops the BehaviorSubject's current value so simply mounting\n // the component never emits a spurious `openChange`.\n this.subscriptions.add(\n this.service.open$.pipe(skip(1)).subscribe(open => {\n this.open = open;\n this.cdr.markForCheck();\n // Only user-driven toggles are worth reporting; echoing a change the\n // parent just made would fight a `[(open)]` binding.\n if (!this.applyingInput) this.openChange.emit(open);\n })\n );\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['open']) this.applyInput(this.open);\n this.service.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private applyInput(open: boolean): void {\n this.applyingInput = true;\n try {\n this.service.setOpen(open);\n } finally {\n this.applyingInput = false;\n }\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(reasoningVariants({ size: this.size }), this.class);\n }\n\n private sync(): void {\n this.service.setOpen(this.open);\n this.service.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\n }\n}\n\n/**\n * Toggle for a `tolle-reasoning`. Shows a live \"Thinking…\" label while the\n * trace is streaming and the elapsed thinking time once it is done.\n */\n@Component({\n selector: 'tolle-reasoning-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-reasoning-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\">\n <i [class]=\"icon\" [class.animate-pulse]=\"streaming\" aria-hidden=\"true\"></i>\n\n <span data-reasoning-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class ReasoningTriggerComponent 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 /** Remixicon class shown before the label. @default 'ri-brain-line' */\n @Input() icon = 'ri-brain-line';\n /** Label shown while the trace is streaming. @default 'Thinking…' */\n @Input() thinkingLabel = 'Thinking…';\n /** Label used when the trace finished without a measured duration. @default 'Thought for a few seconds' */\n @Input() fallbackLabel = 'Thought for a few seconds';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n streaming = false;\n duration = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe(open => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.streaming$.subscribe(streaming => {\n this.streaming = streaming;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.duration$.subscribe(duration => {\n this.duration = duration;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** \"Thinking…\" while streaming, otherwise how long the model thought. */\n get label(): string {\n if (this.streaming) return this.thinkingLabel;\n if (!this.duration || this.duration < 1) return this.fallbackLabel;\n const rounded = Math.round(this.duration);\n return 'Thought for ' + rounded + (rounded === 1 ? ' second' : ' seconds');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/** The reasoning text itself. Rendered only while the trace is expanded. */\n@Component({\n selector: 'tolle-reasoning-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (open) {\n <div data-reasoning-content [class]=\"computedClass\">\n @if (text) {\n <p class=\"whitespace-pre-wrap\">{{ text }}</p>\n }\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class ReasoningContentComponent 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 /** Reasoning text to render; project your own markup instead when richer. @default '' */\n @Input() text = '';\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe(open => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('border-l-2 border-border pl-4 text-muted-foreground', 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": "resizable-panel-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable Panel Item",
|
|
5
|
+
"description": "Resizable Panel Item component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"resizable-panel"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "resizable-panel-item.component.ts",
|
|
14
|
+
"target": "ui/resizable-panel-item.component.ts",
|
|
15
|
+
"content": "import { Component, Input, inject, HostBinding } from '@angular/core';\n\nimport { ResizablePanelComponent } from './resizable-panel.component';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-resizable-panel-item',\n imports: [],\n template: `\n <div [class]=\"computedContainerClass\">\n <ng-content></ng-content>\n </div>\n\n @if (resizable && !isLast) {\n <div [class]=\"computedHandleClass\" (mousedown)=\"onHandleMouseDown($event)\">\n <div class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n <div [class]=\"computedHandleIndicatorClass\"></div>\n </div>\n </div>\n }\n `,\n styles: [\n `\n :host {\n display: block;\n position: relative;\n overflow: visible;\n }\n `,\n ],\n})\nexport class ResizablePanelItemComponent {\n @Input() @HostBinding('style.flex') size: number = 1;\n @Input() minSize: number = 10; // percentage or pixels\n @Input() maxSize?: number;\n @Input() resizable: boolean = true;\n @Input() class: string = '';\n\n isLast: boolean = false;\n private container = inject(ResizablePanelComponent);\n\n get computedContainerClass() {\n return cn('relative h-full w-full overflow-hidden', this.class);\n }\n\n get computedHandleClass() {\n const isHorizontal = this.container?.direction === 'horizontal';\n return cn(\n 'absolute z-20',\n isHorizontal\n ? 'top-0 bottom-0 -right-2 w-4 cursor-col-resize'\n : 'left-0 right-0 -bottom-2 h-4 cursor-row-resize',\n 'hover:bg-primary/5 active:bg-primary/10'\n );\n }\n\n get computedHandleIndicatorClass() {\n const isHorizontal = this.container?.direction === 'horizontal';\n return cn('bg-muted-foreground/40 rounded-full', isHorizontal ? 'w-1 h-6' : 'h-1 w-6');\n }\n\n onHandleMouseDown(event: MouseEvent) {\n this.container.startResize(this, event);\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": "resizable-panel",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable Panel",
|
|
5
|
+
"description": "Resizable Panel component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"resizable-panel-item"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "resizable-panel.component.ts",
|
|
14
|
+
"target": "ui/resizable-panel.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ElementRef,\n inject,\n ChangeDetectorRef,\n OnDestroy,\n forwardRef,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n imports: [],\n template: `\n <div [class]=\"computedContainerClass\">\n <div\n class=\"flex h-full w-full\"\n [class.flex-col]=\"direction === 'vertical'\"\n [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [\n `\n /* Fills its parent by default so consumers don't have to remember a\n manual h-full — the flex row/col wrapper below only has something to\n distribute once this and the container div actually have a height. */\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n `,\n ],\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent))\n panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => (p.isLast = i === panels.length - 1));\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize =\n this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(\n panelA.minSize,\n this.startPanelSizes[this.activePanelIndex] + deltaPercent\n );\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(\n panelB.minSize,\n this.startPanelSizes[this.activePanelIndex + 1] - actualDelta\n );\n\n panelA.size = newSizeA;\n panelB.size =\n this.startPanelSizes[this.activePanelIndex] +\n this.startPanelSizes[this.activePanelIndex + 1] -\n panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size =\n this.startPanelSizes[this.activePanelIndex] +\n this.startPanelSizes[this.activePanelIndex + 1] -\n panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn('w-full h-full', 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": "resizable",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable",
|
|
5
|
+
"description": "Resizable component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "resizable.component.ts",
|
|
12
|
+
"target": "ui/resizable.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n AfterViewInit,\n OnDestroy,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-resizable',\n imports: [],\n template: `\n <div\n #container\n [class]=\"computedContainerClass\"\n [style.width.px]=\"width\"\n [style.height.px]=\"height\"\n [style.minWidth.px]=\"minWidth\"\n [style.minHeight.px]=\"minHeight\"\n [style.maxWidth.px]=\"maxWidth\"\n [style.maxHeight.px]=\"maxHeight\">\n <ng-content></ng-content>\n\n <!-- Resize handles -->\n @if (resizable) {\n <div\n class=\"absolute -bottom-1 -right-1 z-10 h-3 w-3 cursor-se-resize\"\n (mousedown)=\"onResizeStart($event, 'both')\">\n <div\n class=\"absolute bottom-0 right-0 h-2 w-2 rounded-sm border-b-2 border-r-2 border-muted-foreground/50\"></div>\n </div>\n }\n\n @if (resizable && directions.includes('right')) {\n <div\n class=\"absolute -right-1 bottom-0 top-0 z-10 w-2 cursor-col-resize\"\n (mousedown)=\"onResizeStart($event, 'horizontal')\">\n <div\n class=\"absolute right-0 top-1/2 h-4 w-1 -translate-y-1/2 rounded-full bg-muted-foreground/30\"></div>\n </div>\n }\n\n @if (resizable && directions.includes('bottom')) {\n <div\n class=\"absolute -bottom-1 left-0 right-0 z-10 h-2 cursor-row-resize\"\n (mousedown)=\"onResizeStart($event, 'vertical')\">\n <div\n class=\"absolute bottom-0 left-1/2 h-1 w-4 -translate-x-1/2 rounded-full bg-muted-foreground/30\"></div>\n </div>\n }\n </div>\n `,\n styles: [\n `\n :host {\n display: inline-block;\n position: relative;\n }\n `,\n ],\n})\nexport class ResizableComponent implements AfterViewInit, OnDestroy {\n @Input() width: number = 300;\n @Input() height: number = 200;\n @Input() minWidth: number = 50;\n @Input() minHeight: number = 50;\n @Input() maxWidth?: number;\n @Input() maxHeight?: number;\n @Input() resizable: boolean = true;\n @Input() directions: ('right' | 'bottom' | 'both')[] = ['right', 'bottom', 'both'];\n @Input() class: string = '';\n @Input() containerClass: string = '';\n\n @Output() resizeStart = new EventEmitter<void>();\n @Output() resize = new EventEmitter<{ width: number; height: number }>();\n @Output() resizeEnd = new EventEmitter<{ width: number; height: number }>();\n\n @ViewChild('container') container!: ElementRef<HTMLDivElement>;\n\n private isResizing = false;\n private resizeDirection: 'horizontal' | 'vertical' | 'both' | null = null;\n private startX = 0;\n private startY = 0;\n private startWidth = 0;\n private startHeight = 0;\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterViewInit() {\n this.setupGlobalListeners();\n }\n\n ngOnDestroy() {\n this.cleanupGlobalListeners();\n }\n\n get computedContainerClass() {\n return cn(\n 'relative bg-background border border-input rounded-md shadow-sm',\n 'transition-[box-shadow] duration-200',\n this.resizable && 'hover:shadow-md',\n this.isResizing && 'shadow-lg ring-2 ring-ring/20',\n this.containerClass,\n this.class\n );\n }\n\n onResizeStart(event: MouseEvent, direction: 'horizontal' | 'vertical' | 'both') {\n if (!this.resizable) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n this.isResizing = true;\n this.resizeDirection = direction;\n this.startX = event.clientX;\n this.startY = event.clientY;\n this.startWidth = this.width;\n this.startHeight = this.height;\n\n this.resizeStart.emit();\n\n // Add active styling\n document.body.style.userSelect = 'none';\n document.body.style.cursor = this.getResizeCursor(direction);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing || !this.resizeDirection) return;\n\n const deltaX = event.clientX - this.startX;\n const deltaY = event.clientY - this.startY;\n\n let newWidth = this.startWidth;\n let newHeight = this.startHeight;\n\n if (this.resizeDirection === 'horizontal' || this.resizeDirection === 'both') {\n newWidth = Math.max(this.minWidth, this.startWidth + deltaX);\n if (this.maxWidth) newWidth = Math.min(this.maxWidth, newWidth);\n }\n\n if (this.resizeDirection === 'vertical' || this.resizeDirection === 'both') {\n newHeight = Math.max(this.minHeight, this.startHeight + deltaY);\n if (this.maxHeight) newHeight = Math.min(this.maxHeight, newHeight);\n }\n\n this.width = newWidth;\n this.height = newHeight;\n\n this.resize.emit({ width: newWidth, height: newHeight });\n }\n\n private onMouseUp() {\n if (!this.isResizing) return;\n\n this.isResizing = false;\n this.resizeDirection = null;\n\n // Reset cursor and selection\n document.body.style.userSelect = '';\n document.body.style.cursor = '';\n\n this.resizeEnd.emit({ width: this.width, height: this.height });\n }\n\n private getResizeCursor(direction: 'horizontal' | 'vertical' | 'both'): string {\n switch (direction) {\n case 'horizontal':\n return 'col-resize';\n case 'vertical':\n return 'row-resize';\n case 'both':\n return 'se-resize';\n default:\n return '';\n }\n }\n\n private setupGlobalListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.onMouseUp();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupGlobalListeners() {\n if (this.mouseMoveListener) {\n document.removeEventListener('mousemove', this.mouseMoveListener);\n }\n if (this.mouseUpListener) {\n document.removeEventListener('mouseup', this.mouseUpListener);\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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scroll-area",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Scroll Area",
|
|
5
|
+
"description": "Scroll Area component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "scroll-area.component.ts",
|
|
12
|
+
"target": "ui/scroll-area.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-scroll-area',\n imports: [],\n template: `\n <div [class]=\"cn('relative overflow-hidden', class)\" [style.height]=\"height\">\n <div\n class=\"scrollbar-hide h-full w-full overflow-auto rounded-[inherit]\"\n [class.overflow-x-hidden]=\"orientation === 'vertical'\"\n [class.overflow-y-hidden]=\"orientation === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [\n `\n :host {\n display: block;\n }\n\n .scrollbar-hide::-webkit-scrollbar {\n width: 6px;\n height: 6px;\n }\n\n .scrollbar-hide::-webkit-scrollbar-track {\n background: transparent;\n }\n\n .scrollbar-hide::-webkit-scrollbar-thumb {\n background: rgb(var(--border));\n border-radius: 10px;\n }\n\n .scrollbar-hide::-webkit-scrollbar-thumb:hover {\n background: rgb(var(--muted-foreground));\n }\n `,\n ],\n})\nexport class ScrollAreaComponent {\n @Input() class: string = '';\n @Input() height: string = '100%';\n @Input() orientation: 'vertical' | 'horizontal' | 'both' = 'both';\n\n 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
|
+
}
|