@tolle_/tolle-ui 21.1.2 → 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,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "accordion-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Accordion Item",
|
|
5
|
+
"description": "Accordion Item component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "accordion-item.component.ts",
|
|
12
|
+
"target": "ui/accordion-item.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { trigger, state, style, transition, animate } from '@angular/animations';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-accordion-item',\n styles: [':host { display: block; }'],\n imports: [],\n animations: [\n trigger('expandCollapse', [\n state(\n 'collapsed',\n style({\n height: '0px',\n opacity: '0',\n overflow: 'hidden',\n visibility: 'hidden',\n })\n ),\n state(\n 'expanded',\n style({\n height: '*', // \"Star\" means actual content height\n opacity: '1',\n overflow: 'hidden',\n visibility: 'visible',\n })\n ),\n // Use cubic-bezier to match Tailwind/shadcn-ui default ease\n transition('collapsed <=> expanded', [animate('300ms cubic-bezier(0.87, 0, 0.13, 1)')]),\n ]),\n ],\n template: `\n <div [class]=\"cn('flex flex-col border-b border-border', class)\">\n <button\n type=\"button\"\n (click)=\"toggle()\"\n [id]=\"'acc-trigger-' + id\"\n [attr.aria-controls]=\"'acc-content-' + id\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n class=\"group flex flex-1 cursor-pointer items-center justify-between py-4 font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&[data-state=open]>i]:rotate-180\">\n <span class=\"text-left group-hover:underline\">{{ title }}</span>\n <i\n class=\"ri-arrow-down-s-line text-lg text-muted-foreground transition-transform duration-200\"></i>\n </button>\n\n <div\n [@expandCollapse]=\"isOpen ? 'expanded' : 'collapsed'\"\n [id]=\"'acc-content-' + id\"\n role=\"region\"\n [attr.aria-labelledby]=\"'acc-trigger-' + id\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n class=\"overflow-hidden\">\n <div class=\"pb-4 pt-0 text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </div>\n </div>\n </div>\n `,\n})\nexport class AccordionItemComponent {\n @Input() title: string = '';\n @Input() class: string = '';\n @Input() id!: string | number;\n\n isOpen = false;\n\n // This will be set by the parent Accordion component\n onToggle?: (id: string | number) => void;\n\n toggle() {\n if (this.onToggle) {\n this.onToggle(this.id);\n } else {\n this.isOpen = !this.isOpen;\n }\n }\n\n protected readonly 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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "accordion",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Accordion",
|
|
5
|
+
"description": "Accordion component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"accordion-item"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "accordion.component.ts",
|
|
14
|
+
"target": "ui/accordion.component.ts",
|
|
15
|
+
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit } from '@angular/core';\n\nimport { AccordionItemComponent } from './accordion-item.component';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-accordion',\n styles: [':host { display: block; }'],\n imports: [], // No AccordionItemComponent import needed here if projected via ng-content\n template: `\n <div [class]=\"cn('w-full', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class AccordionComponent implements AfterContentInit {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() class: string = '';\n\n @ContentChildren(AccordionItemComponent) items!: QueryList<AccordionItemComponent>;\n\n ngAfterContentInit() {\n // 1. Assign IDs and Listeners on load\n this.initItems();\n\n // 2. Re-init if items change dynamically (optional but good for robustness)\n this.items.changes.subscribe(() => this.initItems());\n }\n\n private initItems() {\n this.items.forEach((item, index) => {\n // Auto-assign ID if missing\n if (item.id === undefined) item.id = `accordion-item-${index}`;\n\n // Set up the toggle bridge\n item.onToggle = id => this.handleToggle(id);\n });\n }\n\n private handleToggle(selectedId: string | number) {\n this.items.forEach(item => {\n if (item.id === selectedId) {\n // Toggle the clicked item\n item.isOpen = !item.isOpen;\n } else if (this.type === 'single') {\n // Close others if in single mode\n item.isOpen = false;\n }\n });\n }\n\n protected readonly cn = cn;\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,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alert-dialog-dynamic",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Alert Dialog Dynamic",
|
|
5
|
+
"description": "Alert Dialog Dynamic component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"alert-dialog",
|
|
9
|
+
"button"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [
|
|
12
|
+
"class-variance-authority"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "alert-dialog-dynamic.component.ts",
|
|
17
|
+
"target": "ui/alert-dialog-dynamic.component.ts",
|
|
18
|
+
"content": "import { Component, Inject, inject, OnInit } from '@angular/core';\n\nimport {\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent,\n} from './alert-dialog.component';\nimport { ButtonComponent } from './button.component';\nimport { AlertDialogConfig, AlertDialogRef } from './alert-dialog.types';\n\n@Component({\n selector: 'tolle-alert-dialog-dynamic',\n styles: [':host { display: block; }'],\n imports: [\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent,\n ButtonComponent,\n ],\n template: `\n <tolle-alert-dialog [open]=\"!closing\" (openChange)=\"onOpenChange($event)\">\n <tolle-alert-dialog-content [size]=\"config.size || 'lg'\">\n <tolle-alert-dialog-header>\n <tolle-alert-dialog-title>{{ config.title }}</tolle-alert-dialog-title>\n <tolle-alert-dialog-description>\n {{ config.description }}\n </tolle-alert-dialog-description>\n </tolle-alert-dialog-header>\n <tolle-alert-dialog-footer>\n <tolle-alert-dialog-cancel (cancelled)=\"result = false\">\n <tolle-button variant=\"outline\">{{ config.cancelText || 'Cancel' }}</tolle-button>\n </tolle-alert-dialog-cancel>\n <tolle-alert-dialog-action (confirmed)=\"result = true\">\n <tolle-button [variant]=\"config.variant === 'destructive' ? 'destructive' : 'default'\">\n {{ config.actionText || 'Continue' }}\n </tolle-button>\n </tolle-alert-dialog-action>\n </tolle-alert-dialog-footer>\n </tolle-alert-dialog-content>\n </tolle-alert-dialog>\n `,\n})\nexport class AlertDialogDynamicComponent {\n config!: AlertDialogConfig;\n dialogRef!: AlertDialogRef;\n\n /**\n * Set by `AlertDialogService` once the dialog's result is ready, so the\n * content panel plays its exit animation before the overlay is torn down.\n */\n closing = false;\n\n /**\n * Result recorded by the action/cancel buttons' `confirmed`/`cancelled`\n * outputs, which fire *before* the button drives the dialog closed. The\n * single close path (`onOpenChange`) then reads it, so a confirm reports\n * `true` instead of racing with the button's own `false` close signal.\n */\n result = false;\n\n onOpenChange(open: boolean) {\n if (!open) {\n this.dialogRef.close(this.result);\n }\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "alert-dialog.types.ts",
|
|
22
|
+
"target": "ui/alert-dialog.types.ts",
|
|
23
|
+
"content": "import { Subject, Observable } from 'rxjs';\n\nexport type AlertDialogSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | 'fit';\n\nexport interface AlertDialogConfig {\n title: string;\n description: string;\n cancelText?: string;\n actionText?: string;\n variant?: 'default' | 'destructive';\n size?: AlertDialogSize;\n}\n\nexport class AlertDialogRef {\n private readonly _afterClosed = new Subject<boolean>();\n afterClosed$: Observable<boolean> = this._afterClosed.asObservable();\n\n /** Guards against the dialog's several close paths racing to emit twice. */\n private closed = false;\n\n close(result: boolean = false): void {\n if (this.closed) return;\n this.closed = true;\n this._afterClosed.next(result);\n this._afterClosed.complete();\n }\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "utils/cn.ts",
|
|
27
|
+
"target": "ui/utils/cn.ts",
|
|
28
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alert-dialog",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Alert Dialog",
|
|
5
|
+
"description": "Alert Dialog component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "alert-dialog.component.ts",
|
|
12
|
+
"target": "ui/alert-dialog.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n HostListener,\n Injectable,\n inject,\n TemplateRef,\n ViewChild,\n ViewContainerRef,\n OnDestroy,\n OnInit,\n ContentChild,\n forwardRef,\n} from '@angular/core';\n\nimport { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay';\nimport { TemplatePortal, ComponentPortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { AlertDialogSize } from './alert-dialog.types';\n\n/**\n * Safety net if the exit animation's `animationend` never fires — reduced\n * motion, no matching keyframe, or a panel that never rendered — so an\n * alert dialog can never get stuck open forever. Also used by\n * `alert-dialog.service.ts` and `alert-dialog-dynamic.component.ts`, which\n * animate the same content component through a different overlay wiring.\n */\nexport const CLOSE_FALLBACK_MS = 300;\n\n@Injectable()\nclass AlertDialogInternalService {\n private openSubject = new BehaviorSubject<boolean>(false);\n open$ = this.openSubject.asObservable();\n\n setOpen(value: boolean) {\n this.openSubject.next(value);\n }\n\n getOpen() {\n return this.openSubject.getValue();\n }\n\n toggle() {\n this.setOpen(!this.getOpen());\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [AlertDialogInternalService],\n template: `<ng-content></ng-content>`,\n})\nexport class AlertDialogComponent {\n @Input() set open(val: boolean) {\n this.alertDialogService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n\n private alertDialogService = inject(AlertDialogInternalService);\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-trigger',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onOpen()',\n },\n})\nexport class AlertDialogTriggerComponent {\n private alertDialogService = inject(AlertDialogInternalService);\n\n onOpen() {\n this.alertDialogService.setOpen(true);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-portal',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <ng-template #portalContent>\n <ng-content></ng-content>\n </ng-template>\n `,\n})\nexport class AlertDialogPortalComponent implements OnInit, OnDestroy {\n @ViewChild('portalContent', { static: true }) portalContent!: TemplateRef<any>;\n private alertDialogService = inject(AlertDialogInternalService);\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private overlayRef?: OverlayRef;\n /** Element focused before the dialog opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n ngOnInit() {\n this.alertDialogService.open$.subscribe(open => {\n if (open) {\n this.show();\n } else {\n this.hide();\n }\n });\n }\n\n private show() {\n if (this.overlayRef) return;\n\n // Remember the trigger so focus can be restored on close.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const config = new OverlayConfig({\n hasBackdrop: true,\n backdropClass: [\n 'cdk-overlay-backdrop',\n 'bg-black/80',\n 'backdrop-blur-sm',\n 'data-[state=open]:animate-in',\n 'data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0',\n 'data-[state=open]:fade-in-0',\n ],\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),\n scrollStrategy: this.overlay.scrollStrategies.block(),\n });\n\n this.overlayRef = this.overlay.create(config);\n\n // Alert-dialog semantics: backdrop clicks must NOT dismiss.\n // Escape still closes the dialog.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.alertDialogService.setOpen(false);\n }\n });\n\n const portal = new TemplatePortal(this.portalContent, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n // The backdrop element only exists once attached — setting this any\n // earlier would silently no-op.\n this.overlayRef.backdropElement?.setAttribute('data-state', 'open');\n }\n\n /**\n * Tears the overlay down once its exit animation finishes (or after\n * `CLOSE_FALLBACK_MS`, whichever comes first) instead of instantly, so\n * `data-[state=closed]:animate-out` actually gets a chance to play.\n */\n private hide() {\n const ref = this.overlayRef;\n if (!ref) return;\n\n ref.backdropElement?.setAttribute('data-state', 'closed');\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n ref.detach();\n ref.dispose();\n if (this.overlayRef === ref) this.overlayRef = undefined;\n // Restore focus to the trigger.\n this.previouslyFocused?.focus?.();\n };\n\n const panel = ref.overlayElement.querySelector<HTMLElement>('[data-state]');\n if (panel) {\n panel.addEventListener('animationend', finish, { once: true });\n setTimeout(finish, CLOSE_FALLBACK_MS);\n } else {\n finish();\n }\n }\n\n ngOnDestroy() {\n this.hide();\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-content',\n imports: [A11yModule],\n template: `<div\n [class]=\"computedClass\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n role=\"alertdialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus\n cdkTrapFocusAutoCapture>\n <ng-content></ng-content>\n </div>`,\n host: {\n '[class]': '\"contents\"',\n },\n})\nexport class AlertDialogContentComponent {\n @Input() class: string = '';\n @Input() size: AlertDialogSize = 'lg';\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => AlertDialogTitleComponent)) titleCmp?: AlertDialogTitleComponent;\n @ContentChild(forwardRef(() => AlertDialogDescriptionComponent))\n descriptionCmp?: AlertDialogDescriptionComponent;\n\n private alertDialogService = inject(AlertDialogInternalService);\n isOpen = false;\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.isOpen = val;\n });\n }\n\n get computedClass() {\n return cn(\n 'fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-50 grid w-full gap-4 border border-input bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] rounded-lg',\n {\n 'max-w-xs': this.size === 'xs',\n 'max-w-sm': this.size === 'sm',\n 'max-w-md': this.size === 'md',\n 'max-w-lg': this.size === 'lg',\n 'max-w-xl': this.size === 'xl',\n 'max-w-2xl': this.size === '2xl',\n 'max-w-full': this.size === 'full',\n 'max-w-fit': this.size === 'fit',\n },\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-header',\n imports: [],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' },\n})\nexport class AlertDialogHeaderComponent {\n @Input() class: string = '';\n get computedClass() {\n return cn('flex flex-col space-y-2 text-center sm:text-left', this.class);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-footer',\n imports: [],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' },\n})\nexport class AlertDialogFooterComponent {\n @Input() class: string = '';\n get computedClass() {\n return cn('flex flex-row justify-end space-x-2', this.class);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-title',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' },\n})\nexport class AlertDialogTitleComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-labelledby`. */\n readonly id = `tolle-alert-dialog-title-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() {\n return cn('text-lg font-semibold text-foreground', this.class);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-description',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' },\n})\nexport class AlertDialogDescriptionComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-describedby`. */\n readonly id = `tolle-alert-dialog-description-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() {\n return cn('text-sm text-muted-foreground', this.class);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-action',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n})\nexport class AlertDialogActionComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can run the confirmed action. */\n @Output() confirmed = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.confirmed.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-cancel',\n styles: [':host { display: block; }'],\n imports: [],\n template: `<ng-content></ng-content>`,\n})\nexport class AlertDialogCancelComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can react to a dismissal. */\n @Output() cancelled = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.cancelled.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "alert-dialog.types.ts",
|
|
17
|
+
"target": "ui/alert-dialog.types.ts",
|
|
18
|
+
"content": "import { Subject, Observable } from 'rxjs';\n\nexport type AlertDialogSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | 'fit';\n\nexport interface AlertDialogConfig {\n title: string;\n description: string;\n cancelText?: string;\n actionText?: string;\n variant?: 'default' | 'destructive';\n size?: AlertDialogSize;\n}\n\nexport class AlertDialogRef {\n private readonly _afterClosed = new Subject<boolean>();\n afterClosed$: Observable<boolean> = this._afterClosed.asObservable();\n\n /** Guards against the dialog's several close paths racing to emit twice. */\n private closed = false;\n\n close(result: boolean = false): void {\n if (this.closed) return;\n this.closed = true;\n this._afterClosed.next(result);\n this._afterClosed.complete();\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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alert",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Alert",
|
|
5
|
+
"description": "Alert component with variant options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "alert.component.ts",
|
|
14
|
+
"target": "ui/alert.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter } from '@angular/core';\n\nimport { trigger, transition, style, animate } from '@angular/animations';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst alertVariants = cva(\n 'relative w-full rounded-lg border p-4 [&>i+div]:translate-y-[-3px] [&>i]:absolute [&>i]:left-4 [&>i]:top-4 [&>i]:text-foreground [&>i~div]:pl-7',\n {\n variants: {\n variant: {\n default: 'bg-background text-foreground',\n destructive: 'border-destructive/50 text-destructive [&>i]:text-destructive',\n success: 'border-success/50 text-success [&>i]:text-success',\n warning: 'border-warning/50 text-warning [&>i]:text-warning',\n info: 'border-info/50 text-info [&>i]:text-info',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n }\n);\n\ntype AlertVariants = VariantProps<typeof alertVariants>;\n\n@Component({\n selector: 'tolle-alert',\n styles: [':host { display: block; }'],\n imports: [],\n animations: [\n trigger('fade', [\n transition(':leave', [\n style({ opacity: 1, transform: 'scale(1)' }),\n animate(\n '300ms ease-in-out',\n style({ opacity: 0, transform: 'scale(0.95)', height: 0, margin: 0, padding: 0 })\n ),\n ]),\n ]),\n ],\n template: `\n @if (!dismissed) {\n <div @fade [class]=\"cn(alertVariants({ variant }), class)\" role=\"alert\">\n <ng-content select=\"[icon]\"></ng-content>\n @if (dismissible) {\n <button\n (click)=\"dismiss()\"\n class=\"absolute right-2 top-2 rounded-md p-1 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n type=\"button\">\n <i class=\"ri-close-line text-lg\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n }\n <div>\n @if (title) {\n <h5 class=\"mb-1 font-medium leading-none tracking-tight\">\n {{ title }}\n </h5>\n }\n <div class=\"text-sm opacity-90 [&_p]:leading-relaxed\">\n <ng-content></ng-content>\n </div>\n </div>\n </div>\n }\n `,\n})\nexport class AlertComponent {\n @Input() variant: AlertVariants['variant'] = 'default';\n @Input() title?: string;\n @Input() class: string = '';\n @Input() dismissible: boolean = false;\n\n @Output() onClose = new EventEmitter<void>();\n\n dismissed = false;\n protected alertVariants = alertVariants;\n protected cn = cn;\n\n dismiss() {\n this.dismissed = true;\n this.onClose.emit();\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": "aspect-ratio",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Aspect Ratio",
|
|
5
|
+
"description": "Aspect Ratio component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "aspect-ratio.component.ts",
|
|
12
|
+
"target": "ui/aspect-ratio.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n ViewChild,\n ElementRef,\n AfterViewInit,\n OnDestroy,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-aspect-ratio',\n imports: [],\n template: `\n <div\n #container\n [class]=\"cn('relative w-full overflow-hidden bg-muted/20', class)\"\n [style.aspect-ratio]=\"ratio\">\n @if (isLoading && !hasError) {\n <div class=\"absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted\">\n <i class=\"ri-image-line text-3xl text-muted-foreground/40\"></i>\n </div>\n }\n\n @if (hasError) {\n <div\n class=\"absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-secondary\">\n <i class=\"ri-image-warning-line text-2xl text-destructive\"></i>\n <span class=\"text-[10px] font-medium uppercase tracking-wider text-muted-foreground\"\n >Failed to load</span\n >\n </div>\n }\n\n <ng-content></ng-content>\n </div>\n `,\n styles: [\n `\n :host {\n display: block;\n width: 100%;\n }\n\n /* Target elements projected into the ng-content */\n :host ::ng-deep img,\n :host ::ng-deep video,\n :host ::ng-deep iframe {\n position: absolute;\n height: 100%;\n width: 100%;\n inset: 0;\n object-fit: cover;\n transition: opacity 0.3s ease-in-out;\n }\n\n /* Target direct div wrappers if needed */\n :host ::ng-deep .aspect-ratio-content {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n `,\n ],\n})\nexport class AspectRatioComponent implements AfterViewInit, OnDestroy {\n @Input() ratio: string | number = '16 / 9';\n @Input() class: string = '';\n\n @ViewChild('container') container!: ElementRef<HTMLElement>;\n\n isLoading = true;\n hasError = false;\n\n private cleanupListeners: (() => void)[] = [];\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngAfterViewInit() {\n const el = this.container.nativeElement;\n\n // Use capture phase because load/error do not bubble\n const onLoad = (event: Event) => {\n // Check if the event target is inside our container\n if (el.contains(event.target as Node)) {\n this.onLoad();\n }\n };\n\n const onError = (event: Event) => {\n if (el.contains(event.target as Node)) {\n this.onError();\n }\n };\n\n el.addEventListener('load', onLoad, true);\n el.addEventListener('error', onError, true);\n\n this.cleanupListeners.push(() => {\n el.removeEventListener('load', onLoad, true);\n el.removeEventListener('error', onError, true);\n });\n\n // Check if there are even any media elements. If not, don't show loader.\n // Or if images were already cached/loaded before AfterViewInit\n setTimeout(() => {\n const media = el.querySelectorAll('img, video, iframe');\n if (media.length === 0) {\n this.onLoad();\n } else {\n // Double check if all images are already complete (cached)\n const allComplete = Array.from(media).every(m => {\n if (m instanceof HTMLImageElement) return m.complete;\n return false; // For video/iframe we wait for event\n });\n if (allComplete) {\n this.onLoad();\n }\n }\n }, 50);\n }\n\n onLoad() {\n this.isLoading = false;\n this.hasError = false;\n this.cdr.detectChanges();\n }\n\n onError() {\n this.isLoading = false;\n this.hasError = true;\n this.cdr.detectChanges();\n }\n\n ngOnDestroy() {\n this.cleanupListeners.forEach(cleanup => cleanup());\n }\n\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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "attachment",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Attachment",
|
|
5
|
+
"description": "Attachment component with state and size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "attachment.component.ts",
|
|
14
|
+
"target": "ui/attachment.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst attachmentVariants = cva(\n 'group/attachment relative flex w-full max-w-xs items-center gap-3 overflow-hidden rounded-lg border text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n {\n variants: {\n state: {\n idle: 'border-border bg-card text-card-foreground hover:bg-accent',\n uploading: 'border-border bg-card text-card-foreground',\n error: 'border-destructive bg-destructive/10 text-foreground',\n },\n size: {\n sm: 'gap-2 p-1.5 text-xs',\n default: 'gap-3 p-2 text-sm',\n },\n },\n defaultVariants: { state: 'idle', size: 'default' },\n }\n);\n\nexport type AttachmentProps = VariantProps<typeof attachmentVariants>;\n\n/**\n * A file or image attached to a chat message, with an upload state and an\n * optional progress bar. Wrap several in `tolle-attachment-group`.\n * @new\n */\n@Component({\n selector: 'tolle-attachment',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"name\"\n (click)=\"onOpen($event)\"\n (keydown.enter)=\"onOpen($event)\"\n (keydown.space)=\"onOpen($event)\">\n @if (isImage) {\n <img [src]=\"url\" [alt]=\"name\" class=\"size-10 shrink-0 rounded-md object-cover\" />\n } @else {\n <span\n class=\"flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground\">\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n </span>\n }\n\n <span class=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <span class=\"truncate font-medium leading-none\">{{ name }}</span>\n\n <span class=\"truncate text-xs text-muted-foreground\">\n @if (state === 'error') {\n {{ errorLabel }}\n } @else {\n {{ formattedSize }}\n }\n </span>\n\n @if (state === 'uploading') {\n <span\n class=\"mt-1 block h-1 w-full overflow-hidden rounded-full bg-muted\"\n role=\"progressbar\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n [attr.aria-valuenow]=\"clampedProgress\">\n <span\n class=\"block h-full rounded-full bg-primary transition-all\"\n [style.width.%]=\"clampedProgress\"></span>\n </span>\n }\n </span>\n\n @if (removable) {\n <button\n type=\"button\"\n [attr.aria-label]=\"removeLabel\"\n (click)=\"onRemove($event)\"\n class=\"shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n </button>\n }\n\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class AttachmentComponent 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 /** File name shown as the card's title. */\n @Input() name = '';\n /** File size in bytes; rendered as B / KB / MB. @default 0 */\n @Input() size = 0;\n /** MIME type, e.g. 'image/png'. Chooses the thumbnail or the file icon. */\n @Input() type = '';\n /** Source for the thumbnail and the target of `open`. */\n @Input() url = '';\n /** Upload lifecycle state of the attachment. @default 'idle' */\n @Input() state: AttachmentProps['state'] = 'idle';\n /** Upload progress from 0 to 100; only shown while uploading. @default 0 */\n @Input() progress = 0;\n /** Density of the card. @default 'default' */\n @Input() density: AttachmentProps['size'] = 'default';\n /** Shows a trailing remove button that emits `remove`. @default false */\n @Input() removable = false;\n /** Accessible label for the remove button. @default 'Remove attachment' */\n @Input() removeLabel = 'Remove attachment';\n /** Text shown in place of the size when the state is 'error'. @default 'Upload failed' */\n @Input() errorLabel = 'Upload failed';\n /** Extra Tailwind classes merged onto the card via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the remove button is pressed. */\n @Output() remove = new EventEmitter<Event>();\n /** Emitted when the card is activated by click, Enter, or Space. */\n @Output() open = new EventEmitter<Event>();\n\n get computedClass(): string {\n return cn(attachmentVariants({ state: this.state, size: this.density }), this.class);\n }\n\n /** Whether the attachment renders as an image thumbnail rather than a file icon. */\n get isImage(): boolean {\n return this.type.startsWith('image/') && !!this.url;\n }\n\n /** Remixicon class picked from the MIME type. */\n get iconClass(): string {\n return this.iconForType(this.type);\n }\n\n /** The `size` input rendered for display, e.g. 1536 becomes \"1.5 KB\". */\n get formattedSize(): string {\n return this.formatBytes(this.size);\n }\n\n /** `progress` constrained to the 0-100 the progress bar can render. */\n get clampedProgress(): number {\n if (!Number.isFinite(this.progress)) return 0;\n return Math.min(100, Math.max(0, this.progress));\n }\n\n private formatBytes(bytes: number): string {\n if (!Number.isFinite(bytes) || bytes < 0) return '';\n if (bytes < 1024) return bytes + ' B';\n\n const units = ['KB', 'MB', 'GB', 'TB'];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n\n // One decimal, with a trailing \".0\" dropped: 1536 -> \"1.5 KB\", 2048 -> \"2 KB\".\n return Math.round(value * 10) / 10 + ' ' + units[unit];\n }\n\n private iconForType(type: string): string {\n if (type.startsWith('image/')) return 'ri-image-line';\n if (type.startsWith('video/')) return 'ri-film-line';\n if (type.startsWith('audio/')) return 'ri-music-2-line';\n if (type === 'application/pdf') return 'ri-file-pdf-line';\n if (type.startsWith('text/')) return 'ri-file-text-line';\n if (type.includes('zip') || type.includes('compressed') || type.includes('tar')) {\n return 'ri-file-zip-line';\n }\n if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {\n return 'ri-file-excel-line';\n }\n return 'ri-file-line';\n }\n\n protected onRemove(event: Event): void {\n // The card itself is a trigger; a remove click must not also open it.\n event.stopPropagation();\n this.remove.emit(event);\n }\n\n protected onOpen(event: Event): void {\n if (event.type === 'keydown') event.preventDefault();\n this.open.emit(event);\n }\n}\n\n/** Wrapping row of attachment cards below or above a message. */\n@Component({\n selector: 'tolle-attachment-group',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex flex-wrap items-start gap-2', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class AttachmentGroupComponent 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 row via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Trailing slot for attachment controls — download, retry, remove. Clicks are\n * kept inside the slot so the card's own trigger does not also fire.\n */\n@Component({\n selector: 'tolle-attachment-actions',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" (click)=\"onClick($event)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class AttachmentActionsComponent 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 /** Keeps the actions visible instead of revealing them on hover or focus. @default false */\n @Input() alwaysVisible = false;\n /** Extra Tailwind classes merged onto the slot via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(\n 'flex shrink-0 items-center gap-1 transition-opacity',\n this.alwaysVisible\n ? 'opacity-100'\n : 'opacity-0 focus-within:opacity-100 group-hover/attachment:opacity-100',\n this.class\n );\n }\n\n protected onClick(event: Event): void {\n event.stopPropagation();\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,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "avatar-fallback",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Avatar Fallback",
|
|
5
|
+
"description": "Avatar Fallback component.",
|
|
6
|
+
"category": "media",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "avatar-fallback.component.ts",
|
|
12
|
+
"target": "ui/avatar-fallback.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-avatar-fallback',\n imports: [],\n template: `\n <div class=\"flex h-full w-full items-center justify-center bg-muted text-muted-foreground font-medium uppercase\">\n <ng-content></ng-content>\n </div>\n `,\n styles: `:host { display: inline-flex; }`\n})\nexport class AvatarFallbackComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "avatar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Avatar",
|
|
5
|
+
"description": "Avatar component with shape and size options.",
|
|
6
|
+
"category": "media",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "avatar.component.ts",
|
|
14
|
+
"target": "ui/avatar.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectorRef, Component, Input, HostBinding } from '@angular/core';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst avatarVariants = cva('relative flex shrink-0 overflow-hidden bg-muted', {\n variants: {\n shape: {\n circle: 'rounded-full',\n square: 'rounded-md',\n },\n size: {\n sm: 'h-8 w-8 text-xs',\n default: 'h-10 w-10',\n lg: 'h-16 w-16 text-lg',\n xl: 'h-24 w-24 text-xl',\n },\n },\n defaultVariants: {\n shape: 'circle',\n size: 'default',\n },\n});\n\nexport type AvatarProps = VariantProps<typeof avatarVariants>;\n\n@Component({\n selector: 'tolle-avatar',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <!-- Image Layer -->\n @if (src && !hasError) {\n <img\n [src]=\"src\"\n [alt]=\"alt\"\n (load)=\"onLoad()\"\n (error)=\"onError()\"\n [class.opacity-0]=\"isLoading\"\n class=\"h-full w-full object-cover transition-opacity duration-300\" />\n }\n\n <!-- Fallback Layer -->\n @if (hasError || !src || isLoading) {\n <div class=\"flex h-full w-full items-center justify-center bg-muted\">\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class AvatarComponent {\n /** Image URL. When absent or failed to load, projected fallback content is shown. */\n @Input() src?: string;\n /** Alternative text for the avatar image. @default '' */\n @Input() alt: string = '';\n /** Size of the avatar. @default 'default' */\n @Input() size: AvatarProps['size'] = 'default';\n /** Shape of the avatar. @default 'circle' */\n @Input() shape: AvatarProps['shape'] = 'circle';\n /** Extra Tailwind classes merged onto the avatar via `cn()` (last-wins). */\n @Input() class = '';\n\n isLoading = true;\n hasError = false;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n onLoad() {\n this.isLoading = false;\n this.cdr.detectChanges();\n }\n\n onError() {\n this.isLoading = false;\n this.hasError = true;\n this.cdr.detectChanges();\n }\n\n // Apply styles directly to the <tolle-avatar> tag\n @HostBinding('class')\n get hostClasses() {\n return cn(avatarVariants({ shape: this.shape, size: this.size }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "badge",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Badge",
|
|
5
|
+
"description": "Badge component with variant and size options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "badge.component.ts",
|
|
14
|
+
"target": "ui/badge.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter } from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst badgeVariants = cva(\n 'inline-flex w-fit items-center justify-center rounded-md border px-2 py-0.5 font-medium transition-colors gap-1 whitespace-nowrap',\n {\n variants: {\n variant: {\n default: 'border-transparent bg-primary text-primary-foreground',\n secondary: 'border-transparent bg-secondary text-secondary-foreground',\n outline: 'text-foreground border-border bg-transparent',\n destructive: 'border-transparent bg-destructive text-destructive-foreground',\n },\n size: {\n xs: 'px-1.5 py-0 text-[10px]',\n sm: 'px-2 py-0 text-[11px]',\n default: 'text-xs',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\nexport type BadgeProps = VariantProps<typeof badgeVariants>;\n\n@Component({\n selector: 'tolle-badge',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <div [class]=\"computedClass\">\n <ng-content select=\"[prefix]\"></ng-content>\n\n <span class=\"truncate\">\n <ng-content></ng-content>\n </span>\n\n @if (removable) {\n <button\n type=\"button\"\n [attr.aria-label]=\"removeLabel\"\n (click)=\"onRemove.emit($event)\"\n class=\"-mr-1 ml-1 rounded-md transition-colors hover:bg-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\">\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n </button>\n }\n </div>\n `,\n})\nexport class BadgeComponent {\n /** Visual style of the badge. @default 'default' */\n @Input() variant: BadgeProps['variant'] = 'default';\n /** Size of the badge. @default 'default' */\n @Input() size: BadgeProps['size'] = 'default';\n /** Shows a trailing dismiss (×) button that emits `onRemove`. @default false */\n @Input() removable = false;\n /** Accessible label for the dismiss button. @default 'Remove' */\n @Input() removeLabel = 'Remove';\n /** Extra Tailwind classes merged onto the badge via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the click event when the dismiss button is pressed. */\n @Output() onRemove = new EventEmitter<MouseEvent>();\n\n get computedClass() {\n return cn(badgeVariants({ variant: this.variant, size: this.size }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bar-list",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Bar List",
|
|
5
|
+
"description": "Bar List component.",
|
|
6
|
+
"category": "components",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "bar-list.component.ts",
|
|
12
|
+
"target": "ui/bar-list.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/** One resolved row: its label, its value, and its share of the list's largest value. */\nexport interface BarListRow {\n label: string;\n value: number;\n percent: number;\n formattedValue: string;\n}\n\n/**\n * A ranked list of horizontal bars — label left, value right, bar width\n * scaled to each row's share of the list's largest value. Unlike a chart\n * series or a category bar's segments, every bar is the same accent: rank is\n * carried by length, not by a categorical palette.\n * @new\n */\n@Component({\n selector: 'tolle-bar-list',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n @for (row of rows; track $index) {\n <div class=\"relative flex items-center overflow-hidden rounded-sm\">\n <div\n class=\"absolute inset-y-0 left-0 rounded-sm bg-primary/15\"\n [style.width.%]=\"row.percent\"></div>\n <span class=\"relative z-10 truncate px-2 py-1.5 text-sm text-foreground\">{{\n row.label\n }}</span>\n <span\n class=\"relative z-10 ml-auto shrink-0 px-2 py-1.5 text-sm font-medium tabular-nums text-foreground\">\n {{ row.formattedValue }}\n </span>\n </div>\n }\n </div>\n `,\n})\nexport class BarListComponent 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 /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n\n /** Rows to rank, in the order they should be drawn. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Row key holding each row's label. @default 'label' */\n @Input() labelKey = 'label';\n /** Row key holding each row's numeric value. @default 'value' */\n @Input() valueKey = 'value';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n get rows(): BarListRow[] {\n const rows = this.data ?? [];\n const values = rows.map(row => {\n const num = Number(row?.[this.valueKey]);\n return Number.isFinite(num) ? num : 0;\n });\n const max = values.reduce((m, v) => Math.max(m, v), 0);\n\n return rows.map((row, i) => {\n const raw = row?.[this.labelKey];\n const value = values[i];\n return {\n label: raw == null ? '' : String(raw),\n value,\n percent: max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0,\n formattedValue: value.toLocaleString('en-US'),\n };\n });\n }\n\n get computedClass() {\n return cn('flex w-full flex-col gap-1', 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,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "breadcrumb-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Breadcrumb Item",
|
|
5
|
+
"description": "Breadcrumb Item component.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "breadcrumb-item.component.ts",
|
|
12
|
+
"target": "ui/breadcrumb-item.component.ts",
|
|
13
|
+
"content": "import {Component, inject, OnInit} from '@angular/core';\n\n@Component({\n selector: 'tolle-breadcrumb-item',\n imports: [],\n template: `\n <li class=\"inline-flex items-center gap-1.5\">\n <ng-content></ng-content>\n </li>\n `,\n styles: `:host { display: inline-flex; }`\n})\nexport class BreadcrumbItemComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "breadcrumb-link",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Breadcrumb Link",
|
|
5
|
+
"description": "Breadcrumb Link component.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "breadcrumb-link.component.ts",
|
|
12
|
+
"target": "ui/breadcrumb-link.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\n\n@Component({\n selector: 'tolle-breadcrumb-link',\n imports: [NgTemplateOutlet],\n template: `\n <ng-template #content>\n <ng-content></ng-content>\n </ng-template>\n\n @if (active) {\n <span\n role=\"link\"\n aria-disabled=\"true\"\n aria-current=\"page\"\n class=\"font-normal text-foreground\">\n <ng-container *ngTemplateOutlet=\"content\"></ng-container>\n </span>\n } @else {\n <a\n class=\"cursor-pointer rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <ng-container *ngTemplateOutlet=\"content\"></ng-container>\n </a>\n }\n `,\n styles: `\n :host {\n display: inline-flex;\n }\n `,\n})\nexport class BreadcrumbLinkComponent {\n @Input() active: boolean = false;\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "breadcrumb-separator",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Breadcrumb Separator",
|
|
5
|
+
"description": "Breadcrumb Separator component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "breadcrumb-separator.component.ts",
|
|
12
|
+
"target": "ui/breadcrumb-separator.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-breadcrumb-separator',\n imports: [],\n template: `\n <li role=\"presentation\" aria-hidden=\"true\" class=\"[&>i]:size-3.5\">\n <ng-content>\n <i class=\"ri-arrow-right-s-line\"></i>\n </ng-content>\n </li>\n `,\n styles: `:host { display: inline-flex; }`\n})\nexport class BreadcrumbSeparatorComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "breadcrumb",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Breadcrumb",
|
|
5
|
+
"description": "Breadcrumb component.",
|
|
6
|
+
"category": "navigation",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "breadcrumb.component.ts",
|
|
12
|
+
"target": "ui/breadcrumb.component.ts",
|
|
13
|
+
"content": "import {Component, Input} from '@angular/core';\nimport { cn } from \"./utils/cn\";\n\n@Component({\n selector: 'tolle-breadcrumb',\n imports: [],\n template: `\n <nav aria-label=\"breadcrumb\" [class]=\"cn('flex flex-wrap items-center break-words text-sm text-muted-foreground', class)\">\n <ol class=\"flex flex-wrap items-center gap-1.5 break-words\">\n <ng-content></ng-content>\n </ol>\n </nav>\n `,\n styles: `:host { display: block; }`\n})\nexport class BreadcrumbComponent {\n @Input() class: string = '';\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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bubble",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Bubble",
|
|
5
|
+
"description": "Bubble component with variant and align and size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "bubble.component.ts",
|
|
14
|
+
"target": "ui/bubble.component.ts",
|
|
15
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst bubbleVariants = cva(\n 'group/bubble relative w-fit max-w-full whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm leading-relaxed transition-colors',\n {\n variants: {\n variant: {\n default: 'border border-border bg-card text-card-foreground',\n primary: 'border border-transparent bg-primary text-primary-foreground',\n muted: 'border border-transparent bg-muted text-foreground',\n outline: 'border border-border bg-transparent text-foreground',\n },\n align: {\n // The corner nearest the sender is squared off to read as a tail.\n start: 'rounded-bl-sm',\n end: 'rounded-br-sm',\n },\n size: {\n sm: 'px-2.5 py-1.5 text-xs',\n default: 'px-3 py-2 text-sm',\n lg: 'px-4 py-3 text-base',\n },\n },\n defaultVariants: { variant: 'default', align: 'start', size: 'default' },\n }\n);\n\nexport type BubbleProps = VariantProps<typeof bubbleVariants>;\n\n/** A single emoji reaction chip and its tally. */\nexport interface BubbleReaction {\n /** The emoji character to display, e.g. a thumbs-up. */\n emoji: string;\n /** How many people reacted with it. */\n count?: number;\n /** Whether the current user is one of them. */\n reacted?: boolean;\n}\n\n/**\n * The visible surface of a chat message — the rounded panel the text sits in.\n * Place it inside `tolle-message-content` and match its `align` to the message.\n * @new\n */\n@Component({\n selector: 'tolle-bubble',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"interactive ? 'button' : null\"\n [attr.tabindex]=\"interactive ? 0 : null\"\n (click)=\"onTrigger($event)\"\n (keydown.enter)=\"onTrigger($event)\"\n (keydown.space)=\"onTrigger($event)\">\n <!-- Single content slot: collapsing only swaps the wrapper's classes so the\n projected nodes are never re-created (and never render empty). -->\n <div [class]=\"contentClass\" [style.max-height]=\"contentStyle\">\n <ng-content></ng-content>\n </div>\n\n @if (collapsible) {\n <button\n type=\"button\"\n [attr.aria-expanded]=\"!collapsed\"\n (click)=\"toggle($event)\"\n class=\"mt-1 inline-flex items-center gap-1 rounded-sm text-xs font-medium underline underline-offset-2 opacity-80 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n {{ collapsed ? expandLabel : collapseLabel }}\n <i\n [class]=\"collapsed ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'\"\n aria-hidden=\"true\"></i>\n </button>\n }\n\n <ng-content select=\"[bubbleFooter]\"></ng-content>\n </div>\n `,\n})\nexport class BubbleComponent 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 /** Visual style of the surface. @default 'default' */\n @Input() variant: BubbleProps['variant'] = 'default';\n /** Which edge the bubble hangs off; squares the matching bottom corner. @default 'start' */\n @Input() align: BubbleProps['align'] = 'start';\n /** Padding and text scale of the surface. @default 'default' */\n @Input() size: BubbleProps['size'] = 'default';\n /** Shows a show-more / show-less toggle for long content. @default false */\n @Input() collapsible = false;\n /** Whether the content is currently clamped. Only has an effect with `collapsible`. @default true */\n @Input() collapsed = true;\n /** Max height of the clamped content, as a CSS length. @default '6rem' */\n @Input() collapsedHeight = '6rem';\n /** Label of the toggle while the content is clamped. @default 'Show more' */\n @Input() expandLabel = 'Show more';\n /** Label of the toggle while the content is expanded. @default 'Show less' */\n @Input() collapseLabel = 'Show less';\n /** Makes the whole surface a keyboard-focusable trigger that emits `bubbleClick`. @default false */\n @Input() interactive = false;\n /** Extra Tailwind classes merged onto the surface via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new collapsed state when the show-more toggle is pressed. */\n @Output() collapsedChange = new EventEmitter<boolean>();\n /** Emitted when an interactive bubble is activated by click, Enter, or Space. */\n @Output() bubbleClick = new EventEmitter<Event>();\n\n get computedClass(): string {\n return cn(\n bubbleVariants({ variant: this.variant, align: this.align, size: this.size }),\n this.interactive &&\n 'cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n this.class\n );\n }\n\n /** Classes for the content wrapper — clamped only while collapsible and collapsed. */\n get contentClass(): string {\n return cn(this.isClamped && 'overflow-hidden');\n }\n\n /** Inline max-height applied while the content is clamped. */\n get contentStyle(): string | null {\n return this.isClamped ? this.collapsedHeight : null;\n }\n\n /** Whether the projected content is currently clamped. */\n get isClamped(): boolean {\n return this.collapsible && this.collapsed;\n }\n\n protected toggle(event: Event): void {\n // The toggle lives inside the trigger surface; don't fire both.\n event.stopPropagation();\n this.collapsed = !this.collapsed;\n this.collapsedChange.emit(this.collapsed);\n }\n\n protected onTrigger(event: Event): void {\n if (!this.interactive) return;\n if (event.type === 'keydown') event.preventDefault();\n this.bubbleClick.emit(event);\n }\n}\n\n/**\n * Hover-revealed row of actions for a bubble — copy, retry, react. Clicks are\n * kept inside the row so an interactive bubble does not also fire.\n */\n@Component({\n selector: 'tolle-bubble-actions',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" (click)=\"onClick($event)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class BubbleActionsComponent 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 /** Keeps the row permanently visible instead of revealing it on hover or focus. @default false */\n @Input() alwaysVisible = false;\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass(): string {\n return cn(\n 'mt-1 flex items-center gap-1 transition-opacity',\n this.alwaysVisible\n ? 'opacity-100'\n : 'opacity-0 focus-within:opacity-100 group-hover/bubble:opacity-100',\n this.class\n );\n }\n\n protected onClick(event: Event): void {\n // Actions stay independently clickable even when the bubble is a trigger.\n event.stopPropagation();\n }\n}\n\n/** Row of emoji reaction chips attached to a bubble. */\n@Component({\n selector: 'tolle-bubble-reactions',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" (click)=\"stop($event)\">\n @for (reaction of reactions; track trackByEmoji($index, reaction)) {\n <button\n type=\"button\"\n [attr.aria-pressed]=\"!!reaction.reacted\"\n [class]=\"chipClass(reaction)\"\n (click)=\"onReact(reaction, $event)\">\n <span aria-hidden=\"true\">{{ reaction.emoji }}</span>\n @if (reaction.count) {\n <span class=\"tabular-nums\">{{ reaction.count }}</span>\n }\n </button>\n }\n\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class BubbleReactionsComponent 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 /** Reaction chips to render, in display order. @default [] */\n @Input() reactions: BubbleReaction[] = [];\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the reaction whose chip was clicked. */\n @Output() react = new EventEmitter<BubbleReaction>();\n\n get computedClass(): string {\n return cn('mt-1 flex flex-wrap items-center gap-1', this.class);\n }\n\n protected chipClass(reaction: BubbleReaction): string {\n return cn(\n 'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n reaction.reacted\n ? 'border-primary bg-primary/10 text-primary'\n : 'border-border bg-muted text-muted-foreground hover:bg-accent'\n );\n }\n\n protected trackByEmoji(_index: number, reaction: BubbleReaction): string {\n return reaction.emoji;\n }\n\n protected onReact(reaction: BubbleReaction, event: Event): void {\n event.stopPropagation();\n this.react.emit(reaction);\n }\n\n protected stop(event: Event): void {\n event.stopPropagation();\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": "button-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Button Group",
|
|
5
|
+
"description": "Button Group component.",
|
|
6
|
+
"category": "actions",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "button-group.component.ts",
|
|
12
|
+
"target": "ui/button-group.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-button-group',\n imports: [],\n template: `\n <div [class]=\"cn('inline-flex items-center -space-x-px rounded-md shadow-sm', class)\">\n <ng-content></ng-content>\n </div>\n `,\n styles: [\n `\n :host {\n display: inline-block;\n }\n\n /* 1. First Button: Remove right rounding and keep border */\n :host ::ng-deep tolle-button:first-child button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Buttons: Remove all rounding and the left border to avoid double-thickness */\n :host ::ng-deep tolle-button:not(:first-child):not(:last-child) button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Button: Remove left rounding and left border */\n :host ::ng-deep tolle-button:last-child:not(:first-child) button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Bring the active button to the front so the focus ring isn't cut off */\n :host ::ng-deep tolle-button button:hover,\n :host ::ng-deep tolle-button button:focus {\n @apply relative z-10;\n }\n `,\n ],\n})\nexport class ButtonGroupComponent {\n @Input() class: string = '';\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,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "button",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Button",
|
|
5
|
+
"description": "Button component with variant and size and busy options.",
|
|
6
|
+
"category": "actions",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "button.component.ts",
|
|
14
|
+
"target": "ui/button.component.ts",
|
|
15
|
+
"content": "import { Component, Input } from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst buttonVariants = cva(\n 'tolle-button-base inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background active:scale-[0.98]',\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',\n outline:\n 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-foreground',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n ghost: 'hover:bg-accent hover:text-accent-foreground',\n link: 'text-primary underline-offset-4 hover:underline',\n },\n size: {\n default: 'h-10 px-4 py-2',\n xs: 'h-8 px-2 py-1 text-xs',\n sm: 'h-9 rounded-md px-3',\n lg: 'h-11 rounded-md px-8',\n 'icon-xs': 'h-8 w-8',\n 'icon-sm': 'h-9 w-9',\n icon: 'h-10 w-10',\n 'icon-lg': 'h-11 w-11',\n },\n busy: { true: 'tolle-button--busy relative !cursor-wait !pointer-events-none' },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n);\n\nexport type ButtonProps = VariantProps<typeof buttonVariants>;\n\n@Component({\n selector: 'tolle-button',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n template: `\n <button\n [type]=\"type\"\n [class]=\"computedClass\"\n [disabled]=\"disabled || busy\"\n [attr.aria-busy]=\"busy\">\n @if (busy) {\n <div class=\"absolute inset-0 flex items-center justify-center\">\n <svg\n class=\"h-5 w-5 animate-spin text-current\"\n xmlns=\"http://www.w3.org/2000/svg\"\n fill=\"none\"\n viewBox=\"0 0 24 24\">\n <circle\n class=\"opacity-25\"\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n stroke-width=\"4\"></circle>\n <path\n class=\"opacity-75\"\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n </div>\n }\n\n <span\n class=\"pointer-events-none flex h-full w-full items-center justify-center\"\n [class.invisible]=\"busy\">\n <ng-content></ng-content>\n </span>\n </button>\n `,\n host: {\n class: 'tolle-button-wrapper inline-block align-middle',\n },\n})\nexport class ButtonComponent {\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class: string = '';\n /** Visual style of the button. @default 'default' */\n @Input() variant: ButtonProps['variant'] = 'default';\n /** Size of the button, including icon-only sizes. @default 'default' */\n @Input() size: ButtonProps['size'] = 'default';\n /** Native button type. Defaults to 'button' so it never submits a form unexpectedly. @default 'button' */\n @Input() type: 'button' | 'submit' | 'reset' = 'button';\n /** Disables the button and blocks pointer events. @default false */\n @Input() disabled: boolean = false;\n /** Shows a spinner, sets `aria-busy`, and prevents interaction while a task runs. @default false */\n @Input() busy: boolean = false;\n\n get computedClass() {\n return cn(\n buttonVariants({\n variant: this.variant,\n size: this.size,\n busy: this.busy,\n }),\n this.class\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|